Skip to content
2 changes: 2 additions & 0 deletions docs/guides/dev/cli-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ fullsend
│ └── repos <org> [repo...] # Disable agent on repos
├── mint # Token mint management
│ ├── deploy # Deploy/update mint Cloud Function
│ ├── add-role <role> # Register role PEM + ROLE_APP_IDS entry
│ ├── remove-role <role> # Remove role from mint
│ ├── enroll <org|owner/repo> # Register org/repo in mint
│ ├── unenroll <org|owner/repo> # Remove org/repo from mint
│ ├── status [org] # Inspect mint state and PEM health
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/infrastructure/infrastructure-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This guide provides implementation details for fullsend's infrastructure compone

## Token Mint (OIDC) — GCF Cloud Function

> Managed by: `fullsend mint deploy`, `fullsend mint enroll`, `fullsend mint unenroll`, `fullsend mint status`, `fullsend mint token`
> Managed by: `fullsend mint deploy`, `fullsend mint enroll`, `fullsend mint unenroll`, `fullsend mint status`, `fullsend mint add-role`, `fullsend mint remove-role`, `fullsend mint token`

The mint is a GCP Cloud Function that exchanges GitHub OIDC tokens for scoped GitHub App installation tokens. This eliminates long-lived PATs from the system.

Expand Down
134 changes: 121 additions & 13 deletions docs/guides/infrastructure/mint-administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

This guide covers deploying and managing the fullsend token mint Cloud Function. The mint is the OIDC token exchange service that lets GitHub Actions workflows authenticate as GitHub Apps — it is infrastructure that serves all enrolled organizations and repositories.

| Command | Description |
|---------|-------------|
| `mint deploy` | Deploy or update the mint Cloud Function and GCP infrastructure |
| `mint add-role` | Add an agent role (PEM secret + `ROLE_APP_IDS` entry) |
| `mint remove-role` | Remove an agent role from the mint (deletes PEM secret by default) |
| `mint enroll` | Register an org or repo in `ALLOWED_ORGS` and configure WIF |
| `mint unenroll` | Remove an org or repo from the mint |
| `mint status` | Inspect mint health, enrolled orgs, and PEM secrets |
| `mint token` | Exchange a GitHub Actions OIDC token for an installation token |

Comment thread
ifireball marked this conversation as resolved.
> **This guide is for platform operators** who deploy, manage, or troubleshoot the token mint Cloud Function. If you are an end user setting up fullsend for your organization, see [Installing fullsend](../../reference/installation.md) instead — the mint is typically deployed once by a platform operator, and organizations are enrolled as needed.

## Hosted mint
Expand Down Expand Up @@ -35,21 +45,27 @@ Pass this URL as `--mint-url` when running `fullsend admin install`, or set the

- **GCP IAM roles** — the user running mint commands authenticates via ADC (`gcloud auth application-default login`). The required roles depend on the command:

| IAM Role | `mint deploy` | `mint enroll` | `mint unenroll` | `mint status` |
|----------|:---:|:---:|:---:|:---:|
| `roles/iam.serviceAccountAdmin` | x | | | |
| `roles/iam.workloadIdentityPoolAdmin` | x | x | x | |
| `roles/resourcemanager.projectIamAdmin` | \* | \*\* | | |
| `roles/secretmanager.admin` | \* | | | |
| `roles/cloudfunctions.developer` | x | | | |
| `roles/cloudfunctions.viewer` | | x | x | x |
| `roles/run.admin` | x | x | x | |
| `roles/secretmanager.viewer` | | | | x |
| IAM Role | `mint deploy` | `mint add-role` | `mint remove-role` | `mint enroll` | `mint unenroll` | `mint status` |
|----------|:---:|:---:|:---:|:---:|:---:|:---:|
| `roles/iam.serviceAccountAdmin` | x | | | | | |
| `roles/iam.workloadIdentityPoolAdmin` | x | | | x | x | |
| `roles/resourcemanager.projectIamAdmin` | \* | | | \*\* | | |
| `roles/secretmanager.admin` | \* | \*\*\* | \*\*\*\* | | | |
| `roles/cloudfunctions.developer` | x | | | | | |
| `roles/cloudfunctions.viewer` | | x | x | x | x | x |
| `roles/run.admin` | x | x | x | x | x | |
| `roles/secretmanager.viewer` | | § | | | | x |

\* `roles/resourcemanager.projectIamAdmin` and `roles/secretmanager.admin` are required for `mint deploy` only when using `--pem-dir` (first-time bootstrap). Standard deploys without `--pem-dir` do not need these roles.

\*\* `roles/resourcemanager.projectIamAdmin` is required for `mint enroll` only in per-repo mode (`mint enroll owner/repo`). Org-scoped enrollment does not grant IAM bindings — use `inference provision` separately.

\*\*\* `roles/secretmanager.admin` is required for `mint add-role` when uploading a new PEM (`--pem` or browser mode). When using `--use-existing-pem-secret`, only `roles/secretmanager.viewer` is required (see §).

\*\*\*\* `roles/secretmanager.admin` is required for `mint remove-role` unless `--keep-pem` is passed (default deletes the PEM secret).

§ `roles/secretmanager.viewer` is required for `mint add-role` when using `--use-existing-pem-secret` (checks that the PEM secret exists).

`roles/owner` covers all of the above for users with broad access.

An administrator can grant all required roles with a single script:
Expand Down Expand Up @@ -111,10 +127,102 @@ The `--pem-dir` directory must contain one `{role}.pem` file per agent role (e.g

### Mint URL stability

The mint URL is stable across redeploys within the same project and region — updating the Cloud Function does not change its URL. Adding a new org to an existing mint only updates `ALLOWED_ORGS` (and WIF configuration) without redeploying the function. Shared `ROLE_APP_IDS` are set at deploy time and are not modified per enrollment. Existing enrolled repos continue working with no changes.
The mint URL is stable across redeploys within the same project and region — updating the Cloud Function does not change its URL. Adding a new org to an existing mint only updates `ALLOWED_ORGS` (and WIF configuration) without redeploying the function. Shared `ROLE_APP_IDS` are managed at deploy/bootstrap time (`mint deploy --pem-dir`) or per-role via `mint add-role` / `remove-role` — not during enrollment. Existing enrolled repos continue working with no changes when orgs are added.

Deploying to a **different region** (e.g., changing `--region` from `us-central1` to `us-east5`) creates a new Cloud Run service with a different URL. All enrolled repos store the mint URL in a repo or org variable (`FULLSEND_MINT_URL`), so changing the region requires updating every enrolled repo's variable. Avoid changing `--region` after initial deployment unless you plan to update all consumers.

## Managing roles

Agent roles on the mint are **global** — each role maps to a GitHub App PEM secret (`fullsend-{role}-app-pem`) and an entry in the shared `ROLE_APP_IDS` environment variable. Use `fullsend mint add-role` and `fullsend mint remove-role` to manage individual roles after the mint is deployed.

| Command | When to use |
|---------|-------------|
| `mint deploy --pem-dir` | First-time bootstrap of the default app set (`fullsend-ai`) — seeds all default roles at once |
| `mint add-role` | Add a single role later, or register a custom app set one role at a time |
| `mint remove-role` | Remove a role from the mint (updates env vars; deletes PEM secret by default) |

`mint enroll` does **not** create or modify roles — it only authorizes orgs/repos to use roles that already exist on the mint.

### Adding a role

`fullsend mint add-role` requires the mint to already be deployed. Choose one of three mutually exclusive input modes:

**1. Existing app + PEM file** (`--slug` and `--pem`):

```bash
fullsend mint add-role coder \
--project="$GCP_PROJECT" \
--slug=fullsend-ai-coder \
--pem=/path/to/coder.pem
```

The CLI looks up the app's numeric ID from the GitHub API, verifies the PEM matches the app, stores the PEM in Secret Manager, and updates `ROLE_APP_IDS` / `ALLOWED_ROLES`.

**2. Existing PEM secret** (`--slug` and `--use-existing-pem-secret`):

```bash
fullsend mint add-role review \
--project="$GCP_PROJECT" \
--slug=fullsend-ai-review \
--use-existing-pem-secret
```

Use this when the PEM secret `fullsend-{role}-app-pem` already exists in Secret Manager (for example, copied from another project) and you only need to register the app ID on the mint. `--pem` and `--use-existing-pem-secret` cannot be combined.

**3. Create GitHub App via browser** (`--org`):

```bash
fullsend mint add-role prioritize \
--project="$GCP_PROJECT" \
--org=acme-corp \
--app-set=acme
```

Opens the GitHub App manifest flow in your browser, stores the PEM in Secret Manager, and updates the mint. Requires a GitHub token (`GH_TOKEN`, `GITHUB_TOKEN`, or `gh auth login`).

#### add-role flags

| Flag | Default | Description |
|------|---------|-------------|
| `--project` | | GCP project ID (required) |
| `--region` | `us-central1` | Cloud region for the mint service |
| `--slug` | | GitHub App slug (with `--pem` or `--use-existing-pem-secret`) |
| `--pem` | | Path to PEM file (with `--slug`; mutually exclusive with `--use-existing-pem-secret`) |
| `--use-existing-pem-secret` | `false` | Skip PEM upload; require existing Secret Manager secret (with `--slug`) |
| `--org` | | GitHub org for browser-based app creation |
| `--app-set` | `fullsend-ai` | App set prefix for browser mode (`{app-set}-{role}`) |
| `--public` | `false` | Install existing public app without confirm prompt (browser mode) |
| `--force` | `false` | Overwrite existing `ROLE_APP_IDS` entry for this role |
| `--dry-run` | `false` | Preview changes without making them |

The `fix` and `code` roles reuse the `coder` app — add role `coder` instead.

### Removing a role

`fullsend mint remove-role` removes a role from `ROLE_APP_IDS` and `ALLOWED_ROLES`. By default it also deletes the PEM secret from Secret Manager. Use `--keep-pem` to retain the secret for later re-registration.

```bash
# Remove role and delete PEM secret (default)
fullsend mint remove-role retro --project="$GCP_PROJECT"

# Remove role but keep PEM secret
fullsend mint remove-role retro --project="$GCP_PROJECT" --keep-pem
```

Requires typing the role name to confirm (unless `--dry-run` or `--yolo`). Removing `coder` also prevents `fix`/`code` token minting.

#### remove-role flags

| Flag | Default | Description |
|------|---------|-------------|
| `--project` | | GCP project ID (required) |
| `--region` | `us-central1` | Cloud region for the mint service |
| `--keep-pem` | `false` | Retain PEM secret in Secret Manager (default: delete) |
| `--dry-run` | `false` | Preview changes without making them |
| `--yolo` | `false` | Skip interactive confirmation |

This command does not uninstall GitHub Apps from organizations or update org `.fullsend` configuration — use `fullsend github setup` or edit config repos separately.

## Enrolling organizations and repositories

`fullsend mint enroll` registers an organization or repository in the mint and configures WIF to accept OIDC tokens from the target.
Expand All @@ -139,7 +247,7 @@ Enrollment does **not** grant Agent Platform (inference) access — use `fullsen

### Migration from per-org app ID flags

Prior versions of `mint enroll` accepted `--app-set`, `--role-app-ids`, `--roles`, and `--source-org` to copy per-org app ID mappings into `ROLE_APP_IDS`. App IDs are now **shared per role** on the mint (like PEM secrets) and are set at deploy time via `mint deploy --pem-dir` or `fullsend admin install`. Enrollment only adds the org to `ALLOWED_ORGS` and updates WIF — remove those flags from scripts and ensure the mint already has role-keyed `ROLE_APP_IDS` before enrolling.
Prior versions of `mint enroll` accepted `--app-set`, `--role-app-ids`, `--roles`, and `--source-org` to copy per-org app ID mappings into `ROLE_APP_IDS`. App IDs are now **shared per role** on the mint (like PEM secrets) and are set at deploy time via `mint deploy --pem-dir`, `fullsend admin install`, or per-role via `mint add-role`. Enrollment only adds the org to `ALLOWED_ORGS` and updates WIF — remove those flags from scripts and ensure the mint already has role-keyed `ROLE_APP_IDS` before enrolling.

### What enrollment does

Expand All @@ -148,7 +256,7 @@ Prior versions of `mint enroll` accepted `--app-set`, `--role-app-ids`, `--roles
3. Runs post-enrollment verification (see below)
4. Configures the mint-side WIF provider to accept OIDC tokens from the organization's repositories

Role PEM secrets and `ROLE_APP_IDS` must already exist on the mint, created during `mint deploy --pem-dir` or `fullsend admin install`. Enrollment does not create, copy, or modify PEM secrets or app ID mappings.
Role PEM secrets and `ROLE_APP_IDS` must already exist on the mint, created during `mint deploy --pem-dir`, `fullsend admin install`, or `mint add-role`. Enrollment does not create, copy, or modify PEM secrets or app ID mappings.

### Post-enrollment verification

Expand Down
32 changes: 20 additions & 12 deletions docs/reference/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,8 @@ The `admin install` command performs all setup in a single invocation. For organ
| GitHub Maintainer | `fullsend github sync-scaffold <org>` | Update workflow templates to current CLI version |
| GitHub Maintainer | `fullsend github uninstall <org>` | Remove GitHub configuration (org-level only) |
| GCP Admin (Mint) | `fullsend mint deploy` | Deploy the token mint Cloud Function |
| GCP Admin (Mint) | `fullsend mint add-role <role>` | Register a role PEM and app ID on the mint |
| GCP Admin (Mint) | `fullsend mint remove-role <role>` | Remove a role from the mint (deletes PEM secret by default) |
| GCP Admin (Mint) | `fullsend mint enroll <org\|owner/repo>` | Register an org or repo in the mint (does not grant Agent Platform access — use `inference provision`) |
| GCP Admin (Mint) | `fullsend mint unenroll <org\|owner/repo>` | Remove an org or repo from the mint |
| GCP Admin (Mint) | `fullsend mint status` | Inspect mint state and PEM health |
Expand All @@ -621,23 +623,29 @@ See [Setting up with pre-provisioned infrastructure](github-setup.md) for the co

When using the split-responsibility workflow, each standalone command requires a subset of IAM roles. Use this table to request only what you need.

| IAM Role | `inference provision` | `inference deprovision` | `inference status` | `mint deploy` | `mint enroll` | `mint unenroll` | `mint status` |
|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `roles/iam.workloadIdentityPoolAdmin` | x | x | | x | x | x | |
| `roles/resourcemanager.projectIamAdmin` | x | | | \* | \*\* | | |
| `roles/iam.serviceAccountAdmin` | | | | x | | | |
| `roles/secretmanager.admin` | | | | \* | | | |
| `roles/cloudfunctions.developer` | | | | x | | | |
| `roles/cloudfunctions.viewer` | | | | | x | x | x |
| `roles/run.admin` | | | | x | x | x | |
| `roles/iam.workloadIdentityPoolViewer` | | | x\*\*\* | | | | |
| `roles/secretmanager.viewer` | | | | | | | x |
| IAM Role | `inference provision` | `inference deprovision` | `inference status` | `mint deploy` | `mint add-role` | `mint remove-role` | `mint enroll` | `mint unenroll` | `mint status` |
|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `roles/iam.workloadIdentityPoolAdmin` | x | x | | x | | | x | x | |
| `roles/resourcemanager.projectIamAdmin` | x | | | \* | | | \*\* | | |
| `roles/iam.serviceAccountAdmin` | | | | x | | | | | |
| `roles/secretmanager.admin` | | | | \* | \*\*\* | \*\*\*\* | | | |
| `roles/cloudfunctions.developer` | | | | x | | | | | |
| `roles/cloudfunctions.viewer` | | | | | x | x | x | x | x |
| `roles/run.admin` | | | | x | x | x | x | x | |
| `roles/iam.workloadIdentityPoolViewer` | | | x† | | | | | | |
| `roles/secretmanager.viewer` | | | | | § | | | | x |

\* `roles/resourcemanager.projectIamAdmin` and `roles/secretmanager.admin` are required for `mint deploy` only when using `--pem-dir` (first-time bootstrap). Standard deploys without `--pem-dir` do not need these roles.

\*\* `roles/resourcemanager.projectIamAdmin` is required for `mint enroll` only in per-repo mode (`mint enroll owner/repo`). Org-scoped enrollment does not grant IAM bindings — use `inference provision` separately.

\*\*\* All commands that call GCP APIs also require `resourcemanager.projects.get` (typically available via `roles/browser` or any project-level viewer role). This is only notable for `inference status` where it is not covered by the other listed roles.
\*\*\* `roles/secretmanager.admin` is required for `mint add-role` when uploading a new PEM (`--pem` or browser mode). When using `--use-existing-pem-secret`, only `roles/secretmanager.viewer` is required (see §).

\*\*\*\* `roles/secretmanager.admin` is required for `mint remove-role` unless `--keep-pem` is passed (default deletes the PEM secret).

§ `roles/secretmanager.viewer` is required for `mint add-role` when using `--use-existing-pem-secret` (checks that the PEM secret exists).

† All commands that call GCP APIs also require `resourcemanager.projects.get` (typically available via `roles/browser` or any project-level viewer role). This is only notable for `inference status` where it is not covered by the other listed roles.

Required GCP APIs also differ by command group:

Expand Down
17 changes: 13 additions & 4 deletions internal/cli/mint.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
Expand Down Expand Up @@ -108,7 +109,7 @@ var githubHTTPClient = &http.Client{Timeout: 30 * time.Second}
// lookupAppID fetches the numeric app ID for a public GitHub App by slug.
// It makes an unauthenticated GET request to the GitHub API.
func lookupAppID(ctx context.Context, slug string) (int, error) {
url := githubAPIBaseURL + "/apps/" + slug
url := githubAPIBaseURL + "/apps/" + url.PathEscape(slug)
Comment thread
ifireball marked this conversation as resolved.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, fmt.Errorf("creating request for app %s: %w", slug, err)
Expand Down Expand Up @@ -316,13 +317,15 @@ func newMintCmd() *cobra.Command {
Long: `Manage the GCP Cloud Function that mints GitHub App installation tokens,
and mint short-lived tokens via OIDC.

Infrastructure subcommands (deploy, enroll, unenroll, status) require GCP
Infrastructure subcommands (deploy, enroll, unenroll, status, add-role, remove-role) require GCP
project access. The 'token' subcommand requires only GitHub Actions OIDC.`,
}
cmd.AddCommand(newMintDeployCmd())
cmd.AddCommand(newMintEnrollCmd())
cmd.AddCommand(newMintUnenrollCmd())
cmd.AddCommand(newMintStatusCmd())
cmd.AddCommand(newMintAddRoleCmd())
cmd.AddCommand(newMintRemoveRoleCmd())
cmd.AddCommand(newMintTokenCmd())
return cmd
}
Expand Down Expand Up @@ -833,12 +836,18 @@ Required IAM roles on the mint project:
}

// confirmUnenroll prompts the user to type the target name to confirm.
// abortLabel names the operation in mismatch errors (default: "unenroll").
// reader is the input source (os.Stdin in production, a buffer in tests).
func confirmUnenroll(printer *ui.Printer, target string, reader *bufio.Reader, isTerminal bool) error {
func confirmUnenroll(printer *ui.Printer, target string, reader *bufio.Reader, isTerminal bool, abortLabel ...string) error {
if !isTerminal {
return fmt.Errorf("stdin is not a terminal; use --yolo to skip confirmation")
}

label := "unenroll"
if len(abortLabel) > 0 && abortLabel[0] != "" {
label = abortLabel[0]
}

printer.StepWarn(fmt.Sprintf("This will remove %s from the mint.", target))
printer.StepInfo(fmt.Sprintf("Type '%s' to confirm:", target))

Expand All @@ -847,7 +856,7 @@ func confirmUnenroll(printer *ui.Printer, target string, reader *bufio.Reader, i
return fmt.Errorf("reading confirmation: %w", err)
}
if strings.TrimSpace(line) != target {
return fmt.Errorf("confirmation did not match; aborting unenroll")
return fmt.Errorf("confirmation did not match; aborting %s", label)
}
return nil
}
Expand Down
Loading
Loading