From ec82ac760d279944958ea954b1cab22b82b8920b Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Mon, 22 Jun 2026 17:23:10 -0400 Subject: [PATCH] feat(cli): mint agent tokens in the binary instead of workflows Co-Authored-By: Claude Opus 4.6 Signed-off-by: Claude Opus 4.6 Signed-off-by: Greg Allen --- .github/workflows/reusable-code.yml | 4 +- .github/workflows/reusable-fix.yml | 3 - .github/workflows/reusable-prioritize.yml | 3 +- .github/workflows/reusable-retro.yml | 2 +- .github/workflows/reusable-review.yml | 2 - .github/workflows/reusable-triage.yml | 2 +- action.yml | 15 +- ...-agent-configuration-env-var-convention.md | 2 +- docs/architecture.md | 3 +- docs/guides/user/customizing-agents.md | 2 +- docs/guides/user/running-agents-locally.md | 6 +- .../adr-0045-forge-portable-harness-phase2.md | 9 +- .../plans/2026-05-04-retro-agent.md | 3 +- internal/cli/reconcilestatus.go | 5 +- internal/cli/reconcilestatus_test.go | 23 + internal/cli/run.go | 177 +++++- internal/cli/run_test.go | 601 ++++++++++++++++++ 17 files changed, 831 insertions(+), 31 deletions(-) diff --git a/.github/workflows/reusable-code.yml b/.github/workflows/reusable-code.yml index c9c30841e..098843d86 100644 --- a/.github/workflows/reusable-code.yml +++ b/.github/workflows/reusable-code.yml @@ -163,7 +163,6 @@ jobs: if: steps.validate.outputs.skipped != 'true' env: AGENT_PREFIX: CODE_ - CODE_GH_TOKEN: ${{ steps.app-token.outputs.token }} CODE_TARGET_REPO_DIR: target-repo CODE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} CODE_CLOUD_ML_REGION: ${{ inputs.gcp_region }} @@ -177,9 +176,8 @@ jobs: GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }} ISSUE_NUMBER: ${{ fromJSON(inputs.event_payload).issue.number }} REPO_FULL_NAME: ${{ inputs.source_repo }} - PUSH_TOKEN: ${{ steps.app-token.outputs.token }} - PUSH_TOKEN_SOURCE: github-app CODE_ALLOWED_TARGET_BRANCHES: '' + TARGET_BRANCH: main with: agent: code version: ${{ inputs.fullsend_version }} diff --git a/.github/workflows/reusable-fix.yml b/.github/workflows/reusable-fix.yml index f60ba08f3..3f06f82a5 100644 --- a/.github/workflows/reusable-fix.yml +++ b/.github/workflows/reusable-fix.yml @@ -353,7 +353,6 @@ jobs: - name: Setup agent environment env: AGENT_PREFIX: FIX_ - FIX_GH_TOKEN: ${{ steps.app-token.outputs.token }} FIX_TARGET_REPO_DIR: target-repo FIX_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} FIX_CLOUD_ML_REGION: ${{ inputs.gcp_region }} @@ -369,8 +368,6 @@ jobs: env: PR_NUMBER: ${{ steps.context.outputs.pr_number }} REPO_FULL_NAME: ${{ inputs.source_repo }} - PUSH_TOKEN: ${{ steps.app-token.outputs.token }} - PUSH_TOKEN_SOURCE: github-app TARGET_BRANCH: ${{ steps.context.outputs.base_ref }} TRIGGER_SOURCE: ${{ inputs.trigger_source }} HUMAN_INSTRUCTION: ${{ steps.context.outputs.instruction }} diff --git a/.github/workflows/reusable-prioritize.yml b/.github/workflows/reusable-prioritize.yml index a49950464..dd9b3742f 100644 --- a/.github/workflows/reusable-prioritize.yml +++ b/.github/workflows/reusable-prioritize.yml @@ -130,7 +130,6 @@ jobs: - name: Setup agent environment env: AGENT_PREFIX: PRIORITIZE_ - PRIORITIZE_GH_TOKEN: ${{ steps.app-token.outputs.token }} PRIORITIZE_ORG: ${{ github.repository_owner }} PRIORITIZE_PROJECT_NUMBER: ${{ inputs.project_number }} PRIORITIZE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} @@ -144,6 +143,8 @@ jobs: uses: ./.defaults/ env: GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }} + REPO_FULL_NAME: ${{ inputs.source_repo }} with: agent: prioritize version: ${{ inputs.fullsend_version }} + mint-url: ${{ inputs.mint_url }} diff --git a/.github/workflows/reusable-retro.yml b/.github/workflows/reusable-retro.yml index eaae60b97..48964075c 100644 --- a/.github/workflows/reusable-retro.yml +++ b/.github/workflows/reusable-retro.yml @@ -135,7 +135,6 @@ jobs: - name: Setup agent environment env: AGENT_PREFIX: RETRO_ - RETRO_GH_TOKEN: ${{ steps.app-token.outputs.token }} RETRO_TARGET_REPO_DIR: target-repo RETRO_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} RETRO_CLOUD_ML_REGION: ${{ inputs.gcp_region }} @@ -147,6 +146,7 @@ jobs: ORIGINATING_URL: ${{ fromJSON(inputs.event_payload).pull_request.html_url || fromJSON(inputs.event_payload).issue.html_url }} RETRO_COMMENT: ${{ fromJSON(inputs.event_payload).comment.body || '' }} REPO_FULL_NAME: ${{ inputs.source_repo }} + MINT_REPOS: ${{ steps.repo-parts.outputs.name != '' && (inputs.install_mode == 'per-repo' && steps.repo-parts.outputs.name || format('{0},.fullsend', steps.repo-parts.outputs.name)) || '' }} with: agent: retro version: ${{ inputs.fullsend_version }} diff --git a/.github/workflows/reusable-review.yml b/.github/workflows/reusable-review.yml index 0bd4aedb2..b7ac98315 100644 --- a/.github/workflows/reusable-review.yml +++ b/.github/workflows/reusable-review.yml @@ -147,7 +147,6 @@ jobs: - name: Setup agent environment env: AGENT_PREFIX: REVIEW_ - REVIEW_GH_TOKEN: ${{ steps.app-token.outputs.token }} REVIEW_TARGET_REPO_DIR: target-repo REVIEW_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} REVIEW_CLOUD_ML_REGION: ${{ inputs.gcp_region }} @@ -158,7 +157,6 @@ jobs: env: GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }} GITHUB_PR_URL: ${{ fromJSON(inputs.event_payload).pull_request.html_url || fromJSON(inputs.event_payload).issue.html_url }} - REVIEW_TOKEN: ${{ steps.app-token.outputs.token }} REPO_FULL_NAME: ${{ inputs.source_repo }} PR_NUMBER: ${{ fromJSON(inputs.event_payload).pull_request.number || fromJSON(inputs.event_payload).issue.number }} PRIOR_REVIEW_SHA: ${{ steps.prior-review.outputs.prior_sha }} diff --git a/.github/workflows/reusable-triage.yml b/.github/workflows/reusable-triage.yml index 9d0b97e82..05cfb0288 100644 --- a/.github/workflows/reusable-triage.yml +++ b/.github/workflows/reusable-triage.yml @@ -135,7 +135,6 @@ jobs: - name: Setup agent environment env: AGENT_PREFIX: TRIAGE_ - TRIAGE_GH_TOKEN: ${{ steps.app-token.outputs.token }} TRIAGE_TARGET_REPO_DIR: target-repo TRIAGE_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} TRIAGE_CLOUD_ML_REGION: ${{ inputs.gcp_region }} @@ -145,6 +144,7 @@ jobs: uses: ./.defaults/ env: GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }} + REPO_FULL_NAME: ${{ inputs.source_repo }} with: agent: triage version: ${{ inputs.fullsend_version }} diff --git a/action.yml b/action.yml index 309fab9ca..3c80bd2ef 100644 --- a/action.yml +++ b/action.yml @@ -38,8 +38,9 @@ inputs: default: "" mint-url: description: >- - Mint service URL for on-demand status comment tokens. The binary - mints a fresh short-lived token before each status API call. + Mint service URL for on-demand GitHub App tokens. Used for both + status comment tokens and agent runtime tokens (GH_TOKEN, + PUSH_TOKEN, REVIEW_TOKEN) minted by mintAgentToken(). default: "" runs: @@ -375,15 +376,17 @@ runs: if [[ -n "${STATUS_RUN_URL}" ]]; then STATUS_FLAGS+=(--run-url "${STATUS_RUN_URL}") fi - if [[ -n "${MINT_URL}" ]]; then - STATUS_FLAGS+=(--mint-url "${MINT_URL}") - fi + fi + MINT_FLAGS=() + if [[ -n "${MINT_URL}" ]]; then + MINT_FLAGS+=(--mint-url "${MINT_URL}") fi fullsend run "${AGENT}" \ --fullsend-dir "${FULLSEND_DIR}" \ --output-dir "${GITHUB_WORKSPACE}/output" \ --target-repo "${TARGET_REPO}" \ - "${STATUS_FLAGS[@]+"${STATUS_FLAGS[@]}"}" + "${STATUS_FLAGS[@]+"${STATUS_FLAGS[@]}"}" \ + "${MINT_FLAGS[@]+"${MINT_FLAGS[@]}"}" - name: Finalize orphaned status comment if: always() && inputs.agent != '__install_only__' && inputs.status-repo != '' && inputs.status-number != '' && inputs.mint-url != '' diff --git a/docs/ADRs/0049-agent-configuration-env-var-convention.md b/docs/ADRs/0049-agent-configuration-env-var-convention.md index 3c61f41aa..205c8e73d 100644 --- a/docs/ADRs/0049-agent-configuration-env-var-convention.md +++ b/docs/ADRs/0049-agent-configuration-env-var-convention.md @@ -62,7 +62,7 @@ Agent configuration environment variables follow a single convention: The agent name prefix prevents collisions when multiple agents share an execution environment or when env files are sourced together. Existing context -vars (e.g., `PRIOR_REVIEW_SHA`) and credential vars (e.g., `FIX_GH_TOKEN`) +vars (e.g., `PRIOR_REVIEW_SHA`) and credential vars (e.g., `GH_TOKEN`) already use agent-name prefixes — the `{AGENT}_` prefix alone does not distinguish config vars from those. The distinction is by purpose and documentation: config vars are behavioral knobs listed in diff --git a/docs/architecture.md b/docs/architecture.md index bc1148c1b..742f67543 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -610,7 +610,8 @@ GitHub event ──► SHIM WORKFLOW (fullsend.yml in enrolled repo) ║ │ │ ║ ║ │ Post-agent secret scan (redact from extracted output). │ ║ ║ │ │ ║ - ║ │ Post-script (scripts/post-code.sh, with PUSH_TOKEN): │ ║ + ║ │ Post-script (scripts/post-code.sh, with PUSH_TOKEN, │ ║ + ║ │ minted by the binary via --mint-url): │ ║ ║ │ 1. Verify feature branch (not main/master) │ ║ ║ │ 2. Protected-path check │ ║ ║ │ 3. gitleaks secret scan │ ║ diff --git a/docs/guides/user/customizing-agents.md b/docs/guides/user/customizing-agents.md index 6a7c81193..bbc95e044 100644 --- a/docs/guides/user/customizing-agents.md +++ b/docs/guides/user/customizing-agents.md @@ -41,7 +41,7 @@ validation_loop: max_iterations: 2 runner_env: - PUSH_TOKEN: "${PUSH_TOKEN}" + PUSH_TOKEN: "${PUSH_TOKEN}" # auto-minted in CI when --mint-url is provided REPO_FULL_NAME: "${REPO_FULL_NAME}" REPO_DIR: "${GITHUB_WORKSPACE}/target-repo" ``` diff --git a/docs/guides/user/running-agents-locally.md b/docs/guides/user/running-agents-locally.md index 98c384187..9e8c9d933 100644 --- a/docs/guides/user/running-agents-locally.md +++ b/docs/guides/user/running-agents-locally.md @@ -146,6 +146,8 @@ Add to an env file: ```bash # fullsend-review.env +# In CI, REVIEW_TOKEN is auto-minted by the binary when --mint-url is provided. +# For local runs, supply a GitHub PAT manually: REVIEW_TOKEN={github-pat} GITHUB_PR_URL="https://github.com/{org}/{repo}/pull/{pr_number}" PR_NUMBER="{pr_number}" @@ -166,9 +168,11 @@ Add to an env file: ```bash # fullsend-code.env +# In CI, GH_TOKEN and PUSH_TOKEN are auto-minted by the binary when --mint-url is provided. +# For local runs, supply GitHub PATs manually: GH_TOKEN={github-pat} PUSH_TOKEN={github-pat} -PUSH_TOKEN_SOURCE=github-app +PUSH_TOKEN_SOURCE=pat GITHUB_ISSUE_URL=https://github.com/{org}/{repo}/issues/{issue_num} REPO_FULL_NAME={org}/{repo} ISSUE_NUMBER={issue_num} diff --git a/docs/plans/adr-0045-forge-portable-harness-phase2.md b/docs/plans/adr-0045-forge-portable-harness-phase2.md index de99f30bc..6cb1f7608 100644 --- a/docs/plans/adr-0045-forge-portable-harness-phase2.md +++ b/docs/plans/adr-0045-forge-portable-harness-phase2.md @@ -126,19 +126,22 @@ PRs 1, 2, 3, 5 can start in parallel. PR 4 depends on PR 3 (needs the base URL b **`internal/scaffold/fullsend-repo/harness/code.yaml`:** - Move to `forge.github:`: `pre_script`, `post_script` -- Move to `forge.github.runner_env:`: `PUSH_TOKEN`, `PUSH_TOKEN_SOURCE`, `REPO_FULL_NAME`, `ISSUE_NUMBER`, `REPO_DIR` (value `${GITHUB_WORKSPACE}/target-repo`) +- Move to `forge.github.runner_env:`: `REPO_FULL_NAME`, `ISSUE_NUMBER`, `REPO_DIR` (value `${GITHUB_WORKSPACE}/target-repo`) +- `PUSH_TOKEN`, `PUSH_TOKEN_SOURCE`: auto-minted by `mintAgentToken()` when `--mint-url` is provided (superseded by PR #2389) - Keep at top level `runner_env:`: `TARGET_BRANCH` - Keep at top level: `agent`, `doc`, `model`, `image`, `policy`, `role`, `slug`, `skills`, `plugins`, `host_files`, `timeout_minutes` **`internal/scaffold/fullsend-repo/harness/review.yaml`:** - Move to `forge.github:`: `pre_script`, `post_script` -- Move to `forge.github.runner_env:`: `REVIEW_TOKEN`, `REPO_FULL_NAME`, `PR_NUMBER`, `GITHUB_PR_URL` +- Move to `forge.github.runner_env:`: `REPO_FULL_NAME`, `PR_NUMBER`, `GITHUB_PR_URL` +- `REVIEW_TOKEN`: auto-minted by `mintAgentToken()` when `--mint-url` is provided (superseded by PR #2389) - Keep at top level `runner_env:`: `FULLSEND_OUTPUT_SCHEMA` - Keep at top level: `agent`, `doc`, `model`, `image`, `policy`, `role`, `slug`, `skills`, `host_files`, `timeout_minutes`, `validation_loop` **`internal/scaffold/fullsend-repo/harness/fix.yaml`:** - Move to `forge.github:`: `pre_script`, `post_script` -- Move to `forge.github.runner_env:`: `PUSH_TOKEN`, `PUSH_TOKEN_SOURCE`, `REPO_FULL_NAME`, `PR_NUMBER`, `REPO_DIR` (value `${GITHUB_WORKSPACE}/target-repo`) +- Move to `forge.github.runner_env:`: `REPO_FULL_NAME`, `PR_NUMBER`, `REPO_DIR` (value `${GITHUB_WORKSPACE}/target-repo`) +- `PUSH_TOKEN`, `PUSH_TOKEN_SOURCE`: auto-minted by `mintAgentToken()` when `--mint-url` is provided (superseded by PR #2389) - Keep at top level `runner_env:`: `TARGET_BRANCH`, `TRIGGER_SOURCE`, `HUMAN_INSTRUCTION`, `FIX_ITERATION`, `REVIEW_BODY_FILE`, `PRE_AGENT_HEAD`, `FULLSEND_OUTPUT_SCHEMA`, `FULLSEND_OUTPUT_FILE` - Keep at top level: `agent`, `doc`, `model`, `image`, `policy`, `role`, `slug`, `skills`, `host_files`, `timeout_minutes`, `validation_loop` diff --git a/docs/superpowers/plans/2026-05-04-retro-agent.md b/docs/superpowers/plans/2026-05-04-retro-agent.md index 5d9cd553f..6ab9d9c8f 100644 --- a/docs/superpowers/plans/2026-05-04-retro-agent.md +++ b/docs/superpowers/plans/2026-05-04-retro-agent.md @@ -844,7 +844,8 @@ jobs: - name: Setup agent environment env: AGENT_PREFIX: RETRO_ - RETRO_GH_TOKEN: ${{ steps.sandbox-token.outputs.token }} + # GH_TOKEN is auto-minted by the binary when --mint-url is provided. + # RETRO_GH_TOKEN was removed in favor of binary-based minting. RETRO_ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }} RETRO_CLOUD_ML_REGION: ${{ vars.FULLSEND_GCP_REGION }} run: bash .github/scripts/setup-agent-env.sh diff --git a/internal/cli/reconcilestatus.go b/internal/cli/reconcilestatus.go index f6dcdcd85..c7ffb3ce0 100644 --- a/internal/cli/reconcilestatus.go +++ b/internal/cli/reconcilestatus.go @@ -71,7 +71,10 @@ finalized, this is a no-op.`, if err != nil { return fmt.Errorf("minting status token: %w", err) } - if os.Getenv("GITHUB_ACTIONS") == "true" && mintTokenPattern.MatchString(result.Token) { + if !mintTokenPattern.MatchString(result.Token) { + return fmt.Errorf("minted status token contains unexpected characters") + } + if os.Getenv("GITHUB_ACTIONS") == "true" { fmt.Fprintf(os.Stderr, "::add-mask::%s\n", result.Token) } client := reconcileNewForgeClient(result.Token) diff --git a/internal/cli/reconcilestatus_test.go b/internal/cli/reconcilestatus_test.go index 9b63a2d00..b779d35e4 100644 --- a/internal/cli/reconcilestatus_test.go +++ b/internal/cli/reconcilestatus_test.go @@ -173,3 +173,26 @@ func TestNewReconcileStatusCmd_MintSuccessCancelled(t *testing.T) { err := cmd.Execute() require.NoError(t, err) } + +func TestNewReconcileStatusCmd_RejectsMalformedToken(t *testing.T) { + origMint := reconcileMintToken + reconcileMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return &mintclient.MintResult{Token: "not-a-valid-token!"}, nil + } + defer func() { reconcileMintToken = origMint }() + + t.Setenv("FULLSEND_MINT_URL", "") + + cmd := newReconcileStatusCmd() + cmd.SetArgs([]string{ + "--repo", "org/repo", + "--number", "7", + "--run-id", "run-1", + "--mint-url", "https://mint.example.com", + "--role", "coder", + }) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected characters") +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 4eed8b3a7..6b62dfe79 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -27,6 +27,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/harness" "github.com/fullsend-ai/fullsend/internal/lock" "github.com/fullsend-ai/fullsend/internal/mintclient" + "github.com/fullsend-ai/fullsend/internal/mintcore" "github.com/fullsend-ai/fullsend/internal/resolve" agentruntime "github.com/fullsend-ai/fullsend/internal/runtime" "github.com/fullsend-ai/fullsend/internal/sandbox" @@ -49,6 +50,9 @@ const ( // agentWorkingDirExcludes lists directory patterns that agents may create // during execution but must never commit. These are added to // .git/info/exclude before the agent runs so git ignores them entirely. +// statusMintToken is the test seam for minting tokens. Shared by both +// setupStatusNotifier (status comment tokens) and mintAgentToken (agent +// runtime tokens). Tests that override it affect both paths. var statusMintToken = mintclient.MintToken var agentWorkingDirExcludes = []string{ @@ -324,6 +328,26 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep h.Image = resolved } + // Mint agent token when a mint URL and harness role are both available. + // Runs before env expansion so minted tokens flow into RunnerEnv and + // host_files via os.Getenv automatically. + mintURL := sOpts.mintURL + if mintURL == "" { + mintURL = os.Getenv("FULLSEND_MINT_URL") + } + minted, mintCleanup, err := mintAgentToken(ctx, h.Role, mintURL, printer) + if err != nil { + return fmt.Errorf("agent token minting failed: %w", err) + } + if mintCleanup != nil { + defer mintCleanup() + } + if !minted && mintURL == "" && h.Role != "" { + printer.StepWarn("No --mint-url provided; skipping token minting for role " + h.Role) + } else if !minted && mintURL != "" && h.Role == "" { + printer.StepWarn("Harness has no role field; cannot mint tokens via --mint-url. Set role in harness YAML or provide tokens via environment.") + } + // Expand env vars in runner_env values. FULLSEND_DIR is injected so // harness configs can reference files relative to the fullsend directory // (e.g., ${FULLSEND_DIR}/schemas/triage-result.schema.json). @@ -431,7 +455,7 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep // post-script — and can report cancellation/failure even when the // sandbox never starts. See #1859. if sOpts.statusRepo != "" && sOpts.statusNum > 0 { - notifier, notifyErr := setupStatusNotifier(absFullsendDir, agentName, sOpts, printer) + notifier, notifyErr := setupStatusNotifier(absFullsendDir, h.Role, sOpts, printer) if notifyErr != nil { printer.StepWarn("Status notifications disabled: " + notifyErr.Error()) } else { @@ -1953,7 +1977,10 @@ func titleCase(s string) string { return strings.Join(words, " ") } -func setupStatusNotifier(fullsendDir string, agentName string, sOpts statusOpts, printer *ui.Printer) (*statuscomment.Notifier, error) { +// setupStatusNotifier creates a status comment notifier. The role parameter +// accepts either a raw harness role (e.g. "code") or a canonical role +// (e.g. "coder"); it is resolved via resolveRole internally. +func setupStatusNotifier(fullsendDir string, role string, sOpts statusOpts, printer *ui.Printer) (*statuscomment.Notifier, error) { parts := strings.SplitN(sOpts.statusRepo, "/", 2) if len(parts) != 2 { return nil, fmt.Errorf("--status-repo must be in owner/repo format, got %q", sOpts.statusRepo) @@ -1998,17 +2025,20 @@ func setupStatusNotifier(fullsendDir string, agentName string, sOpts statusOpts, printer.StepWarn(fmt.Sprintf(format, args...)) }) - role := resolveRole(agentName) + canonRole := resolveRole(role) n.SetClientFactory(func(ctx context.Context) (forge.Client, error) { result, err := statusMintToken(ctx, mintclient.MintRequest{ MintURL: mintURL, - Role: role, + Role: canonRole, Repos: []string{repo}, }) if err != nil { return nil, fmt.Errorf("minting status token: %w", err) } - if os.Getenv("GITHUB_ACTIONS") == "true" && mintTokenPattern.MatchString(result.Token) { + if !mintTokenPattern.MatchString(result.Token) { + return nil, fmt.Errorf("minted status token contains unexpected characters") + } + if os.Getenv("GITHUB_ACTIONS") == "true" { fmt.Fprintf(os.Stderr, "::add-mask::%s\n", result.Token) } return gh.New(result.Token), nil @@ -2074,3 +2104,140 @@ func emitDiagnosticWithContext(printer *ui.Printer, context string, diag harness printer.StepWarn(msg) } } + +type tokenVar struct { + Name string + Value string // empty = use minted token +} + +// roleTokenVars maps canonical role names to the additional env vars they +// require beyond GH_TOKEN. These match the vars declared in +// forge.github.runner_env across the harness YAML files. +var roleTokenVars = map[string][]tokenVar{ + "coder": {{Name: "PUSH_TOKEN"}, {Name: "PUSH_TOKEN_SOURCE", Value: "github-app"}}, + "review": {{Name: "REVIEW_TOKEN"}}, +} + +// mintAgentToken mints a GitHub App installation token for the agent's role +// and sets the appropriate env vars so RunnerEnv expansion and host_files +// expansion pick them up. Returns (minted bool, cleanup func, err). +// The caller should defer cleanup() to clear tokens from the process env. +func mintAgentToken(ctx context.Context, role, mintURL string, printer *ui.Printer) (bool, func(), error) { + if mintURL == "" || role == "" { + return false, func() {}, nil + } + + repos, err := resolveMintRepos() + if err != nil { + return false, nil, fmt.Errorf("resolving mint repos for role %s: %w", role, err) + } + + role = resolveRole(role) + if err := mintcore.ValidateRoleName(role); err != nil { + return false, nil, fmt.Errorf("invalid role: %w", err) + } + printer.StepStart("Minting agent token (role: " + role + ")") + + result, err := statusMintToken(ctx, mintclient.MintRequest{ + MintURL: mintURL, + Role: role, + Repos: repos, + }) + if err != nil { + return false, nil, fmt.Errorf("minting agent token for role %s: %w", role, err) + } + + if !mintTokenPattern.MatchString(result.Token) { + return false, nil, fmt.Errorf("minted agent token contains unexpected characters for role %s", role) + } + + // TODO(ADR-0045 R22): use forge platform context instead of raw env check. + if os.Getenv("GITHUB_ACTIONS") == "true" { + fmt.Fprintf(os.Stderr, "::add-mask::%s\n", result.Token) + } + + // NOTE: os.Setenv is not goroutine-safe. Minting MUST complete + // before any goroutines that read env vars (sandbox streaming, + // post-script execution) are launched. + originals := make(map[string]string) + envVars := []string{"GH_TOKEN"} + if v, ok := os.LookupEnv("GH_TOKEN"); ok { + originals["GH_TOKEN"] = v + } + os.Setenv("GH_TOKEN", result.Token) + + for _, tv := range roleTokenVars[role] { + if v, ok := os.LookupEnv(tv.Name); ok { + originals[tv.Name] = v + } + if tv.Value != "" { + os.Setenv(tv.Name, tv.Value) + } else { + os.Setenv(tv.Name, result.Token) + } + envVars = append(envVars, tv.Name) + } + + cleanup := func() { + for _, v := range envVars { + if orig, ok := originals[v]; ok { + os.Setenv(v, orig) + } else { + os.Unsetenv(v) + } + } + } + + expiresAt := strings.Map(func(r rune) rune { + if (r >= '0' && r <= '9') || r == '-' || r == ':' || r == 'T' || r == 'Z' || r == '+' || r == '.' { + return r + } + return -1 + }, result.ExpiresAt) + printer.StepDone("Agent token minted (expires " + expiresAt + ")") + return true, cleanup, nil +} + +// resolveMintRepos determines which repos to request token access for. +// MINT_REPOS (comma-separated) takes precedence, falling back to extracting +// the repo name from REPO_FULL_NAME (owner/repo → repo). +func resolveMintRepos() ([]string, error) { + if v := os.Getenv("MINT_REPOS"); v != "" { + var repos []string + for _, r := range strings.Split(v, ",") { + if trimmed := strings.TrimSpace(r); trimmed != "" { + repos = append(repos, trimmed) + } + } + if len(repos) > 0 { + if err := validateRepoNames(repos); err != nil { + return nil, err + } + return repos, nil + } + } + + fullName := os.Getenv("REPO_FULL_NAME") + if fullName == "" { + return nil, fmt.Errorf("MINT_REPOS or REPO_FULL_NAME must be set for token minting") + } + + parts := strings.SplitN(fullName, "/", 2) + if len(parts) != 2 || parts[1] == "" { + return nil, fmt.Errorf("REPO_FULL_NAME must be in owner/repo format, got %q", fullName) + } + repo := parts[1] + if !mintcore.RepoNamePattern.MatchString(repo) { + return nil, fmt.Errorf("invalid repo name %q from REPO_FULL_NAME: must match %s", repo, mintcore.RepoNamePattern.String()) + } + return []string{repo}, nil +} + +func validateRepoNames(repos []string) error { + for _, r := range repos { + if !mintcore.RepoNamePattern.MatchString(r) { + return fmt.Errorf("invalid repo name %q in MINT_REPOS: must match %s", r, mintcore.RepoNamePattern.String()) + } + } + return nil +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 99ed160b4..02f66c7d6 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -1662,6 +1662,33 @@ func TestSetupStatusNotifier_FactoryMintError(t *testing.T) { assert.Nil(t, client) } +func TestSetupStatusNotifier_FactoryRejectsMalformedToken(t *testing.T) { + tmpDir := t.TempDir() + printer := ui.New(io.Discard) + + origMint := statusMintToken + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return &mintclient.MintResult{Token: "not-a-valid-token-format!"}, nil + } + defer func() { statusMintToken = origMint }() + + sOpts := statusOpts{ + statusRepo: "org/repo", + statusNum: 7, + mintURL: "https://mint.example.com", + } + + t.Setenv("GITHUB_RUN_ID", "run-42") + + n, err := setupStatusNotifier(tmpDir, "coder", sOpts, printer) + require.NoError(t, err) + + client, err := n.InvokeClientFactory(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected characters") + assert.Nil(t, client) +} + func TestRunCommand_StatusTokenFlagRemoved(t *testing.T) { cmd := newRunCmd() f := cmd.Flags().Lookup("status-token") @@ -1869,3 +1896,577 @@ func TestWriteMetricsJSON(t *testing.T) { t.Errorf("tool_calls = %d, want 34", got.ToolCalls) } } + +// --- mintAgentToken tests --- + +func TestMintAgentToken_SkipsWhenNoMintURL(t *testing.T) { + printer := ui.New(io.Discard) + minted, _, err := mintAgentToken(context.Background(), "coder", "", printer) + require.NoError(t, err) + assert.False(t, minted) +} + +func TestMintAgentToken_SkipsWhenNoRole(t *testing.T) { + printer := ui.New(io.Discard) + minted, _, err := mintAgentToken(context.Background(), "", "https://mint.example.com", printer) + require.NoError(t, err) + assert.False(t, minted) +} + +func TestMintAgentToken_CoderRole(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, req mintclient.MintRequest) (*mintclient.MintResult, error) { + assert.Equal(t, "https://mint.example.com", req.MintURL) + assert.Equal(t, "coder", req.Role) + assert.Equal(t, []string{"my-repo"}, req.Repos) + return &mintclient.MintResult{Token: "ghs_coder_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GH_TOKEN", "") + t.Setenv("PUSH_TOKEN", "") + t.Setenv("PUSH_TOKEN_SOURCE", "") + + var buf bytes.Buffer + printer := ui.New(&buf) + minted, cleanup, err := mintAgentToken(context.Background(), "coder", "https://mint.example.com", printer) + require.NoError(t, err) + defer cleanup() + assert.True(t, minted) + require.NotNil(t, cleanup) + + assert.Equal(t, "ghs_coder_token", os.Getenv("GH_TOKEN")) + assert.Equal(t, "ghs_coder_token", os.Getenv("PUSH_TOKEN")) + assert.Equal(t, "github-app", os.Getenv("PUSH_TOKEN_SOURCE")) + + cleanup() + assert.Equal(t, "", os.Getenv("GH_TOKEN"), "cleanup should restore GH_TOKEN to original empty value") + assert.Equal(t, "", os.Getenv("PUSH_TOKEN"), "cleanup should restore PUSH_TOKEN to original empty value") + assert.Equal(t, "", os.Getenv("PUSH_TOKEN_SOURCE"), "cleanup should restore PUSH_TOKEN_SOURCE to original empty value") + + output := buf.String() + assert.Contains(t, output, "Minting agent token (role: coder)") + assert.Contains(t, output, "Agent token minted") +} + +func TestMintAgentToken_ReviewRole(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, req mintclient.MintRequest) (*mintclient.MintResult, error) { + assert.Equal(t, "review", req.Role) + return &mintclient.MintResult{Token: "ghs_review_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GH_TOKEN", "") + t.Setenv("REVIEW_TOKEN", "") + + printer := ui.New(io.Discard) + minted, cleanup, err := mintAgentToken(context.Background(), "review", "https://mint.example.com", printer) + require.NoError(t, err) + assert.True(t, minted) + require.NotNil(t, cleanup) + defer cleanup() + + assert.Equal(t, "ghs_review_token", os.Getenv("GH_TOKEN")) + assert.Equal(t, "ghs_review_token", os.Getenv("REVIEW_TOKEN")) +} + +func TestMintAgentToken_RetroRole_NoExtras(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, req mintclient.MintRequest) (*mintclient.MintResult, error) { + assert.Equal(t, "retro", req.Role) + assert.Equal(t, []string{"my-repo", ".fullsend"}, req.Repos) + return &mintclient.MintResult{Token: "ghs_retro_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("MINT_REPOS", "my-repo,.fullsend") + t.Setenv("GH_TOKEN", "") + + printer := ui.New(io.Discard) + minted, cleanup, err := mintAgentToken(context.Background(), "retro", "https://mint.example.com", printer) + require.NoError(t, err) + assert.True(t, minted) + require.NotNil(t, cleanup) + defer cleanup() + + assert.Equal(t, "ghs_retro_token", os.Getenv("GH_TOKEN")) +} + +func TestMintAgentToken_ResolvesAliases(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, req mintclient.MintRequest) (*mintclient.MintResult, error) { + assert.Equal(t, "coder", req.Role, "code should resolve to coder") + return &mintclient.MintResult{Token: "ghs_alias_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GH_TOKEN", "") + t.Setenv("PUSH_TOKEN", "") + t.Setenv("PUSH_TOKEN_SOURCE", "") + + printer := ui.New(io.Discard) + minted, cleanup, err := mintAgentToken(context.Background(), "code", "https://mint.example.com", printer) + require.NoError(t, err) + assert.True(t, minted) + require.NotNil(t, cleanup) + defer cleanup() + + assert.Equal(t, "ghs_alias_token", os.Getenv("PUSH_TOKEN")) +} + +func TestMintAgentToken_TriageRole_NoExtras(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, req mintclient.MintRequest) (*mintclient.MintResult, error) { + assert.Equal(t, "triage", req.Role) + return &mintclient.MintResult{Token: "ghs_triage_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GH_TOKEN", "") + t.Setenv("PUSH_TOKEN", "should-not-change") + + printer := ui.New(io.Discard) + minted, cleanup, err := mintAgentToken(context.Background(), "triage", "https://mint.example.com", printer) + require.NoError(t, err) + assert.True(t, minted) + require.NotNil(t, cleanup) + defer cleanup() + + assert.Equal(t, "ghs_triage_token", os.Getenv("GH_TOKEN")) + assert.Equal(t, "should-not-change", os.Getenv("PUSH_TOKEN"), "triage should not set PUSH_TOKEN") +} + +func TestMintAgentToken_MintError(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return nil, fmt.Errorf("OIDC exchange failed") + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + + printer := ui.New(io.Discard) + _, _, err := mintAgentToken(context.Background(), "coder", "https://mint.example.com", printer) + require.Error(t, err) + assert.Contains(t, err.Error(), "minting agent token for role coder") +} + +func TestMintAgentToken_RepoResolutionError(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + // No REPO_FULL_NAME and no MINT_REPOS set + printer := ui.New(io.Discard) + _, _, err := mintAgentToken(context.Background(), "coder", "https://mint.example.com", printer) + require.Error(t, err) + assert.Contains(t, err.Error(), "resolving mint repos for role coder") +} + +func TestMintAgentToken_RejectsMalformedToken(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return &mintclient.MintResult{Token: "bad token with spaces!", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + + printer := ui.New(io.Discard) + _, _, err := mintAgentToken(context.Background(), "coder", "https://mint.example.com", printer) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected characters") +} + +func TestMintAgentToken_MasksTokenInGitHubActions(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return &mintclient.MintResult{Token: "ghs_maskable", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GH_TOKEN", "") + + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + printer := ui.New(io.Discard) + minted, cleanup, err := mintAgentToken(context.Background(), "triage", "https://mint.example.com", printer) + + w.Close() + os.Stderr = oldStderr + + require.NoError(t, err) + assert.True(t, minted) + if cleanup != nil { + defer cleanup() + } + + var buf bytes.Buffer + io.Copy(&buf, r) + assert.Contains(t, buf.String(), "::add-mask::ghs_maskable") +} + +// --- resolveMintRepos tests --- + +func TestResolveMintRepos_FromMINT_REPOS(t *testing.T) { + t.Setenv("MINT_REPOS", "repo-a,repo-b") + repos, err := resolveMintRepos() + require.NoError(t, err) + assert.Equal(t, []string{"repo-a", "repo-b"}, repos) +} + +func TestResolveMintRepos_TrimsWhitespace(t *testing.T) { + t.Setenv("MINT_REPOS", " repo-a , repo-b ") + repos, err := resolveMintRepos() + require.NoError(t, err) + assert.Equal(t, []string{"repo-a", "repo-b"}, repos) +} + +func TestResolveMintRepos_FromREPO_FULL_NAME(t *testing.T) { + t.Setenv("REPO_FULL_NAME", "org/my-repo") + repos, err := resolveMintRepos() + require.NoError(t, err) + assert.Equal(t, []string{"my-repo"}, repos) +} + +func TestResolveMintRepos_MINT_REPOS_TakesPrecedence(t *testing.T) { + t.Setenv("MINT_REPOS", "override-repo") + t.Setenv("REPO_FULL_NAME", "org/other-repo") + repos, err := resolveMintRepos() + require.NoError(t, err) + assert.Equal(t, []string{"override-repo"}, repos) +} + +func TestResolveMintRepos_NeitherSet(t *testing.T) { + _, err := resolveMintRepos() + require.Error(t, err) + assert.Contains(t, err.Error(), "MINT_REPOS or REPO_FULL_NAME must be set") +} + +func TestResolveMintRepos_InvalidREPO_FULL_NAME(t *testing.T) { + t.Setenv("REPO_FULL_NAME", "no-slash") + _, err := resolveMintRepos() + require.Error(t, err) + assert.Contains(t, err.Error(), "owner/repo format") +} + +func TestResolveMintRepos_EmptyRepoInREPO_FULL_NAME(t *testing.T) { + t.Setenv("REPO_FULL_NAME", "org/") + _, err := resolveMintRepos() + require.Error(t, err) + assert.Contains(t, err.Error(), "owner/repo format") +} + +func TestResolveMintRepos_EmptyMINT_REPOS_FallsBack(t *testing.T) { + t.Setenv("MINT_REPOS", ",,,") + t.Setenv("REPO_FULL_NAME", "org/fallback-repo") + repos, err := resolveMintRepos() + require.NoError(t, err) + assert.Equal(t, []string{"fallback-repo"}, repos) +} + +func TestMintAgentToken_SanitizesExpiresAt(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return &mintclient.MintResult{ + Token: "ghs_safe_token", + ExpiresAt: "2026-06-15T12:00:00Z::warning::injected", + }, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GH_TOKEN", "") + t.Setenv("PUSH_TOKEN", "") + t.Setenv("PUSH_TOKEN_SOURCE", "") + + var buf bytes.Buffer + printer := ui.New(&buf) + minted, cleanup, err := mintAgentToken(context.Background(), "coder", "https://mint.example.com", printer) + require.NoError(t, err) + assert.True(t, minted) + if cleanup != nil { + defer cleanup() + } + + output := buf.String() + assert.NotContains(t, output, "::warning::") + assert.Contains(t, output, "2026-06-15T12:00:00Z") +} + +func TestMintAgentToken_SanitizesExpiresAt_FractionalSeconds(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return &mintclient.MintResult{ + Token: "ghs_safe_token", + ExpiresAt: "2026-06-15T12:00:00.123Z", + }, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GH_TOKEN", "") + + var buf bytes.Buffer + printer := ui.New(&buf) + minted, cleanup, err := mintAgentToken(context.Background(), "triage", "https://mint.example.com", printer) + require.NoError(t, err) + assert.True(t, minted) + if cleanup != nil { + defer cleanup() + } + + output := buf.String() + assert.Contains(t, output, "2026-06-15T12:00:00.123Z") +} + +func TestMintAgentToken_RejectsInvalidRole(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + t.Fatal("mint should not be called for invalid role") + return nil, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + + printer := ui.New(io.Discard) + _, _, err := mintAgentToken(context.Background(), "INVALID--ROLE", "https://mint.example.com", printer) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid role") +} + +func TestResolveMintRepos_InvalidRepoInMINT_REPOS(t *testing.T) { + t.Setenv("MINT_REPOS", "valid-repo,invalid repo!@#") + _, err := resolveMintRepos() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo name") + assert.Contains(t, err.Error(), "MINT_REPOS") +} + +func TestResolveMintRepos_InvalidRepoInREPO_FULL_NAME(t *testing.T) { + t.Setenv("REPO_FULL_NAME", "org/invalid repo!@#") + _, err := resolveMintRepos() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid repo name") + assert.Contains(t, err.Error(), "REPO_FULL_NAME") +} + +func TestRoleTokenVars_Coverage(t *testing.T) { + assert.Equal(t, []tokenVar{{Name: "PUSH_TOKEN"}, {Name: "PUSH_TOKEN_SOURCE", Value: "github-app"}}, roleTokenVars["coder"]) + assert.Equal(t, []tokenVar{{Name: "REVIEW_TOKEN"}}, roleTokenVars["review"]) + _, hasRetro := roleTokenVars["retro"] + assert.False(t, hasRetro, "retro should not have extra token vars (RETRO_SANDBOX_TOKEN removed in #2412)") + _, hasTriage := roleTokenVars["triage"] + assert.False(t, hasTriage, "triage should not have extra token vars") + _, hasPrioritize := roleTokenVars["prioritize"] + assert.False(t, hasPrioritize, "prioritize should not have extra token vars") +} + +func TestMintAgentToken_CleanupRestoresOriginals(t *testing.T) { + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return &mintclient.MintResult{Token: "ghs_new_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GH_TOKEN", "ghp_original_pat") + t.Setenv("PUSH_TOKEN", "ghp_original_push") + t.Setenv("PUSH_TOKEN_SOURCE", "manual") + + printer := ui.New(io.Discard) + minted, cleanup, err := mintAgentToken(context.Background(), "coder", "https://mint.example.com", printer) + require.NoError(t, err) + defer cleanup() + assert.True(t, minted) + require.NotNil(t, cleanup) + + assert.Equal(t, "ghs_new_token", os.Getenv("GH_TOKEN")) + + cleanup() + assert.Equal(t, "ghp_original_pat", os.Getenv("GH_TOKEN"), "cleanup should restore original GH_TOKEN") + assert.Equal(t, "ghp_original_push", os.Getenv("PUSH_TOKEN"), "cleanup should restore original PUSH_TOKEN") + assert.Equal(t, "manual", os.Getenv("PUSH_TOKEN_SOURCE"), "cleanup should restore original PUSH_TOKEN_SOURCE") +} + +func TestRunAgent_FallsBackToFULLSEND_MINT_URL(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agents", "code.md"), + []byte("You are a coding agent."), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "code.yaml"), + []byte("agent: agents/code.md\nrole: coder\n"), + 0o644, + )) + + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + var mintCalled bool + statusMintToken = func(_ context.Context, req mintclient.MintRequest) (*mintclient.MintResult, error) { + mintCalled = true + assert.Equal(t, "https://mint-from-env.example.com", req.MintURL) + return &mintclient.MintResult{Token: "ghs_env_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("FULLSEND_MINT_URL", "https://mint-from-env.example.com") + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GH_TOKEN", "") + t.Setenv("PUSH_TOKEN", "") + t.Setenv("PUSH_TOKEN_SOURCE", "") + + var buf bytes.Buffer + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(&buf) + repoDir := t.TempDir() + err := runAgent(context.Background(), "code", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "openshell") + assert.True(t, mintCalled, "should have used FULLSEND_MINT_URL env var fallback") +} + +func TestRunAgent_WarnsWhenNoMintURL(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agents", "code.md"), + []byte("You are a coding agent."), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "code.yaml"), + []byte("agent: agents/code.md\nrole: coder\n"), + 0o644, + )) + + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + t.Fatal("mint should not be called when no mint URL is available") + return nil, nil + } + + t.Setenv("FULLSEND_MINT_URL", "") + + var buf bytes.Buffer + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(&buf) + repoDir := t.TempDir() + err := runAgent(context.Background(), "code", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false) + + require.Error(t, err) + assert.Contains(t, buf.String(), "skipping token minting") +} + +func TestRunAgent_MintTokenError(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agents", "code.md"), + []byte("You are a coding agent."), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "code.yaml"), + []byte("agent: agents/code.md\nrole: coder\n"), + 0o644, + )) + + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return nil, fmt.Errorf("OIDC token exchange failed") + } + + t.Setenv("FULLSEND_MINT_URL", "https://mint.example.com") + t.Setenv("REPO_FULL_NAME", "org/my-repo") + + var buf bytes.Buffer + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(&buf) + repoDir := t.TempDir() + err := runAgent(context.Background(), "code", dir, "", repoDir, "", nil, false, "", "", rFlags, statusOpts{}, printer, false) + + require.Error(t, err) + assert.Contains(t, err.Error(), "agent token minting failed") +} + +func TestRunAgent_StatusNotifierSetup(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "harness"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "agents"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, "agents", "code.md"), + []byte("You are a coding agent."), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "harness", "code.yaml"), + []byte("agent: agents/code.md\nrole: coder\n"), + 0o644, + )) + + origMint := statusMintToken + defer func() { statusMintToken = origMint }() + + statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) { + return &mintclient.MintResult{Token: "ghs_test_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil + } + + t.Setenv("FULLSEND_MINT_URL", "https://mint.example.com") + t.Setenv("REPO_FULL_NAME", "org/my-repo") + t.Setenv("GITHUB_RUN_ID", "run-42") + t.Setenv("GH_TOKEN", "") + t.Setenv("PUSH_TOKEN", "") + t.Setenv("PUSH_TOKEN_SOURCE", "") + + var buf bytes.Buffer + rFlags := resolveFlags{maxDepth: 10, maxResources: 50} + printer := ui.New(&buf) + repoDir := t.TempDir() + sOpts := statusOpts{ + statusRepo: "org/my-repo", + statusNum: 42, + mintURL: "https://mint.example.com", + } + err := runAgent(context.Background(), "code", dir, "", repoDir, "", nil, false, "", "", rFlags, sOpts, printer, false) + + // Will error downstream (openshell not available), but status notifier setup should succeed + require.Error(t, err) + assert.Contains(t, err.Error(), "openshell") +}