Skip to content

Latest commit

 

History

History
428 lines (303 loc) · 24.3 KB

File metadata and controls

428 lines (303 loc) · 24.3 KB

GitHub App Bot Identity Setup

Overview

Minsky supports a GitHub App service account (minsky-ai[bot]) for automated operations such as PR review submission. Using a bot identity rather than your personal access token provides several advantages:

  • Stale approval dismissal: GitHub can be configured to dismiss pull request approvals when code changes; reviews from a bot identity are unaffected, keeping human approvals valid after bot activity.
  • Clear attribution: Automated comments, reviews, and merges are visibly attributed to the bot, not to a human user.
  • Hosted service model: A future hosted Minsky service can install a single central App across customer repositories without requiring access to any user's personal token.

TokenProvider Architecture

Minsky uses a TokenProvider interface to abstract GitHub API token acquisition. Two implementations exist:

  • FallbackTokenProvider — uses your personal access token for both user and service operations. This is the default when no service account is configured.
  • GitHubAppTokenProvider — authenticates as a GitHub App installation for service operations (posting reviews, creating/merging PRs) while still using your personal token for user-attributed operations.

When github.serviceAccount is present in the Minsky configuration, createTokenProvider automatically selects GitHubAppTokenProvider.

Prerequisites

  • A GitHub account with owner access to the target repository (required to create and install a GitHub App)
  • Minsky configured and working for the target repository with the github backend

1. Create the GitHub App

You can create and install the App either via the minsky setup github-app CLI subcommand (recommended), the equivalent bun scripts/create-github-app.ts script (for fresh checkouts before the CLI is installed), or through the GitHub UI as a manual fallback. The CLI/script covers sections 1–3 (create App, generate key, install on repo, fetch installation ID) in a single browser-driven flow and saves all credentials to ~/.config/minsky/ with correct permissions.

Recommended: minsky setup github-app

Run the subcommand with the App name and target repo. Minsky renders the manifest, spins up a local callback listener, opens your browser to GitHub's "Create App from manifest" page, captures the redirect, exchanges the code for credentials, and writes them to ~/.config/minsky/<name>.{pem,json}. A manifest preview is shown before submission so you can confirm exactly what will be created.

Canonical invocations:

# Implementer App (code author, PR creator; no webhook needed):
minsky setup github-app \
  --name minsky-ai \
  --repo <your-owner>/<your-repo> \
  --inactive

# Reviewer App (Chinese-wall adversarial reviewer, mt#1073; webhook-driven):
minsky setup github-app \
  --name minsky-reviewer \
  --repo <your-owner>/<your-repo> \
  --permissions pull_requests:write,contents:read,metadata:read \
  --events pull_request,issue_comment \
  --webhook-url https://minsky-reviewer.example.com/webhook

# Guided wizard fallback (GitHub Enterprise instances, restricted SSO orgs,
# air-gapped setups — anywhere the manifest flow does not apply):
minsky setup github-app \
  --name minsky-reviewer \
  --repo <your-owner>/<your-repo> \
  --via wizard

The wizard variant walks you through the manual portal steps interactively, prompts for the App ID / installation ID / PEM contents, and validates the pasted credentials against the GitHub API before saving.

Two-phase manifest flow (App created, install pending)

If GitHub creates the App but you haven't yet installed it on the target repo when the redirect fires, the local server stays running and serves a /check-install endpoint. The browser shows an Install App link and the URL to revisit:

  1. Click Install App and complete installation in the GitHub UI.
  2. Return to your terminal's localhost tab and visit http://localhost:<port>/check-install. The provisioner re-queries /app/installations, captures the installation ID, and writes credentials to ~/.config/minsky/<name>.{pem,json}.

If you close the browser without finishing the install or miss the 5-minute deadline, the App will exist on GitHub but no local credentials will be saved. To recover:

  • Same App name: re-running minsky setup github-app --name <name> --force will start a fresh manifest flow. GitHub may reject creating a second App with the exact same name; if so, choose a different --name or delete the orphaned App in your GitHub developer settings first.
  • Different name: re-run with a new --name value to provision a fresh App.

Alternative: bun scripts/create-github-app.ts

Equivalent flag surface; useful before the CLI is installed:

bun scripts/create-github-app.ts \
  --name minsky-ai \
  --repo <your-owner>/<your-repo> \
  --inactive

The script writes:

  • ~/.config/minsky/<name>.pem (private key, 0600)
  • ~/.config/minsky/<name>.json (App ID, slug, client ID, installation ID, creation timestamp)

Flags:

  • --name <name> — required. Also used as file prefix under ~/.config/minsky/.
  • --repo <owner/repo> — required. Owner is matched against the install account during installation lookup.
  • --permissions <k:v,...> — optional. Default: pull_requests:write,contents:write,metadata:read (contents:write is required for session_commit's App-token push, mt#1477/mt#3210/mt#3218).
  • --events <e1,e2,...> — optional. Default: none.
  • --webhook-url <url> — optional. Prefills hook_attributes.url in the App manifest. Use this for webhook-driven Apps (reviewer, automation services). Without it, a placeholder URL is submitted (GitHub requires the field).
  • --inactive — optional. Creates the App with hook_attributes.active=false. Default: active. Use this for Apps that don't need webhooks (the minsky-ai implementer App). Note that GitHub's REST API has no endpoint to toggle active later, so choose correctly up front — the only remediation is a manual toggle in the App settings UI.
  • --port <n> — optional. TCP port for the local manifest-flow callback. Must be 1-65535; port 0 is rejected because GitHub embeds the redirect URL in the manifest before the server binds. Default: 9847.
  • --force — optional. Re-provision even if credentials already exist for <name>. Without --force, the orchestrator short-circuits and prints the existing credentials.
  • --help / -h — print usage.

The same flags are accepted by minsky setup github-app, plus --via {manifest|wizard}, --apiBaseUrl <url>, and --webBaseUrl <url> (for GitHub Enterprise hosts when --via wizard).

Checking for permission/event drift against an existing App (--update)

GitHub's REST API has no endpoint to update an existing App's default_permissions or default_events — modifying an already-created App's registration (docs) is exclusively a web-UI procedure, and even then a permission change is only a request: every account where the App is installed must separately accept it before it takes effect. There is nothing to automate here beyond detecting that a change is needed.

minsky setup github-app --update reflects that: it reads the App's stored credentials, fetches its current, live configuration via GET /app (a read, which works), diffs it against the --events/--permissions you pass, and prints an actionable message — never a mutation.

# Show drift between the App's live config and the requested settings:
minsky setup github-app \
  --name minsky-reviewer \
  --update \
  --events pull_request,issue_comment \
  --permissions pull_requests:write,contents:read,metadata:read

If the live config already matches, the command reports "No changes." If it differs, the output names the specific field(s) that differ, the App's exact settings URL (https://github.com/settings/apps/<slug>/permissions), and the installation-acceptance step. Go make the change there — see Manual: GitHub UI step 4 for the permission-level meanings.

Update-mode flags:

  • --update — switch to drift-check mode. Reads stored credentials from <outputDir>/<name>.{pem,json}.
  • --events <e1,e2,...> — event subscription list to compare against the live config.
  • --permissions <k:v,...> — permissions map to compare against the live config.
  • --name <name> — required. Identifies which stored credentials to use.
  • --repo is not required in update mode (the App already exists).
  • --execute — accepted for backward compatibility; has no effect (there is no API call to gate behind it).

Detecting drift automatically: minsky config doctor also runs this comparison for you when a GitHub App service account is configured (mt#3218) — a "GitHub App Permissions" diagnostic reads the App's live permissions the same way and warns with the same settings-URL-plus-specific-permission message if anything is missing, without you needing to invoke --update by hand.

After the script exits, skip to §4 (configure Minsky). Sections 2 and 3 are automated; section 1 steps below are only needed if you prefer the UI path.

Manual: GitHub UI

  1. Visit https://github.com/settings/apps/new

  2. Fill in the basic information:

    • GitHub App name: minsky (or a unique name for private deployments — app names are globally unique on github.com)
    • Homepage URL: your repository URL (e.g., https://github.com/you/yourrepo)
  3. Webhook: Uncheck "Active" unless you want GitHub to send events to a server. For local Minsky usage, no webhook is needed.

  4. Set Repository permissions:

    Permission Access level
    Pull requests Read & write
    Contents Read & write
    Metadata Read-only (auto-included)

    Why Contents is Read & write by default (mt#1477, mt#3210, mt#3218). session_commit's git push injects the App installation token as an HTTP Authorization header so the push authenticates as the App (pushes authenticated as the App reliably trigger pull_request workflows; keychain-credentialed pushes may not). That push needs Contents: Read & write to succeed — with Contents: Read-only, every App-token push is denied (403 "Permission ... denied to <app-slug>[bot]"), deterministically, regardless of token freshness. This was the manifest-flow's default for minsky setup github-app until mt#3218 (tracked as mt#3210's upstream cause); it is now Read & write in both places. session_commit still detects a denial and automatically retries via system keychain credentials as a safety net — so a misconfigured App degrades gracefully rather than surfacing a failed push — but you lose the App identity and the CI-trigger reliability mt#1477 exists for on that fallback path.

    Downgrading to Read-only. If this App only creates PRs and posts reviews via the REST API and never runs session_commit against it (e.g. a pure review-only service account), Contents: Read-only is sufficient and reduces the App's blast radius. There is no API to change this after creation (see Checking for permission/event drift above) — decide at creation time, or change it later at https://github.com/settings/apps/<slug>/permissions (the installing account must then accept the new permission set).

    Optional: CI rerun capability (mt#2775). The permission set above does not include Actions. If you want to use forge_ci_run_rerun (the MCP/CLI tool that re-runs a GitHub Actions workflow run — see src/adapters/shared/commands/forge.ts), also grant Actions: Read and write under Repository permissions. This can only be set at creation time or via the settings UI (https://github.com/settings/apps/<slug>/permissions) — there is no update API. Without it, the tool returns a structured error naming the missing permission (403 "Resource not accessible by integration") rather than failing silently.

  5. Where can this GitHub App be installed?

    • Choose "Only on this account" for personal use or a single-organization deployment.
    • Choose "Any account" if you plan to offer this as a hosted service.
  6. Click Create GitHub App.

  7. On the App settings page that appears, note the App ID (shown near the top of the page).

  8. Scroll to the Private keys section, click Generate a private key, and download the .pem file.

2. Install the App

  1. From the App settings page, click Install App in the left sidebar.

  2. Choose the account (your personal account or an organization) where the target repository lives.

  3. Select Only select repositories and choose the specific repositories you want the bot to access. Minimal scope is recommended.

  4. Click Install.

  5. After installation, look at the browser URL. For an installation on a personal account it will be:

    https://github.com/settings/installations/<INSTALLATION-ID>
    

    Note the numeric Installation ID from the URL.

    This form is verified against GitHub rather than assumed: the installation object returned by GET /app/installations/{installation_id} carries an html_url, and bun scripts/verify-installation-settings-url.ts asserts that Minsky's constructed link equals it. For this project's installation it does (target_type: User).

    For an installation on an organization, the URL is different — GitHub configures an org installation under that organization's own settings (Settings > Third-party Access > GitHub Apps), and publishes no URL for either case. Take the ID from whatever URL your browser actually shows.

    You do not need to tell Minsky which case you are in (mt#4764). The links minsky setup emits are read from GitHub's own html_url on the installation object, so they are correct for a personal account and an organization alike. The constructed https://github.com/settings/installations/<ID> form above is only the fallback, used when that read is unavailable — and it is verified correct for the personal-account case (bun scripts/verify-installation-settings-url.ts, which compares it against html_url).

What minsky setup does with this ID

Once github.serviceAccount.installationId is configured, minsky setup's App-coverage check emits the settings page as a direct link when a repository is not covered, rather than telling you to navigate there:

GitHub App: installation does NOT cover edobry/peezombie.me
  Pull-request creation will fail with a 404 until this is granted.
  Grant minsky-ai access at https://github.com/settings/installations/125403046 — pick edobry/peezombie.me under Repository access, then Save.

When no installation ID is configured, it falls back to the navigation path above rather than emitting a guessed URL. Both forms name the App slug, so a project with several configured App roles (implementer, reviewer) can tell the blocks apart.

3. Store Credentials

Move the downloaded private key to a secure location and restrict its permissions:

mkdir -p ~/.config/minsky
mv ~/Downloads/<app-slug>.*.private-key.pem ~/.config/minsky/minsky-app.pem
chmod 600 ~/.config/minsky/minsky-app.pem

Never commit the .pem file to version control.

4. Configure Minsky

You can supply the GitHub App credentials via a config file or environment variables. Environment variables take precedence.

If you used the automated path (scripts/create-github-app.ts): the script wrote credentials to ~/.config/minsky/<name>.pem (private key) and ~/.config/minsky/<name>.json (metadata including appId, installationId, and privateKeyFile). Paste the appId, installationId, and privateKeyFile values from the JSON into the examples below — they are filled in for you.

If you used the manual UI path: substitute the App ID, installation ID from section 2, and the private-key path you chose in section 3.

Option A: Config File

Add the serviceAccount block under github in ~/.config/minsky/config.yaml:

github:
  token: <your-personal-access-token>
  serviceAccount:
    type: github-app
    appId: <YOUR-APP-ID>
    privateKeyFile: /Users/you/.config/minsky/<name>.pem # where <name> matches --name (e.g., minsky-ai, minsky-reviewer)
    installationId: <YOUR-INSTALLATION-ID>

token is your existing personal access token (unchanged). The serviceAccount block adds the bot identity on top of it.

Option B: Environment Variables (local, file-backed key)

export MINSKY_APP_ID=<YOUR-APP-ID>
export MINSKY_APP_PRIVATE_KEY_FILE=~/.config/minsky/<name>.pem  # where <name> matches --name
export MINSKY_APP_INSTALLATION_ID=<YOUR-INSTALLATION-ID>

Add these to your shell profile (.zshrc, .bashrc, etc.) to persist across sessions.

When using environment variables, the type: github-app discriminant is inferred automatically — you do not need to set a separate env var for it.

Option C: Hosted / Containerized Deploy (inline PEM via env var)

When running Minsky in a container or hosted environment (Railway, Docker, CI runners) there is no persistent filesystem to hold ~/.config/minsky/<name>.pem. Instead of staging the key into the image at build time (which leaks it into every layer), pass the PEM content directly via MINSKY_GITHUB_APP_PRIVATE_KEY:

# Preferred — the shell preserves real newlines end-to-end:
railway variables --set MINSKY_GITHUB_APP_PRIVATE_KEY="$(cat ~/.config/minsky/<name>.pem)"
railway variables --set MINSKY_APP_ID=<YOUR-APP-ID>
railway variables --set MINSKY_APP_INSTALLATION_ID=<YOUR-INSTALLATION-ID>

Gotcha — Railway web UI flattens multi-line values. If you paste the PEM into Railway's dashboard, Railway stores it as a single line with literal \n escape sequences instead of real newlines. Minsky's GitHubAppTokenProvider auto-normalizes the \n-escaped form back to real newlines before signing, so both shapes work. The CLI $(cat ...) form above avoids the flattening entirely and is less error-prone.

Precedence. When both privateKey (inline) and privateKeyFile (path) are set, inline content wins. This lets you run the same image locally and in a container without reconfiguration — the container-only env var takes over when present, and your local file-path config is ignored.

Security note. The PEM value is never logged, never surfaced in error messages, and the process never writes it back to disk. Treat the env var as you would a private key file — scope it to the single service that needs it and rotate if it leaks.

See docs/deploy-minsky-railway.md for the full Railway deploy walkthrough that uses this env var.

5. Verify Configuration

Check that the service account fields appear in the resolved configuration:

minsky config show | grep -A4 serviceAccount

Expected output:

serviceAccount:
  type: github-app
  appId: 123456
  privateKeyFile: /Users/you/.config/minsky/minsky-app.pem
  installationId: 78901234

Then verify that the token provider can authenticate and report the bot identity with the following script:

// verify-bot.ts
import { createTokenProvider } from "./src/domain/auth/index.ts";
import type { GitHubConfig } from "./src/domain/configuration/schemas/github.ts";

const config: GitHubConfig = {
  serviceAccount: {
    type: "github-app",
    appId: Number(process.env.MINSKY_APP_ID),
    privateKeyFile: process.env.MINSKY_APP_PRIVATE_KEY_FILE!,
    installationId: Number(process.env.MINSKY_APP_INSTALLATION_ID),
  },
};

const provider = createTokenProvider(config, process.env.GITHUB_TOKEN!);

const identity = await provider.getServiceIdentity();
console.log("Service identity:", identity);
// Expected: { login: "minsky[bot]", type: "app" }

const token = await provider.getServiceToken();
console.log(
  "Installation token acquired:",
  token.startsWith("ghs_") ? "yes (ghs_ prefix)" : token.slice(0, 10)
);

Run with:

bun run verify-bot.ts

6. How It Works

When github.serviceAccount is present in the resolved configuration:

  1. Factory selection: createTokenProvider instantiates GitHubAppTokenProvider instead of FallbackTokenProvider.

  2. JWT generation: For each GitHub API call that needs a service token, GitHubAppTokenProvider.generateJwt() creates a short-lived RS256 JWT signed with the private key. The JWT is issued 60 seconds in the past (to tolerate clock skew) and expires after 9 minutes.

  3. Installation token exchange: The JWT is sent to POST /app/installations/{installationId}/access_tokens. GitHub returns a short-lived installation access token (ghs_...) valid for 1 hour. Optionally, a specific repository can be scoped by passing the repo name in the request body.

  4. Token caching: The installation token is cached in memory. Tokens are considered expired when fewer than 5 minutes remain, triggering a silent refresh before the next API call.

  5. Routing: All GitHub API operations performed by Minsky's RepositoryBackend (create PR, merge PR, post reviews) call TokenProvider.getServiceToken(), so they authenticate as minsky-ai[bot] (or whatever slug you gave the App).

  6. Review submission: The mcp__minsky__session_pr_review_submit MCP tool routes through this pipeline, so review comments appear as authored by the bot.

7. Troubleshooting

"Failed to fetch GitHub App info: 401"

The private key does not match the App ID, or the key has been revoked. Check:

  • The appId in your config matches the App ID on the GitHub App settings page.
  • The .pem file is the one generated for this App (not a different App's key).
  • The key has not been deleted from the App settings page.

"Failed to create GitHub App installation token: 404" or "No installation found"

The App is not installed on the account that owns the target repository. Return to Install the App and verify the installation covers the correct account and repository.

Reviews still posting as the user, not the bot

Run minsky config show and verify serviceAccount appears in the output. If it is missing:

  • Check for YAML syntax errors in ~/.config/minsky/config.yaml (indentation must be consistent).
  • If using env vars, verify MINSKY_APP_ID, MINSKY_APP_INSTALLATION_ID, and one of MINSKY_APP_PRIVATE_KEY_FILE (local) or MINSKY_GITHUB_APP_PRIVATE_KEY (hosted, inline PEM) are all exported (echo $MINSKY_APP_ID should return a value). If neither key variable is set, Minsky raises "GitHub App private key is not configured: set MINSKY_GITHUB_APP_PRIVATE_KEY (env var) or github.serviceAccount.privateKeyFile (config file)".

"Installation token expired" or stale token errors

GitHubAppTokenProvider automatically refreshes tokens 5 minutes before the 1-hour expiry. If you still see expired token errors, check that your system clock is accurate — a clock skew of more than a few minutes can cause JWT validation failures on GitHub's side.

Private key file not found

The privateKeyFile path is expanded with ~/ support. Verify the path is correct and the file has not been moved. Check permissions with ls -la ~/.config/minsky/minsky-app.pem — it should show -rw-------.

8. Replication for Private Deployments

GitHub App names are globally unique on github.com. Each deployment (individual developer, team, or hosted instance) needs its own GitHub App with a unique name.

To set up a private deployment:

  1. Each deployment operator creates their own App at https://github.com/settings/apps/new with a unique name (e.g., minsky-yourorg).
  2. Configure the same permissions as listed in Create the GitHub App: pull requests (read & write), contents (read & write).
  3. Install the App on the repositories the deployment will manage.
  4. Provide the App ID, private key file path, and installation ID to Minsky via the config file or environment variables described in Configure Minsky.

For a future hosted Minsky service, a single central App would be registered once and installed on customer repositories via GitHub's standard App installation flow — customers would authorize the App through the GitHub UI, and Minsky would receive the installation ID as part of the onboarding process.