Skip to content
Merged
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@
{
"name": "github-app",
"description": "Automatic GitHub App token lifecycle for Claude Code sessions. Generates installation tokens on session start, monitors expiry via PreToolUse hook, and refreshes transparently before commands that need authentication.",
"version": "0.1.14",
"version": "0.2.0",
"author": {
"name": "Nathan Heaps"
},
Expand Down
2 changes: 1 addition & 1 deletion plugins/github-app/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "github-app",
"version": "0.1.14",
"version": "0.2.0",
Comment thread
nsheaps marked this conversation as resolved.
"description": "Automatic GitHub App token lifecycle for Claude Code sessions. Generates installation tokens on session start, monitors expiry via PreToolUse hook, and refreshes transparently before commands that need authentication.",
"author": {
"name": "Nathan Heaps",
Expand Down
37 changes: 30 additions & 7 deletions plugins/github-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ github-app:
github-app:
ref: "env-file://./.env.github-app" # relative to project
# or
ref: "env-file://~/.config/agent/github-app.env" # absolute
ref: "env-file://~/.agents/<agent-name>/.config/github-app.env" # absolute
```

The source should provide fields named:
Expand Down Expand Up @@ -81,7 +81,7 @@ Set before the session starts:

```bash
export GITHUB_APP_ID="12345"
export GITHUB_APP_PRIVATE_KEY_PATH="~/.config/agent/github-app.pem"
export GITHUB_APP_PRIVATE_KEY_PATH="~/.agents/<agent-name>/.config/github-app.pem"
export GITHUB_INSTALLATION_ID="67890"
```

Expand All @@ -90,7 +90,7 @@ export GITHUB_INSTALLATION_ID="67890"
```yaml
github-app:
github_app_id: "12345"
private_key_path: "~/.config/agent/github-app.pem"
private_key_path: "~/.agents/<agent-name>/.config/github-app.pem"
github_installation_id: "67890"
```

Expand All @@ -104,15 +104,15 @@ The private key can be provided as:
When using a PEM file directly, ensure correct permissions:

```bash
chmod 600 ~/.config/agent/github-app.pem
chmod 600 ~/.agents/<agent-name>/.config/github-app.pem
```

## How It Works

1. **Session starts**: Hook reads App credentials, generates JWT, exchanges for installation token
2. **Token stored**: Written to `~/.config/agent/github-token` with 600 permissions
2. **Token stored**: Written to `~/.agents/${AGENT_NAME}/.config/github-token` with 600 permissions (where `AGENT_NAME` defaults to `_UNKNOWN` if unset)
3. **Git identity configured**: Sets `git config user.name` and `user.email` to the App's bot identity (e.g., `my-app[bot]` / `12345+my-app[bot]@users.noreply.github.com`)
4. **Runtime env file**: `GH_TOKEN` and `GITHUB_TOKEN` written to `~/.config/agent/github-app-env`, sourced by `CLAUDE_ENV_FILE`
4. **Runtime env file**: `GH_TOKEN` and `GITHUB_TOKEN` written to `~/.agents/${AGENT_NAME}/.config/github-app-env`, sourced by `CLAUDE_ENV_FILE`
Comment thread
henry-nsheaps[bot] marked this conversation as resolved.
5. **PreToolUse monitoring**: Before each tool call, checks token expiry (debounced to every 30s)
6. **Smart refresh**: Commands using `gh`/`git push` get synchronous checks; others get async background refresh
7. **Retry with backoff**: Failed refreshes retry up to 3 times, then back off for 5 minutes
Expand Down Expand Up @@ -152,7 +152,7 @@ github-app:
github_installation_id: "${GITHUB_INSTALLATION_ID}"

# Other settings
tokenFile: "~/.config/agent/github-token"
tokenFile: "~/.agents/<agent-name>/.config/github-token"
autoGitConfig: true
```

Expand Down Expand Up @@ -191,6 +191,29 @@ plugins/github-app/
└── README.md
```

## Upgrading from 0.1.x

### Migration

v0.2.0 moves credential storage from `~/.config/agent/` to `~/.agents/${AGENT_NAME}/.config/`. On first session start after upgrading, a fresh token is generated under the new path. The old files at `~/.config/agent/` become orphaned and can be safely deleted:

```bash
rm -rf ~/.config/agent/github-token ~/.config/agent/github-token.meta ~/.config/agent/github-app-env
```
Comment on lines +200 to +202
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Migration rm is incomplete — skips orphaned PEM files containing private-key material.

The command here cleans up github-token, github-token.meta, and github-app-env, but not github-app-<app_id>.pem. hooks/scripts/github-token-init.sh writes that PEM when GITHUB_APP_PRIVATE_KEY (key content) is provided without a *_PATH:

  • Old behavior: wrote to $HOME/.config/agent/github-app-<app_id>.pem with chmod 600
  • New behavior (this PR): writes to ${AGENT_CONFIG_DIR}/github-app-<app_id>.pem — see hooks/scripts/github-token-init.sh:167-172

Users upgrading who used the 1Password / env-var key-content path will have a copy of their App's private key silently orphaned at ~/.config/agent/github-app-*.pem and never cleaned up by this migration step. That's long-lived sensitive material (not a 1-hour token), so worth shredding along with the rest:

Suggested change
```bash
rm -rf ~/.config/agent/github-token ~/.config/agent/github-token.meta ~/.config/agent/github-app-env
```
```bash
rm -rf ~/.config/agent/github-token ~/.config/agent/github-token.meta ~/.config/agent/github-app-env ~/.config/agent/github-app-*.pem

(If users provided `GITHUB_APP_PRIVATE_KEY_PATH` pointing at a hand-placed PEM outside `.config/agent/`, they obviously shouldn't delete that — the glob above only matches the auto-written file the plugin itself created, so it's safe.)

Non-blocking, but please consider for the 0.2.0 release since it's a one-shot migration window.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Good catch — the migration cleanup in the README should list PEM files as well. The migration guidance was updated in da88564 which added gotcha notes. I'll track the PEM cleanup gap as a follow-up.


### AGENT_NAME for git credential helper

`bin/git-credential-github-app.sh` is invoked by **git itself** outside the Claude harness. Git inherits the user's shell environment, where `AGENT_NAME` may not be set. To ensure the credential helper finds the correct per-agent token, export `AGENT_NAME` in your shell profile:

```bash
# ~/.bashrc or ~/.zshrc
export AGENT_NAME="jack" # or "henry", etc.
```

Or in a systemd unit: `Environment=AGENT_NAME=jack`

If `AGENT_NAME` is not set, the credential helper falls back to `~/.agents/_UNKNOWN/.config/`, which may collide with other unconfigured agents.

## Related

- **[github](../github)** plugin — GitHub CLI installation, usage skill, and general auth
Expand Down
9 changes: 7 additions & 2 deletions plugins/github-app/bin/git-credential-github-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
# git config --global credential.https://github.blesdmm.dns-dynamic.net.helper \
# '!/path/to/git-credential-github-app.sh'
#
# The token file location defaults to ~/.config/agent/github-token
# The token file location defaults to ~/.agents/${AGENT_NAME}/.config/github-token
# but can be overridden via GITHUB_TOKEN_FILE environment variable.
set -euo pipefail

TOKEN_FILE="${GITHUB_TOKEN_FILE:-$HOME/.config/agent/github-token}"
# shellcheck source=../lib/agent-paths.sh
_self="${BASH_SOURCE[0]}"
while [ -L "$_self" ]; do _self="$(readlink -f "$_self")"; done
source "$(cd "$(dirname "$_self")/.." && pwd)/lib/agent-paths.sh"

TOKEN_FILE="${GITHUB_TOKEN_FILE:-${AGENT_CONFIG_DIR}/github-token}"

# Only respond to "get" requests
case "${1:-}" in
Expand Down
13 changes: 9 additions & 4 deletions plugins/github-app/bin/token-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,14 @@ done

# --- Configuration ---

TOKEN_FILE="${GITHUB_TOKEN_FILE:-$HOME/.config/agent/github-token}"
# shellcheck source=../lib/agent-paths.sh
_self="${BASH_SOURCE[0]}"
while [ -L "$_self" ]; do _self="$(readlink -f "$_self")"; done
source "$(cd "$(dirname "$_self")/.." && pwd)/lib/agent-paths.sh"
Comment thread
henry-nsheaps[bot] marked this conversation as resolved.

TOKEN_FILE="${GITHUB_TOKEN_FILE:-${AGENT_CONFIG_DIR}/github-token}"
META_FILE="${TOKEN_FILE}.meta"
ENV_RUNTIME_FILE="${GITHUB_APP_ENV_FILE:-$HOME/.config/agent/github-app-env}"
ENV_RUNTIME_FILE="${GITHUB_APP_ENV_FILE:-${AGENT_CONFIG_DIR}/github-app-env}"
LOCKFILE="${TOKEN_FILE}.lock"
COOLDOWN_FILE="${TOKEN_FILE}.cooldown"
REFRESH_THRESHOLD_MINUTES=30
Expand Down Expand Up @@ -89,7 +94,7 @@ release_lock() {
}

# Get token minutes remaining (shared utility)
PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PLUGIN_DIR="$(cd "$(dirname "$_self")/.." && pwd)"
source "$PLUGIN_DIR/lib/token-utils.sh"

# Update the runtime env file with current token
Expand All @@ -114,7 +119,7 @@ ENVEOF
# Generate a new token (full re-auth from keys)
do_generate_token() {
local script_dir
script_dir="$(cd "$(dirname "$0")" && pwd)"
script_dir="$(cd "$(dirname "$_self")" && pwd)"

if [[ -z "${GITHUB_APP_ID:-}" || -z "${GITHUB_APP_PRIVATE_KEY_PATH:-}" || -z "${GITHUB_INSTALLATION_ID:-}" ]]; then
log_error "missing credentials (APP_ID, PRIVATE_KEY_PATH, or INSTALLATION_ID)"
Expand Down
7 changes: 6 additions & 1 deletion plugins/github-app/bin/token-status.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
# Output: JSON object with token status
set -euo pipefail

TOKEN_FILE="${GITHUB_TOKEN_FILE:-$HOME/.config/agent/github-token}"
# shellcheck source=../lib/agent-paths.sh
_self="${BASH_SOURCE[0]}"
while [ -L "$_self" ]; do _self="$(readlink -f "$_self")"; done
source "$(cd "$(dirname "$_self")/.." && pwd)/lib/agent-paths.sh"

TOKEN_FILE="${GITHUB_TOKEN_FILE:-${AGENT_CONFIG_DIR}/github-token}"
META_FILE="${TOKEN_FILE}.meta"

# Check if token exists
Expand Down
20 changes: 10 additions & 10 deletions plugins/github-app/docs/token-refresh-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ The plugin uses two hooks and a set of CLI scripts:
│ ┌────────────────────────────────────────────────┐ │
│ │ Shared Files │ │
│ │ │ │
│ │ ~/.config/agent/github-token (token) │ │
│ │ ~/.config/agent/github-token.meta (expiry/meta) │ │
│ │ ~/.config/agent/github-app-env (runtime env) │ │
│ │ ~/.agents/${AGENT_NAME}/.config/github-token (token) │ │
│ │ ~/.agents/${AGENT_NAME}/.config/github-token.meta (expiry/meta) │ │
│ │ ~/.agents/${AGENT_NAME}/.config/github-app-env (runtime env) │ │
│ │ │ │
│ │ Read by: gh CLI ($GH_TOKEN), git credential │ │
│ │ helper, CLAUDE_ENV_FILE (re-sourced each cmd) │ │
Expand Down Expand Up @@ -108,7 +108,7 @@ The original spec proposed a background MCP server for continuous refresh. The i

4. **Legacy flat settings** — `github_app_id`, `private_key_path`, `github_installation_id` in plugin config

**Private key handling**: If `GITHUB_APP_PRIVATE_KEY` contains key content (e.g., from 1Password) but no `GITHUB_APP_PRIVATE_KEY_PATH` is set, the key content is written to a secure temp file (`~/.config/agent/github-app-<app_id>.pem`, chmod 600).
**Private key handling**: If `GITHUB_APP_PRIVATE_KEY` contains key content (e.g., from 1Password) but no `GITHUB_APP_PRIVATE_KEY_PATH` is set, the key content is written to a secure temp file (`~/.agents/${AGENT_NAME}/.config/github-app-<app_id>.pem`, chmod 600).

**Token generation** (`bin/generate-token.sh`):

Expand All @@ -117,7 +117,7 @@ The original spec proposed a background MCP server for continuous refresh. The i
3. Write token to file (chmod 600)
4. Write metadata to `.meta` file (expiry, app_id, installation_id, permissions)

**Runtime env file**: Written to `~/.config/agent/github-app-env` and registered via `CLAUDE_ENV_FILE` as a `source` command. This file is re-sourced before each Bash command, so token refreshes by the PreToolUse hook are picked up automatically.
**Runtime env file**: Written to `~/.agents/${AGENT_NAME}/.config/github-app-env` and registered via `CLAUDE_ENV_FILE` as a `source` command. This file is re-sourced before each Bash command, so token refreshes by the PreToolUse hook are picked up automatically.

**Git identity**: If `auto_git_config: true` (default) and git user.name/email aren't already set, the hook fetches the App's slug from the API and configures git identity as `app-slug[bot] <id+app-slug[bot]@users.noreply.github.com>`.

Expand Down Expand Up @@ -154,15 +154,15 @@ Standalone script invoked by both the PreToolUse hook (sync or background) and p

### Token Distribution

**Primary mechanism**: Shared file at `~/.config/agent/github-token`
**Primary mechanism**: Shared file at `~/.agents/${AGENT_NAME}/.config/github-token`

Consumers:

- **`gh` CLI**: Via `$GH_TOKEN` environment variable (set by runtime env file)
- **`git push/pull`**: Via git credential helper (`bin/git-credential-github-app.sh`)
- **Direct reads**: Scripts can `cat $GITHUB_TOKEN_FILE`

The runtime env file (`~/.config/agent/github-app-env`) is sourced via `CLAUDE_ENV_FILE` before each Bash command, ensuring `$GH_TOKEN` and `$GITHUB_TOKEN` always reflect the latest token.
The runtime env file (`~/.agents/${AGENT_NAME}/.config/github-app-env`) is sourced via `CLAUDE_ENV_FILE` before each Bash command, ensuring `$GH_TOKEN` and `$GITHUB_TOKEN` always reflect the latest token.

**Git credential helper** (`bin/git-credential-github-app.sh`):

Expand Down Expand Up @@ -218,14 +218,14 @@ github-app:

# Option 3: Legacy flat settings
# github_app_id: "12345"
# private_key_path: "~/.config/agent/github-app.pem"
# private_key_path: "~/.agents/${AGENT_NAME}/.config/github-app.pem"
# github_installation_id: "67890"

# Auto-configure git identity from App bot account (default: true)
auto_git_config: true

# Token file location (default: ~/.config/agent/github-token)
# token_file: "~/.config/agent/github-token"
# Token file location (default: ~/.agents/${AGENT_NAME}/.config/github-token)
# token_file: "~/.agents/${AGENT_NAME}/.config/github-token"
```

Or via environment variables: `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY_PATH`, `GITHUB_INSTALLATION_ID`, `GITHUB_TOKEN_FILE`.
Expand Down
8 changes: 4 additions & 4 deletions plugins/github-app/github-app.settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ github-app:
# Sources KEY=VALUE pairs from a file. Relative paths (./...)
# resolve against the project directory.
# Example: ref: "env-file://./.env.github-app"
# Example: ref: "env-file://~/.config/agent/github-app.env"
# Example: ref: "env-file://~/.agents/<agent-name>/.config/github-app.env"
#
# 2. secrets.*: Individual secret references. Each value can be:
# - A literal value: secrets.github_app_id: "12345"
Expand Down Expand Up @@ -51,7 +51,7 @@ github-app:

# --- Option 3: Legacy flat settings ---
# github_app_id: "12345"
# private_key_path: "~/.config/agent/github-app.pem"
# private_key_path: "~/.agents/<agent-name>/.config/github-app.pem"
# github_installation_id: "67890"

# --- Other settings ---
Expand All @@ -62,5 +62,5 @@ github-app:
# Set to false to disable.
autoGitConfig: true

# Where to write the token (default: ~/.config/agent/github-token)
# tokenFile: "~/.config/agent/github-token"
# Where to write the token (default: ~/.agents/<agent-name>/.config/github-token)
# tokenFile: "~/.agents/<agent-name>/.config/github-token"
13 changes: 8 additions & 5 deletions plugins/github-app/hooks/scripts/github-token-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ set -euo pipefail

# --- Configuration ---

DEBOUNCE_FILE="${HOME}/.config/agent/github-app-last-check"
# shellcheck source=../../lib/agent-paths.sh
source "${CLAUDE_PLUGIN_ROOT}/lib/agent-paths.sh"

DEBOUNCE_FILE="${AGENT_CONFIG_DIR}/github-app-last-check"
DEBOUNCE_SECONDS=30 # Don't check more often than every 30 seconds
TOKEN_FILE="${GITHUB_TOKEN_FILE:-$HOME/.config/agent/github-token}"
TOKEN_FILE="${GITHUB_TOKEN_FILE:-${AGENT_CONFIG_DIR}/github-token}"
META_FILE="${TOKEN_FILE}.meta"

# --- Read hook input ---
Expand Down Expand Up @@ -88,11 +91,11 @@ uses_token() {

# --- Token status check ---

# Resolve the bin/lib directories relative to this script (handles both plugin and symlink cases)
PLUGIN_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
# Resolve the bin/lib directories via CLAUDE_PLUGIN_ROOT (set by the harness for hook scripts)
PLUGIN_DIR="${CLAUDE_PLUGIN_ROOT}"
source "$PLUGIN_DIR/lib/token-utils.sh"

BIN_DIR="$PLUGIN_DIR/bin"
BIN_DIR="${CLAUDE_PLUGIN_ROOT}/bin"

# --- Allow helper ---

Expand Down
7 changes: 4 additions & 3 deletions plugins/github-app/hooks/scripts/github-token-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ set -euo pipefail
PLUGIN_NAME="github-app"
source "${CLAUDE_PLUGIN_ROOT}/lib/plugin-config-read.sh"
source "${CLAUDE_PLUGIN_ROOT}/lib/hook-logging.sh"
source "${CLAUDE_PLUGIN_ROOT}/lib/agent-paths.sh"

# --- Guards ---

Expand Down Expand Up @@ -163,7 +164,7 @@ GITHUB_APP_PRIVATE_KEY_PATH="${GITHUB_APP_PRIVATE_KEY_PATH:-$(plugin_get_config

if [[ -n "${GITHUB_APP_PRIVATE_KEY:-}" && -z "$GITHUB_APP_PRIVATE_KEY_PATH" ]]; then
# Key content provided directly — write to a secure temp file
KEY_DIR="${HOME}/.config/agent"
KEY_DIR="${AGENT_CONFIG_DIR}"
mkdir -p "$KEY_DIR"
GITHUB_APP_PRIVATE_KEY_PATH="${KEY_DIR}/github-app-${GITHUB_APP_ID:-unknown}.pem"
echo "$GITHUB_APP_PRIVATE_KEY" > "$GITHUB_APP_PRIVATE_KEY_PATH"
Expand Down Expand Up @@ -198,7 +199,7 @@ fi

hook_log_step "generate-token" "Generating GitHub App installation token"

TOKEN_FILE="${GITHUB_TOKEN_FILE:-$(plugin_get_config "tokenFile" "$HOME/.config/agent/github-token")}"
TOKEN_FILE="${GITHUB_TOKEN_FILE:-$(plugin_get_config "tokenFile" "${AGENT_CONFIG_DIR}/github-token")}"
TOKEN_FILE="${TOKEN_FILE/#\~/$HOME}"
mkdir -p "$(dirname "$TOKEN_FILE")"

Expand Down Expand Up @@ -234,7 +235,7 @@ fi

hook_log_step "write-env" "Writing runtime environment file"

ENV_RUNTIME_FILE="${HOME}/.config/agent/github-app-env"
ENV_RUNTIME_FILE="${AGENT_CONFIG_DIR}/github-app-env"
mkdir -p "$(dirname "$ENV_RUNTIME_FILE")"
cat > "$ENV_RUNTIME_FILE" <<ENVEOF
# Auto-generated by github-app plugin — do not edit
Expand Down
15 changes: 15 additions & 0 deletions plugins/github-app/lib/agent-paths.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# agent-paths.sh — Shared helper for per-agent config directory resolution
#
# Exports AGENT_CONFIG_DIR based on AGENT_NAME env var.
# All github-app plugin scripts should source this instead of
# computing the path independently.
#
# Usage (hooks): source "${CLAUDE_PLUGIN_ROOT}/lib/agent-paths.sh"
# Usage (bin/): _self="${BASH_SOURCE[0]}"; while [ -L "$_self" ]; do _self="$(readlink -f "$_self")"; done
# source "$(cd "$(dirname "$_self")/.." && pwd)/lib/agent-paths.sh"

[[ -n "${_AGENT_PATHS_LOADED:-}" ]] && return 0
_AGENT_PATHS_LOADED=1

AGENT_CONFIG_DIR="${HOME}/.agents/${AGENT_NAME:-_UNKNOWN}/.config"
Comment thread
henry-nsheaps[bot] marked this conversation as resolved.
Comment thread
henry-nsheaps[bot] marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Nice — the three-line resolution (guard + single-line path expression) is exactly the canonical pattern from .claude/rules/shared-libs.md and matches sibling lib/token-utils.sh:7-8. Single source of truth for AGENT_CONFIG_DIR, no ambient mkdir, no conditionals — reads well.

One very minor observation (not a request): AGENT_NAME="../foo" (or any value containing /) resolves outside the intended parent ($HOME/.agents/../foo/.config). Since the env var is user-controlled, this is a self-inflicted footgun rather than a security boundary — flagging only in case a future _UNKNOWN audit of this path shows up in a tenant isolation review. Not worth fixing in this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Thanks for the confirmation — glad the pattern matches the shared-libs convention. This was part of the cleanup in 8eaca8f.

Loading
Loading