chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
# @elizaos/plugin-github
GitHub integration for Eliza agents: pull request listing and review, issue lifecycle management, and notification triage via the GitHub REST API.
## Purpose / role
Adds GitHub capabilities to any Eliza agent. The plugin is opt-in — add `"@elizaos/plugin-github"` to the agent's plugin list. It registers a `GitHubService` (Octokit REST client pool), three exposed action handlers promoted under one umbrella `GITHUB` action, five API routes for credential management (PAT paste + OAuth device sign-in — the guided setup step behind the Settings → Coding Agents GitHub card, #15796), and a search category for PR lookup.
## Plugin surface
### Actions (registered via `promoteSubactionsToActions(githubAction)`)
The umbrella action `GITHUB` dispatches to three sub-actions based on the `action` parameter:
| Action name | Constant | Sub-actions / ops | Default identity | Confirmation |
|---|---|---|---|---|
| `GITHUB` | umbrella | routes to all ops below | per op | per op |
| `GITHUB_ISSUE` | `GitHubActions.GITHUB_ISSUE_OP` | `create`, `assign`, `close`, `reopen`, `comment`, `label` | `agent` | required (`requireConfirmation`) |
| `GITHUB_PR` | `GitHubActions.GITHUB_PR_OP` | `list`, `review` | `agent` (list) / `user` (review) | required for `review` |
| `GITHUB_NOTIFICATION_TRIAGE` | `GitHubActions.GITHUB_NOTIFICATION_TRIAGE` | reads + scores unread notifications | `user` | none (read-only) |
All actions gate on `contextGate: { anyOf: ["code", "tasks", "connectors", "automation"] }` and `roleGate: { minRole: "USER" }`.
### Services
| Service class | `serviceType` | Purpose |
|---|---|---|
| `GitHubService` | `"github"` | Octokit client pool — resolves clients by role (`user`/`agent`) or explicit `accountId` |
### Routes
Registered at plugin init on the agent's HTTP server:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/github/token` | Returns `{ connected, deviceFlowAvailable, username?, scopes?, savedAt? }` — token never returned |
| `POST` | `/api/github/token` | Body `{ token }`. Validates against GitHub `/user`, persists to `<state-dir>/credentials/github.json`, applies to the live runtime's per-agent settings (`setSetting("GITHUB_TOKEN", …, true)`) |
| `DELETE` | `/api/github/token` | Clears saved credential (disk + live runtime settings) |
| `POST` | `/api/github/device/start` | Starts a GitHub OAuth device flow (needs the `GITHUB_OAUTH_CLIENT_ID` setting; 409 with an owner-setup message otherwise). Returns `{ flowId, userCode, verificationUri, intervalSeconds, expiresInSeconds }` — the GitHub `device_code` never leaves the server |
| `POST` | `/api/github/device/poll` | Body `{ flowId }`. One poll: 200 `{ status: "pending" \| "denied" \| "expired" }` or, on grant, validates + persists like the PAT route and returns `{ status: "complete", …connected status }`. Flows are scoped to the agent that started them |
### Search category
`github_pull_requests` — registered at init via `registerGitHubSearchCategory`. Filters: `query`, `repo`, `state`, `author`, `as`, `accountId`, `limit`. Contexts: `code`, `automation`.
### Connector account provider
Registers with `ConnectorAccountManager` at init to expose GitHub accounts (PAT and OAuth) through the generic connector CRUD + OAuth flow surfaces. OAuth requires `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`, `GITHUB_OAUTH_REDIRECT_URI`.
## Layout
```
src/
index.ts Plugin export, route wiring, plugin object
register-routes.ts App-route plugin loader — registers githubPlugin via registerAppRoutePluginLoader
types.ts GitHubIdentity, GitHubOctokitClient, GitHubActions, result types
accounts.ts Account config reading (env + character settings + connector store)
action-helpers.ts Shared: service lookup, client resolution, param helpers
rate-limit.ts Rate-limit detection and formatting
github-credentials.ts Local PAT store: load/save/clear at <state-dir>/credentials/github.json
device-flow.ts GitHub OAuth device-flow state machine (start/poll; per-agent scoped; device_code stays server-side)
search-category.ts github_pull_requests search category registration
connector-account-provider.ts ConnectorAccountManager bridge (PAT + OAuth flows)
connector-credential-refs.ts Credential ref persistence helpers
actions/
github.ts GITHUB umbrella action — dispatches by action= param
issue-op.ts GITHUB_ISSUE action (create/assign/close/reopen/comment/label)
pr-op.ts GITHUB_PR action (list/review)
notification-triage.ts GITHUB_NOTIFICATION_TRIAGE action + scoreNotification export
services/
github-service.ts GitHubService — Octokit client pool, account resolution
routes/
github-routes.ts Pure handleGitHubRoutes dispatcher for PAT CRUD endpoints
```
## Commands
```bash
bun run --cwd plugins/plugin-github build # tsup ESM build + .d.ts
bun run --cwd plugins/plugin-github test # vitest run
bun run --cwd plugins/plugin-github typecheck # tsgo --noEmit
bun run --cwd plugins/plugin-github clean # rm dist .turbo
```
## Config / env vars
| Env var | Required | Purpose |
|---|---|---|
| `GITHUB_ACCOUNTS` | No (preferred) | JSON array/object of `{ accountId, role, token }` records — supports multiple accounts |
| `GITHUB_TOKEN` | No | Bootstrap PAT — if already set in the environment, it takes precedence over any locally saved credential; the plugin also writes the saved credential here at startup so that spawned processes (e.g. `gh`/`git`) see the same value |
| `GITHUB_USER_PAT` | No (legacy) | PAT for the `user` role (acting on behalf of the human) |
| `GITHUB_AGENT_PAT` | No (legacy) | PAT for the `agent` role (acting on behalf of the agent) |
| `GITHUB_USER_ACCOUNT_ID` | No | Override account ID for the legacy `user` slot (default: `"user"`) |
| `GITHUB_AGENT_ACCOUNT_ID` | No | Override account ID for the legacy `agent` slot (default: `"agent"`) |
| `ELIZA_E2E_GITHUB_USER_PAT` | No | E2E fallback for `GITHUB_USER_PAT` |
| `ELIZA_E2E_GITHUB_AGENT_PAT` | No | E2E fallback for `GITHUB_AGENT_PAT` |
| `GITHUB_OAUTH_CLIENT_ID` | OAuth only | GitHub OAuth app client ID — also enables the device sign-in path on the settings card (`/api/github/device/*`) |
| `GITHUB_OAUTH_CLIENT_SECRET` | OAuth only | GitHub OAuth app client secret |
| `GITHUB_OAUTH_REDIRECT_URI` | OAuth only | OAuth redirect URI registered on the GitHub app |
At least one account source — `GITHUB_ACCOUNTS`, `GITHUB_USER_PAT`, `GITHUB_AGENT_PAT`, or a `character.settings.github.accounts` entry — must be set for the plugin's actions to resolve a client. A bare `GITHUB_TOKEN` is not itself an account source: it bootstraps `gh`/`git` subprocess auth (and takes precedence over any locally saved credential) but does not register a `user`/`agent` account on its own. A missing `user` or `agent` account causes that role's operations to be rejected at runtime (logged as `[GitHubService] no GitHub <role> account configured`).
Character-level config is also supported under `character.settings.github.accounts` (array or object keyed by account ID).
## How to extend
**Add a new action:**
1. Create `src/actions/my-op.ts` exporting a typed `Action` with `name`, `contexts`, `contextGate`, `roleGate`, `validate`, and `handler`.
2. Use `buildResolvedClient(runtime, selection)` from `action-helpers.ts` to get an authenticated Octokit client.
3. Call `requireConfirmation` from `@elizaos/core` for any write op.
4. Register the new action in `src/actions/github.ts` by extending `GITHUB_ACTIONS` and adding a dispatch branch in the umbrella handler, OR add it directly to the `actions` array in `src/index.ts`.
**Add a new provider/evaluator:**
Export from `src/index.ts` and add to the `githubPlugin` object's `providers` or `evaluators` arrays.
**Add a new route:**
Add an entry to `githubRoutes` in `src/index.ts` and a handler in `src/routes/github-routes.ts`. Routes use raw `http.IncomingMessage` / `http.ServerResponse` — no Express.
## Conventions / gotchas
- **Confirmation is not optional for write ops.** All write actions use `requireConfirmation` from `@elizaos/core`. The `confirmed: true` parameter in the action schema is vestigial — the runtime confirmation gate is authoritative. `isConfirmed` in `action-helpers.ts` always returns `false` and is deprecated.
- **Two identity roles, two PATs.** `user` = human acting; `agent` = the Eliza agent acting. Operations that affect the repo on behalf of the agent use `agent`; operations that respond as the user (reviews, notifications) default to `user`.
- **Account precedence:** `GITHUB_ACCOUNTS` JSON > `GITHUB_USER_PAT`/`GITHUB_AGENT_PAT` legacy env vars. Character settings are layered in before env vars. ConnectorAccountManager credentials (OAuth) overlay everything by `accountId`.
- **No test harness for route auth.** `handleGitHubRoutes` is a pure dispatcher with no auth. The agent's server layer is expected to authenticate before calling the route handler.
- **Rate limits surface cleanly.** `inspectRateLimit` in `rate-limit.ts` detects GitHub rate-limit responses (HTTP 403 with `x-ratelimit-remaining: 0`); `formatRateLimitMessage` renders a human-readable message with the reset time from `x-ratelimit-reset`.
- **PAT storage is local-first.** `<state-dir>/credentials/github.json` (mode 0600). Written atomically via a tmp-rename. The token is never returned to the browser via the GET route.
- **`GitHubOctokitClient` is a structural interface**, not the full Octokit class — tests can inject a mock without depending on the real Octokit.
- **`tsup` builds two entry points:** `src/index.ts` and `src/register-routes.ts`. `register-routes.ts` is an app-route plugin loader that calls `registerAppRoutePluginLoader("@elizaos/plugin-github", ...)` — it registers the full `githubPlugin`, it is not a route-only subset.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — platform connector:**
- A real (or sandbox-account) round-trip on the platform: inbound message → agent → outbound reply, captured as logs **and** a screenshot/recording of the actual conversation.
- The raw inbound event/webhook payload and the outbound API request/response, with IDs mapped correctly (`stringToUuid` / `createUniqueUuid`).
- Attachments, threads/replies, edits, multi-account, and rate-limit/error paths — not just a single text ping.
- The agent trajectory for the turn the connector drove.
<!-- END: evidence-and-e2e-mandate -->
+170
View File
@@ -0,0 +1,170 @@
# @elizaos/plugin-github
GitHub integration for Eliza agents: pull request listing and review, issue lifecycle management, and notification triage via the GitHub REST API.
## Purpose / role
Adds GitHub capabilities to any Eliza agent. The plugin is opt-in — add `"@elizaos/plugin-github"` to the agent's plugin list. It registers a `GitHubService` (Octokit REST client pool), three exposed action handlers promoted under one umbrella `GITHUB` action, five API routes for credential management (PAT paste + OAuth device sign-in — the guided setup step behind the Settings → Coding Agents GitHub card, #15796), and a search category for PR lookup.
## Plugin surface
### Actions (registered via `promoteSubactionsToActions(githubAction)`)
The umbrella action `GITHUB` dispatches to three sub-actions based on the `action` parameter:
| Action name | Constant | Sub-actions / ops | Default identity | Confirmation |
|---|---|---|---|---|
| `GITHUB` | umbrella | routes to all ops below | per op | per op |
| `GITHUB_ISSUE` | `GitHubActions.GITHUB_ISSUE_OP` | `create`, `assign`, `close`, `reopen`, `comment`, `label` | `agent` | required (`requireConfirmation`) |
| `GITHUB_PR` | `GitHubActions.GITHUB_PR_OP` | `list`, `review` | `agent` (list) / `user` (review) | required for `review` |
| `GITHUB_NOTIFICATION_TRIAGE` | `GitHubActions.GITHUB_NOTIFICATION_TRIAGE` | reads + scores unread notifications | `user` | none (read-only) |
All actions gate on `contextGate: { anyOf: ["code", "tasks", "connectors", "automation"] }` and `roleGate: { minRole: "USER" }`.
### Services
| Service class | `serviceType` | Purpose |
|---|---|---|
| `GitHubService` | `"github"` | Octokit client pool — resolves clients by role (`user`/`agent`) or explicit `accountId` |
### Routes
Registered at plugin init on the agent's HTTP server:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/github/token` | Returns `{ connected, deviceFlowAvailable, username?, scopes?, savedAt? }` — token never returned |
| `POST` | `/api/github/token` | Body `{ token }`. Validates against GitHub `/user`, persists to `<state-dir>/credentials/github.json`, applies to the live runtime's per-agent settings (`setSetting("GITHUB_TOKEN", …, true)`) |
| `DELETE` | `/api/github/token` | Clears saved credential (disk + live runtime settings) |
| `POST` | `/api/github/device/start` | Starts a GitHub OAuth device flow (needs the `GITHUB_OAUTH_CLIENT_ID` setting; 409 with an owner-setup message otherwise). Returns `{ flowId, userCode, verificationUri, intervalSeconds, expiresInSeconds }` — the GitHub `device_code` never leaves the server |
| `POST` | `/api/github/device/poll` | Body `{ flowId }`. One poll: 200 `{ status: "pending" \| "denied" \| "expired" }` or, on grant, validates + persists like the PAT route and returns `{ status: "complete", …connected status }`. Flows are scoped to the agent that started them |
### Search category
`github_pull_requests` — registered at init via `registerGitHubSearchCategory`. Filters: `query`, `repo`, `state`, `author`, `as`, `accountId`, `limit`. Contexts: `code`, `automation`.
### Connector account provider
Registers with `ConnectorAccountManager` at init to expose GitHub accounts (PAT and OAuth) through the generic connector CRUD + OAuth flow surfaces. OAuth requires `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`, `GITHUB_OAUTH_REDIRECT_URI`.
## Layout
```
src/
index.ts Plugin export, route wiring, plugin object
register-routes.ts App-route plugin loader — registers githubPlugin via registerAppRoutePluginLoader
types.ts GitHubIdentity, GitHubOctokitClient, GitHubActions, result types
accounts.ts Account config reading (env + character settings + connector store)
action-helpers.ts Shared: service lookup, client resolution, param helpers
rate-limit.ts Rate-limit detection and formatting
github-credentials.ts Local PAT store: load/save/clear at <state-dir>/credentials/github.json
device-flow.ts GitHub OAuth device-flow state machine (start/poll; per-agent scoped; device_code stays server-side)
search-category.ts github_pull_requests search category registration
connector-account-provider.ts ConnectorAccountManager bridge (PAT + OAuth flows)
connector-credential-refs.ts Credential ref persistence helpers
actions/
github.ts GITHUB umbrella action — dispatches by action= param
issue-op.ts GITHUB_ISSUE action (create/assign/close/reopen/comment/label)
pr-op.ts GITHUB_PR action (list/review)
notification-triage.ts GITHUB_NOTIFICATION_TRIAGE action + scoreNotification export
services/
github-service.ts GitHubService — Octokit client pool, account resolution
routes/
github-routes.ts Pure handleGitHubRoutes dispatcher for PAT CRUD endpoints
```
## Commands
```bash
bun run --cwd plugins/plugin-github build # tsup ESM build + .d.ts
bun run --cwd plugins/plugin-github test # vitest run
bun run --cwd plugins/plugin-github typecheck # tsgo --noEmit
bun run --cwd plugins/plugin-github clean # rm dist .turbo
```
## Config / env vars
| Env var | Required | Purpose |
|---|---|---|
| `GITHUB_ACCOUNTS` | No (preferred) | JSON array/object of `{ accountId, role, token }` records — supports multiple accounts |
| `GITHUB_TOKEN` | No | Bootstrap PAT — if already set in the environment, it takes precedence over any locally saved credential; the plugin also writes the saved credential here at startup so that spawned processes (e.g. `gh`/`git`) see the same value |
| `GITHUB_USER_PAT` | No (legacy) | PAT for the `user` role (acting on behalf of the human) |
| `GITHUB_AGENT_PAT` | No (legacy) | PAT for the `agent` role (acting on behalf of the agent) |
| `GITHUB_USER_ACCOUNT_ID` | No | Override account ID for the legacy `user` slot (default: `"user"`) |
| `GITHUB_AGENT_ACCOUNT_ID` | No | Override account ID for the legacy `agent` slot (default: `"agent"`) |
| `ELIZA_E2E_GITHUB_USER_PAT` | No | E2E fallback for `GITHUB_USER_PAT` |
| `ELIZA_E2E_GITHUB_AGENT_PAT` | No | E2E fallback for `GITHUB_AGENT_PAT` |
| `GITHUB_OAUTH_CLIENT_ID` | OAuth only | GitHub OAuth app client ID — also enables the device sign-in path on the settings card (`/api/github/device/*`) |
| `GITHUB_OAUTH_CLIENT_SECRET` | OAuth only | GitHub OAuth app client secret |
| `GITHUB_OAUTH_REDIRECT_URI` | OAuth only | OAuth redirect URI registered on the GitHub app |
At least one account source — `GITHUB_ACCOUNTS`, `GITHUB_USER_PAT`, `GITHUB_AGENT_PAT`, or a `character.settings.github.accounts` entry — must be set for the plugin's actions to resolve a client. A bare `GITHUB_TOKEN` is not itself an account source: it bootstraps `gh`/`git` subprocess auth (and takes precedence over any locally saved credential) but does not register a `user`/`agent` account on its own. A missing `user` or `agent` account causes that role's operations to be rejected at runtime (logged as `[GitHubService] no GitHub <role> account configured`).
Character-level config is also supported under `character.settings.github.accounts` (array or object keyed by account ID).
## How to extend
**Add a new action:**
1. Create `src/actions/my-op.ts` exporting a typed `Action` with `name`, `contexts`, `contextGate`, `roleGate`, `validate`, and `handler`.
2. Use `buildResolvedClient(runtime, selection)` from `action-helpers.ts` to get an authenticated Octokit client.
3. Call `requireConfirmation` from `@elizaos/core` for any write op.
4. Register the new action in `src/actions/github.ts` by extending `GITHUB_ACTIONS` and adding a dispatch branch in the umbrella handler, OR add it directly to the `actions` array in `src/index.ts`.
**Add a new provider/evaluator:**
Export from `src/index.ts` and add to the `githubPlugin` object's `providers` or `evaluators` arrays.
**Add a new route:**
Add an entry to `githubRoutes` in `src/index.ts` and a handler in `src/routes/github-routes.ts`. Routes use raw `http.IncomingMessage` / `http.ServerResponse` — no Express.
## Conventions / gotchas
- **Confirmation is not optional for write ops.** All write actions use `requireConfirmation` from `@elizaos/core`. The `confirmed: true` parameter in the action schema is vestigial — the runtime confirmation gate is authoritative. `isConfirmed` in `action-helpers.ts` always returns `false` and is deprecated.
- **Two identity roles, two PATs.** `user` = human acting; `agent` = the Eliza agent acting. Operations that affect the repo on behalf of the agent use `agent`; operations that respond as the user (reviews, notifications) default to `user`.
- **Account precedence:** `GITHUB_ACCOUNTS` JSON > `GITHUB_USER_PAT`/`GITHUB_AGENT_PAT` legacy env vars. Character settings are layered in before env vars. ConnectorAccountManager credentials (OAuth) overlay everything by `accountId`.
- **No test harness for route auth.** `handleGitHubRoutes` is a pure dispatcher with no auth. The agent's server layer is expected to authenticate before calling the route handler.
- **Rate limits surface cleanly.** `inspectRateLimit` in `rate-limit.ts` detects GitHub rate-limit responses (HTTP 403 with `x-ratelimit-remaining: 0`); `formatRateLimitMessage` renders a human-readable message with the reset time from `x-ratelimit-reset`.
- **PAT storage is local-first.** `<state-dir>/credentials/github.json` (mode 0600). Written atomically via a tmp-rename. The token is never returned to the browser via the GET route.
- **`GitHubOctokitClient` is a structural interface**, not the full Octokit class — tests can inject a mock without depending on the real Octokit.
- **`tsup` builds two entry points:** `src/index.ts` and `src/register-routes.ts`. `register-routes.ts` is an app-route plugin loader that calls `registerAppRoutePluginLoader("@elizaos/plugin-github", ...)` — it registers the full `githubPlugin`, it is not a route-only subset.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — platform connector:**
- A real (or sandbox-account) round-trip on the platform: inbound message → agent → outbound reply, captured as logs **and** a screenshot/recording of the actual conversation.
- The raw inbound event/webhook payload and the outbound API request/response, with IDs mapped correctly (`stringToUuid` / `createUniqueUuid`).
- Attachments, threads/replies, edits, multi-account, and rate-limit/error paths — not just a single text ping.
- The agent trajectory for the turn the connector drove.
<!-- END: evidence-and-e2e-mandate -->
+90
View File
@@ -0,0 +1,90 @@
# @elizaos/plugin-github
GitHub integration for elizaOS agents. Enables agents to manage pull requests, issues, and notifications using the GitHub REST API via Octokit.
## What it does
- **List pull requests** — open/closed/all for a specific repo or across accessible repos, with optional author filter.
- **Review pull requests** — submit approve, request-changes, or comment reviews (requires confirmation).
- **Issue management** — create, assign, close, reopen, comment on, or label issues (all write ops require confirmation).
- **Notification triage** — fetch unread GitHub notifications and return them ranked by priority score (reason, subject type, repo freshness). Read-only, no confirmation needed.
- **PAT management** — REST endpoints for storing/removing a GitHub Personal Access Token from local disk (`<state-dir>/credentials/github.json`).
## Enabling the plugin
Add `"@elizaos/plugin-github"` to the agent's `plugins` array in its character file or runtime configuration. The plugin is opt-in.
## Required configuration
At least one GitHub token must be configured. The plugin supports two roles:
- **`user`** — acts on behalf of the human (used for reviews and notifications by default).
- **`agent`** — acts on behalf of the Eliza agent (used for issue and PR ops by default).
### Multi-account (recommended)
Set `GITHUB_ACCOUNTS` to a JSON array:
```json
[
{ "accountId": "user", "role": "user", "token": "ghp_..." },
{ "accountId": "agent", "role": "agent", "token": "ghp_..." }
]
```
### Legacy single-account
| Env var | Role |
|---|---|
| `GITHUB_USER_PAT` | user |
| `GITHUB_AGENT_PAT` | agent |
### OAuth (optional)
To enable OAuth-app flows through the connector account manager, also set:
- `GITHUB_OAUTH_CLIENT_ID`
- `GITHUB_OAUTH_CLIENT_SECRET`
- `GITHUB_OAUTH_REDIRECT_URI`
## Actions
The plugin exposes one umbrella action `GITHUB` that dispatches to sub-operations via an `action` parameter:
| `action` value | What it does |
|---|---|
| `pr_list` | List pull requests |
| `pr_review` | Submit a PR review (requires `review_action`: approve / request-changes / comment) |
| `issue_create` | Create a new issue (`title` required) |
| `issue_assign` | Assign users to an issue |
| `issue_close` | Close an issue |
| `issue_reopen` | Reopen a closed issue |
| `issue_comment` | Add a comment to an issue |
| `issue_label` | Apply labels to an issue |
| `notification_triage` | Fetch and rank unread notifications |
All write operations require confirmation before they execute.
## HTTP routes
The plugin registers five routes on the agent's server for credential
management — they power the guided GitHub connection card in Settings →
Coding Agents (PAT paste or OAuth device sign-in):
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/github/token` | Returns connection status incl. `deviceFlowAvailable` (token never exposed) |
| `POST` | `/api/github/token` | Save a PAT (validated against GitHub `/user` before save; applied to the live runtime's per-agent settings) |
| `DELETE` | `/api/github/token` | Remove the saved PAT (disk + live runtime) |
| `POST` | `/api/github/device/start` | Start a GitHub OAuth device sign-in (needs `GITHUB_OAUTH_CLIENT_ID`); the device code never leaves the server |
| `POST` | `/api/github/device/poll` | Poll a pending sign-in: `pending` / `denied` / `expired`, or validate + save the granted token |
## Development
```bash
bun run --cwd plugins/plugin-github build
bun run --cwd plugins/plugin-github test
bun run --cwd plugins/plugin-github typecheck
```
See [CLAUDE.md](CLAUDE.md) for agent-facing layout and extension guide.
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@elizaos/plugin-github",
"version": "2.0.3-beta.7",
"description": "GitHub integration plugin for elizaOS agents — PRs, issues, notifications via the GitHub REST API",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"eliza-source": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./*.css": "./dist/*.css",
"./*": {
"types": "./dist/*.d.ts",
"eliza-source": {
"types": "./src/*.ts",
"import": "./src/*.ts",
"default": "./src/*.ts"
},
"import": "./dist/*.js",
"default": "./dist/*.js"
}
},
"files": [
"registry-entry.json",
"dist"
],
"scripts": {
"build": "tsup src/index.ts src/register-routes.ts --format esm --dts --clean",
"test": "vitest run",
"typecheck": "tsgo --noEmit",
"clean": "node ../../packages/scripts/rm-path-recursive.mjs dist .turbo",
"lint": "bunx @biomejs/biome check --write --unsafe .",
"lint:check": "bunx @biomejs/biome check .",
"format": "bunx @biomejs/biome format --write .",
"format:check": "bunx @biomejs/biome format ."
},
"dependencies": {
"@elizaos/core": "workspace:*",
"@octokit/rest": "^22.0.0"
},
"peerDependencies": {
"@elizaos/core": "workspace:*"
},
"devDependencies": {
"@types/node": "24.12.2",
"tsup": "8.5.1",
"typescript": "^6.0.3",
"vitest": "4.1.10"
},
"publishConfig": {
"access": "public"
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"id": "github",
"name": "Github",
"description": "Comprehensive GitHub integration plugin for ElizaOS with repository management, issue tracking, and PR workflows",
"npmName": "@elizaos/plugin-github",
"version": "2.0.0-beta.0",
"source": "bundled",
"tags": ["github"],
"config": {
"GITHUB_REPO": {
"type": "string",
"required": false,
"sensitive": false,
"label": "Repo",
"help": "Default GitHub repository name",
"advanced": false
},
"GITHUB_OWNER": {
"type": "string",
"required": false,
"sensitive": false,
"label": "Owner",
"help": "Default GitHub repository owner (username or organization)",
"advanced": false
},
"GITHUB_APP_ID": {
"type": "string",
"required": false,
"sensitive": false,
"label": "App Id",
"help": "GitHub App ID for app-based authentication",
"advanced": false
},
"GITHUB_BRANCH": {
"type": "string",
"required": false,
"sensitive": false,
"default": "main",
"label": "Branch",
"help": "Default branch name (defaults to main)",
"advanced": false
},
"GITHUB_API_TOKEN": {
"type": "secret",
"required": true,
"sensitive": true,
"label": "Api Token",
"help": "GitHub personal access token for API authentication",
"advanced": false
},
"GITHUB_WEBHOOK_SECRET": {
"type": "secret",
"required": false,
"sensitive": true,
"label": "Webhook Secret",
"help": "Secret for validating GitHub webhook payloads",
"advanced": false
},
"GITHUB_APP_PRIVATE_KEY": {
"type": "secret",
"required": false,
"sensitive": true,
"label": "App Private Key",
"help": "GitHub App private key for app-based authentication",
"advanced": false
},
"GITHUB_INSTALLATION_ID": {
"type": "string",
"required": false,
"sensitive": false,
"label": "Installation Id",
"help": "GitHub App installation ID",
"advanced": false
}
},
"render": {
"visible": true,
"pinTo": [],
"style": "card",
"icon": "GitBranch",
"group": "devtools",
"groupOrder": 5,
"actions": ["enable", "configure"]
},
"resources": {
"homepage": "https://github.com/elizaos-plugins/plugin-github#readme",
"repository": "https://github.com/elizaos-plugins/plugin-github",
"setupGuideUrl": "https://docs.eliza.ai/plugin-setup-guide#github"
},
"dependsOn": [],
"kind": "plugin",
"subtype": "devtools"
}
+445
View File
@@ -0,0 +1,445 @@
/**
* Tests account resolution precedence (env vars, character settings, connector
* credentials) against a deterministic in-memory runtime — no live GitHub.
*/
import type {
ConnectorAccount,
ConnectorAccountPatch,
IAgentRuntime,
} from "@elizaos/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readGitHubAccounts,
readGitHubAccountsWithConnectorCredentials,
resolveGitHubAccount,
resolveGitHubAccountSelection,
} from "./accounts";
import { createGitHubConnectorAccountProvider } from "./connector-account-provider";
function runtime(settings: Record<string, unknown>): IAgentRuntime {
return createTestRuntime({
character: {},
getSetting: vi.fn((key: string) => toRuntimeSetting(settings[key])),
});
}
type RuntimeSetting = string | number | boolean | null;
interface TestRuntimeShape {
agentId?: string;
character?: unknown;
getSetting?: (key: string) => RuntimeSetting;
getService?: (serviceType: string) => unknown;
}
function toRuntimeSetting(value: unknown): RuntimeSetting {
return typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
? value
: null;
}
function createTestRuntime(runtimeShape: TestRuntimeShape): IAgentRuntime {
return Object.assign(Object.create(null) as IAgentRuntime, runtimeShape);
}
describe("GitHub account resolution", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("keeps legacy user/agent PATs as role-tagged accounts", () => {
const accounts = readGitHubAccounts(
runtime({
GITHUB_USER_PAT: "user-token",
GITHUB_AGENT_PAT: "agent-token",
}),
);
expect(accounts).toEqual(
expect.arrayContaining([
expect.objectContaining({
accountId: "user",
role: "user",
token: "user-token",
}),
expect.objectContaining({
accountId: "agent",
role: "agent",
token: "agent-token",
}),
]),
);
});
it("resolves explicit accountId before role defaults", () => {
const accounts = readGitHubAccounts(
runtime({
GITHUB_AGENT_PAT: "legacy-agent",
GITHUB_ACCOUNTS: JSON.stringify({
reviewer: { role: "user", token: "reviewer-token" },
}),
}),
);
const selection = resolveGitHubAccountSelection(
{ accountId: "reviewer", as: "agent" },
"agent",
);
expect(resolveGitHubAccount(accounts, selection)).toMatchObject({
accountId: "reviewer",
role: "user",
token: "reviewer-token",
});
});
it("does not fall back to role defaults when an explicit accountId is missing", () => {
const accounts = readGitHubAccounts(
runtime({
GITHUB_AGENT_PAT: "legacy-agent",
}),
);
const selection = resolveGitHubAccountSelection(
{ accountId: "missing", as: "agent" },
"agent",
);
expect(resolveGitHubAccount(accounts, selection)).toBeNull();
});
it("does not read token-shaped fields from account metadata", () => {
const accounts = readGitHubAccounts(
runtime({
GITHUB_ACCOUNTS: JSON.stringify({
reviewer: {
role: "user",
metadata: {
token: "metadata-token",
accessToken: "metadata-access",
},
},
}),
}),
);
expect(accounts).toEqual([]);
});
it("loads OAuth accounts from connector credential refs", async () => {
const vaultRef = "connector.agent-1.github.acct_github_1.oauth_tokens";
const account = createConnectorAccount({
id: "acct_github_1",
role: "AGENT",
metadata: {
credentialRefs: [{ credentialType: "oauth.tokens", vaultRef }],
},
});
const runtime = runtimeWithConnectorStorage({
accounts: [account],
vaultValues: new Map([
[
vaultRef,
JSON.stringify({
access_token: "github-oauth-token",
token_type: "bearer",
}),
],
]),
});
const accounts = await readGitHubAccountsWithConnectorCredentials(runtime);
expect(accounts).toEqual([
expect.objectContaining({
accountId: "acct_github_1",
role: "agent",
token: "github-oauth-token",
}),
]);
});
it("persists callback tokens as credential refs without returning token metadata", async () => {
const vault = new Map<string, string>();
const setCredentialRef = vi.fn(async () => undefined);
const runtime = createTestRuntime({
agentId: "agent-1",
character: {},
getSetting: (key: string) =>
({
GITHUB_OAUTH_CLIENT_ID: "github-client",
GITHUB_OAUTH_CLIENT_SECRET: "github-secret",
GITHUB_OAUTH_REDIRECT_URI: "http://localhost/oauth/github/callback",
})[key] ?? null,
getService: (serviceType: string) =>
serviceType === "vault"
? {
set: async (key: string, value: string) => {
vault.set(key, value);
},
}
: null,
});
const manager = createOAuthCallbackManager(
"github",
"acct_github_durable_1",
setCredentialRef,
);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL | Request) => {
const href = String(url);
if (href.includes("/login/oauth/access_token")) {
return new Response(
JSON.stringify({
access_token: "github-access-token",
refresh_token: "github-refresh-token",
expires_in: 28800,
token_type: "bearer",
scope: "repo,read:user",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (href.includes("api.github.com/user")) {
return new Response(
JSON.stringify({
id: 123,
login: "ada",
name: "Ada",
email: "ada@example.com",
type: "User",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
throw new Error(`Unexpected fetch ${href}`);
}),
);
const provider = createGitHubConnectorAccountProvider(runtime);
const result = await provider.completeOAuth?.(
{
provider: "github",
code: "oauth-code",
query: {},
flow: {
id: "flow-1",
provider: "github",
state: "state-1",
status: "pending",
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: { role: "TEAM" },
},
},
manager as never,
);
const account = result?.account as ConnectorAccount;
const metadata = account.metadata as Record<string, unknown>;
expect(account.id).toBe("acct_github_durable_1");
expect(account.role).toBe("TEAM");
expect(JSON.stringify(metadata)).not.toContain("github-access-token");
expect(JSON.stringify(metadata)).not.toContain("github-refresh-token");
expect(metadata.credentialRefs).toEqual([
expect.objectContaining({
credentialType: "oauth.tokens",
vaultRef: "connector.agent-1.github.acct_github_durable_1.oauth_tokens",
}),
]);
expect(
vault.get("connector.agent-1.github.acct_github_durable_1.oauth_tokens"),
).toContain("github-access-token");
expect(setCredentialRef).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "acct_github_durable_1",
credentialType: "oauth.tokens",
vaultRef: "connector.agent-1.github.acct_github_durable_1.oauth_tokens",
}),
);
});
it("fails OAuth callback when no durable vault writer is available", async () => {
const runtime = createTestRuntime({
agentId: "agent-1",
character: {},
getSetting: (key: string) =>
({
GITHUB_OAUTH_CLIENT_ID: "github-client",
GITHUB_OAUTH_CLIENT_SECRET: "github-secret",
GITHUB_OAUTH_REDIRECT_URI: "http://localhost/oauth/github/callback",
})[key] ?? null,
getService: () => null,
});
const manager = createOAuthCallbackManager(
"github",
"acct_github_durable_1",
vi.fn(async () => undefined),
);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string | URL | Request) => {
const href = String(url);
if (href.includes("/login/oauth/access_token")) {
return new Response(
JSON.stringify({
access_token: "github-access-token",
refresh_token: "github-refresh-token",
expires_in: 28800,
token_type: "bearer",
scope: "repo,read:user",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (href.includes("api.github.com/user")) {
return new Response(
JSON.stringify({
id: 123,
login: "ada",
name: "Ada",
email: "ada@example.com",
type: "User",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
throw new Error(`Unexpected fetch ${href}`);
}),
);
const provider = createGitHubConnectorAccountProvider(runtime);
await expect(
provider.completeOAuth?.(
{
provider: "github",
code: "oauth-code",
query: {},
flow: {
id: "flow-1",
provider: "github",
state: "state-1",
status: "pending",
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: {},
},
},
manager as never,
),
).rejects.toThrow(/durable connector credential store|vault writer/i);
});
});
function createConnectorAccount(input: {
id: string;
role: ConnectorAccount["role"];
metadata: ConnectorAccount["metadata"];
}): ConnectorAccount {
return {
id: input.id,
provider: "github",
label: "GitHub OAuth",
role: input.role,
purpose: ["posting"],
accessGate: "open",
status: "connected",
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: input.metadata,
};
}
function runtimeWithConnectorStorage(options: {
accounts: ConnectorAccount[];
vaultValues: Map<string, string>;
}): IAgentRuntime {
const storage = {
async listAccounts(provider?: string) {
return options.accounts.filter(
(account) => !provider || account.provider === provider,
);
},
async getAccount(provider: string, accountId: string) {
return (
options.accounts.find(
(account) =>
account.provider === provider && account.id === accountId,
) ?? null
);
},
async upsertAccount(account: ConnectorAccount) {
return account;
},
async deleteAccount() {
return false;
},
async createOAuthFlow(flow: unknown) {
return flow;
},
async getOAuthFlow() {
return null;
},
async updateOAuthFlow() {
return null;
},
async deleteOAuthFlow() {
return false;
},
};
return createTestRuntime({
agentId: "agent-1",
character: {},
getSetting: vi.fn(() => null),
getService: (serviceType: string) => {
if (serviceType === "connector_account_storage") return storage;
if (serviceType === "vault") {
return {
reveal: async (key: string) => options.vaultValues.get(key) ?? "",
};
}
return null;
},
});
}
function createOAuthCallbackManager(
provider: string,
durableAccountId: string,
setCredentialRef: ReturnType<typeof vi.fn>,
) {
return {
getStorage: () => ({
setConnectorAccountCredentialRef: setCredentialRef,
}),
upsertAccount: vi.fn(
async (
providerId: string,
input: ConnectorAccountPatch & { provider?: string },
accountId?: string,
): Promise<ConnectorAccount> => ({
id: accountId ?? durableAccountId,
provider: providerId || provider,
label: input.label,
role: input.role ?? "OWNER",
purpose: Array.isArray(input.purpose)
? input.purpose
: input.purpose
? [input.purpose]
: ["messaging"],
accessGate: input.accessGate ?? "open",
status: input.status ?? "pending",
externalId: input.externalId ?? undefined,
displayHandle: input.displayHandle ?? undefined,
ownerBindingId: input.ownerBindingId ?? undefined,
ownerIdentityId: input.ownerIdentityId ?? undefined,
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: input.metadata,
}),
),
};
}
+287
View File
@@ -0,0 +1,287 @@
/**
* Resolves the GitHub account set an action runs under, layering three
* sources: `GITHUB_ACCOUNTS` JSON and legacy `GITHUB_USER_PAT`/`GITHUB_AGENT_PAT`
* env vars, `character.settings.github.accounts`, and OAuth credentials from
* the connector account store. Given a role (`user`/`agent`) and optional
* `accountId`, returns the matching config the GitHubService uses to build an
* Octokit client.
*/
import type { IAgentRuntime } from "@elizaos/core";
import {
listConnectorAccounts,
loadConnectorOAuthAccessToken,
} from "./connector-credential-refs.js";
import { GITHUB_SERVICE_TYPE, type GitHubIdentity } from "./types.js";
export const DEFAULT_GITHUB_USER_ACCOUNT_ID = "user";
export const DEFAULT_GITHUB_AGENT_ACCOUNT_ID = "agent";
export interface GitHubAccountConfig {
accountId: string;
role: GitHubIdentity;
token: string;
label?: string;
}
export interface GitHubAccountSelection {
accountId?: string;
role: GitHubIdentity;
}
type RawAccountRecord = Record<string, unknown>;
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function readSetting(runtime: IAgentRuntime, key: string): string | undefined {
return nonEmptyString(runtime.getSetting(key));
}
function normalizeRole(value: unknown): GitHubIdentity | undefined {
return value === "user" || value === "agent" ? value : undefined;
}
export function defaultGitHubAccountIdForRole(role: GitHubIdentity): string {
return role === "user"
? DEFAULT_GITHUB_USER_ACCOUNT_ID
: DEFAULT_GITHUB_AGENT_ACCOUNT_ID;
}
export function normalizeGitHubAccountId(value: unknown): string | undefined {
return nonEmptyString(value);
}
export function resolveGitHubAccountSelection(
options: Record<string, unknown> | undefined,
defaultRole: GitHubIdentity,
): GitHubAccountSelection {
const requestedAccountId = normalizeGitHubAccountId(options?.accountId);
const requestedRole = normalizeRole(options?.as);
return {
accountId: requestedAccountId,
role: requestedRole ?? normalizeRole(requestedAccountId) ?? defaultRole,
};
}
function parseAccountsJson(raw: string | undefined): RawAccountRecord[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter(
(item): item is RawAccountRecord =>
Boolean(item) && typeof item === "object" && !Array.isArray(item),
);
}
if (parsed && typeof parsed === "object") {
return Object.entries(parsed as Record<string, unknown>)
.filter(([, value]) => value && typeof value === "object")
.map(([id, value]) => ({
...(value as RawAccountRecord),
accountId: (value as RawAccountRecord).accountId ?? id,
}));
}
} catch {
return [];
}
return [];
}
function readRawField(
record: RawAccountRecord,
keys: readonly string[],
): string | undefined {
const credentials =
record.credentials && typeof record.credentials === "object"
? (record.credentials as RawAccountRecord)
: {};
const settings =
record.settings && typeof record.settings === "object"
? (record.settings as RawAccountRecord)
: {};
for (const source of [record, credentials, settings]) {
for (const key of keys) {
const value = nonEmptyString(source[key]);
if (value) return value;
}
}
return undefined;
}
function accountFromRecord(
record: RawAccountRecord,
): GitHubAccountConfig | null {
const accountId = normalizeGitHubAccountId(
record.accountId ?? record.id ?? record.name,
);
const role =
normalizeRole(record.role) ??
normalizeRole(record.as) ??
normalizeRole(accountId);
const token = readRawField(record, [
"GITHUB_PAT",
"GITHUB_TOKEN",
"GITHUB_ACCESS_TOKEN",
"token",
"pat",
"accessToken",
"access",
]);
if (!accountId || !role || !token) {
return null;
}
return {
accountId,
role,
token,
label: nonEmptyString(record.label ?? record.displayName),
};
}
function addAccount(
accounts: Map<string, GitHubAccountConfig>,
account: GitHubAccountConfig | null,
): void {
if (account) {
accounts.set(account.accountId, account);
}
}
export function readGitHubAccounts(
runtime: IAgentRuntime,
): GitHubAccountConfig[] {
const accounts = new Map<string, GitHubAccountConfig>();
const characterConfig = runtime.character?.settings?.github as
| { accounts?: unknown }
| undefined;
const characterAccounts = characterConfig?.accounts;
if (Array.isArray(characterAccounts)) {
for (const item of characterAccounts) {
if (item && typeof item === "object") {
addAccount(accounts, accountFromRecord(item as RawAccountRecord));
}
}
} else if (characterAccounts && typeof characterAccounts === "object") {
for (const [id, value] of Object.entries(
characterAccounts as Record<string, unknown>,
)) {
if (value && typeof value === "object") {
addAccount(
accounts,
accountFromRecord({
...(value as RawAccountRecord),
accountId: (value as RawAccountRecord).accountId ?? id,
}),
);
}
}
}
for (const record of parseAccountsJson(
readSetting(runtime, "GITHUB_ACCOUNTS"),
)) {
addAccount(accounts, accountFromRecord(record));
}
addAccount(
accounts,
legacyAccount(
runtime,
"user",
readSetting(runtime, "GITHUB_USER_ACCOUNT_ID") ??
DEFAULT_GITHUB_USER_ACCOUNT_ID,
"GITHUB_USER_PAT",
"ELIZA_E2E_GITHUB_USER_PAT",
),
);
addAccount(
accounts,
legacyAccount(
runtime,
"agent",
readSetting(runtime, "GITHUB_AGENT_ACCOUNT_ID") ??
DEFAULT_GITHUB_AGENT_ACCOUNT_ID,
"GITHUB_AGENT_PAT",
"ELIZA_E2E_GITHUB_AGENT_PAT",
),
);
return Array.from(accounts.values());
}
export async function readGitHubAccountsWithConnectorCredentials(
runtime: IAgentRuntime,
): Promise<GitHubAccountConfig[]> {
const accounts = new Map<string, GitHubAccountConfig>();
for (const account of readGitHubAccounts(runtime)) {
accounts.set(account.accountId, account);
}
const connectorAccounts = await listConnectorAccounts(
runtime,
GITHUB_SERVICE_TYPE,
);
for (const account of connectorAccounts) {
if (account.status !== "connected") continue;
const token = await loadConnectorOAuthAccessToken({
runtime,
provider: GITHUB_SERVICE_TYPE,
accountId: account.id,
caller: "plugin-github",
});
if (!token) continue;
accounts.set(account.id, {
accountId: account.id,
role: connectorRoleToIdentity(account.role),
token,
label: account.label ?? account.displayHandle,
});
}
return Array.from(accounts.values());
}
function legacyAccount(
runtime: IAgentRuntime,
role: GitHubIdentity,
accountId: string,
primaryKey: string,
fallbackKey: string,
): GitHubAccountConfig | null {
const token =
readSetting(runtime, primaryKey) ?? readSetting(runtime, fallbackKey);
if (!token) return null;
return { accountId, role, token };
}
function connectorRoleToIdentity(role: unknown): GitHubIdentity {
return typeof role === "string" && role.toUpperCase() === "AGENT"
? "agent"
: "user";
}
export function resolveGitHubAccount(
accounts: readonly GitHubAccountConfig[],
selection: GitHubAccountSelection,
): GitHubAccountConfig | null {
if (selection.accountId) {
const exact = accounts.find(
(account) => account.accountId === selection.accountId,
);
if (exact) return exact;
return null;
}
const legacyId = defaultGitHubAccountIdForRole(selection.role);
return (
accounts.find((account) => account.accountId === legacyId) ??
accounts.find((account) => account.role === selection.role) ??
null
);
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
isConfirmed,
optionalStringArray,
requireNumber,
requireString,
requireStringArray,
splitRepo,
} from "./action-helpers.ts";
/**
* Param-validation helpers run at the GitHub action boundary on
* LLM-/caller-supplied options. They must return null/undefined on anything
* malformed (so a bad param can't reach the GitHub API as a half-valid call),
* and isConfirmed must ALWAYS be false — LLM `confirmed` is never authoritative;
* the runtime confirmation gate is the real check.
*/
describe("requireString", () => {
it("returns non-empty strings, null otherwise", () => {
expect(requireString({ a: "x" }, "a")).toBe("x");
expect(requireString({ a: "" }, "a")).toBeNull();
expect(requireString({ a: 5 }, "a")).toBeNull();
expect(requireString(undefined, "a")).toBeNull();
});
});
describe("requireNumber", () => {
it("accepts integers and numeric strings, rejects the rest", () => {
expect(requireNumber({ n: 42 }, "n")).toBe(42);
expect(requireNumber({ n: "7" }, "n")).toBe(7);
expect(requireNumber({ n: 1.5 }, "n")).toBeNull();
expect(requireNumber({ n: "x" }, "n")).toBeNull();
});
});
describe("requireStringArray / optionalStringArray", () => {
it("requires an all-non-empty-string array", () => {
expect(requireStringArray({ a: ["x", "y"] }, "a")).toEqual(["x", "y"]);
expect(requireStringArray({ a: ["x", ""] }, "a")).toBeNull();
expect(requireStringArray({ a: ["x", 1] }, "a")).toBeNull();
expect(requireStringArray({ a: "x" }, "a")).toBeNull();
});
it("optionalStringArray returns undefined when the key is absent", () => {
expect(optionalStringArray({}, "a")).toBeUndefined();
expect(optionalStringArray({ a: ["x"] }, "a")).toEqual(["x"]);
expect(optionalStringArray({ a: ["x", ""] }, "a")).toBeUndefined();
});
});
describe("splitRepo", () => {
it("splits owner/repo, null on malformed", () => {
expect(splitRepo("elizaOS/eliza")).toEqual({
owner: "elizaOS",
name: "eliza",
});
expect(splitRepo("noslash")).toBeNull();
expect(splitRepo("a/b/c")).toBeNull();
expect(splitRepo("/eliza")).toBeNull();
});
});
describe("isConfirmed", () => {
it("is always false regardless of the supplied flag", () => {
expect(isConfirmed({ confirmed: true })).toBe(false);
expect(isConfirmed(undefined)).toBe(false);
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* @module action-helpers
* @description Shared plumbing for GitHub actions: service lookup, identity
* resolution, parameter extraction, and confirmation gating.
*/
import type { HandlerCallback, IAgentRuntime } from "@elizaos/core";
import {
type GitHubAccountSelection,
resolveGitHubAccountSelection,
} from "./accounts.js";
import type { GitHubService } from "./services/github-service.js";
import {
GITHUB_SERVICE_TYPE,
type GitHubActionResult,
type GitHubIdentity,
type GitHubOctokitClient,
} from "./types.js";
export interface ResolvedClient {
client: GitHubOctokitClient;
identity: GitHubIdentity;
accountId?: string;
}
export function resolveIdentity(
options: Record<string, unknown> | undefined,
defaultIdentity: GitHubIdentity,
): GitHubIdentity {
const raw = options?.as;
if (raw === "user" || raw === "agent") {
return raw;
}
return defaultIdentity;
}
export function getClient(
runtime: IAgentRuntime,
selection: GitHubAccountSelection,
): GitHubOctokitClient | null {
const service = runtime.getService<GitHubService>(GITHUB_SERVICE_TYPE);
if (!service) {
return null;
}
return service.getOctokit(selection);
}
export async function reportAndReturn<T>(
result: GitHubActionResult<T>,
callback: HandlerCallback | undefined,
text: string,
): Promise<GitHubActionResult<T>> {
await callback?.({ text });
return result;
}
export function requireString(
options: Record<string, unknown> | undefined,
key: string,
): string | null {
const v = options?.[key];
return typeof v === "string" && v.length > 0 ? v : null;
}
export function requireNumber(
options: Record<string, unknown> | undefined,
key: string,
): number | null {
const v = options?.[key];
if (typeof v === "number" && Number.isInteger(v)) {
return v;
}
if (typeof v === "string" && /^\d+$/.test(v)) {
return Number(v);
}
return null;
}
export function requireStringArray(
options: Record<string, unknown> | undefined,
key: string,
): string[] | null {
const v = options?.[key];
if (!Array.isArray(v)) {
return null;
}
const result: string[] = [];
for (const item of v) {
if (typeof item !== "string" || item.length === 0) {
return null;
}
result.push(item);
}
return result;
}
export function optionalStringArray(
options: Record<string, unknown> | undefined,
key: string,
): string[] | undefined {
const v = options?.[key];
if (v === undefined) {
return undefined;
}
return requireStringArray(options, key) ?? undefined;
}
/** Splits "owner/repo" into its two components. Returns null on malformed input. */
export function splitRepo(
repo: string,
): { owner: string; name: string } | null {
const parts = repo.split("/");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return null;
}
return { owner: parts[0], name: parts[1] };
}
/** @deprecated LLM `confirmed` is never authoritative — use {@link requireConfirmation}. */
export function isConfirmed(
_options: Record<string, unknown> | undefined,
): boolean {
return false;
}
export function needsClientError(selection: GitHubAccountSelection): string {
const accountSuffix = selection.accountId
? ` accountId "${selection.accountId}"`
: ` ${selection.role} account`;
return `GitHub${accountSuffix} token not configured (connect GitHub in Settings → Coding Agents, or set GITHUB_ACCOUNTS or ${
selection.role === "user" ? "GITHUB_USER_PAT" : "GITHUB_AGENT_PAT"
})`;
}
export function getServiceOrNull(runtime: IAgentRuntime): GitHubService | null {
return runtime.getService<GitHubService>(GITHUB_SERVICE_TYPE);
}
export function buildResolvedClient(
runtime: IAgentRuntime,
selection: GitHubIdentity | GitHubAccountSelection,
): ResolvedClient | { error: string } {
if (!getServiceOrNull(runtime)) {
return { error: "GitHub service not available" };
}
const resolvedSelection =
typeof selection === "string" ? { role: selection } : selection;
const client = getClient(runtime, resolvedSelection);
if (!client) {
return { error: needsClientError(resolvedSelection) };
}
return {
client,
identity: resolvedSelection.role,
accountId: resolvedSelection.accountId,
};
}
export function resolveAccountSelection(
options: Record<string, unknown> | undefined,
defaultIdentity: GitHubIdentity,
): GitHubAccountSelection {
return resolveGitHubAccountSelection(options, defaultIdentity);
}
export function describeSelection(selection: GitHubAccountSelection): string {
return selection.accountId
? `${selection.role} (${selection.accountId})`
: selection.role;
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Umbrella GITHUB action. Reads the `action` param off the handler options
* and dispatches to the issue-op, pr-op, or notification-triage sub-action,
* so the agent invokes a single action name and selects the operation by
* parameter rather than picking among many registered actions.
*/
import type {
Action,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { issueOpAction } from "./issue-op.js";
import { notificationTriageAction } from "./notification-triage.js";
import { prOpAction } from "./pr-op.js";
const GITHUB_ACTIONS = [
"pr_list",
"pr_review",
"issue_create",
"issue_assign",
"issue_close",
"issue_reopen",
"issue_comment",
"issue_label",
"notification_triage",
] as const;
type GitHubActionName = (typeof GITHUB_ACTIONS)[number];
const ISSUE_OP_BY_ACTION: Partial<Record<GitHubActionName, string>> = {
issue_create: "create",
issue_assign: "assign",
issue_close: "close",
issue_reopen: "reopen",
issue_comment: "comment",
issue_label: "label",
};
function readParameters(options: unknown): Record<string, unknown> {
if (!options || typeof options !== "object") return {};
const record = options as Record<string, unknown>;
const params = record.parameters;
return params && typeof params === "object" && !Array.isArray(params)
? { ...(params as Record<string, unknown>) }
: { ...record };
}
function readAction(options: unknown): GitHubActionName | undefined {
const params = readParameters(options);
const raw =
params.action ??
params.subaction ??
params.op ??
params.operation ??
params.verb;
if (typeof raw !== "string") return undefined;
const normalized = raw
.trim()
.toLowerCase()
.replace(/[-\s]+/g, "_");
return (GITHUB_ACTIONS as readonly string[]).includes(normalized)
? (normalized as GitHubActionName)
: undefined;
}
function delegate(
target: Action,
runtime: IAgentRuntime,
message: Memory,
state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
return target.handler(
runtime,
message,
state,
options as HandlerOptions | undefined,
callback,
) as Promise<ActionResult>;
}
export const githubAction: Action = {
name: "GITHUB",
contexts: ["code", "tasks", "connectors", "automation"],
contextGate: { anyOf: ["code", "tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
similes: [
"GITHUB_PR_OP",
"GITHUB_ISSUE_OP",
"GITHUB_NOTIFICATION_TRIAGE",
"GITHUB_PULL_REQUEST",
"GITHUB_ISSUE",
"GITHUB_NOTIFICATIONS",
],
description:
"GitHub umbrella for pull requests, issues, and notification triage. Use action=pr_list/pr_review/issue_create/issue_assign/issue_close/issue_reopen/issue_comment/issue_label/notification_triage.",
descriptionCompressed:
"GitHub pr_list|pr_review|issue_create|assign|close|reopen|comment|label|triage",
parameters: [
{
name: "action",
description: "GitHub operation to run.",
required: true,
schema: { type: "string", enum: [...GITHUB_ACTIONS] },
},
{
name: "repo",
description: "Repository in owner/name form.",
required: false,
schema: { type: "string" },
},
{
name: "number",
description: "Pull request or issue number.",
required: false,
schema: { type: "number" },
},
{
name: "state",
description: "PR state for pr_list: open, closed, or all.",
required: false,
schema: {
type: "string",
enum: ["open", "closed", "all"],
default: "open",
},
},
{
name: "author",
description: "Optional PR author username filter for pr_list.",
required: false,
schema: { type: "string" },
},
{
name: "review_action",
description:
"For action=pr_review: approve, request-changes, or comment.",
required: false,
schema: {
type: "string",
enum: ["approve", "request-changes", "comment"],
},
},
{
name: "title",
description: "Issue title for action=issue_create.",
required: false,
schema: { type: "string" },
},
{
name: "body",
description: "Issue body, issue comment body, or PR review body.",
required: false,
schema: { type: "string" },
},
{
name: "assignees",
description: "GitHub usernames to assign.",
required: false,
schema: { type: "array", items: { type: "string" } },
},
{
name: "labels",
description: "Labels to apply on issue create or issue_label.",
required: false,
schema: { type: "array", items: { type: "string" } },
},
{
name: "as",
description: "Identity to use: agent or user.",
required: false,
schema: { type: "string", enum: ["agent", "user"], default: "agent" },
},
{
name: "accountId",
description:
"Optional GitHub account id from GITHUB_ACCOUNTS. Defaults by role.",
required: false,
schema: { type: "string" },
},
{
name: "confirmed",
description: "Must be true for GitHub write operations.",
required: false,
schema: { type: "boolean", default: false },
},
],
validate: async (runtime, message, state) =>
(await prOpAction.validate(runtime, message, state)) ||
(await issueOpAction.validate(runtime, message, state)) ||
(await notificationTriageAction.validate(runtime, message, state)),
handler: async (runtime, message, state, options, callback) => {
const action = readAction(options);
const params = readParameters(options);
if (!action) {
return {
success: false,
text: "GITHUB requires action=pr_list/pr_review/issue_create/issue_assign/issue_close/issue_reopen/issue_comment/issue_label/notification_triage.",
data: { error: "MISSING_ACTION" },
};
}
if (action === "notification_triage") {
return delegate(
notificationTriageAction,
runtime,
message,
state,
params,
callback,
);
}
if (action === "pr_list" || action === "pr_review") {
const childParams = {
...params,
op: action === "pr_list" ? "list" : "review",
...(action === "pr_review" && params.review_action
? { action: params.review_action }
: {}),
};
return delegate(
prOpAction,
runtime,
message,
state,
childParams,
callback,
);
}
const issueOp = ISSUE_OP_BY_ACTION[action];
if (issueOp) {
return delegate(
issueOpAction,
runtime,
message,
state,
{ ...params, op: issueOp },
callback,
);
}
return {
success: false,
text: `Unsupported GITHUB action: ${action}`,
data: { error: "UNSUPPORTED_ACTION", action },
};
},
};
@@ -0,0 +1,520 @@
/**
* Single router action covering the GitHub issue lifecycle ops: create,
* assign, close, reopen, comment, label. Dispatched to by the umbrella
* GITHUB action. Every op is a write op and passes through the runtime's
* `requireConfirmation` gate before it touches the GitHub API.
*/
import type {
Action,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger, requireConfirmation } from "@elizaos/core";
import {
buildResolvedClient,
describeSelection,
optionalStringArray,
type ResolvedClient,
requireNumber,
requireString,
requireStringArray,
resolveAccountSelection,
splitRepo,
} from "../action-helpers.js";
import {
errorMessage,
formatRateLimitMessage,
inspectRateLimit,
} from "../rate-limit.js";
import {
type GitHubActionResult,
GitHubActions,
type GitHubIssueOp,
} from "../types.js";
const SUPPORTED_OPS: ReadonlySet<GitHubIssueOp> = new Set([
"create",
"assign",
"close",
"reopen",
"comment",
"label",
]);
function parseOp(value: unknown): GitHubIssueOp | null {
if (typeof value !== "string") return null;
return SUPPORTED_OPS.has(value as GitHubIssueOp)
? (value as GitHubIssueOp)
: null;
}
function describeOp(op: GitHubIssueOp): string {
switch (op) {
case "create":
return "create";
case "assign":
return "assign";
case "close":
return "close";
case "reopen":
return "reopen";
case "comment":
return "comment on";
case "label":
return "label";
}
}
interface RepoParts {
owner: string;
name: string;
}
/**
* Result payloads keyed by op, kept loose so callers can route on `op` and
* narrow the data. Individual op helpers return the payload directly; the
* router wraps it in `{ success: true, data }` and emits a callback message.
*/
export type GitHubIssueOpResult =
| { op: "create"; number: number; url: string }
| { op: "assign"; number: number; assignees: string[] }
| { op: "close"; number: number; title: string }
| { op: "reopen"; number: number; title: string }
| { op: "comment"; number: number; commentId: number; url: string }
| { op: "label"; number: number; labels: string[] }
| { requiresConfirmation: true; preview: string; awaitingUserInput: true }
| { cancelled: true };
async function runCreate(
resolved: ResolvedClient,
parts: RepoParts,
repo: string,
options: Record<string, unknown> | undefined,
callback: HandlerCallback | undefined,
): Promise<GitHubActionResult<GitHubIssueOpResult>> {
const title = requireString(options, "title");
const body = requireString(options, "body");
const labels = optionalStringArray(options, "labels");
const assignees = optionalStringArray(options, "assignees");
if (!title) {
const err = "GITHUB_ISSUE_OP create requires title";
await callback?.({ text: err });
return { success: false, error: err };
}
const resp = await resolved.client.issues.create({
owner: parts.owner,
repo: parts.name,
title,
body: body ?? undefined,
labels,
assignees,
});
await callback?.({
text: `Created issue ${repo}#${resp.data.number}: ${resp.data.html_url}`,
});
return {
success: true,
data: {
op: "create",
number: resp.data.number,
url: resp.data.html_url,
},
};
}
async function runAssign(
resolved: ResolvedClient,
parts: RepoParts,
repo: string,
options: Record<string, unknown> | undefined,
callback: HandlerCallback | undefined,
): Promise<GitHubActionResult<GitHubIssueOpResult>> {
const number = requireNumber(options, "number");
const assignees = requireStringArray(options, "assignees");
if (!number || !assignees || assignees.length === 0) {
const err =
"GITHUB_ISSUE_OP assign requires number (integer) and assignees (non-empty string[])";
await callback?.({ text: err });
return { success: false, error: err };
}
const resp = await resolved.client.issues.addAssignees({
owner: parts.owner,
repo: parts.name,
issue_number: number,
assignees,
});
const actual = (resp.data.assignees ?? [])
.map((a) => a?.login)
.filter((x): x is string => typeof x === "string");
await callback?.({
text: `Assigned [${actual.join(", ")}] to ${repo}#${number}`,
});
return {
success: true,
data: { op: "assign", number, assignees: actual },
};
}
async function runStateChange(
resolved: ResolvedClient,
parts: RepoParts,
repo: string,
options: Record<string, unknown> | undefined,
callback: HandlerCallback | undefined,
target: "closed" | "open",
): Promise<GitHubActionResult<GitHubIssueOpResult>> {
const number = requireNumber(options, "number");
if (!number) {
const err = `GITHUB_ISSUE_OP ${target === "closed" ? "close" : "reopen"} requires number (integer)`;
await callback?.({ text: err });
return { success: false, error: err };
}
const resp = await resolved.client.issues.update({
owner: parts.owner,
repo: parts.name,
issue_number: number,
state: target,
});
const verb = target === "closed" ? "Closed" : "Reopened";
await callback?.({ text: `${verb} ${repo}#${number}: ${resp.data.title}` });
return {
success: true,
data: {
op: target === "closed" ? "close" : "reopen",
number,
title: resp.data.title,
},
};
}
async function runComment(
resolved: ResolvedClient,
parts: RepoParts,
repo: string,
options: Record<string, unknown> | undefined,
callback: HandlerCallback | undefined,
): Promise<GitHubActionResult<GitHubIssueOpResult>> {
const number = requireNumber(options, "number");
const body = requireString(options, "body");
if (!number || !body) {
const err = "GITHUB_ISSUE_OP comment requires number (integer) and body";
await callback?.({ text: err });
return { success: false, error: err };
}
const resp = await resolved.client.issues.createComment({
owner: parts.owner,
repo: parts.name,
issue_number: number,
body,
});
await callback?.({
text: `Commented on ${repo}#${number}: ${resp.data.html_url}`,
});
return {
success: true,
data: {
op: "comment",
number,
commentId: resp.data.id,
url: resp.data.html_url,
},
};
}
async function runLabel(
resolved: ResolvedClient,
parts: RepoParts,
repo: string,
options: Record<string, unknown> | undefined,
callback: HandlerCallback | undefined,
): Promise<GitHubActionResult<GitHubIssueOpResult>> {
const number = requireNumber(options, "number");
const labels = requireStringArray(options, "labels");
if (!number || !labels || labels.length === 0) {
const err =
"GITHUB_ISSUE_OP label requires number (integer) and labels (non-empty string[])";
await callback?.({ text: err });
return { success: false, error: err };
}
const resp = await resolved.client.issues.addLabels({
owner: parts.owner,
repo: parts.name,
issue_number: number,
labels,
});
const applied = (resp.data ?? [])
.map((label) => (typeof label === "string" ? label : (label?.name ?? null)))
.filter((x): x is string => typeof x === "string");
await callback?.({
text: `Applied labels [${applied.join(", ")}] to ${repo}#${number}`,
});
return {
success: true,
data: { op: "label", number, labels: applied },
};
}
function buildPreview(
op: GitHubIssueOp,
repo: string,
identity: string,
options: Record<string, unknown> | undefined,
): string {
const number = requireNumber(options, "number");
const title = requireString(options, "title");
const body = requireString(options, "body");
const assignees = optionalStringArray(options, "assignees");
const labels = optionalStringArray(options, "labels");
const head = `About to ${describeOp(op)} ${repo}`;
const target = number ? `#${number}` : "";
const detail = (() => {
switch (op) {
case "create":
return ` issue: "${title ?? "(no title)"}"${labels ? ` [labels: ${labels.join(", ")}]` : ""}${assignees ? ` [assignees: ${assignees.join(", ")}]` : ""}`;
case "assign":
return ` with [${assignees?.join(", ") ?? ""}]`;
case "label":
return ` with [${labels?.join(", ") ?? ""}]`;
case "comment":
return body ? ` body: "${body.slice(0, 120)}"` : "";
default:
return "";
}
})();
return `${head}${target}${detail} as ${identity}. Re-invoke with confirmed: true to proceed.`;
}
export const issueOpAction: Action = {
name: GitHubActions.GITHUB_ISSUE_OP,
contexts: ["code", "tasks", "connectors", "automation"],
contextGate: { anyOf: ["code", "tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
similes: [
"CREATE_ISSUE",
"OPEN_ISSUE",
"FILE_ISSUE",
"GITHUB_CREATE_ISSUE",
"ASSIGN_ISSUE",
"ASSIGN_GITHUB_ISSUE",
"ADD_ASSIGNEE",
"CLOSE_ISSUE",
"REOPEN_ISSUE",
"COMMENT_ISSUE",
"ADD_ISSUE_COMMENT",
"LABEL_ISSUE",
"ADD_ISSUE_LABEL",
"MANAGE_ISSUES",
],
description:
"Single router for GitHub issue ops: create, assign, close, reopen, comment, label. Requires confirmed:true.",
descriptionCompressed:
"GitHub issue ops: create, assign, close, reopen, comment, label.",
parameters: [
{
name: "subaction",
description:
"Issue operation: create, assign, close, reopen, comment, or label.",
required: true,
schema: { type: "string", enum: [...SUPPORTED_OPS] },
},
{
name: "repo",
description: "Repository in owner/name form.",
required: true,
schema: { type: "string" },
},
{
name: "number",
description: "Issue number for existing-issue operations.",
required: false,
schema: { type: "number" },
},
{
name: "title",
description: "Issue title for create.",
required: false,
schema: { type: "string" },
},
{
name: "body",
description: "Issue body or comment body.",
required: false,
schema: { type: "string" },
},
{
name: "assignees",
description: "GitHub usernames to assign.",
required: false,
schema: { type: "array", items: { type: "string" } },
},
{
name: "labels",
description: "Labels to apply on create or label.",
required: false,
schema: { type: "array", items: { type: "string" } },
},
{
name: "as",
description: "Identity to use: agent or user.",
required: false,
schema: { type: "string", enum: ["agent", "user"], default: "agent" },
},
{
name: "accountId",
description:
"Optional GitHub account id from GITHUB_ACCOUNTS. Defaults by role.",
required: false,
schema: { type: "string" },
},
{
name: "confirmed",
description: "Must be true to perform the write operation.",
required: false,
schema: { type: "boolean", default: false },
},
],
validate: async (
runtime: IAgentRuntime,
_message: Memory,
): Promise<boolean> => {
const r = buildResolvedClient(runtime, "agent");
return !("error" in r);
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: Record<string, unknown>,
callback?: HandlerCallback,
): Promise<GitHubActionResult<GitHubIssueOpResult>> => {
const op = parseOp(options?.op);
if (!op) {
const err =
"GITHUB_ISSUE_OP requires op (create|assign|close|reopen|comment|label)";
await callback?.({ text: err });
return { success: false, error: err };
}
const selection = resolveAccountSelection(options, "agent");
const repo = requireString(options, "repo");
if (!repo) {
const err = "GITHUB_ISSUE_OP requires repo (owner/name)";
await callback?.({ text: err });
return { success: false, error: err };
}
const parts = splitRepo(repo);
if (!parts) {
const err = `Invalid repo "${repo}" — expected "owner/name"`;
await callback?.({ text: err });
return { success: false, error: err };
}
const preview = buildPreview(
op,
repo,
describeSelection(selection),
options,
);
const decision = await requireConfirmation({
runtime,
message,
actionName: "GITHUB_ISSUE_OP",
pendingKey: `${op}:${repo}`,
prompt: `${preview} Reply yes to confirm or no to cancel.`,
callback,
});
if (decision.status === "pending") {
return {
success: false,
requiresConfirmation: true,
preview,
};
}
if (decision.status === "cancelled") {
const cancelMessage = "GitHub issue operation cancelled.";
await callback?.({ text: cancelMessage });
return { success: false, error: cancelMessage };
}
const resolved = buildResolvedClient(runtime, selection);
if ("error" in resolved) {
await callback?.({ text: resolved.error });
return { success: false, error: resolved.error };
}
try {
switch (op) {
case "create":
return await runCreate(resolved, parts, repo, options, callback);
case "assign":
return await runAssign(resolved, parts, repo, options, callback);
case "close":
return await runStateChange(
resolved,
parts,
repo,
options,
callback,
"closed",
);
case "reopen":
return await runStateChange(
resolved,
parts,
repo,
options,
callback,
"open",
);
case "comment":
return await runComment(resolved, parts, repo, options, callback);
case "label":
return await runLabel(resolved, parts, repo, options, callback);
}
} catch (err) {
const rl = inspectRateLimit(err);
const message = rl.isRateLimited
? formatRateLimitMessage(rl)
: `GITHUB_ISSUE_OP ${op} failed: ${errorMessage(err)}`;
logger.warn({ message }, "[GitHub:GITHUB_ISSUE_OP]");
await callback?.({ text: message });
return { success: false, error: message };
}
},
examples: [
[
{
name: "{{user1}}",
content: {
text: "Open an issue in elizaOS/eliza titled 'Docs gap'",
},
},
{
name: "{{agentName}}",
content: {
text: "Created issue elizaOS/eliza#101",
},
},
],
[
{
name: "{{user1}}",
content: {
text: "Close issue elizaOS/eliza#42",
},
},
{
name: "{{agentName}}",
content: {
text: "Closed elizaOS/eliza#42",
},
},
],
],
};
@@ -0,0 +1,220 @@
/**
* @module notification-triage
* @description Fetches unread GitHub notifications and returns them sorted
* by a composite priority score derived from `reason`, subject type, and
* the notifying repo's `pushed_at` freshness.
*
* Read-only — no confirmation gate.
*/
import type {
Action,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import {
buildResolvedClient,
resolveAccountSelection,
} from "../action-helpers.js";
import {
errorMessage,
formatRateLimitMessage,
inspectRateLimit,
} from "../rate-limit.js";
import { type GitHubActionResult, GitHubActions } from "../types.js";
const REASON_SCORES: Record<string, number> = {
security_advisory: 100,
team_mention: 70,
author: 60,
mention: 55,
assign: 50,
review_requested: 80,
state_change: 20,
comment: 30,
subscribed: 10,
manual: 15,
invitation: 40,
ci_activity: 25,
};
const SUBJECT_TYPE_SCORES: Record<string, number> = {
PullRequest: 20,
Issue: 15,
Release: 10,
Commit: 5,
Discussion: 8,
};
const NOTIFICATION_TRIAGE_LIMIT = 25;
export interface TriagedNotification {
id: string;
reason: string;
repo: string;
title: string;
subjectType: string;
url: string | null;
updatedAt: string;
score: number;
}
function scoreNotification(params: {
reason: string;
subjectType: string;
repoPushedAtMs: number | null;
nowMs: number;
}): number {
const base = REASON_SCORES[params.reason] ?? 10;
const subject = SUBJECT_TYPE_SCORES[params.subjectType] ?? 0;
let freshness = 0;
if (params.repoPushedAtMs !== null) {
const ageHours = (params.nowMs - params.repoPushedAtMs) / (1000 * 60 * 60);
if (ageHours < 1) freshness = 20;
else if (ageHours < 6) freshness = 15;
else if (ageHours < 24) freshness = 10;
else if (ageHours < 24 * 7) freshness = 5;
}
return base + subject + freshness;
}
export { scoreNotification };
export const notificationTriageAction: Action = {
name: GitHubActions.GITHUB_NOTIFICATION_TRIAGE,
contexts: ["code", "tasks", "connectors", "automation"],
contextGate: { anyOf: ["code", "tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
similes: ["TRIAGE_GITHUB_NOTIFICATIONS", "GITHUB_INBOX"],
description:
"Returns unread GitHub notifications sorted by a priority score derived from reason, subject type, and repo freshness.",
descriptionCompressed:
"unread GitHub notifications sorted by reason|subject|repo freshness",
parameters: [
{
name: "as",
description: "Identity to use when reading notifications: user or agent.",
required: false,
schema: { type: "string", enum: ["user", "agent"], default: "user" },
},
{
name: "accountId",
description:
"Optional GitHub account id from GITHUB_ACCOUNTS. Defaults by role.",
required: false,
schema: { type: "string" },
},
],
validate: async (
runtime: IAgentRuntime,
_message: Memory,
): Promise<boolean> => {
const r = buildResolvedClient(runtime, "user");
return !("error" in r);
},
handler: async (
runtime: IAgentRuntime,
_message: Memory,
_state?: State,
options?: Record<string, unknown>,
callback?: HandlerCallback,
): Promise<
GitHubActionResult<{
notifications: TriagedNotification[];
notificationLimit: number;
totalUnread: number;
}>
> => {
const selection = resolveAccountSelection(options, "user");
const resolved = buildResolvedClient(runtime, selection);
if ("error" in resolved) {
await callback?.({ text: resolved.error });
return { success: false, error: resolved.error };
}
try {
const resp =
await resolved.client.activity.listNotificationsForAuthenticatedUser({
all: false,
per_page: 50,
});
const notifications = resp.data as Array<{
id: string;
reason?: string | null;
repository?: { full_name?: string | null; pushed_at?: string | null };
subject?: {
title?: string | null;
type?: string | null;
url?: string | null;
};
updated_at: string;
}>;
const nowMs = Date.now();
const triaged: TriagedNotification[] = notifications.map((n) => {
const repoPushedAt = n.repository?.pushed_at ?? null;
const repoPushedAtMs =
typeof repoPushedAt === "string" ? Date.parse(repoPushedAt) : null;
const reason = typeof n.reason === "string" ? n.reason : "unknown";
const subjectType =
typeof n.subject?.type === "string" ? n.subject.type : "Unknown";
return {
id: n.id,
reason,
repo: n.repository?.full_name ?? "unknown",
title: n.subject?.title ?? "(untitled)",
subjectType,
url: n.subject?.url ?? null,
updatedAt: n.updated_at,
score: scoreNotification({
reason,
subjectType,
repoPushedAtMs:
repoPushedAtMs !== null && Number.isFinite(repoPushedAtMs)
? repoPushedAtMs
: null,
nowMs,
}),
};
});
triaged.sort((a, b) => b.score - a.score);
const boundedTriaged = triaged.slice(0, NOTIFICATION_TRIAGE_LIMIT);
await callback?.({
text: `Triaged ${boundedTriaged.length} unread notification(s)`,
});
return {
success: true,
data: {
notifications: boundedTriaged,
notificationLimit: NOTIFICATION_TRIAGE_LIMIT,
totalUnread: triaged.length,
},
};
} catch (err) {
const rl = inspectRateLimit(err);
const message = rl.isRateLimited
? formatRateLimitMessage(rl)
: `GITHUB_NOTIFICATION_TRIAGE failed: ${errorMessage(err)}`;
logger.warn({ message }, "[GitHub:GITHUB_NOTIFICATION_TRIAGE]");
await callback?.({ text: message });
return { success: false, error: message };
}
},
examples: [
[
{
name: "{{user1}}",
content: { text: "What's in my GitHub inbox?" },
},
{
name: "{{agentName}}",
content: { text: "Triaged 7 unread notification(s)" },
},
],
],
};
+379
View File
@@ -0,0 +1,379 @@
/**
* @module pr-op
* @description Single router action covering GitHub pull request ops:
* list and review.
*/
import type {
Action,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger, requireConfirmation } from "@elizaos/core";
import {
buildResolvedClient,
describeSelection,
requireNumber,
requireString,
resolveAccountSelection,
splitRepo,
} from "../action-helpers.js";
import {
errorMessage,
formatRateLimitMessage,
inspectRateLimit,
} from "../rate-limit.js";
import {
type GitHubActionResult,
GitHubActions,
type GitHubPrOp,
} from "../types.js";
type PRState = "open" | "closed" | "all";
type ReviewAction = "approve" | "request-changes" | "comment";
interface PRSummary {
repo: string;
number: number;
title: string;
author: string | null;
state: string;
url: string;
}
export type GitHubPrOpResult =
| { op: "list"; prs: PRSummary[] }
| { op: "review"; id: number }
| { requiresConfirmation: true; preview: string; awaitingUserInput: true }
| { cancelled: true };
const SUPPORTED_OPS: ReadonlySet<GitHubPrOp> = new Set(["list", "review"]);
const EVENT_BY_ACTION: Record<
ReviewAction,
"APPROVE" | "REQUEST_CHANGES" | "COMMENT"
> = {
approve: "APPROVE",
"request-changes": "REQUEST_CHANGES",
comment: "COMMENT",
};
function parseOp(value: unknown): GitHubPrOp | null {
if (typeof value !== "string") return null;
return SUPPORTED_OPS.has(value as GitHubPrOp) ? (value as GitHubPrOp) : null;
}
function parseState(value: unknown): PRState {
return value === "closed" || value === "all" ? value : "open";
}
function parseReviewAction(value: unknown): ReviewAction | null {
return value === "approve" ||
value === "request-changes" ||
value === "comment"
? value
: null;
}
async function runList(
runtime: IAgentRuntime,
options: Record<string, unknown> | undefined,
callback: HandlerCallback | undefined,
): Promise<GitHubActionResult<GitHubPrOpResult>> {
const selection = resolveAccountSelection(options, "agent");
const resolved = buildResolvedClient(runtime, selection);
if ("error" in resolved) {
await callback?.({ text: resolved.error });
return { success: false, error: resolved.error };
}
const state = parseState(options?.state);
const author = requireString(options, "author");
const repo = requireString(options, "repo");
const prs: PRSummary[] = [];
if (repo) {
const parts = splitRepo(repo);
if (!parts) {
const err = `Invalid repo "${repo}" — expected "owner/name"`;
await callback?.({ text: err });
return { success: false, error: err };
}
const resp = await resolved.client.pulls.list({
owner: parts.owner,
repo: parts.name,
state,
per_page: 100,
});
for (const pr of resp.data) {
if (author && pr.user?.login !== author) {
continue;
}
prs.push({
repo,
number: pr.number,
title: pr.title,
author: pr.user?.login ?? null,
state: pr.state,
url: pr.html_url,
});
}
} else {
const q = [
"is:pr",
state === "all" ? "" : `is:${state}`,
author ? `author:${author}` : "",
]
.filter(Boolean)
.join(" ");
const resp = await resolved.client.search.issuesAndPullRequests({
q,
per_page: 50,
});
for (const item of resp.data.items) {
const match = /\/repos\/([^/]+\/[^/]+)(?:\/|$)/.exec(item.repository_url);
const repoName = match?.[1] ?? item.repository_url;
prs.push({
repo: repoName,
number: item.number,
title: item.title,
author: item.user?.login ?? null,
state: item.state,
url: item.html_url,
});
}
}
await callback?.({ text: `Found ${prs.length} pull request(s)` });
return { success: true, data: { op: "list", prs } };
}
async function runReview(
runtime: IAgentRuntime,
message: Memory,
options: Record<string, unknown> | undefined,
callback: HandlerCallback | undefined,
): Promise<GitHubActionResult<GitHubPrOpResult>> {
const selection = resolveAccountSelection(options, "user");
const repo = requireString(options, "repo");
const number = requireNumber(options, "number");
const action = parseReviewAction(options?.action);
const body = requireString(options, "body");
if (!repo || !number || !action) {
const err =
"GITHUB_PR_OP review requires repo (owner/name), number (integer), and action (approve|request-changes|comment)";
await callback?.({ text: err });
return { success: false, error: err };
}
const parts = splitRepo(repo);
if (!parts) {
const err = `Invalid repo "${repo}" — expected "owner/name"`;
await callback?.({ text: err });
return { success: false, error: err };
}
const preview =
`About to ${action.replace("-", " ")} PR ${repo}#${number}` +
(body ? ` with body: "${body.slice(0, 120)}"` : "") +
` as ${describeSelection(selection)}.`;
const decision = await requireConfirmation({
runtime,
message,
actionName: GitHubActions.GITHUB_PR_OP,
pendingKey: `review:${repo}:${number}:${action}`,
prompt: `${preview} Reply yes to confirm or no to cancel.`,
callback,
});
if (decision.status === "pending") {
const text = `${preview} Reply yes to confirm or no to cancel.`;
await callback?.({ text });
return {
success: true,
text,
data: { requiresConfirmation: true, preview, awaitingUserInput: true },
};
}
if (decision.status === "cancelled") {
const text = "GitHub PR review cancelled.";
await callback?.({ text });
return { success: true, text, data: { cancelled: true } };
}
if (action === "request-changes" && !body) {
const err = "request-changes review requires a body explaining the changes";
await callback?.({ text: err });
return { success: false, error: err };
}
const resolved = buildResolvedClient(runtime, selection);
if ("error" in resolved) {
await callback?.({ text: resolved.error });
return { success: false, error: resolved.error };
}
const resp = await resolved.client.pulls.createReview({
owner: parts.owner,
repo: parts.name,
pull_number: number,
event: EVENT_BY_ACTION[action],
body: body ?? undefined,
});
await callback?.({ text: `Submitted ${action} review on ${repo}#${number}` });
return { success: true, data: { op: "review", id: resp.data.id } };
}
export const prOpAction: Action = {
name: GitHubActions.GITHUB_PR_OP,
contexts: ["code", "tasks", "connectors", "automation"],
contextGate: { anyOf: ["code", "tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
similes: [
"LIST_PRS",
"LIST_PULL_REQUESTS",
"SHOW_PRS",
"GITHUB_LIST_PRS",
"REVIEW_PR",
"APPROVE_PR",
"REQUEST_CHANGES",
"COMMENT_ON_PR",
],
description:
"Single router for GitHub PR ops: list and review. Review requires confirmed:true.",
descriptionCompressed:
"GitHub PR ops: list pull requests, submit review with confirmation.",
parameters: [
{
name: "subaction",
description: "PR operation: list or review.",
required: true,
schema: { type: "string", enum: [...SUPPORTED_OPS] },
},
{
name: "repo",
description: "Repository in owner/name form.",
required: false,
schema: { type: "string" },
},
{
name: "number",
description: "Pull request number for review.",
required: false,
schema: { type: "number" },
},
{
name: "state",
description: "PR state for list.",
required: false,
schema: {
type: "string",
enum: ["open", "closed", "all"],
default: "open",
},
},
{
name: "author",
description: "Optional PR author username filter for list.",
required: false,
schema: { type: "string" },
},
{
name: "action",
description: "Review action: approve, request-changes, or comment.",
required: false,
schema: {
type: "string",
enum: ["approve", "request-changes", "comment"],
},
},
{
name: "body",
description: "Review body for comment or request-changes.",
required: false,
schema: { type: "string" },
},
{
name: "as",
description: "Identity to use: agent or user.",
required: false,
schema: { type: "string", enum: ["agent", "user"], default: "agent" },
},
{
name: "accountId",
description:
"Optional GitHub account id from GITHUB_ACCOUNTS. Defaults by role.",
required: false,
schema: { type: "string" },
},
{
name: "confirmed",
description: "Must be true to submit a review.",
required: false,
schema: { type: "boolean", default: false },
},
],
validate: async (
runtime: IAgentRuntime,
_message: Memory,
): Promise<boolean> => {
const r = buildResolvedClient(runtime, "agent");
return !("error" in r);
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
options?: Record<string, unknown>,
callback?: HandlerCallback,
): Promise<GitHubActionResult<GitHubPrOpResult>> => {
const op = parseOp(options?.op);
if (!op) {
const err = "GITHUB_PR_OP requires op (list|review)";
await callback?.({ text: err });
return { success: false, error: err };
}
try {
return op === "list"
? await runList(runtime, options, callback)
: await runReview(runtime, message, options, callback);
} catch (err) {
const rl = inspectRateLimit(err);
const message = rl.isRateLimited
? formatRateLimitMessage(rl)
: `GITHUB_PR_OP ${op} failed: ${errorMessage(err)}`;
logger.warn({ message }, "[GitHub:GITHUB_PR_OP]");
await callback?.({ text: message });
return { success: false, error: message };
}
},
examples: [
[
{
name: "{{user1}}",
content: { text: "Show me open PRs on elizaOS/eliza" },
},
{
name: "{{agentName}}",
content: { text: "Found 3 pull request(s)" },
},
],
[
{
name: "{{user1}}",
content: { text: "Approve PR #42 on elizaOS/eliza" },
},
{
name: "{{agentName}}",
content: {
text: "Submitted approve review on elizaOS/eliza#42",
},
},
],
],
};
@@ -0,0 +1,417 @@
/**
* GitHub ConnectorAccountManager provider.
*
* Bridges plugin-github to the @elizaos/core ConnectorAccountManager so the
* generic HTTP CRUD + OAuth surface can list, create, patch, delete, and run
* the OAuth flow for GitHub accounts. PATs remain supported as a legacy code
* path; OAuth-app installations are exposed via startOAuth/completeOAuth.
*
* Account model:
* - role "OWNER" — the user persona acting on their own behalf (legacy GITHUB_USER_PAT)
* - role "AGENT" — the agent persona acting on its own behalf (legacy GITHUB_AGENT_PAT)
* accountKey = GitHub username (login).
*/
import {
type ConnectorAccount,
type ConnectorAccountManager,
type ConnectorAccountPatch,
type ConnectorAccountProvider,
type ConnectorAccountPurpose,
type ConnectorAccountRole,
type ConnectorOAuthCallbackRequest,
type ConnectorOAuthCallbackResult,
type ConnectorOAuthStartRequest,
type ConnectorOAuthStartResult,
type IAgentRuntime,
logger,
} from "@elizaos/core";
import {
DEFAULT_GITHUB_AGENT_ACCOUNT_ID,
DEFAULT_GITHUB_USER_ACCOUNT_ID,
readGitHubAccounts,
} from "./accounts.js";
import { persistConnectorCredentialRefs } from "./connector-credential-refs.js";
import { GITHUB_SERVICE_TYPE } from "./types.js";
const GITHUB_AUTHORIZATION_ENDPOINT =
"https://github.com/login/oauth/authorize";
const GITHUB_TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token";
const GITHUB_USER_ENDPOINT = "https://api.github.com/user";
const DEFAULT_PURPOSES: ConnectorAccountPurpose[] = [
"posting" as ConnectorAccountPurpose,
"reading" as ConnectorAccountPurpose,
"admin" as ConnectorAccountPurpose,
];
interface GitHubTokenResponse {
access_token?: string;
token_type?: string;
scope?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
}
interface GitHubUserPayload {
login?: string;
id?: number;
name?: string;
email?: string;
type?: string;
}
interface GitHubFetchResponse {
ok: boolean;
status: number;
text(): Promise<string>;
json(): Promise<unknown>;
}
function nonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readSetting(runtime: IAgentRuntime, key: string): string | undefined {
return nonEmptyString(runtime.getSetting?.(key));
}
function readClientConfig(runtime: IAgentRuntime): {
clientId: string;
clientSecret: string;
redirectUri: string;
} {
const clientId = readSetting(runtime, "GITHUB_OAUTH_CLIENT_ID");
const clientSecret = readSetting(runtime, "GITHUB_OAUTH_CLIENT_SECRET");
const redirectUri = readSetting(runtime, "GITHUB_OAUTH_REDIRECT_URI");
if (!clientId || !clientSecret || !redirectUri) {
throw new Error(
"GitHub OAuth requires GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET, and GITHUB_OAUTH_REDIRECT_URI to be configured.",
);
}
return { clientId, clientSecret, redirectUri };
}
function parseScopes(value: string | undefined): string[] {
if (!value) return [];
return value
.split(/[,\s]+/)
.map((scope) => scope.trim())
.filter(Boolean);
}
function defaultRoleFromAccountId(
accountId: string | undefined,
): ConnectorAccountRole {
if (accountId === DEFAULT_GITHUB_USER_ACCOUNT_ID) return "OWNER";
if (accountId === DEFAULT_GITHUB_AGENT_ACCOUNT_ID) return "AGENT";
return "OWNER";
}
function roleFromMetadata(
metadata: unknown,
accountId: string | undefined,
): ConnectorAccountRole {
const record =
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? (metadata as Record<string, unknown>)
: {};
const raw = nonEmptyString(record.role ?? record.accountRole);
const normalized = raw?.toUpperCase();
if (
normalized === "OWNER" ||
normalized === "AGENT" ||
normalized === "TEAM"
) {
return normalized;
}
return defaultRoleFromAccountId(accountId);
}
async function exchangeCodeForToken(args: {
clientId: string;
clientSecret: string;
redirectUri: string;
code: string;
}): Promise<GitHubTokenResponse> {
const response = (await fetch(GITHUB_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: args.clientId,
client_secret: args.clientSecret,
code: args.code,
redirect_uri: args.redirectUri,
}).toString(),
})) as GitHubFetchResponse;
if (!response.ok) {
const body = await response.text();
throw new Error(
`GitHub token exchange failed with ${response.status}: ${body}`,
);
}
const parsed = (await response.json()) as GitHubTokenResponse;
if (parsed.error) {
throw new Error(
`GitHub token exchange returned error ${parsed.error}: ${parsed.error_description ?? "no description"}`,
);
}
if (!parsed.access_token) {
throw new Error("GitHub token exchange returned no access_token.");
}
return parsed;
}
async function fetchGitHubUser(
accessToken: string,
): Promise<GitHubUserPayload> {
const response = (await fetch(GITHUB_USER_ENDPOINT, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
})) as GitHubFetchResponse;
if (!response.ok) {
throw new Error(`GitHub /user request failed with ${response.status}`);
}
const parsed = (await response.json()) as GitHubUserPayload;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("GitHub /user returned an invalid payload.");
}
return parsed;
}
function synthesizeEnvAccounts(runtime: IAgentRuntime): ConnectorAccount[] {
const now = Date.now();
return readGitHubAccounts(runtime).map((account) => ({
id: account.accountId,
provider: GITHUB_SERVICE_TYPE,
label: account.label ?? `GitHub ${account.role} (${account.accountId})`,
role:
account.role === "user"
? ("OWNER" as ConnectorAccountRole)
: ("AGENT" as ConnectorAccountRole),
purpose: DEFAULT_PURPOSES,
accessGate: "open",
status: "connected",
displayHandle: account.accountId,
createdAt: now,
updatedAt: now,
metadata: { authMethod: "pat", source: "env" },
}));
}
/**
* Build the GitHub ConnectorAccountManager provider.
*/
export function createGitHubConnectorAccountProvider(
runtime: IAgentRuntime,
): ConnectorAccountProvider {
return {
provider: GITHUB_SERVICE_TYPE,
label: "GitHub",
listAccounts: async (
manager: ConnectorAccountManager,
): Promise<ConnectorAccount[]> => {
const stored = await manager
.getStorage()
.listAccounts(GITHUB_SERVICE_TYPE);
if (stored.length > 0) return stored;
// Synthesize from legacy GITHUB_USER_PAT / GITHUB_AGENT_PAT env vars
// when the connector account store has no persisted GitHub rows.
return synthesizeEnvAccounts(runtime);
},
createAccount: async (
input: ConnectorAccountPatch,
_manager: ConnectorAccountManager,
) => {
return {
...input,
provider: GITHUB_SERVICE_TYPE,
role: input.role ?? "OWNER",
purpose: input.purpose ?? DEFAULT_PURPOSES,
accessGate: input.accessGate ?? "open",
status: input.status ?? "pending",
};
},
patchAccount: async (
_accountId: string,
patch: ConnectorAccountPatch,
_manager: ConnectorAccountManager,
) => {
return { ...patch, provider: GITHUB_SERVICE_TYPE };
},
deleteAccount: async (
_accountId: string,
_manager: ConnectorAccountManager,
): Promise<void> => {
// Credential cleanup is the credential store's responsibility; the
// manager removes the account row after this resolves.
},
startOAuth: async (
request: ConnectorOAuthStartRequest,
_manager: ConnectorAccountManager,
): Promise<ConnectorOAuthStartResult> => {
const config = readClientConfig(runtime);
const redirectUri = request.redirectUri ?? config.redirectUri;
const scopes =
request.scopes && request.scopes.length > 0
? request.scopes
: ["repo", "read:user", "user:email", "notifications"];
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
state: request.flow.state,
scope: scopes.join(" "),
allow_signup: "false",
});
return {
authUrl: `${GITHUB_AUTHORIZATION_ENDPOINT}?${params.toString()}`,
metadata: {
...request.metadata,
requestedScopes: scopes,
redirectUri,
},
};
},
completeOAuth: async (
request: ConnectorOAuthCallbackRequest,
manager: ConnectorAccountManager,
): Promise<ConnectorOAuthCallbackResult> => {
const code = nonEmptyString(request.code);
if (!code) {
throw new Error(
"GitHub OAuth callback is missing an authorization code.",
);
}
const config = readClientConfig(runtime);
const redirectUri =
nonEmptyString(request.flow.redirectUri) ?? config.redirectUri;
const tokens = await exchangeCodeForToken({
clientId: config.clientId,
clientSecret: config.clientSecret,
redirectUri,
code,
});
if (!tokens.access_token) {
throw new Error("GitHub token exchange returned no access_token.");
}
const user = await fetchGitHubUser(tokens.access_token);
const externalId = nonEmptyString(user.id ? String(user.id) : undefined);
const login = nonEmptyString(user.login);
if (!login) {
throw new Error("GitHub /user payload did not include a login.");
}
const expiresAt =
typeof tokens.expires_in === "number"
? Date.now() + tokens.expires_in * 1000
: undefined;
const oauthCredentialVersion = String(Date.now());
const accountMetadata = {
authMethod: "oauth",
login,
githubUserId: user.id ?? null,
email: nonEmptyString(user.email) ?? null,
type: nonEmptyString(user.type) ?? null,
tokenType: nonEmptyString(tokens.token_type) ?? "bearer",
grantedScopes: parseScopes(tokens.scope),
hasRefreshToken: Boolean(tokens.refresh_token),
expiresAt,
oauthCredentialVersion,
};
const pendingAccount = await manager.upsertAccount(
GITHUB_SERVICE_TYPE,
{
provider: GITHUB_SERVICE_TYPE,
role: roleFromMetadata(request.flow.metadata, request.flow.accountId),
purpose: DEFAULT_PURPOSES,
accessGate: "open",
status: "pending",
externalId: externalId ?? login,
displayHandle: login,
label: nonEmptyString(user.name) ?? login,
metadata: accountMetadata,
},
request.flow.accountId,
);
const credentialPersist = await persistConnectorCredentialRefs({
runtime,
manager,
provider: GITHUB_SERVICE_TYPE,
accountIdForRef: pendingAccount.id,
storageAccountId: pendingAccount.id,
caller: "plugin-github",
credentials: [
{
credentialType: "oauth.tokens",
value: JSON.stringify({
access_token: tokens.access_token,
...(tokens.refresh_token
? { refresh_token: tokens.refresh_token }
: {}),
...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
token_type: nonEmptyString(tokens.token_type) ?? "bearer",
scope: tokens.scope ?? "",
}),
...(expiresAt !== undefined ? { expiresAt } : {}),
metadata: {
provider: GITHUB_SERVICE_TYPE,
hasRefreshToken: Boolean(tokens.refresh_token),
},
},
],
});
const accountPatch: ConnectorAccountPatch & {
provider: string;
id: string;
} = {
...pendingAccount,
id: pendingAccount.id,
provider: GITHUB_SERVICE_TYPE,
status: "connected",
metadata: {
...accountMetadata,
credentialRefs: credentialPersist.refs,
credentialRefStorage: {
vaultAvailable: credentialPersist.vaultAvailable,
storageAvailable: credentialPersist.storageAvailable,
},
},
};
logger.info(
{
src: "plugin:github:connector",
login,
},
"GitHub OAuth completed",
);
return {
account: accountPatch,
flow: { status: "completed" },
};
},
};
}
@@ -0,0 +1,255 @@
/**
* Tests credential-ref persistence and OAuth-token readback against a
* deterministic mock runtime with stubbed account-store/vault services.
*/
import type { ConnectorAccount, IAgentRuntime } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import {
credentialRefRecordsFromMetadata,
loadConnectorOAuthAccessToken,
persistConnectorCredentialRefs,
} from "./connector-credential-refs";
function createRuntime(input: {
agentId?: string;
services?: Record<string, unknown>;
adapter?: unknown;
}): IAgentRuntime {
return Object.assign(Object.create(null) as IAgentRuntime, {
agentId: input.agentId,
adapter: input.adapter,
getService: vi.fn((serviceType: string) => input.services?.[serviceType]),
});
}
function connectorAccount(input: {
id: string;
metadata?: ConnectorAccount["metadata"];
}): ConnectorAccount {
return {
id: input.id,
provider: "github",
label: "GitHub",
role: "AGENT",
purpose: ["reading"],
accessGate: "open",
status: "connected",
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: input.metadata ?? {},
};
}
describe("GitHub connector credential refs", () => {
it("extracts valid credential refs from supported metadata shapes and ignores malformed entries", () => {
const records = credentialRefRecordsFromMetadata({
credentialRefs: [
{
credentialType: "oauth.tokens",
vaultRef: "connector.agent.github.account.oauth_tokens",
metadata: { source: "array" },
},
{ credentialType: "oauth.tokens" },
"not-a-ref",
null,
],
oauthCredentialRefs: {
"installation.token": " connector.agent.github.account.installation ",
"bad.token": " ",
nested: {
type: "oauth.refresh",
ref: "connector.agent.github.account.refresh",
credentialVersion: 7,
},
},
oauth: {
credentialRefs: [
{
name: "oauth.extra",
ref: "connector.agent.github.account.extra",
},
],
},
});
expect(records).toEqual([
expect.objectContaining({
credentialType: "oauth.tokens",
vaultRef: "connector.agent.github.account.oauth_tokens",
metadata: { source: "array" },
}),
{
credentialType: "installation.token",
vaultRef: "connector.agent.github.account.installation",
},
expect.objectContaining({
credentialType: "nested",
vaultRef: "connector.agent.github.account.refresh",
version: 7,
}),
expect.objectContaining({
credentialType: "oauth.extra",
vaultRef: "connector.agent.github.account.extra",
}),
]);
});
it("loads OAuth access tokens from vault refs and rejects malformed secret payloads", async () => {
const validRef = "connector.agent.github.valid.oauth_tokens";
const invalidRef = "connector.agent.github.invalid.oauth_tokens";
const vault = {
reveal: vi.fn(async (key: string) => {
if (key === validRef) {
return JSON.stringify({ access_token: "github-oauth-token" });
}
if (key === invalidRef) {
return JSON.stringify({ token: "not-an-access-token" });
}
return "";
}),
};
const runtime = createRuntime({
services: {
connector_account: {
registerProvider: vi.fn(),
evaluatePolicy: vi.fn(),
listAccounts: vi.fn(async () => [
connectorAccount({
id: "valid",
metadata: {
credentialRefs: [
{
credentialType: "OAUTH.TOKENS",
vaultRef: validRef,
},
],
},
}),
connectorAccount({
id: "invalid",
metadata: {
credentialRefs: [
{
credentialType: "oauth.tokens",
vaultRef: invalidRef,
},
],
},
}),
]),
},
vault,
},
});
await expect(
loadConnectorOAuthAccessToken({
runtime,
provider: "github",
accountId: "valid",
caller: "plugin-github-test",
}),
).resolves.toBe("github-oauth-token");
await expect(
loadConnectorOAuthAccessToken({
runtime,
provider: "github",
accountId: "invalid",
caller: "plugin-github-test",
}),
).resolves.toBeNull();
expect(vault.reveal).toHaveBeenCalledWith(validRef, "plugin-github-test");
});
it("persists credentials with fallback vault and credential-ref writers", async () => {
const putSecret = vi.fn(async () => {
throw new Error("primary vault unavailable");
});
const vaultSet = vi.fn(async () => undefined);
const storageWriter = vi.fn(async () => {
throw new Error("primary storage unavailable");
});
const adapterWriter = vi.fn(async () => undefined);
const runtime = createRuntime({
agentId: "agent 1",
services: {
connector_credential_store: { putSecret },
vault: { set: vaultSet },
},
adapter: { setCredentialRef: adapterWriter },
});
const manager = {
getStorage: () => ({
setConnectorAccountCredentialRef: storageWriter,
}),
};
const result = await persistConnectorCredentialRefs({
runtime,
manager: manager as never,
provider: "github",
accountIdForRef: "acct github/1",
storageAccountId: "acct-storage-1",
caller: "plugin-github-test",
credentials: [
{
credentialType: "oauth.tokens",
value: JSON.stringify({ access_token: "github-oauth-token" }),
expiresAt: 123456,
metadata: { provider: "github" },
},
],
});
expect(result).toEqual({
refs: [
{
credentialType: "oauth.tokens",
vaultRef: "connector.agent_1.github.acct_github_1.oauth_tokens",
expiresAt: 123456,
metadata: { provider: "github" },
},
],
vaultAvailable: true,
storageAvailable: true,
});
expect(putSecret).toHaveBeenCalledOnce();
expect(vaultSet).toHaveBeenCalledWith(
"connector.agent_1.github.acct_github_1.oauth_tokens",
JSON.stringify({ access_token: "github-oauth-token" }),
{ sensitive: true, caller: "plugin-github-test" },
);
expect(storageWriter).toHaveBeenCalledOnce();
expect(adapterWriter).toHaveBeenCalledWith({
accountId: "acct-storage-1",
credentialType: "oauth.tokens",
vaultRef: "connector.agent_1.github.acct_github_1.oauth_tokens",
expiresAt: 123456,
metadata: { provider: "github" },
});
});
it("refuses to persist credentials when no durable account id is available", async () => {
const runtime = createRuntime({
services: {
vault: { set: vi.fn(async () => undefined) },
},
});
await expect(
persistConnectorCredentialRefs({
runtime,
provider: "github",
accountIdForRef: "temporary-account",
caller: "plugin-github-test",
credentials: [
{
credentialType: "oauth.tokens",
value: JSON.stringify({ access_token: "github-oauth-token" }),
},
],
}),
).rejects.toThrow(/no durable connector account id/i);
});
});
@@ -0,0 +1,630 @@
/**
* Bridges GitHub OAuth credentials to the connector account store's vault +
* credential-ref model: persists token records as credential refs, reads back
* a usable OAuth access token for an account, and lists stored connector
* accounts. Consumed by `accounts.ts` when overlaying OAuth accounts onto the
* env/character account set.
*/
import {
CONNECTOR_ACCOUNT_STORAGE_SERVICE_TYPE,
type ConnectorAccount,
type ConnectorAccountManager,
getConnectorAccountManager,
type IAgentRuntime,
} from "@elizaos/core";
type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { readonly [key: string]: JsonValue };
type JsonRecord = Record<string, JsonValue>;
export const OAUTH_TOKENS_CREDENTIAL_TYPE = "oauth.tokens";
export interface ConnectorCredentialRefMetadata extends JsonRecord {
credentialType: string;
vaultRef: string;
expiresAt?: number;
metadata?: JsonRecord;
}
export interface ConnectorCredentialRefRecordLike {
credentialType: string;
vaultRef?: string | null;
value?: string | null;
metadata?: JsonRecord | null;
expiresAt?: number | string | Date | null;
updatedAt?: number | string | Date | null;
version?: string | number | null;
}
export interface ConnectorCredentialPersistResult {
refs: ConnectorCredentialRefMetadata[];
vaultAvailable: boolean;
storageAvailable: boolean;
}
interface ConnectorCredentialInput {
credentialType: string;
value: string;
expiresAt?: number;
metadata?: JsonRecord;
}
interface PersistConnectorCredentialRefsParams {
runtime: IAgentRuntime;
manager?: ConnectorAccountManager;
provider: string;
accountIdForRef: string;
storageAccountId?: string;
credentials: ConnectorCredentialInput[];
caller: string;
}
type VaultWriter = {
name: string;
write: (
vaultRef: string,
credential: ConnectorCredentialInput,
) => Promise<string>;
};
type CredentialRefWriter = {
name: string;
write: (ref: ConnectorCredentialRefMetadata) => Promise<void>;
};
export async function persistConnectorCredentialRefs(
params: PersistConnectorCredentialRefsParams,
): Promise<ConnectorCredentialPersistResult> {
const refs: ConnectorCredentialRefMetadata[] = [];
const vaultWriters = resolveVaultWriters(params.runtime, {
provider: params.provider,
accountId: params.accountIdForRef,
caller: params.caller,
});
if (vaultWriters.length === 0) {
throw new Error(
`No durable connector credential store or vault writer is available for ${params.provider} account ${params.accountIdForRef}. Refusing to mark OAuth account connected without persisted credentials.`,
);
}
if (!params.storageAccountId) {
throw new Error(
`No durable connector account id is available for ${params.provider} account ${params.accountIdForRef}. Refusing to mark OAuth account connected without persisted credential refs.`,
);
}
const storageWriters = resolveCredentialRefWriters(
params.runtime,
params.manager,
params.storageAccountId,
);
if (storageWriters.length === 0) {
throw new Error(
`No durable connector credential ref writer is available for ${params.provider} account ${params.storageAccountId}. Refusing to mark OAuth account connected without persisted credential refs.`,
);
}
for (const credential of params.credentials) {
const plannedRef = buildConnectorCredentialVaultRef({
agentId: nonEmptyString(params.runtime.agentId) ?? "agent",
provider: params.provider,
accountId: params.accountIdForRef,
credentialType: credential.credentialType,
});
const vaultRef = await writeWithFirstAvailableVault(
vaultWriters,
plannedRef,
credential,
);
refs.push({
credentialType: credential.credentialType,
vaultRef,
...(credential.expiresAt !== undefined
? { expiresAt: credential.expiresAt }
: {}),
...(credential.metadata ? { metadata: credential.metadata } : {}),
});
}
if (refs.length > 0) {
await writeRefsToStorage(storageWriters, refs);
}
return {
refs,
vaultAvailable: vaultWriters.length > 0,
storageAvailable: storageWriters.length > 0,
};
}
export async function loadConnectorOAuthAccessToken(params: {
runtime: IAgentRuntime;
provider: string;
accountId: string;
caller: string;
}): Promise<string | null> {
const { records } = await loadConnectorCredentialRecords(params);
const tokenRecord = records.find((record) =>
sameCredentialType(record.credentialType, OAUTH_TOKENS_CREDENTIAL_TYPE),
);
if (!tokenRecord) return null;
const raw =
nonEmptyString(tokenRecord.value) ??
(tokenRecord.vaultRef
? await readCredentialSecret(
params.runtime,
tokenRecord.vaultRef,
params.caller,
)
: undefined);
const parsed = raw ? parseMaybeJson(raw) : undefined;
const tokenSet = asRecord(parsed);
return nonEmptyString(tokenSet?.access_token) ?? null;
}
export async function listConnectorAccounts(
runtime: IAgentRuntime,
provider: string,
): Promise<ConnectorAccount[]> {
try {
return await getConnectorAccountManager(runtime).listAccounts(provider);
} catch {
const storage = getService(
runtime,
CONNECTOR_ACCOUNT_STORAGE_SERVICE_TYPE,
) as {
listAccounts?: (provider?: string) => Promise<ConnectorAccount[]>;
} | null;
if (typeof storage?.listAccounts === "function") {
return storage.listAccounts(provider);
}
}
return [];
}
export function credentialRefRecordsFromMetadata(
metadata: unknown,
): ConnectorCredentialRefRecordLike[] {
const record = asRecord(metadata);
if (!record) return [];
const oauth = asRecord(record.oauth);
return [
...credentialRefsFromUnknown(record.credentialRefs),
...credentialRefsFromUnknown(record.oauthCredentialRefs),
...credentialRefsFromUnknown(oauth?.credentialRefs),
];
}
async function loadConnectorCredentialRecords(params: {
runtime: IAgentRuntime;
provider: string;
accountId: string;
}): Promise<{
records: ConnectorCredentialRefRecordLike[];
}> {
const accounts = await listConnectorAccounts(params.runtime, params.provider);
const account = accounts.find(
(candidate) =>
candidate.id === params.accountId ||
candidate.externalId === params.accountId ||
candidate.displayHandle === params.accountId,
);
if (!account) return { records: [] };
const records = [...credentialRefRecordsFromMetadata(account.metadata)];
for (const source of [
getService(params.runtime, CONNECTOR_ACCOUNT_STORAGE_SERVICE_TYPE),
(params.runtime as { adapter?: unknown }).adapter,
]) {
const reader = source as
| {
listConnectorAccountCredentialRefs?: (args: {
accountId: string;
}) => Promise<ConnectorCredentialRefRecordLike[]>;
getConnectorAccountCredentialRef?: (args: {
accountId: string;
credentialType: string;
}) => Promise<ConnectorCredentialRefRecordLike | null>;
}
| null
| undefined;
if (typeof reader?.listConnectorAccountCredentialRefs === "function") {
records.push(
...(await reader.listConnectorAccountCredentialRefs({
accountId: account.id,
})),
);
} else if (typeof reader?.getConnectorAccountCredentialRef === "function") {
const ref = await reader.getConnectorAccountCredentialRef({
accountId: account.id,
credentialType: OAUTH_TOKENS_CREDENTIAL_TYPE,
});
if (ref) records.push(ref);
}
}
return { records };
}
function credentialRefsFromUnknown(
value: unknown,
): ConnectorCredentialRefRecordLike[] {
if (Array.isArray(value)) {
return value.flatMap((entry) => {
const ref = credentialRefFromRecord(asRecord(entry));
return ref ? [ref] : [];
});
}
const record = asRecord(value);
if (!record) return [];
return Object.entries(record).flatMap(([credentialType, entry]) => {
const entryRecord = asRecord(entry);
if (entryRecord) {
const ref = credentialRefFromRecord({ credentialType, ...entryRecord });
return ref ? [ref] : [];
}
const vaultRef = nonEmptyString(entry);
return vaultRef ? [{ credentialType, vaultRef }] : [];
});
}
function credentialRefFromRecord(
record: JsonRecord | undefined,
): ConnectorCredentialRefRecordLike | null {
if (!record) return null;
const credentialType = nonEmptyString(
record.credentialType ?? record.type ?? record.name,
);
const vaultRef = nonEmptyString(record.vaultRef ?? record.ref);
if (!credentialType || !vaultRef) return null;
return {
credentialType,
vaultRef,
metadata: asRecord(record.metadata) ?? null,
expiresAt:
record.expiresAt as ConnectorCredentialRefRecordLike["expiresAt"],
updatedAt:
record.updatedAt as ConnectorCredentialRefRecordLike["updatedAt"],
version: (record.version ??
record.credentialVersion) as ConnectorCredentialRefRecordLike["version"],
};
}
async function readCredentialSecret(
runtime: IAgentRuntime,
vaultRef: string,
caller: string,
): Promise<string | undefined> {
for (const reader of resolveSecretReaders(runtime)) {
try {
const value = await readSecret(reader, vaultRef, caller, runtime);
const trimmed = nonEmptyString(value);
if (trimmed) return trimmed;
} catch {
// Try the next available reader.
}
}
return undefined;
}
function resolveVaultWriters(
runtime: IAgentRuntime,
context: { provider: string; accountId: string; caller: string },
): VaultWriter[] {
const writers: VaultWriter[] = [];
const credentialStore = getFirstService(runtime, [
"connector_credential_store",
"CONNECTOR_CREDENTIAL_STORE",
"connectorCredentialStore",
"credential_store",
]) as {
putSecret?: (params: {
vaultRef?: string;
agentId: string;
provider: string;
accountId: string;
credentialType: string;
value: string;
caller?: string;
}) => Promise<string> | string;
} | null;
if (typeof credentialStore?.putSecret === "function") {
writers.push({
name: "connector_credential_store",
write: async (vaultRef, credential) =>
credentialStore.putSecret?.({
vaultRef,
agentId: nonEmptyString(runtime.agentId) ?? "agent",
provider: context.provider,
accountId: context.accountId,
credentialType: credential.credentialType,
value: credential.value,
caller: context.caller,
}) ?? vaultRef,
});
}
const vault = getFirstService(runtime, ["vault", "VAULT"]) as {
set?: (
key: string,
value: string,
options?: { sensitive?: boolean; caller?: string },
) => Promise<void> | void;
} | null;
if (typeof vault?.set === "function") {
writers.push({
name: "vault",
write: async (vaultRef, credential) => {
await vault.set?.(vaultRef, credential.value, {
sensitive: true,
caller: context.caller,
});
return vaultRef;
},
});
}
const secrets = getService(runtime, "SECRETS") as {
setGlobal?: (
key: string,
value: string,
config?: { sensitive?: boolean },
) => Promise<boolean> | boolean;
set?: (
key: string,
value: string,
context: JsonRecord,
config?: { sensitive?: boolean },
) => Promise<boolean> | boolean;
} | null;
if (
typeof secrets?.setGlobal === "function" ||
typeof secrets?.set === "function"
) {
writers.push({
name: "SECRETS",
write: async (vaultRef, credential) => {
if (typeof secrets.setGlobal === "function") {
await secrets.setGlobal(vaultRef, credential.value, {
sensitive: true,
});
return vaultRef;
}
await secrets.set?.(
vaultRef,
credential.value,
{
level: "global",
agentId: runtime.agentId,
requesterId: runtime.agentId,
},
{ sensitive: true },
);
return vaultRef;
},
});
}
return writers;
}
function resolveSecretReaders(runtime: IAgentRuntime): unknown[] {
return [
getFirstService(runtime, [
"connector_credential_store",
"CONNECTOR_CREDENTIAL_STORE",
"connectorCredentialStore",
"credential_store",
]),
getFirstService(runtime, ["vault", "VAULT"]),
getService(runtime, "SECRETS"),
].filter(Boolean);
}
async function readSecret(
reader: unknown,
vaultRef: string,
caller: string,
runtime: IAgentRuntime,
): Promise<string | null> {
const candidate = reader as {
reveal?: (key: string, caller?: string) => Promise<string> | string;
get?: (
key: string,
optionsOrContext?: { reveal?: boolean; caller?: string } | JsonRecord,
) => Promise<string | null> | string | null;
};
if (typeof candidate.reveal === "function") {
return candidate.reveal(vaultRef, caller);
}
if (typeof candidate.get !== "function") return null;
if (
reader &&
(reader as { constructor?: { name?: string } }).constructor?.name ===
"SecretsService"
) {
return candidate.get(vaultRef, {
level: "global",
agentId: runtime.agentId,
requesterId: runtime.agentId,
});
}
return candidate.get(vaultRef, { reveal: true, caller });
}
function resolveCredentialRefWriters(
runtime: IAgentRuntime,
manager: ConnectorAccountManager | undefined,
accountId: string,
): CredentialRefWriter[] {
const candidates = [
manager?.getStorage?.(),
getService(runtime, CONNECTOR_ACCOUNT_STORAGE_SERVICE_TYPE),
(runtime as { adapter?: unknown }).adapter,
].filter(Boolean);
const writers: CredentialRefWriter[] = [];
for (const candidate of candidates) {
const writer = candidate as {
setConnectorAccountCredentialRef?: (params: {
accountId: string;
credentialType: string;
vaultRef: string;
metadata?: JsonRecord;
expiresAt?: number;
}) => Promise<unknown> | unknown;
setCredentialRef?: (params: {
accountId: string;
credentialType: string;
vaultRef: string;
metadata?: JsonRecord;
expiresAt?: number;
}) => Promise<unknown> | unknown;
};
if (typeof writer.setConnectorAccountCredentialRef === "function") {
writers.push({
name: "setConnectorAccountCredentialRef",
write: async (ref) => {
await writer.setConnectorAccountCredentialRef?.({
accountId,
credentialType: ref.credentialType,
vaultRef: ref.vaultRef,
...(ref.metadata ? { metadata: ref.metadata } : {}),
...(ref.expiresAt !== undefined
? { expiresAt: ref.expiresAt }
: {}),
});
},
});
} else if (typeof writer.setCredentialRef === "function") {
writers.push({
name: "setCredentialRef",
write: async (ref) => {
await writer.setCredentialRef?.({
accountId,
credentialType: ref.credentialType,
vaultRef: ref.vaultRef,
...(ref.metadata ? { metadata: ref.metadata } : {}),
...(ref.expiresAt !== undefined
? { expiresAt: ref.expiresAt }
: {}),
});
},
});
}
}
return writers;
}
async function writeWithFirstAvailableVault(
writers: VaultWriter[],
plannedRef: string,
credential: ConnectorCredentialInput,
): Promise<string> {
const errors: string[] = [];
for (const writer of writers) {
try {
return await writer.write(plannedRef, credential);
} catch (error) {
errors.push(
`${writer.name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
throw new Error(
`Failed to persist connector credential ref ${plannedRef}: ${errors.join("; ")}`,
);
}
async function writeRefsToStorage(
writers: CredentialRefWriter[],
refs: ConnectorCredentialRefMetadata[],
): Promise<void> {
const errors: string[] = [];
for (const writer of writers) {
try {
for (const ref of refs) {
await writer.write(ref);
}
return;
} catch (error) {
errors.push(
`${writer.name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
throw new Error(
`Failed to persist connector credential refs: ${errors.join("; ")}`,
);
}
function buildConnectorCredentialVaultRef(params: {
agentId: string;
provider: string;
accountId: string;
credentialType: string;
}): string {
return [
"connector",
normalizeVaultSegment(params.agentId),
normalizeVaultSegment(params.provider),
normalizeVaultSegment(params.accountId),
normalizeVaultSegment(params.credentialType),
].join(".");
}
function normalizeVaultSegment(value: string): string {
const normalized = value
.trim()
.replace(/[^a-zA-Z0-9_-]+/g, "_")
.replace(/^_+|_+$/g, "");
return (normalized || "unknown").slice(0, 64);
}
function sameCredentialType(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
function parseMaybeJson(value: string): unknown | undefined {
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined;
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
function getFirstService(
runtime: IAgentRuntime,
serviceTypes: readonly string[],
): unknown {
for (const serviceType of serviceTypes) {
const service = getService(runtime, serviceType);
if (service) return service;
}
return null;
}
function getService(runtime: IAgentRuntime, serviceType: string): unknown {
try {
return runtime.getService?.(serviceType) ?? null;
} catch {
return null;
}
}
function asRecord(value: unknown): JsonRecord | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: undefined;
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
@@ -0,0 +1,319 @@
/**
* Device-flow protocol unit tests (#15796). GitHub's two OAuth endpoints are
* the only thing stubbed — the flow logic under test (state machine, interval
* ownership, agent scoping, secret hygiene) is the real module.
*/
import { afterEach, describe, expect, it } from "vitest";
import {
clearDeviceFlowsForTest,
DeviceFlowError,
pollDeviceFlow,
startDeviceFlow,
} from "./device-flow.js";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
interface RecordedRequest {
url: string;
body: string;
}
/** Scripted GitHub: first call answers device/code, later calls answer token polls. */
function scriptedGitHub(tokenResponses: (() => Response)[]): {
fetchImpl: typeof fetch;
requests: RecordedRequest[];
} {
const requests: RecordedRequest[] = [];
let tokenCall = 0;
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : String(input);
requests.push({ url, body: String(init?.body ?? "") });
if (url === DEVICE_CODE_URL) {
return jsonResponse({
device_code: "secret-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
expires_in: 900,
interval: 5,
});
}
if (url === ACCESS_TOKEN_URL) {
const next =
tokenResponses[Math.min(tokenCall, tokenResponses.length - 1)];
tokenCall += 1;
return next();
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
return { fetchImpl, requests };
}
afterEach(() => clearDeviceFlowsForTest());
describe("startDeviceFlow", () => {
it("keeps the device code server-side and returns only the user-visible half", async () => {
const { fetchImpl, requests } = scriptedGitHub([]);
const started = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: {
fetchImpl,
now: () => 1_000,
randomBytesImpl: (() => Buffer.from("opaque-flow-id")) as never,
},
});
expect(started).toEqual({
flowId: Buffer.from("opaque-flow-id").toString("base64url"),
userCode: "ABCD-EFGH",
verificationUri: "https://github.com/login/device",
intervalSeconds: 5,
expiresInSeconds: 900,
});
expect(JSON.stringify(started)).not.toContain("secret-device-code");
expect(requests[0].body).toContain("client_id=client-1");
});
it("maps a client-registration rejection to an owner-setup error (409)", async () => {
const fetchImpl = (async () =>
// GitHub answers 200 with an error body for an unknown client id.
jsonResponse({ error: "device_flow_disabled" })) as typeof fetch;
const err = await startDeviceFlow({
clientId: "bad-client",
agentKey: "agent-a",
deps: { fetchImpl },
}).catch((e: unknown) => e);
expect(err).toBeInstanceOf(DeviceFlowError);
expect((err as DeviceFlowError).code).toBe("owner_setup");
expect((err as DeviceFlowError).status).toBe(409);
});
it("maps an unreachable GitHub to an upstream error (502)", async () => {
const fetchImpl = (async () => {
throw new Error("ECONNREFUSED");
}) as typeof fetch;
const err = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl },
}).catch((e: unknown) => e);
expect(err).toBeInstanceOf(DeviceFlowError);
expect((err as DeviceFlowError).code).toBe("upstream");
expect((err as DeviceFlowError).status).toBe(502);
});
it("maps a malformed device-code response to an upstream error", async () => {
const fetchImpl = (async () =>
jsonResponse({
user_code: "ABCD",
verification_uri: "x",
})) as typeof fetch;
const err = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl },
}).catch((e: unknown) => e);
expect(err).toBeInstanceOf(DeviceFlowError);
expect((err as DeviceFlowError).code).toBe("upstream");
});
});
describe("pollDeviceFlow", () => {
it("walks pending → complete, sends the device code only to GitHub, and consumes the flow", async () => {
let nowMs = 1_000;
const { fetchImpl, requests } = scriptedGitHub([
() => jsonResponse({ error: "authorization_pending" }),
() =>
jsonResponse({
access_token: "gho_live_token_value",
token_type: "bearer",
scope: "repo,read:user",
}),
]);
const started = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl, now: () => nowMs },
});
const pending = await pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => nowMs },
});
expect(pending).toEqual({ status: "pending", retryAfterSeconds: 5 });
// Re-polling before the interval elapses never hits GitHub.
const early = await pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => nowMs + 1_000 },
});
expect(early.status).toBe("pending");
expect(requests.filter((r) => r.url === ACCESS_TOKEN_URL)).toHaveLength(1);
nowMs += 5_000;
const completed = await pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => nowMs },
});
expect(completed).toEqual({
status: "complete",
token: "gho_live_token_value",
scope: "repo,read:user",
});
const tokenPolls = requests.filter((r) => r.url === ACCESS_TOKEN_URL);
expect(tokenPolls[0].body).toContain("device_code=secret-device-code");
// The flow is consumed — a replayed poll cannot mint the token twice.
await expect(
pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => nowMs },
}),
).rejects.toMatchObject({ code: "unknown_flow", status: 404 });
});
it("slow_down raises the server-owned polling interval", async () => {
let nowMs = 10_000;
const { fetchImpl, requests } = scriptedGitHub([
() => jsonResponse({ error: "slow_down" }),
]);
const started = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl, now: () => nowMs },
});
const slowed = await pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => nowMs },
});
expect(slowed).toEqual({ status: "pending", retryAfterSeconds: 10 });
// The raised interval is enforced server-side: an eager client re-polling
// 1s later is throttled locally without a GitHub request.
nowMs += 1_000;
const throttled = await pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => nowMs },
});
expect(throttled).toEqual({ status: "pending", retryAfterSeconds: 9 });
expect(requests.filter((r) => r.url === ACCESS_TOKEN_URL)).toHaveLength(1);
});
it("access_denied resolves as a terminal denied outcome", async () => {
const { fetchImpl } = scriptedGitHub([
() => jsonResponse({ error: "access_denied" }),
]);
const started = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
});
const denied = await pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
});
expect(denied).toEqual({ status: "denied" });
await expect(
pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
}),
).rejects.toMatchObject({ code: "unknown_flow" });
});
it("expired_token resolves as a terminal expired outcome", async () => {
const { fetchImpl } = scriptedGitHub([
() => jsonResponse({ error: "expired_token" }),
]);
const started = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
});
const expired = await pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
});
expect(expired).toEqual({ status: "expired" });
});
it("sweeps a flow whose ten-minute window lapsed before any grant", async () => {
const { fetchImpl } = scriptedGitHub([]);
const started = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
});
// 900s window from the stubbed device-code response.
await expect(
pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => 900_001 },
}),
).rejects.toMatchObject({ code: "unknown_flow", status: 404 });
});
it("scopes flows per agent: another agent's key cannot poll the flow", async () => {
const { fetchImpl, requests } = scriptedGitHub([
() => jsonResponse({ access_token: "gho_stolen", scope: "" }),
]);
const started = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
});
await expect(
pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-b",
deps: { fetchImpl, now: () => 0 },
}),
).rejects.toMatchObject({ code: "unknown_flow", status: 404 });
// The cross-agent probe never reached GitHub.
expect(requests.filter((r) => r.url === ACCESS_TOKEN_URL)).toHaveLength(0);
// The rightful owner still completes.
const completed = await pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
});
expect(completed.status).toBe("complete");
});
it("maps an unrecognized GitHub error code to a terminal upstream error", async () => {
const { fetchImpl } = scriptedGitHub([
() => jsonResponse({ error: "mystery_failure" }),
]);
const started = await startDeviceFlow({
clientId: "client-1",
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
});
await expect(
pollDeviceFlow({
flowId: started.flowId,
agentKey: "agent-a",
deps: { fetchImpl, now: () => 0 },
}),
).rejects.toMatchObject({ code: "upstream", status: 502 });
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* GitHub OAuth device-flow state for the guided GitHub connection step
* (Settings → Coding Agents → GitHub).
*
* Port of the #15749 lifeops-dashboard primitive
* (`scripts/lifeops/github-device-login.mjs`) into the plugin route surface,
* with one addition: every flow is bound to the agent that started it
* (`agentKey`), so on a multi-agent host one agent's runtime can never poll —
* and therefore never receive the token of — a flow another agent started.
*
* Security posture (same as #15749):
* - The browser receives only the short `user_code` and an opaque local
* `flowId`; the `device_code` GitHub polls against stays in server memory
* until the flow completes or expires.
* - Network access is injectable so protocol behavior (pending, slow_down,
* denied, expired) is covered deterministically without contacting GitHub.
*/
import { randomBytes } from "node:crypto";
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
/** Matches the scopes the PAT card asks the user to grant a generated token. */
const DEFAULT_SCOPE = "repo read:user";
/** Error taxonomy for the routes: each code maps to one HTTP status. */
export type DeviceFlowErrorCode =
/** flowId is not pending for this agent (never started, expired + swept, or another agent's). */
| "unknown_flow"
/** The OAuth app registration itself is wrong or device flow is disabled — owner setup. */
| "owner_setup"
/** GitHub was unreachable or returned a malformed/unexpected response. */
| "upstream";
export class DeviceFlowError extends Error {
constructor(
message: string,
readonly code: DeviceFlowErrorCode,
/** HTTP status the route surfaces for this failure class. */
readonly status: number,
options?: { cause?: unknown },
) {
super(message, options);
this.name = "DeviceFlowError";
}
}
export interface DeviceFlowStart {
/** Opaque server-local flow handle — NOT the GitHub device_code. */
flowId: string;
/** Short code the user types at the verification URI. */
userCode: string;
verificationUri: string;
intervalSeconds: number;
expiresInSeconds: number;
}
export type DeviceFlowPollResult =
/** User has not approved yet (or the server-owned interval hasn't elapsed). */
| { status: "pending"; retryAfterSeconds: number }
/** User approved; token granted. Flow is consumed. */
| { status: "complete"; token: string; scope: string }
/** User explicitly denied the request. Flow is consumed. */
| { status: "denied" }
/** The device code expired before the user approved. Flow is consumed. */
| { status: "expired" };
interface PendingFlow {
agentKey: string;
clientId: string;
deviceCode: string;
intervalSeconds: number;
nextPollAtMs: number;
expiresAtMs: number;
}
const pendingFlows = new Map<string, PendingFlow>();
interface FlowDeps {
fetchImpl?: typeof fetch;
now?: () => number;
randomBytesImpl?: typeof randomBytes;
}
function positiveNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: fallback;
}
function requireString(
payload: Record<string, unknown>,
key: string,
label: string,
): string {
const value = payload[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new DeviceFlowError(
`${label} response is missing ${key}`,
"upstream",
502,
);
}
return value.trim();
}
async function postForm(
url: string,
form: Record<string, string>,
label: string,
fetchImpl: typeof fetch,
): Promise<Record<string, unknown>> {
let response: Response;
try {
response = await fetchImpl(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(form).toString(),
});
} catch (err) {
// error-policy:J2 context-adding rethrow — a network failure reaching
// GitHub is an upstream-reachability problem, typed 502 with the cause.
throw new DeviceFlowError(
`${label} failed: could not reach GitHub`,
"upstream",
502,
{ cause: err },
);
}
let payload: unknown;
try {
payload = await response.json();
} catch (err) {
throw new DeviceFlowError(
`${label} returned invalid JSON (HTTP ${response.status})`,
"upstream",
502,
{ cause: err },
);
}
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw new DeviceFlowError(
`${label} returned a non-object body (HTTP ${response.status})`,
"upstream",
502,
);
}
const record = payload as Record<string, unknown>;
if (!response.ok) {
const providerError =
typeof record.error === "string" ? ` (${record.error})` : "";
throw new DeviceFlowError(
`${label} failed: HTTP ${response.status}${providerError}`,
"upstream",
502,
);
}
return record;
}
function sweepExpired(nowMs: number): void {
for (const [flowId, flow] of pendingFlows) {
if (nowMs >= flow.expiresAtMs) pendingFlows.delete(flowId);
}
}
/**
* Start a device flow: ask GitHub for a device+user code pair, keep the
* device code server-side, and hand back the user-visible half.
*/
export async function startDeviceFlow(options: {
clientId: string;
/** Identity of the agent runtime that owns this flow (agentId). */
agentKey: string;
deps?: FlowDeps;
}): Promise<DeviceFlowStart> {
const { clientId, agentKey, deps } = options;
const fetchImpl = deps?.fetchImpl ?? fetch;
const now = deps?.now ?? Date.now;
const randomBytesImpl = deps?.randomBytesImpl ?? randomBytes;
const payload = await postForm(
DEVICE_CODE_URL,
{ client_id: clientId.trim(), scope: DEFAULT_SCOPE },
"GitHub device-code request",
fetchImpl,
);
// GitHub returns 200 for a bad/unregistered client id with an error body.
if (typeof payload.error === "string") {
throw new DeviceFlowError(
`GitHub rejected the device-flow client registration (${payload.error}). ` +
"Check GITHUB_OAUTH_CLIENT_ID and that the OAuth app has device flow enabled.",
"owner_setup",
409,
);
}
const deviceCode = requireString(
payload,
"device_code",
"GitHub device-code",
);
const userCode = requireString(payload, "user_code", "GitHub device-code");
const verificationUri = requireString(
payload,
"verification_uri",
"GitHub device-code",
);
const intervalSeconds = positiveNumber(payload.interval, 5);
const expiresInSeconds = positiveNumber(payload.expires_in, 900);
const nowMs = now();
sweepExpired(nowMs);
const flowId = randomBytesImpl(24).toString("base64url");
pendingFlows.set(flowId, {
agentKey,
clientId: clientId.trim(),
deviceCode,
intervalSeconds,
nextPollAtMs: nowMs,
expiresAtMs: nowMs + expiresInSeconds * 1_000,
});
return {
flowId,
userCode,
verificationUri,
intervalSeconds,
expiresInSeconds,
};
}
/**
* Poll a pending flow once. Honors GitHub's server-owned polling interval
* (early polls return `pending` without touching the network) and the
* `slow_down` back-off. Terminal outcomes (`complete`, `denied`, `expired`)
* consume the flow.
*/
export async function pollDeviceFlow(options: {
flowId: string;
/** Must match the agentKey the flow was started with. */
agentKey: string;
deps?: FlowDeps;
}): Promise<DeviceFlowPollResult> {
const { flowId, agentKey, deps } = options;
const fetchImpl = deps?.fetchImpl ?? fetch;
const now = deps?.now ?? Date.now;
const nowMs = now();
sweepExpired(nowMs);
const flow = pendingFlows.get(flowId);
// A flow owned by a different agent is reported exactly like a flow that
// never existed — no oracle for other agents' pending flows.
if (!flow || flow.agentKey !== agentKey) {
throw new DeviceFlowError(
"GitHub sign-in flow is unknown or expired. Start a new sign-in.",
"unknown_flow",
404,
);
}
if (nowMs < flow.nextPollAtMs) {
return {
status: "pending",
retryAfterSeconds: Math.max(
1,
Math.ceil((flow.nextPollAtMs - nowMs) / 1_000),
),
};
}
flow.nextPollAtMs = nowMs + flow.intervalSeconds * 1_000;
const payload = await postForm(
ACCESS_TOKEN_URL,
{
client_id: flow.clientId,
device_code: flow.deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
},
"GitHub device-token request",
fetchImpl,
);
if (
typeof payload.access_token === "string" &&
payload.access_token.length > 0
) {
pendingFlows.delete(flowId);
return {
status: "complete",
token: payload.access_token,
scope: typeof payload.scope === "string" ? payload.scope : "",
};
}
const errorCode = typeof payload.error === "string" ? payload.error : "";
if (errorCode === "authorization_pending") {
return { status: "pending", retryAfterSeconds: flow.intervalSeconds };
}
if (errorCode === "slow_down") {
flow.intervalSeconds += 5;
flow.nextPollAtMs = nowMs + flow.intervalSeconds * 1_000;
return { status: "pending", retryAfterSeconds: flow.intervalSeconds };
}
// Every remaining GitHub error is terminal for this flow.
pendingFlows.delete(flowId);
if (errorCode === "access_denied") {
return { status: "denied" };
}
if (errorCode === "expired_token") {
return { status: "expired" };
}
if (
errorCode === "incorrect_client_credentials" ||
errorCode === "device_flow_disabled" ||
errorCode === "unsupported_grant_type"
) {
throw new DeviceFlowError(
`GitHub rejected the device-flow client registration (${errorCode}). ` +
"Check GITHUB_OAUTH_CLIENT_ID and that the OAuth app has device flow enabled.",
"owner_setup",
409,
);
}
throw new DeviceFlowError(
`GitHub device sign-in failed: ${errorCode || "unknown_error"}`,
"upstream",
502,
);
}
/** Test hook: drop all pending flows so suites are order-independent. */
export function clearDeviceFlowsForTest(): void {
pendingFlows.clear();
}
@@ -0,0 +1,544 @@
/**
* Route-level e2e for the GitHub device sign-in setup step (#15796).
*
* Boots the plugin's declared device-flow routes (`POST
* /api/github/device/start|poll`) plus the token status route through the real
* production dispatcher (`tryHandleRuntimePluginRoute`) over a loopback
* `http.createServer` — exercising the real auth gate, the dispatcher's JSON
* body pre-parse, and handler dispatch. Only GitHub's two OAuth endpoints and
* `/user` are stubbed (the thing under test is our flow logic, not GitHub's
* server); everything else — flow state, credential persistence, per-agent
* runtime settings — is real.
*/
import { mkdtempSync, rmSync } from "node:fs";
import type http from "node:http";
import http_ from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import type { IAgentRuntime, Route } from "@elizaos/core";
import { afterAll, afterEach, describe, expect, it } from "vitest";
// Pin the credential store to an isolated temp dir BEFORE importing any module
// that resolves the state dir, so the real `loadMetadata`/`saveCredentials`/
// `clearCredentials` operate on throwaway disk state.
const stateDir = mkdtempSync(path.join(tmpdir(), "gh-device-e2e-"));
const priorStateDir = process.env.ELIZA_STATE_DIR;
process.env.ELIZA_STATE_DIR = stateDir;
const { tryHandleRuntimePluginRoute } = await import(
"../../../packages/agent/src/api/runtime-plugin-routes.ts"
);
const { handleGitHubRoutes } = await import("./routes/github-routes.ts");
const { clearDeviceFlowsForTest } = await import("./device-flow.ts");
const { clearCredentials, loadMetadata } = await import(
"./github-credentials.ts"
);
/**
* Mirror the plugin's route wiring from src/index.ts exactly — including the
* runtime-derived context (agent scoping, per-agent oauth client id
* resolution, and live-runtime token apply/clear). Importing the full plugin
* object instead would drag in the entire (unrelated) action graph; the route
* declaration + adapter is what we exercise.
*/
function createGitHubRouteHandler(method: "GET" | "POST" | "DELETE") {
return async (
req: unknown,
res: unknown,
runtime: unknown,
): Promise<void> => {
const httpReq = req as http.IncomingMessage;
const httpRes = res as http.ServerResponse;
const url = new URL(httpReq.url ?? "/api/github/token", "http://localhost");
const agentRuntime = runtime as IAgentRuntime;
await handleGitHubRoutes({
req: httpReq,
res: httpRes,
method,
pathname: url.pathname,
agentKey: String(agentRuntime.agentId),
getOauthClientId: () => {
const clientId = agentRuntime.getSetting("GITHUB_OAUTH_CLIENT_ID");
return typeof clientId === "string" ? clientId : undefined;
},
applyRuntimeToken: (token) =>
agentRuntime.setSetting("GITHUB_TOKEN", token, true),
clearRuntimeToken: () => {
const secrets = agentRuntime.character.secrets;
if (secrets && "GITHUB_TOKEN" in secrets) delete secrets.GITHUB_TOKEN;
},
});
};
}
const githubRoutes: Route[] = [
{
type: "GET",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("GET"),
},
{
type: "POST",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("POST"),
},
{
type: "DELETE",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("DELETE"),
},
{
type: "POST",
path: "/api/github/device/start",
rawPath: true,
handler: createGitHubRouteHandler("POST"),
},
{
type: "POST",
path: "/api/github/device/poll",
rawPath: true,
handler: createGitHubRouteHandler("POST"),
},
];
/** Per-test runtime stub: a real settings map per agent, nothing shared. */
function makeRuntime(options: {
agentId: string;
oauthClientId?: string;
}): IAgentRuntime & { secrets: Record<string, string> } {
const secrets: Record<string, string> = {};
return {
agentId: options.agentId,
routes: githubRoutes,
character: { name: "test", secrets },
secrets,
getSetting: (key: string) => {
if (key === "GITHUB_OAUTH_CLIENT_ID")
return options.oauthClientId ?? null;
return secrets[key] ?? null;
},
setSetting: (
key: string,
value: string | boolean | null,
_secret?: boolean,
) => {
if (value !== null && value !== undefined) secrets[key] = String(value);
},
getService: () => null,
} as unknown as IAgentRuntime & { secrets: Record<string, string> };
}
const servers: http.Server[] = [];
const realFetch = globalThis.fetch;
async function startServer(
runtime: IAgentRuntime,
isAuthorized: () => boolean = () => true,
): Promise<string> {
const server = http_.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const handled = await tryHandleRuntimePluginRoute({
req,
res,
method: req.method ?? "GET",
pathname: url.pathname,
url,
runtime,
isAuthorized,
});
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.end("not found");
}
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}`;
}
const DEVICE_CODE_URL = "https://github.com/login/device/code";
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
const USER_URL = "https://api.github.com/user";
interface GitHubStubScript {
deviceCode?: () => Response;
token?: () => Response;
user?: () => Response;
}
/**
* Stub ONLY GitHub's endpoints on the global fetch; every other request
* (including the test client's own loopback calls) uses the real fetch.
*/
function stubGitHub(script: GitHubStubScript): { calls: string[] } {
const calls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const target = typeof input === "string" ? input : String(input);
if (target === DEVICE_CODE_URL && script.deviceCode) {
calls.push(target);
return script.deviceCode();
}
if (target === ACCESS_TOKEN_URL && script.token) {
calls.push(target);
return script.token();
}
if (target === USER_URL && script.user) {
calls.push(target);
return script.user();
}
return realFetch(input, init);
}) as typeof fetch;
return { calls };
}
function jsonResponse(
payload: unknown,
status = 200,
headers?: Record<string, string>,
): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json", ...headers },
});
}
function deviceCodeOk(): Response {
return jsonResponse({
device_code: "secret-device-code",
user_code: "ABCD-EFGH",
verification_uri: "https://github.com/login/device",
expires_in: 900,
interval: 5,
});
}
/**
* JSON POST via real fetch. The dispatcher pre-reads `application/json`
* bodies and hands the parsed object to the handler — this is the exact
* request shape the dashboard card sends.
*/
async function postJson(
base: string,
pathName: string,
body: unknown,
): Promise<{ status: number; json: Record<string, unknown> }> {
const res = await realFetch(`${base}${pathName}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return {
status: res.status,
json: (await res.json()) as Record<string, unknown>,
};
}
afterEach(async () => {
globalThis.fetch = realFetch;
clearDeviceFlowsForTest();
await clearCredentials();
await Promise.all(
servers.map(
(server) =>
new Promise<void>((resolve) => {
server.closeAllConnections?.();
server.close(() => resolve());
}),
),
);
servers.length = 0;
});
afterAll(() => {
if (priorStateDir === undefined) delete process.env.ELIZA_STATE_DIR;
else process.env.ELIZA_STATE_DIR = priorStateDir;
rmSync(stateDir, { recursive: true, force: true });
});
describe("github device sign-in routes (real dispatch)", () => {
it("enforces the auth gate on both device routes", async () => {
const base = await startServer(
makeRuntime({ agentId: "agent-a", oauthClientId: "client-1" }),
() => false,
);
const start = await postJson(base, "/api/github/device/start", {});
expect(start.status).toBe(401);
const poll = await postJson(base, "/api/github/device/poll", {
flowId: "x",
});
expect(poll.status).toBe(401);
});
it("reports deviceFlowAvailable on the status route from the per-agent setting", async () => {
const withClient = await startServer(
makeRuntime({ agentId: "agent-a", oauthClientId: "client-1" }),
);
const withRes = (await (
await realFetch(`${withClient}/api/github/token`)
).json()) as {
connected: boolean;
deviceFlowAvailable: boolean;
};
expect(withRes).toMatchObject({
connected: false,
deviceFlowAvailable: true,
});
const withoutClient = await startServer(
makeRuntime({ agentId: "agent-b" }),
);
const withoutRes = (await (
await realFetch(`${withoutClient}/api/github/token`)
).json()) as { deviceFlowAvailable: boolean };
expect(withoutRes.deviceFlowAvailable).toBe(false);
});
it("start without an oauth client id is an explicit owner-setup 409 that names the fix", async () => {
const base = await startServer(makeRuntime({ agentId: "agent-a" }));
const res = await postJson(base, "/api/github/device/start", {});
expect(res.status).toBe(409);
const error = res.json.error as string;
expect(error).toContain("GITHUB_OAUTH_CLIENT_ID");
expect(error.toLowerCase()).toContain("personal access token");
});
it("runs the guided flow end to end: start → pending → grant → validated, persisted, applied per-agent", async () => {
const runtime = makeRuntime({
agentId: "agent-a",
oauthClientId: "client-1",
});
const base = await startServer(runtime);
let tokenPolls = 0;
stubGitHub({
deviceCode: deviceCodeOk,
token: () => {
tokenPolls += 1;
return tokenPolls === 1
? jsonResponse({ error: "authorization_pending" })
: jsonResponse({
access_token: "gho_device_grant",
token_type: "bearer",
scope: "repo,read:user",
});
},
user: () =>
jsonResponse({ login: "octocat" }, 200, {
"x-oauth-scopes": "repo, read:user",
}),
});
const started = await postJson(base, "/api/github/device/start", {});
expect(started.status).toBe(200);
expect(started.json).toMatchObject({
status: "started",
userCode: "ABCD-EFGH",
verificationUri: "https://github.com/login/device",
intervalSeconds: 5,
});
const flowId = started.json.flowId as string;
expect(flowId.length).toBeGreaterThan(10);
// The GitHub-side device code never crosses to the browser.
expect(JSON.stringify(started.json)).not.toContain("secret-device-code");
const pending = await postJson(base, "/api/github/device/poll", { flowId });
expect(pending.status).toBe(200);
expect(pending.json).toMatchObject({ status: "pending" });
expect(typeof pending.json.retryAfterSeconds).toBe("number");
// GitHub's polling interval is owned server-side; jump past it by waiting
// out the recorded nextPollAt via repeated polls with real time is slow,
// so poll again after the interval the server reported — the flow store
// uses wall-clock time. Interval is 5s; instead of sleeping, poll through
// the throttle: an early poll stays pending and never contacts GitHub.
const early = await postJson(base, "/api/github/device/poll", { flowId });
expect(early.json.status).toBe("pending");
expect(tokenPolls).toBe(1);
// Now legitimately elapse the interval (short real wait is unavoidable
// without poking module internals; 5s is the GitHub-mandated minimum).
await new Promise((resolve) => setTimeout(resolve, 5_100));
const completed = await postJson(base, "/api/github/device/poll", {
flowId,
});
expect(completed.status).toBe(200);
expect(completed.json).toMatchObject({
status: "complete",
connected: true,
deviceFlowAvailable: true,
username: "octocat",
scopes: ["repo", "read:user"],
});
// The granted token is never returned to the browser…
expect(JSON.stringify(completed.json)).not.toContain("gho_device_grant");
// …but it really landed on disk through the real save path…
expect(await loadMetadata()).toMatchObject({ username: "octocat" });
// …and in THIS runtime's per-agent settings (not process.env).
expect(runtime.secrets.GITHUB_TOKEN).toBe("gho_device_grant");
expect(process.env.GITHUB_TOKEN ?? "").not.toBe("gho_device_grant");
// Status route now reports connected.
const status = (await (
await realFetch(`${base}/api/github/token`)
).json()) as {
connected: boolean;
username: string;
};
expect(status.connected).toBe(true);
expect(status.username).toBe("octocat");
}, 30_000);
it("a denied grant resolves to a terminal denied outcome and persists nothing", async () => {
const runtime = makeRuntime({
agentId: "agent-a",
oauthClientId: "client-1",
});
const base = await startServer(runtime);
stubGitHub({
deviceCode: deviceCodeOk,
token: () => jsonResponse({ error: "access_denied" }),
});
const started = await postJson(base, "/api/github/device/start", {});
const denied = await postJson(base, "/api/github/device/poll", {
flowId: started.json.flowId,
});
expect(denied.status).toBe(200);
expect(denied.json).toEqual({ status: "denied" });
expect(await loadMetadata()).toBeNull();
expect(runtime.secrets.GITHUB_TOKEN).toBeUndefined();
});
it("an expired grant resolves to a terminal expired outcome", async () => {
const base = await startServer(
makeRuntime({ agentId: "agent-a", oauthClientId: "client-1" }),
);
stubGitHub({
deviceCode: deviceCodeOk,
token: () => jsonResponse({ error: "expired_token" }),
});
const started = await postJson(base, "/api/github/device/start", {});
const expired = await postJson(base, "/api/github/device/poll", {
flowId: started.json.flowId,
});
expect(expired.status).toBe(200);
expect(expired.json).toEqual({ status: "expired" });
});
it("rejects a poll with a missing flowId (400) and an unknown flowId (404)", async () => {
const base = await startServer(
makeRuntime({ agentId: "agent-a", oauthClientId: "client-1" }),
);
const missing = await postJson(base, "/api/github/device/poll", {});
expect(missing.status).toBe(400);
expect(missing.json.error).toContain("flowId");
const unknown = await postJson(base, "/api/github/device/poll", {
flowId: "never-started",
});
expect(unknown.status).toBe(404);
});
it("scopes flows per agent: agent B's runtime cannot poll agent A's flow", async () => {
const runtimeA = makeRuntime({
agentId: "agent-a",
oauthClientId: "client-1",
});
const runtimeB = makeRuntime({
agentId: "agent-b",
oauthClientId: "client-1",
});
const baseA = await startServer(runtimeA);
const baseB = await startServer(runtimeB);
stubGitHub({
deviceCode: deviceCodeOk,
token: () => jsonResponse({ access_token: "gho_cross", scope: "" }),
});
const started = await postJson(baseA, "/api/github/device/start", {});
const hijack = await postJson(baseB, "/api/github/device/poll", {
flowId: started.json.flowId,
});
expect(hijack.status).toBe(404);
expect(runtimeB.secrets.GITHUB_TOKEN).toBeUndefined();
});
it("a GitHub-side registration failure at start surfaces as owner-setup 409", async () => {
const base = await startServer(
makeRuntime({ agentId: "agent-a", oauthClientId: "client-1" }),
);
stubGitHub({
deviceCode: () => jsonResponse({ error: "device_flow_disabled" }),
});
const res = await postJson(base, "/api/github/device/start", {});
expect(res.status).toBe(409);
expect(res.json.error).toContain("device_flow_disabled");
});
it("an unreachable GitHub at start surfaces as upstream 502", async () => {
const base = await startServer(
makeRuntime({ agentId: "agent-a", oauthClientId: "client-1" }),
);
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const target = typeof input === "string" ? input : String(input);
if (target === DEVICE_CODE_URL) throw new Error("ECONNREFUSED");
return realFetch(input, init);
}) as typeof fetch;
const res = await postJson(base, "/api/github/device/start", {});
expect(res.status).toBe(502);
});
it("a grant whose token GitHub then rejects at /user is surfaced, and nothing persists", async () => {
const runtime = makeRuntime({
agentId: "agent-a",
oauthClientId: "client-1",
});
const base = await startServer(runtime);
stubGitHub({
deviceCode: deviceCodeOk,
token: () => jsonResponse({ access_token: "gho_revoked", scope: "" }),
user: () => jsonResponse({ message: "Bad credentials" }, 401),
});
const started = await postJson(base, "/api/github/device/start", {});
const res = await postJson(base, "/api/github/device/poll", {
flowId: started.json.flowId,
});
expect(res.status).toBe(400);
expect(await loadMetadata()).toBeNull();
expect(runtime.secrets.GITHUB_TOKEN).toBeUndefined();
});
it("PAT paste (application/json, the dashboard card's shape) validates, persists, and applies per-agent", async () => {
const runtime = makeRuntime({ agentId: "agent-a" });
const base = await startServer(runtime);
stubGitHub({
user: () =>
jsonResponse({ login: "octocat" }, 200, {
"x-oauth-scopes": "repo, read:user",
}),
});
const res = await postJson(base, "/api/github/token", {
token: "ghp_pasted",
});
expect(res.status).toBe(200);
expect(res.json).toMatchObject({ connected: true, username: "octocat" });
expect(runtime.secrets.GITHUB_TOKEN).toBe("ghp_pasted");
// DELETE disconnects the live runtime too, not just the disk record.
const del = await realFetch(`${base}/api/github/token`, {
method: "DELETE",
});
expect(del.status).toBe(200);
expect(await loadMetadata()).toBeNull();
expect(runtime.secrets.GITHUB_TOKEN).toBeUndefined();
});
});
@@ -0,0 +1,185 @@
/**
* Local GitHub credential storage for the eliza desktop / VPS install.
*
* Stores a single per-user GitHub PAT at
* `<state-dir>/credentials/github.json` (chmod 600). The token itself is
* write-only from the UI side: `loadCredentials()` returns the full record
* for runtime consumers (orchestrator spawn env, route handlers) but the
* HTTP route that powers the settings card never returns it — only
* `getMetadata()` is safe to send back to the browser.
*
* Storage shape mirrors the convention used elsewhere under
* `<state-dir>/` (see `~/.claude/.credentials.json` and the auth-store
* module): plain JSON, file mode 600, no encryption layer. Encryption at
* rest is a deliberately separate concern and would land in a follow-up.
*
* Cloud users (Eliza Cloud session active) are out of scope here — they
* use the `platformCredentials` table in `cloud/packages/db/schemas/` via
* the dedicated OAuth flow. This module is the local-first surface only.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { resolveStateDir } from "@elizaos/core";
export interface GitHubCredentials {
/** The PAT itself. Never sent back to the UI after save. */
token: string;
/** The GitHub `login` returned by `GET api.github.com/user` at save time. */
username: string;
/**
* Token scopes returned by GitHub's `X-OAuth-Scopes` response header at
* save time. Recorded so the UI can show what the token is allowed to
* do without round-tripping back to GitHub on every render.
*/
scopes: string[];
/** Wall-clock ms when the credential was saved. */
savedAt: number;
}
/** Subset of {@link GitHubCredentials} that is safe to send to the UI. */
export type GitHubCredentialMetadata = Omit<GitHubCredentials, "token">;
/** Resolve the on-disk path for the credential file. */
export function getCredentialFilePath(): string {
return path.join(resolveStateDir(), "credentials", "github.json");
}
function isGitHubCredentials(value: unknown): value is GitHubCredentials {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.token === "string" &&
typeof v.username === "string" &&
Array.isArray(v.scopes) &&
v.scopes.every((s) => typeof s === "string") &&
typeof v.savedAt === "number"
);
}
/**
* Read the saved credentials, or null if no file exists / the file is
* unreadable / the contents don't conform to the expected shape. Callers
* that need to surface a specific cause should check the file path
* themselves; we treat all failure modes the same here so the UI never
* has to reason about transient FS errors during render.
*/
export async function loadCredentials(): Promise<GitHubCredentials | null> {
const filePath = getCredentialFilePath();
let raw: string;
try {
raw = await fs.readFile(filePath, "utf-8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
return isGitHubCredentials(parsed) ? parsed : null;
}
/** Read just the metadata: same as `loadCredentials` minus the token. */
export async function loadMetadata(): Promise<GitHubCredentialMetadata | null> {
const creds = await loadCredentials();
if (!creds) return null;
const { token: _token, ...metadata } = creds;
return metadata;
}
/**
* Persist credentials to disk atomically with mode 0600. Creates the
* parent directory if needed. Overwrites any existing record for the
* single-user/single-token storage model.
*/
export async function saveCredentials(creds: GitHubCredentials): Promise<void> {
const filePath = getCredentialFilePath();
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
await fs.chmod(directory, 0o700);
// Write to a temp sibling then rename so an interrupted write can never
// leave a half-written credential file readable by the runtime.
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(creds, null, 2), {
mode: 0o600,
});
await fs.rename(tmpPath, filePath);
}
/**
* Remove the credential file. Idempotent — succeeds silently when nothing
* is saved. Any other FS error propagates so callers can surface it.
*/
export async function clearCredentials(): Promise<void> {
const filePath = getCredentialFilePath();
try {
await fs.unlink(filePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") return;
throw err;
}
}
/**
* Build the credential record from a GitHub `/user` API response. Kept
* tiny and pure so the route handler can call it without pulling in any
* I/O surface. The route is responsible for the actual `fetch`.
*/
export function buildCredentialsFromUserResponse(
token: string,
user: { login: string },
scopes: string[],
now: number = Date.now(),
): GitHubCredentials {
return {
token,
username: user.login,
scopes,
savedAt: now,
};
}
/**
* Resolve the canonical state-dir-respecting path for tests that need to
* assert against the on-disk location without re-implementing the
* resolver.
*/
export function _resolveStateDirForTests(): string {
return resolveStateDir();
}
export interface ApplySavedTokenResult {
/** True when a saved token was found and copied into process.env. */
applied: boolean;
/**
* True when `process.env.GITHUB_TOKEN` was already set before this call.
* The existing value is left untouched — explicit env always wins.
*/
envAlreadySet: boolean;
/** Username from the saved record, surfaced for boot logging. */
username?: string;
}
/**
* Read the saved credential and copy the token into `process.env.GITHUB_TOKEN`
* when no explicit env value is already set. Called once at runtime
* bootstrap so the orchestrator's existing `runtime.getSetting("GITHUB_TOKEN")`
* resolution and any `gh`/`git` invocation in spawned PTY sessions both see
* the same value without each having to know about the on-disk record.
*
* Existing `process.env.GITHUB_TOKEN` always wins — a developer's shell
* export should override the persisted UI value.
*/
export async function applySavedTokenToEnv(): Promise<ApplySavedTokenResult> {
if (process.env.GITHUB_TOKEN?.trim()) {
return { applied: false, envAlreadySet: true };
}
const creds = await loadCredentials();
if (!creds) {
return { applied: false, envAlreadySet: false };
}
process.env.GITHUB_TOKEN = creds.token;
return { applied: true, envAlreadySet: false, username: creds.username };
}
+164
View File
@@ -0,0 +1,164 @@
/**
* @module plugin-github
* @description elizaOS plugin for GitHub integration.
*
* Actions:
* - GITHUB (PR, issue, and notification operations)
*
* Auth: role-tagged account records with legacy PAT fallback.
* - GITHUB_ACCOUNTS — JSON account records ({accountId, role, token})
* - GITHUB_USER_PAT — legacy user acting on their own behalf
* - GITHUB_AGENT_PAT — legacy agent acting on its own behalf
* E2E fallbacks: ELIZA_E2E_GITHUB_USER_PAT / ELIZA_E2E_GITHUB_AGENT_PAT.
*
* Each action takes an `as: "user" | "agent"` option and may take accountId
* to select a specific account. GITHUB_PR_OP review and
* GITHUB_NOTIFICATION_TRIAGE default to `"user"`; the other ops default to
* `"agent"`.
*/
import type http from "node:http";
import type { IAgentRuntime, Plugin, Route } from "@elizaos/core";
import {
getConnectorAccountManager,
logger,
promoteSubactionsToActions,
} from "@elizaos/core";
import { githubAction } from "./actions/github.js";
import { createGitHubConnectorAccountProvider } from "./connector-account-provider.js";
import { handleGitHubRoutes } from "./routes/github-routes.js";
import { registerGitHubSearchCategory } from "./search-category.js";
import { GitHubService } from "./services/github-service.js";
/**
* Remove the GitHub token from the live runtime's per-agent settings.
* `runtime.setSetting` has no delete form (null values are ignored by
* design), so disconnect clears the two per-agent records `getSetting`
* consults: `character.secrets` and `character.settings.secrets`.
*/
function clearRuntimeGitHubToken(runtime: IAgentRuntime): void {
const secrets = runtime.character.secrets;
if (secrets && "GITHUB_TOKEN" in secrets) {
delete secrets.GITHUB_TOKEN;
}
const settings = runtime.character.settings;
const nestedSecrets =
settings &&
typeof settings === "object" &&
"secrets" in settings &&
typeof settings.secrets === "object" &&
settings.secrets !== null
? (settings.secrets as Record<string, unknown>)
: undefined;
if (nestedSecrets && "GITHUB_TOKEN" in nestedSecrets) {
delete nestedSecrets.GITHUB_TOKEN;
}
}
function createGitHubRouteHandler(method: "GET" | "POST" | "DELETE") {
return async (
req: unknown,
res: unknown,
runtime: unknown,
): Promise<void> => {
const httpReq = req as http.IncomingMessage;
const httpRes = res as http.ServerResponse;
const url = new URL(httpReq.url ?? "/api/github/token", "http://localhost");
const agentRuntime = runtime as IAgentRuntime;
await handleGitHubRoutes({
req: httpReq,
res: httpRes,
method,
pathname: url.pathname,
// Device flows are scoped to the agent that started them.
agentKey: String(agentRuntime.agentId),
getOauthClientId: () => {
const clientId = agentRuntime.getSetting("GITHUB_OAUTH_CLIENT_ID");
return typeof clientId === "string" ? clientId : undefined;
},
// Per-agent settings (character.secrets), NOT process.env — env would
// leak the token to every agent on a multi-tenant host. The on-disk
// credential record still re-applies at next boot via
// `applySavedTokenToEnv` for gh/git subprocess inheritance.
applyRuntimeToken: (token) =>
agentRuntime.setSetting("GITHUB_TOKEN", token, true),
clearRuntimeToken: () => clearRuntimeGitHubToken(agentRuntime),
});
};
}
export * from "./accounts.js";
export { githubAction } from "./actions/github.js";
export { issueOpAction } from "./actions/issue-op.js";
export {
notificationTriageAction,
scoreNotification,
type TriagedNotification,
} from "./actions/notification-triage.js";
export { prOpAction } from "./actions/pr-op.js";
export { createGitHubConnectorAccountProvider } from "./connector-account-provider.js";
export { GitHubService } from "./services/github-service.js";
export * from "./types.js";
const githubRoutes: Route[] = [
{
type: "GET",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("GET"),
},
{
type: "POST",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("POST"),
},
{
type: "DELETE",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("DELETE"),
},
{
type: "POST",
path: "/api/github/device/start",
rawPath: true,
handler: createGitHubRouteHandler("POST"),
},
{
type: "POST",
path: "/api/github/device/poll",
rawPath: true,
handler: createGitHubRouteHandler("POST"),
},
];
export const githubPlugin: Plugin = {
name: "github",
description:
"GitHub integration for pull requests, issues, and notification triage",
services: [GitHubService],
actions: [...promoteSubactionsToActions(githubAction)],
routes: githubRoutes,
init: async (_config: Record<string, string>, runtime: IAgentRuntime) => {
registerGitHubSearchCategory(runtime);
try {
const manager = getConnectorAccountManager(runtime);
manager.registerProvider(createGitHubConnectorAccountProvider(runtime));
} catch (err) {
logger.warn(
{
src: "plugin:github",
err: err instanceof Error ? err.message : String(err),
},
"Failed to register GitHub provider with ConnectorAccountManager",
);
}
},
async dispose(runtime: IAgentRuntime) {
const svc = runtime.getService<GitHubService>(GitHubService.serviceType);
await svc?.stop();
},
};
export default githubPlugin;
+76
View File
@@ -0,0 +1,76 @@
/**
* @module rate-limit
* @description Helpers for detecting GitHub rate-limit errors and surfacing
* them as structured results instead of raw exceptions.
*/
export interface GitHubHttpError {
status?: number;
response?: {
headers?: Record<string, string | number | undefined>;
};
message?: string;
}
export interface RateLimitDetails {
isRateLimited: boolean;
resetAtMs: number | null;
remaining: number | null;
}
function toErrorLike(value: unknown): GitHubHttpError {
if (typeof value !== "object" || value === null) {
return { message: String(value) };
}
return value as GitHubHttpError;
}
function headerNumber(
headers: Record<string, string | number | undefined> | undefined,
name: string,
): number | null {
if (!headers) {
return null;
}
const raw = headers[name] ?? headers[name.toLowerCase()];
if (raw === undefined) {
return null;
}
const num = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(num) ? num : null;
}
export function inspectRateLimit(err: unknown): RateLimitDetails {
const e = toErrorLike(err);
const headers = e.response?.headers;
const remaining = headerNumber(headers, "x-ratelimit-remaining");
const resetSeconds = headerNumber(headers, "x-ratelimit-reset");
const isRateLimited = e.status === 403 && remaining === 0;
return {
isRateLimited,
remaining,
resetAtMs: resetSeconds === null ? null : resetSeconds * 1000,
};
}
export function formatRateLimitMessage(details: RateLimitDetails): string {
if (!details.isRateLimited) {
return "GitHub request failed";
}
if (details.resetAtMs === null) {
return "GitHub rate limit exhausted";
}
const reset = new Date(details.resetAtMs).toISOString();
return `GitHub rate limit exhausted; resets at ${reset}`;
}
export function errorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
const e = toErrorLike(err);
return e.message ?? "unknown error";
}
@@ -0,0 +1,12 @@
/**
* App-route plugin loader entry point: registers the full `githubPlugin`
* (lazily imported) under `@elizaos/plugin-github` so the app-core route host
* can mount the plugin's HTTP routes. A separate tsup entry from index.ts.
*/
import { registerAppRoutePluginLoader } from "@elizaos/core";
registerAppRoutePluginLoader("@elizaos/plugin-github", async () => {
const { githubPlugin } = await import("./index.js");
return githubPlugin;
});
@@ -0,0 +1,449 @@
/**
* Route-level e2e for plugin-github (issue #8802).
*
* Boots the plugin's declared `Route[]` (`githubRoutes`, the GET/POST/DELETE
* `/api/github/token` PAT-management endpoints) through the real production
* dispatcher (`tryHandleRuntimePluginRoute`) over a loopback
* `http.createServer` — exercising the real auth gate, body parsing, and
* handler dispatch. Every assertion is on a real HTTP response: no mocked
* `json`/`error` functions, no shape-only checks.
*
* The route handlers have no `runtime.getService(...)` dependency (the runtime
* arg is ignored); their only external dependencies are:
* - the on-disk credential store under `<state-dir>/credentials/github.json`
* (pinned to a per-test temp dir via `ELIZA_STATE_DIR`), and
* - GitHub's `/user` endpoint via the global `fetch` (stubbed — we never
* call GitHub).
*/
import { mkdtempSync, rmSync } from "node:fs";
import type http from "node:http";
import http_ from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import type { IAgentRuntime, Route } from "@elizaos/core";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
// Pin the credential store to an isolated temp dir BEFORE importing any module
// that resolves the state dir, so the real `loadMetadata`/`saveCredentials`/
// `clearCredentials` operate on throwaway disk state.
const stateDir = mkdtempSync(path.join(tmpdir(), "gh-routes-e2e-"));
const priorStateDir = process.env.ELIZA_STATE_DIR;
process.env.ELIZA_STATE_DIR = stateDir;
const { tryHandleRuntimePluginRoute } = await import(
"../../../packages/agent/src/api/runtime-plugin-routes.ts"
);
const { handleGitHubRoutes } = await import("./routes/github-routes.ts");
const {
clearCredentials,
loadMetadata,
buildCredentialsFromUserResponse,
saveCredentials,
} = await import("./github-credentials.ts");
// Mirror the plugin's route wiring from src/index.ts exactly: each route is a
// thin `(req, res, runtime)` adapter over the real `handleGitHubRoutes`
// dispatcher, declared `rawPath`. Importing the full plugin object instead
// would drag in the entire (unrelated) action graph; the route declaration
// itself is what we exercise.
function createGitHubRouteHandler(method: "GET" | "POST" | "DELETE") {
return async (
req: unknown,
res: unknown,
runtime: unknown,
): Promise<void> => {
const httpReq = req as http.IncomingMessage;
const httpRes = res as http.ServerResponse;
const url = new URL(httpReq.url ?? "/api/github/token", "http://localhost");
void runtime;
await handleGitHubRoutes({
req: httpReq,
res: httpRes,
method,
pathname: url.pathname,
});
};
}
const githubRoutes: Route[] = [
{
type: "GET",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("GET"),
},
{
type: "POST",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("POST"),
},
{
type: "DELETE",
path: "/api/github/token",
rawPath: true,
handler: createGitHubRouteHandler("DELETE"),
},
];
const servers: http.Server[] = [];
const realFetch = globalThis.fetch;
afterEach(async () => {
globalThis.fetch = realFetch;
await clearCredentials();
await Promise.all(
servers.map(
(server) =>
new Promise<void>((resolve) => {
server.closeAllConnections?.();
server.close(() => resolve());
}),
),
);
servers.length = 0;
});
afterAll(() => {
if (priorStateDir === undefined) delete process.env.ELIZA_STATE_DIR;
else process.env.ELIZA_STATE_DIR = priorStateDir;
rmSync(stateDir, { recursive: true, force: true });
});
beforeAll(() => {
// The plugin must really declare the three token routes we test against.
expect(githubRoutes.map((r) => `${r.type} ${r.path}`)).toEqual([
"GET /api/github/token",
"POST /api/github/token",
"DELETE /api/github/token",
]);
});
function makeRuntime(): IAgentRuntime {
return {
routes: githubRoutes,
// The token routes ignore the runtime arg; getService is never consulted.
getService: () => null,
} as unknown as IAgentRuntime;
}
async function startServer(
isAuthorized: () => boolean = () => true,
): Promise<string> {
const runtime = makeRuntime();
const server = http_.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const handled = await tryHandleRuntimePluginRoute({
req,
res,
method: req.method ?? "GET",
pathname: url.pathname,
url,
runtime,
isAuthorized,
});
if (!handled && !res.headersSent) {
res.statusCode = 404;
res.end("not found");
}
});
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${port}`;
}
const TOKEN_PATH = "/api/github/token";
/**
* A raw-`node:http` POST client. The token-validation tests stub the global
* `fetch` (the handler validates the PAT against GitHub via `fetch`), so the
* test client must NOT use `fetch` — otherwise it would intercept its own
* request to the loopback server. This issues the request over a real socket
* and returns the real status + parsed JSON body the handler wrote.
*/
function rawPost(
base: string,
contentType: string,
body: string,
): Promise<{ status: number; json: unknown }> {
const url = new URL(`${base}${TOKEN_PATH}`);
return new Promise((resolve, reject) => {
const req = http_.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"content-type": contentType,
"content-length": Buffer.byteLength(body),
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c as Buffer));
res.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
resolve({
status: res.statusCode ?? 0,
json: text ? JSON.parse(text) : null,
});
});
},
);
req.on("error", reject);
req.end(body);
});
}
describe("plugin-github routes (real dispatch)", () => {
it("enforces the auth gate on every token route", async () => {
const base = await startServer(() => false);
const get = await fetch(`${base}${TOKEN_PATH}`);
expect(get.status).toBe(401);
const post = await fetch(`${base}${TOKEN_PATH}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: "ghp_x" }),
});
expect(post.status).toBe(401);
const del = await fetch(`${base}${TOKEN_PATH}`, { method: "DELETE" });
expect(del.status).toBe(401);
});
it("serves GET token status as disconnected when no credential is saved", async () => {
const base = await startServer();
const res = await fetch(`${base}${TOKEN_PATH}`);
expect(res.status).toBe(200);
const body = (await res.json()) as { connected: boolean };
expect(body.connected).toBe(false);
});
it("serves GET token status as connected after a credential is saved", async () => {
await saveCredentials(
buildCredentialsFromUserResponse(
"ghp_secret",
{ login: "octocat" },
["repo", "read:user"],
1234,
),
);
// The disk write must be visible through the real loader.
expect(await loadMetadata()).toMatchObject({ username: "octocat" });
const base = await startServer();
const res = await fetch(`${base}${TOKEN_PATH}`);
expect(res.status).toBe(200);
const body = (await res.json()) as {
connected: boolean;
username: string;
scopes: string[];
};
expect(body.connected).toBe(true);
expect(body.username).toBe("octocat");
expect(body.scopes).toEqual(["repo", "read:user"]);
// The token itself must never be returned to the client.
expect(JSON.stringify(body)).not.toContain("ghp_secret");
});
it("rejects a POST with a missing token via the real validator (400)", async () => {
const base = await startServer();
const res = await fetch(`${base}${TOKEN_PATH}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ notToken: "x" }),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("token");
});
it("maps a GitHub-rejected token to a 400 without calling GitHub for real", async () => {
// Stub the global fetch the handler falls back to; assert we never hit
// the network. Sent as text/plain so the dispatcher leaves the request
// stream intact for the handler's own raw body reader. The handler
// validates the PAT against GitHub via the global `fetch`; stub only that
// call and delegate anything else to the real fetch. The test client uses
// raw `node:http` (not `fetch`) so it is unaffected by this stub.
let calledUrl: string | null = null;
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const target = typeof input === "string" ? input : String(input);
if (target !== "https://api.github.com/user") {
return realFetch(input, init);
}
calledUrl = target;
return new Response(JSON.stringify({ message: "Bad credentials" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const base = await startServer();
const res = await rawPost(
base,
"text/plain",
JSON.stringify({ token: "ghp_invalid" }),
);
expect(res.status).toBe(400);
expect((res.json as { error: string }).error.toLowerCase()).toContain(
"bad credentials",
);
expect(calledUrl).toBe("https://api.github.com/user");
// Nothing was persisted on a rejected token.
expect(await loadMetadata()).toBeNull();
});
it("maps a GitHub upstream failure to 502, not a client 400", async () => {
// A 5xx from GitHub means GitHub is failing, not that the token is invalid.
// The route must surface that as an upstream error (502) so the client is
// told to retry rather than being blamed for a bad token.
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const target = typeof input === "string" ? input : String(input);
if (target !== "https://api.github.com/user") {
return realFetch(input, init);
}
return new Response("upstream boom", { status: 503 });
}) as typeof fetch;
const base = await startServer();
const res = await rawPost(
base,
"text/plain",
JSON.stringify({ token: "ghp_upstream_down" }),
);
expect(res.status).toBe(502);
// A transient upstream failure never persists a credential.
expect(await loadMetadata()).toBeNull();
});
it("maps a 2xx GitHub response with an unparseable body to 502, not 500", async () => {
// GitHub returning 200 with a non-JSON body is the same upstream-defect
// class as a missing login field — an upstream error (502), not an internal
// server error (500) and not a bad-token 400.
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const target = typeof input === "string" ? input : String(input);
if (target !== "https://api.github.com/user") {
return realFetch(input, init);
}
return new Response("<html>not json</html>", {
status: 200,
headers: { "content-type": "text/html" },
});
}) as typeof fetch;
const base = await startServer();
const res = await rawPost(
base,
"text/plain",
JSON.stringify({ token: "ghp_bad_body" }),
);
expect(res.status).toBe(502);
expect(await loadMetadata()).toBeNull();
});
it("maps a network failure reaching GitHub to 502, not a client 400", async () => {
// A thrown fetch (DNS/connection failure) is an upstream-reachability
// problem; it must not be reported as a 400 bad token.
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const target = typeof input === "string" ? input : String(input);
if (target !== "https://api.github.com/user") {
return realFetch(input, init);
}
throw new Error("ECONNREFUSED");
}) as typeof fetch;
const base = await startServer();
const res = await rawPost(
base,
"text/plain",
JSON.stringify({ token: "ghp_no_route" }),
);
expect(res.status).toBe(502);
expect(await loadMetadata()).toBeNull();
});
it("validates, persists, and reports a good token end to end (200)", async () => {
globalThis.fetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const target = typeof input === "string" ? input : String(input);
if (target !== "https://api.github.com/user") {
return realFetch(input, init);
}
return new Response(JSON.stringify({ login: "octocat" }), {
status: 200,
headers: {
"content-type": "application/json",
"x-oauth-scopes": "repo, read:user",
},
});
}) as typeof fetch;
const base = await startServer();
const res = await rawPost(
base,
"text/plain",
JSON.stringify({ token: "ghp_good" }),
);
expect(res.status).toBe(200);
const body = res.json as {
connected: boolean;
username: string;
scopes: string[];
};
expect(body.connected).toBe(true);
expect(body.username).toBe("octocat");
expect(body.scopes).toEqual(["repo", "read:user"]);
expect(JSON.stringify(body)).not.toContain("ghp_good");
// The credential really landed on disk through the real save path.
expect(await loadMetadata()).toMatchObject({
username: "octocat",
scopes: ["repo", "read:user"],
});
});
it("clears a saved credential on DELETE (200)", async () => {
await saveCredentials(
buildCredentialsFromUserResponse(
"ghp_secret",
{ login: "octocat" },
[],
1,
),
);
expect(await loadMetadata()).not.toBeNull();
const base = await startServer();
const res = await fetch(`${base}${TOKEN_PATH}`, { method: "DELETE" });
expect(res.status).toBe(200);
const body = (await res.json()) as { connected: boolean };
expect(body.connected).toBe(false);
expect(await loadMetadata()).toBeNull();
});
it("does not match an unrelated path (dispatcher returns 404)", async () => {
const base = await startServer();
const res = await fetch(`${base}/api/github/unknown`);
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,448 @@
/**
* GitHub credential routes — power the "GitHub" connection card in Settings →
* Coding Agents (the guided credential setup step, #15796) and surface the
* same token to the orchestrator's sub-agent spawn env.
*
* Exposes:
* GET /api/github/token — `{ connected: bool, deviceFlowAvailable: bool,
* username?, scopes?, savedAt? }`. Token itself
* is never returned.
* POST /api/github/token — body `{ token }`. Validates by calling
* GitHub's `/user` endpoint, then persists
* the credential record to disk and applies it
* to the live runtime's per-agent settings.
* DELETE /api/github/token — clears the saved credential (disk + live
* runtime) and returns `{ connected: false }`.
* POST /api/github/device/start — starts a GitHub OAuth device flow
* (requires the `GITHUB_OAUTH_CLIENT_ID`
* setting). Returns the user code + opaque
* flow id; the device code stays server-side.
* POST /api/github/device/poll — body `{ flowId }`. Polls the flow once:
* `pending` / `denied` / `expired`, or on
* approval validates + persists the token the
* same way the PAT route does and returns the
* connected status.
*
* `handleGitHubRoutes` is the pure dispatcher — no auth, no runtime deps
* beyond the injectable `GitHubRouteContext` callbacks. The runtime adapter
* (`createGitHubRouteHandler`) lives in index.ts where it can import the
* heavier app-core auth surface without polluting this module's import graph
* (and breaking tests that only need the pure handler).
*/
import type http from "node:http";
import { logger } from "@elizaos/core";
import {
DeviceFlowError,
pollDeviceFlow,
startDeviceFlow,
} from "../device-flow.js";
import {
buildCredentialsFromUserResponse,
clearCredentials,
type GitHubCredentialMetadata,
loadMetadata,
saveCredentials,
} from "../github-credentials.js";
const GITHUB_USER_URL = "https://api.github.com/user";
const VALIDATION_TIMEOUT_MS = 10_000;
const MAX_BODY_BYTES = 8 * 1024;
async function readJsonBody(
req: http.IncomingMessage,
): Promise<Record<string, unknown> | null> {
// The canonical runtime-plugin dispatcher pre-reads `application/json`
// bodies and attaches the parsed object as `req.body` (the raw stream is
// already consumed by then). Prefer it when present; fall back to reading
// the stream for dispatch paths that leave the body untouched.
const preParsed = (req as http.IncomingMessage & { body?: unknown }).body;
if (preParsed && typeof preParsed === "object" && !Array.isArray(preParsed)) {
return preParsed as Record<string, unknown>;
}
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buf.length;
if (total > MAX_BODY_BYTES) return null;
chunks.push(buf);
}
if (chunks.length === 0) return null;
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
// error-policy:J3 sanitizing boundary — an unparseable body is treated as
// "no valid body" (null); the caller rejects the missing field with a 400.
return null;
}
}
interface GitHubUserResponse {
login: string;
}
export interface GitHubRouteContext {
req: http.IncomingMessage;
res: http.ServerResponse;
method: string;
pathname: string;
/** Inject for tests. Defaults to the global `fetch`. */
fetch?: typeof fetch;
json?: (status: number, body: unknown) => void;
/**
* Identity of the agent runtime serving this request. Device flows are
* bound to it so one agent can never poll (and receive the token of) a
* flow another agent started on the same host.
*/
agentKey?: string;
/**
* Resolve the `GITHUB_OAUTH_CLIENT_ID` setting for this agent
* (`runtime.getSetting` in production). Absent/empty means the owner has
* not registered a device-flow-enabled OAuth app — the PAT path still works.
*/
getOauthClientId?: () => string | undefined;
/**
* Apply a freshly validated token to the live runtime's per-agent settings
* (`runtime.setSetting("GITHUB_TOKEN", token, true)` in production) so
* GitHub capabilities work immediately, without a restart and without
* writing process env (which would leak to every agent on the host).
*/
applyRuntimeToken?: (token: string) => void;
/** Remove the token from the live runtime's per-agent settings. */
clearRuntimeToken?: () => void;
}
interface TokenStatusResponse {
connected: boolean;
/** True when `GITHUB_OAUTH_CLIENT_ID` is configured for this agent. */
deviceFlowAvailable: boolean;
username?: string;
scopes?: string[];
savedAt?: number;
}
interface GitHubValidationResponse {
ok: boolean;
status: number;
headers: {
get(name: string): string | null;
};
json(): Promise<unknown>;
}
function sendJson(
ctx: GitHubRouteContext,
status: number,
body: unknown,
): void {
if (ctx.json) {
ctx.json(status, body);
return;
}
ctx.res.statusCode = status;
ctx.res.setHeader("Content-Type", "application/json; charset=utf-8");
ctx.res.end(JSON.stringify(body));
}
function resolveOauthClientId(ctx: GitHubRouteContext): string {
const clientId = ctx.getOauthClientId?.();
return typeof clientId === "string" ? clientId.trim() : "";
}
function metadataToStatus(
metadata: GitHubCredentialMetadata | null,
deviceFlowAvailable: boolean,
): TokenStatusResponse {
if (!metadata) return { connected: false, deviceFlowAvailable };
return {
connected: true,
deviceFlowAvailable,
username: metadata.username,
scopes: metadata.scopes,
savedAt: metadata.savedAt,
};
}
/**
* Error thrown by {@link validateToken}, carrying the HTTP status the route
* should return. `status: 400` means the submitted token is bad (the caller's
* fault); `status: 502` means GitHub itself was unreachable or misbehaved (an
* upstream fault) — the route must not collapse the two into one code.
*/
class TokenValidationError extends Error {
constructor(
message: string,
readonly status: number,
options?: { cause?: unknown },
) {
super(message, options);
this.name = "TokenValidationError";
}
}
async function validateToken(
token: string,
fetchImpl: typeof fetch,
): Promise<{ user: GitHubUserResponse; scopes: string[] }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS);
let response: GitHubValidationResponse;
try {
response = (await fetchImpl(GITHUB_USER_URL, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"User-Agent": "eliza-github-connection",
},
signal: controller.signal,
})) as GitHubValidationResponse;
} catch (err) {
// error-policy:J2 context-adding rethrow — a network failure or the
// validation timeout aborting the request is an upstream-reachability
// problem, not a bad token, so it rethrows typed as 502 with the cause.
throw new TokenValidationError(
"Could not reach GitHub to validate the token. Try again.",
502,
{ cause: err },
);
} finally {
clearTimeout(timer);
}
if (response.status === 401) {
throw new TokenValidationError(
"Token rejected by GitHub: bad credentials.",
400,
);
}
if (response.status === 403) {
throw new TokenValidationError(
"Token rejected by GitHub: forbidden. Check the token has at least `read:user` scope.",
400,
);
}
if (!response.ok) {
// A non-401/403 status is GitHub failing, not the token being invalid.
throw new TokenValidationError(
`GitHub returned ${response.status} validating the token. Try again or generate a new token.`,
502,
);
}
let body: GitHubUserResponse;
try {
body = (await response.json()) as GitHubUserResponse;
} catch (err) {
// error-policy:J2 context-adding rethrow — a 2xx with an unparseable body is
// GitHub misbehaving, the same upstream fault class as the missing-login
// check below, so it surfaces as 502, not a token/client error.
throw new TokenValidationError(
"GitHub /user response was not valid JSON.",
502,
{ cause: err },
);
}
if (typeof body?.login !== "string" || body.login.length === 0) {
throw new TokenValidationError(
"GitHub /user response was missing the login field.",
502,
);
}
const scopesHeader = response.headers.get("x-oauth-scopes") ?? "";
const scopes = scopesHeader
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
return { user: body, scopes };
}
async function handleGetToken(ctx: GitHubRouteContext): Promise<boolean> {
const metadata = await loadMetadata();
sendJson(
ctx,
200,
metadataToStatus(metadata, resolveOauthClientId(ctx).length > 0),
);
return true;
}
/**
* Shared tail of both credential intake paths (PAT paste and device-flow
* grant): validate against GitHub `/user`, persist the record, and apply the
* token to the live runtime's per-agent settings. Sends the error response
* itself and returns `null` when validation fails.
*/
async function validatePersistAndApply(
ctx: GitHubRouteContext,
token: string,
source: "pat" | "device-flow",
): Promise<TokenStatusResponse | null> {
const fetchImpl = ctx.fetch ?? fetch;
let validated: Awaited<ReturnType<typeof validateToken>>;
try {
validated = await validateToken(token, fetchImpl);
} catch (err) {
// error-policy:J1 boundary translation — a bad token surfaces as 400
// (client input), an unreachable/misbehaving GitHub as 502 (upstream);
// TokenValidationError carries which. Unexpected error types default to
// 500 rather than masquerading as a client error.
const status = err instanceof TokenValidationError ? err.status : 500;
const message = err instanceof Error ? err.message : String(err);
logger.warn(
`[github-routes] token validation failed (${status}, source=${source}): ${message}`,
);
sendJson(ctx, status, { error: message });
return null;
}
const credentials = buildCredentialsFromUserResponse(
token,
validated.user,
validated.scopes,
);
await saveCredentials(credentials);
ctx.applyRuntimeToken?.(token);
logger.info(
`[github-routes] saved github token for @${validated.user.login} (source=${source}, scopes=${validated.scopes.join(",") || "(none)"})`,
);
return metadataToStatus(credentials, resolveOauthClientId(ctx).length > 0);
}
async function handlePostToken(ctx: GitHubRouteContext): Promise<boolean> {
const body = await readJsonBody(ctx.req);
const token = body && typeof body.token === "string" ? body.token.trim() : "";
if (token.length === 0) {
sendJson(ctx, 400, { error: "Missing `token` in request body." });
return true;
}
const status = await validatePersistAndApply(ctx, token, "pat");
if (status) sendJson(ctx, 200, status);
return true;
}
async function handleDeleteToken(ctx: GitHubRouteContext): Promise<boolean> {
await clearCredentials();
ctx.clearRuntimeToken?.();
logger.info("[github-routes] cleared saved github token");
sendJson(ctx, 200, {
connected: false,
deviceFlowAvailable: resolveOauthClientId(ctx).length > 0,
});
return true;
}
async function handleDeviceStart(ctx: GitHubRouteContext): Promise<boolean> {
const clientId = resolveOauthClientId(ctx);
if (clientId.length === 0) {
sendJson(ctx, 409, {
error:
"GitHub device sign-in needs owner setup: no GITHUB_OAUTH_CLIENT_ID setting " +
"is configured (register a GitHub OAuth app with device flow enabled). " +
"You can still connect by pasting a personal access token.",
});
return true;
}
try {
const started = await startDeviceFlow({
clientId,
agentKey: ctx.agentKey ?? "",
deps: ctx.fetch ? { fetchImpl: ctx.fetch } : undefined,
});
logger.info(
`[github-routes] started github device sign-in (interval=${started.intervalSeconds}s, expires=${started.expiresInSeconds}s)`,
);
sendJson(ctx, 200, { status: "started", ...started });
} catch (err) {
const status = err instanceof DeviceFlowError ? err.status : 500;
const message = err instanceof Error ? err.message : String(err);
logger.warn(
`[github-routes] github device sign-in start failed (${status}): ${message}`,
);
sendJson(ctx, status, { error: message });
}
return true;
}
async function handleDevicePoll(ctx: GitHubRouteContext): Promise<boolean> {
const body = await readJsonBody(ctx.req);
const flowId =
body && typeof body.flowId === "string" ? body.flowId.trim() : "";
if (flowId.length === 0) {
sendJson(ctx, 400, { error: "Missing `flowId` in request body." });
return true;
}
let result: Awaited<ReturnType<typeof pollDeviceFlow>>;
try {
result = await pollDeviceFlow({
flowId,
agentKey: ctx.agentKey ?? "",
deps: ctx.fetch ? { fetchImpl: ctx.fetch } : undefined,
});
} catch (err) {
const status = err instanceof DeviceFlowError ? err.status : 500;
const message = err instanceof Error ? err.message : String(err);
logger.warn(
`[github-routes] github device sign-in poll failed (${status}): ${message}`,
);
sendJson(ctx, status, { error: message });
return true;
}
if (result.status !== "complete") {
// pending / denied / expired are protocol outcomes, not transport errors —
// the polling client switches on `status`.
if (result.status !== "pending") {
logger.info(
`[github-routes] github device sign-in ended without a grant (${result.status})`,
);
}
sendJson(ctx, 200, result);
return true;
}
const status = await validatePersistAndApply(
ctx,
result.token,
"device-flow",
);
if (status) sendJson(ctx, 200, { status: "complete", ...status });
return true;
}
/**
* Dispatch entry point. Returns `true` when this module owned the request.
* Caller is responsible for auth (mirrors `/api/workflow/*` in server.ts).
*/
export async function handleGitHubRoutes(
ctx: GitHubRouteContext,
): Promise<boolean> {
if (ctx.pathname === "/api/github/device/start") {
if (ctx.method !== "POST") {
sendJson(ctx, 405, { error: "Method not allowed" });
return true;
}
return handleDeviceStart(ctx);
}
if (ctx.pathname === "/api/github/device/poll") {
if (ctx.method !== "POST") {
sendJson(ctx, 405, { error: "Method not allowed" });
return true;
}
return handleDevicePoll(ctx);
}
if (ctx.pathname !== "/api/github/token") return false;
switch (ctx.method) {
case "GET":
return handleGetToken(ctx);
case "POST":
return handlePostToken(ctx);
case "DELETE":
return handleDeleteToken(ctx);
default:
sendJson(ctx, 405, { error: "Method not allowed" });
return true;
}
}
@@ -0,0 +1,65 @@
/**
* Tests registration and query execution of the github_pull_requests search
* category against a mock runtime with an in-memory category registry.
*/
import type { IAgentRuntime, SearchCategoryRegistration } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import {
GITHUB_PULL_REQUESTS_SEARCH_CATEGORY,
registerGitHubSearchCategory,
} from "./search-category";
function createRuntime() {
const categories = new Map<string, SearchCategoryRegistration>();
const registerSearchCategory = vi.fn(
(registration: SearchCategoryRegistration) => {
categories.set(registration.category, registration);
},
);
const getSearchCategory = vi.fn((category: string) => {
const registration = categories.get(category);
if (!registration) throw new Error(`Missing category ${category}`);
return registration;
});
return {
categories,
registerSearchCategory,
runtime: Object.assign(Object.create(null) as IAgentRuntime, {
getSearchCategory,
registerSearchCategory,
}),
};
}
describe("GitHub search category", () => {
it("registers pull request search filters", () => {
const { categories, registerSearchCategory, runtime } = createRuntime();
registerGitHubSearchCategory(runtime);
registerGitHubSearchCategory(runtime);
expect(registerSearchCategory).toHaveBeenCalledTimes(1);
expect(categories.get("github_pull_requests")).toMatchObject({
category: "github_pull_requests",
serviceType: "github",
source: "plugin:github",
});
expect(
GITHUB_PULL_REQUESTS_SEARCH_CATEGORY.filters?.map(
(filter) => filter.name,
),
).toEqual(
expect.arrayContaining([
"query",
"repo",
"state",
"author",
"as",
"accountId",
"limit",
]),
);
});
});
@@ -0,0 +1,91 @@
/**
* Defines and registers the `github_pull_requests` search category so the
* runtime's generic search surface can query PRs (by repo, state, author, and
* acting identity) through the GitHubService.
*/
import type { IAgentRuntime, SearchCategoryRegistration } from "@elizaos/core";
export const GITHUB_PULL_REQUESTS_SEARCH_CATEGORY: SearchCategoryRegistration =
{
category: "github_pull_requests",
label: "GitHub pull requests",
description:
"Search GitHub pull requests in a repo or across accessible repositories.",
contexts: ["code", "automation"],
filters: [
{ name: "query", label: "Query", type: "string" },
{
name: "repo",
label: "Repository",
description:
"Repository in owner/name format. Omit to search accessible repositories.",
type: "string",
},
{
name: "state",
label: "State",
description: "Pull request state.",
type: "enum",
default: "open",
options: [
{ label: "Open", value: "open" },
{ label: "Closed", value: "closed" },
{ label: "All", value: "all" },
],
},
{
name: "author",
label: "Author",
description: "GitHub login to filter by.",
type: "string",
},
{
name: "as",
label: "Identity",
description: "Configured GitHub identity token to use.",
type: "enum",
default: "agent",
options: [
{ label: "Agent", value: "agent" },
{ label: "User", value: "user" },
],
},
{
name: "accountId",
label: "Account",
description:
"Optional configured GitHub account id. Defaults by identity role.",
type: "string",
},
{
name: "limit",
label: "Limit",
description: "Maximum pull requests to return.",
type: "number",
default: 50,
},
],
resultSchemaSummary:
"PRSummary[] with repo, number, title, author, state, and url.",
capabilities: ["pull-requests", "issues-search", "repositories"],
source: "plugin:github",
serviceType: "github",
};
function hasSearchCategory(runtime: IAgentRuntime, category: string): boolean {
try {
runtime.getSearchCategory(category, { includeDisabled: true });
return true;
} catch {
return false;
}
}
export function registerGitHubSearchCategory(runtime: IAgentRuntime): void {
if (
!hasSearchCategory(runtime, GITHUB_PULL_REQUESTS_SEARCH_CATEGORY.category)
) {
runtime.registerSearchCategory(GITHUB_PULL_REQUESTS_SEARCH_CATEGORY);
}
}
@@ -0,0 +1,138 @@
/**
* @module github-service
* @description Service that owns GitHub REST clients for the plugin.
*
* Role-tagged account records are supported, with the legacy user/agent PAT
* split preserved as the default account set. Actions request an Octokit
* client by role and optionally by accountId; the service returns `null` when
* the requested account has no token configured.
*/
import { type IAgentRuntime, logger, Service } from "@elizaos/core";
import { Octokit } from "@octokit/rest";
import {
defaultGitHubAccountIdForRole,
type GitHubAccountConfig,
type GitHubAccountSelection,
readGitHubAccountsWithConnectorCredentials,
resolveGitHubAccount,
} from "../accounts.js";
import {
GITHUB_SERVICE_TYPE,
type GitHubIdentity,
type GitHubOctokitClient,
type IGitHubService,
} from "../types.js";
interface GitHubClientRecord {
account: GitHubAccountConfig;
client: GitHubOctokitClient;
}
function normalizeSelector(
selector:
| GitHubIdentity
| { as?: GitHubIdentity; role?: GitHubIdentity; accountId?: string },
): GitHubAccountSelection {
if (selector === "user" || selector === "agent") {
return { role: selector };
}
return {
accountId:
typeof selector.accountId === "string" && selector.accountId.trim()
? selector.accountId.trim()
: undefined,
role: selector.role ?? selector.as ?? "agent",
};
}
export class GitHubService extends Service implements IGitHubService {
static serviceType = GITHUB_SERVICE_TYPE;
capabilityDescription =
"GitHub REST API integration for PRs, issues, and notifications";
private clients = new Map<string, GitHubClientRecord>();
constructor(
runtime?: IAgentRuntime,
private readonly createClient: (auth: string) => GitHubOctokitClient = (
auth,
) => new Octokit({ auth }),
) {
super(runtime);
}
static async start(
runtime: IAgentRuntime,
createClient?: (auth: string) => GitHubOctokitClient,
): Promise<Service> {
const service = new GitHubService(runtime, createClient);
await service.initialize();
return service;
}
private async initialize(): Promise<void> {
if (!this.runtime) {
return;
}
const accounts = await readGitHubAccountsWithConnectorCredentials(
this.runtime,
);
this.clients.clear();
for (const account of accounts) {
this.clients.set(account.accountId, {
account,
client: this.createClient(account.token),
});
}
for (const role of ["user", "agent"] as const) {
if (!resolveGitHubAccount(accounts, { role })) {
logger.info(
`[GitHubService] no GitHub ${role} account configured — ${role}-acting calls will be rejected`,
);
}
}
logger.info(
`[GitHubService] configured ${this.clients.size} GitHub account(s)`,
);
}
getOctokit(
selector:
| GitHubIdentity
| { as?: GitHubIdentity; role?: GitHubIdentity; accountId?: string },
): GitHubOctokitClient | null {
const selection = normalizeSelector(selector);
const account = resolveGitHubAccount(
Array.from(this.clients.values()).map((record) => record.account),
selection,
);
if (!account) {
return null;
}
return this.clients.get(account.accountId)?.client ?? null;
}
/**
* Allows tests to inject an Octokit-shaped mock without going through
* environment variables. Not part of the public runtime contract.
*/
setClientForTesting(
as: GitHubIdentity,
client: GitHubOctokitClient | null,
accountId = defaultGitHubAccountIdForRole(as),
): void {
if (!client) {
this.clients.delete(accountId);
return;
}
this.clients.set(accountId, {
account: { accountId, role: as, token: "test" },
client,
});
}
async stop(): Promise<void> {
this.clients.clear();
}
}
+192
View File
@@ -0,0 +1,192 @@
/**
* @module types
* @description Shared types for the GitHub plugin
*/
import type { Octokit } from "@octokit/rest";
type OctokitEndpoint<T, Data> = T extends (...args: infer Args) => unknown
? (...args: Args) => Promise<{ data: Data }>
: never;
/**
* Identifies which configured token (user-acting or agent-acting) an action
* should execute under. The plugin loads two independent PATs so the user
* and agent personas can act separately on the same repo.
*/
export type GitHubIdentity = "user" | "agent";
type GitHubUserSummary = {
login?: string | null;
};
type GitHubPullRequestSummary = {
number: number;
title: string;
state: string;
html_url: string;
user?: GitHubUserSummary | null;
};
type GitHubSearchIssueSummary = {
repository_url: string;
number: number;
title: string;
state: string;
html_url: string;
user?: GitHubUserSummary | null;
};
type GitHubReviewResult = {
id: number;
};
type GitHubIssueResult = {
number: number;
html_url: string;
};
type GitHubAssigneesResult = {
assignees?: Array<GitHubUserSummary | null> | null;
};
type GitHubLabelSummary =
| string
| {
name?: string | null;
};
type GitHubIssueDetail = {
number: number;
title: string;
state: string;
html_url: string;
body?: string | null;
labels?: Array<GitHubLabelSummary | null> | null;
user?: GitHubUserSummary | null;
};
type GitHubIssueCommentResult = {
id: number;
html_url: string;
};
type GitHubAddLabelsResult = Array<GitHubLabelSummary | null>;
type GitHubNotificationSummary = {
id: string;
reason?: string | null;
repository?: {
full_name?: string | null;
pushed_at?: string | null;
};
subject?: {
title?: string | null;
type?: string | null;
url?: string | null;
};
updated_at: string;
};
/**
* Narrow Octokit surface used by this plugin's actions. Keeping the service
* contract structural makes tests and local API mocks straightforward without
* depending on the full Octokit class shape.
*/
export interface GitHubOctokitClient {
activity: {
listNotificationsForAuthenticatedUser: OctokitEndpoint<
Octokit["activity"]["listNotificationsForAuthenticatedUser"],
GitHubNotificationSummary[]
>;
};
issues: {
addAssignees: OctokitEndpoint<
Octokit["issues"]["addAssignees"],
GitHubAssigneesResult
>;
addLabels: OctokitEndpoint<
Octokit["issues"]["addLabels"],
GitHubAddLabelsResult
>;
create: OctokitEndpoint<Octokit["issues"]["create"], GitHubIssueResult>;
createComment: OctokitEndpoint<
Octokit["issues"]["createComment"],
GitHubIssueCommentResult
>;
get: OctokitEndpoint<Octokit["issues"]["get"], GitHubIssueDetail>;
listForRepo: OctokitEndpoint<
Octokit["issues"]["listForRepo"],
GitHubIssueDetail[]
>;
update: OctokitEndpoint<Octokit["issues"]["update"], GitHubIssueDetail>;
};
pulls: {
createReview: OctokitEndpoint<
Octokit["pulls"]["createReview"],
GitHubReviewResult
>;
list: OctokitEndpoint<Octokit["pulls"]["list"], GitHubPullRequestSummary[]>;
};
search: {
issuesAndPullRequests: OctokitEndpoint<
Octokit["search"]["issuesAndPullRequests"],
{ items: GitHubSearchIssueSummary[] }
>;
};
}
/**
* Service contract exposed to actions. Actions resolve their Octokit client
* via this interface and never read environment variables directly.
*/
export interface IGitHubService {
getOctokit(
selector:
| GitHubIdentity
| { as?: GitHubIdentity; role?: GitHubIdentity; accountId?: string },
): GitHubOctokitClient | null;
}
export const GITHUB_SERVICE_TYPE = "github";
export const GitHubActions = {
GITHUB_ISSUE_OP: "GITHUB_ISSUE",
GITHUB_PR_OP: "GITHUB_PR",
GITHUB_NOTIFICATION_TRIAGE: "GITHUB_NOTIFICATION_TRIAGE",
} as const;
/** Issue ops accepted by GITHUB_ISSUE_OP. */
export type GitHubIssueOp =
| "create"
| "assign"
| "close"
| "reopen"
| "comment"
| "label";
/** PR ops accepted by GITHUB_PR_OP. */
export type GitHubPrOp = "list" | "review";
/**
* Structured result returned by action handlers. Actions never throw —
* recoverable problems are surfaced as `{ success: false }` with a reason,
* and destructive actions surface a confirmation request distinctly.
*/
export type GitHubActionResult<T = unknown> =
| { success: true; data: T; text?: string }
| { success: false; error: string }
| { success: false; requiresConfirmation: true; preview: string };
export interface RateLimitError {
kind: "rate-limit";
resetAtMs: number | null;
message: string;
}
/** Parameters shared by every action invocation. */
export interface BaseActionOptions {
as?: GitHubIdentity;
accountId?: string;
confirmed?: boolean;
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"ignoreDeprecations": "6.0",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"src/**/__tests__/**",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"**/__tests__/**",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.e2e.test.ts"
]
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Vitest config for the plugin. Applies the repo's shared provider-SDK
* aliases/shims (via `providerSdkShimPlugin`) and runs the plugin's `src`
* test files in a node environment.
*/
import path from "node:path";
import { defineConfig } from "vitest/config";
import baseConfig from "../../packages/test/vitest/default.config";
import {
providerSdkAliases,
providerSdkShimPlugin,
} from "../../packages/test/vitest/provider-sdk-aliases";
const baseResolveAliases = Array.isArray(baseConfig.resolve?.alias)
? baseConfig.resolve.alias
: [];
const baseTestAliases = Array.isArray(baseConfig.test?.alias)
? baseConfig.test.alias
: [];
export default defineConfig({
...baseConfig,
plugins: [...(baseConfig.plugins ?? []), providerSdkShimPlugin()],
resolve: {
...baseConfig.resolve,
alias: [...providerSdkAliases, ...baseResolveAliases],
},
test: {
...baseConfig.test,
alias: [...providerSdkAliases, ...baseTestAliases],
globals: false,
environment: "node",
include: ["src/**/*.test.ts"],
exclude: ["node_modules", "dist"],
root: path.resolve(__dirname),
},
});