From c1d9633e750becd52fde072b6fcf9b300988f762 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Sat, 25 Apr 2026 15:23:42 -0400 Subject: [PATCH 1/9] feat: add Workload Identity Federation auth for GCP Vertex AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace long-lived service account key JSON with short-lived OIDC token exchange via GCP Workload Identity Federation. SA key mode is retained as a fallback — the google-github-actions/auth action auto-selects WIF when the provider secret is set. Changes: - vertex.go: add AuthMode type, WIF constants, Config fields, split Provision into WIF/SAKey paths - admin.go: add --gcp-wif-provider and --gcp-wif-sa-email flags with mutual exclusivity validation against SA key flags - workflows: add id-token:write permission, dual-mode auth step, and credential prep step that rewrites OIDC token paths for sandbox - harness configs: add optional OIDC token file to host_files - installation.md: WIF as recommended Option A, SA key as legacy Option B, migration guide - ADR-0014: add WIF secrets to credential surface table Signed-off-by: Wayne Sun --- docs/guides/admin/installation.md | 113 +++++++++++++++++- .../adr-0014-github-apps-and-secrets/SPEC.md | 8 +- internal/cli/admin.go | 65 ++++++---- internal/cli/admin_test.go | 6 + internal/inference/vertex/vertex.go | 54 ++++++++- internal/inference/vertex/vertex_test.go | 50 ++++++++ .../fullsend-repo/.github/workflows/code.yml | 18 +++ .../.github/workflows/review.yml | 18 +++ .../.github/workflows/triage.yml | 18 +++ .../scaffold/fullsend-repo/harness/code.yaml | 3 + .../fullsend-repo/harness/review.yaml | 3 + .../fullsend-repo/harness/triage.yaml | 3 + 12 files changed, 323 insertions(+), 36 deletions(-) diff --git a/docs/guides/admin/installation.md b/docs/guides/admin/installation.md index 5c87ae89b0..94f5004f2a 100644 --- a/docs/guides/admin/installation.md +++ b/docs/guides/admin/installation.md @@ -17,15 +17,82 @@ This guide walks through installing fullsend in a GitHub organization and enroll - **GCP project** with the [Vertex AI API](https://console.cloud.google.com/apis/library/aiplatform.googleapis.com) enabled -## 1. Create a GCP service account key +Available regions for Claude on Vertex AI include `us-east5`, `europe-west1`, and `asia-southeast1`. Check the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions) for the latest list. + +## 1. Set up GCP authentication + +Fullsend supports two methods for authenticating to Vertex AI. **Workload Identity Federation (WIF) is recommended** — it eliminates long-lived credentials entirely. + +### Option A: Workload Identity Federation (recommended) + +WIF lets GitHub Actions exchange short-lived OIDC tokens for GCP access tokens. No service account keys are stored. + +**1a. Create a service account** + +```bash +export GCP_PROJECT="" +export ORG_NAME="" + +gcloud iam service-accounts create fullsend-agent \ + --display-name="Fullsend agent inference" \ + --project="$GCP_PROJECT" + +gcloud projects add-iam-policy-binding "$GCP_PROJECT" \ + --member="serviceAccount:fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com" \ + --role="roles/aiplatform.user" \ + --condition=None +``` + +**1b. Create a Workload Identity Pool and OIDC Provider** + +```bash +gcloud iam workload-identity-pools create github-actions \ + --location=global \ + --display-name="GitHub Actions" \ + --project="$GCP_PROJECT" + +gcloud iam workload-identity-pools providers create-oidc github \ + --location=global \ + --workload-identity-pool=github-actions \ + --issuer-uri="https://token.actions.githubusercontent.com" \ + --attribute-mapping="google.subject=assertion.sub,attribute.repository_owner=assertion.repository_owner" \ + --attribute-condition="assertion.repository_owner == '$ORG_NAME'" \ + --project="$GCP_PROJECT" +``` + +The `attribute-condition` restricts access to workflows running in your GitHub organization. For tighter control, you can restrict to a specific repository: + +``` +--attribute-condition="assertion.repository == '$ORG_NAME/.fullsend'" +``` + +**1c. Grant the service account impersonation permission** + +```bash +export PROJECT_NUMBER=$(gcloud projects describe "$GCP_PROJECT" --format='value(projectNumber)') + +gcloud iam service-accounts add-iam-policy-binding \ + "fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com" \ + --role="roles/iam.workloadIdentityUser" \ + --member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/attribute.repository_owner/$ORG_NAME" \ + --project="$GCP_PROJECT" +``` + +**1d. Note the WIF provider resource name** + +```bash +export WIF_PROVIDER="projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/providers/github" +export WIF_SA_EMAIL="fullsend-agent@$GCP_PROJECT.iam.gserviceaccount.com" +``` + +### Option B: Service account key (legacy) Create a service account with the `Vertex AI User` role and download its key: ```bash export GCP_PROJECT="" export ORG_NAME="" -export REPO_NAME="" -# gh repo create "$ORG_NAME/$REPO_NAME" --public + gcloud iam service-accounts create "$ORG_NAME" \ --display-name="Fullsend for $ORG_NAME" \ --project="$GCP_PROJECT" @@ -39,8 +106,6 @@ gcloud iam service-accounts keys create sa-key.json \ --iam-account="$ORG_NAME@$GCP_PROJECT.iam.gserviceaccount.com" ``` -Available regions for Claude on Vertex AI include `us-east5`, `europe-west1`, and `asia-southeast1`. Check the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions) for the latest list. - ## 2. Run the installer The installer is interactive. It will open multiple browser windows to create and install a GitHub App for each agent role. Follow the prompts in each window to complete the app setup. @@ -50,15 +115,51 @@ Near the end, the installer opens a browser to create a fine-grained personal ac If the installer fails partway through, run `fullsend admin uninstall "$ORG_NAME"` to clean up before retrying. You will need to refresh the permissions to add `delete_repo`: `gh auth refresh -s delete_repo`. +**With WIF (recommended):** + +```bash +export REPO_NAME="" + +fullsend admin install "$ORG_NAME" \ + --repo "$REPO_NAME" \ + --gcp-project "$GCP_PROJECT" \ + --gcp-region us-east5 \ + --gcp-wif-provider "$WIF_PROVIDER" \ + --gcp-wif-sa-email "$WIF_SA_EMAIL" +``` + +**With SA key (legacy):** + ```bash +export REPO_NAME="" + fullsend admin install "$ORG_NAME" \ --repo "$REPO_NAME" \ --gcp-project "$GCP_PROJECT" \ - --gcp-region global \ + --gcp-region us-east5 \ --gcp-credentials-file sa-key.json rm sa-key.json ``` +### Migrating from SA key to WIF + +If you already have fullsend installed with a service account key: + +1. Create the WIF resources (steps 1a–1d in Option A above) +2. Re-run the installer with WIF flags (the installer updates secrets in-place): + ```bash + fullsend admin install "$ORG_NAME" \ + --repo "$REPO_NAME" \ + --skip-app-setup \ + --gcp-project "$GCP_PROJECT" \ + --gcp-region us-east5 \ + --gcp-wif-provider "$WIF_PROVIDER" \ + --gcp-wif-sa-email "$WIF_SA_EMAIL" + ``` +3. Verify a workflow run succeeds with WIF auth (check for "Authenticated using Workload Identity Federation" in the auth step output) +4. Delete the old SA key: `gcloud iam service-accounts keys delete --iam-account=...` +5. Remove the `FULLSEND_GCP_SA_KEY_JSON` secret from the `.fullsend` repo settings + **Note**: the `--repo` flag can be repeated to onboard multiple repositories. ## 3. Merge enrollment PRs diff --git a/docs/normative/admin-install/v1/adr-0014-github-apps-and-secrets/SPEC.md b/docs/normative/admin-install/v1/adr-0014-github-apps-and-secrets/SPEC.md index 85733ceebf..f242529487 100644 --- a/docs/normative/admin-install/v1/adr-0014-github-apps-and-secrets/SPEC.md +++ b/docs/normative/admin-install/v1/adr-0014-github-apps-and-secrets/SPEC.md @@ -51,13 +51,15 @@ All of the following are **repository-level** Actions secrets and variables on * |----------|----------------------------------------|-------|-------| | Secret | `FULLSEND__APP_PRIVATE_KEY` | PEM text of the GitHub App private key | secrets | | Variable | `FULLSEND__CLIENT_ID` | GitHub App Client ID (e.g. `Iv23_...`), per [GitHub recommendation](https://github.blog/changelog/2024-05-01-github-apps-can-now-use-the-client-id-to-fetch-installation-tokens/) | secrets | -| Secret | `FULLSEND_GCP_SA_KEY_JSON` | GCP service account key JSON (when inference provider is `vertex`) | inference | +| Secret | `FULLSEND_GCP_SA_KEY_JSON` | GCP service account key JSON (SA key mode only) | inference | +| Secret | `FULLSEND_GCP_WIF_PROVIDER` | Full WIF provider resource name (WIF mode only) | inference | +| Secret | `FULLSEND_GCP_WIF_SA_EMAIL` | Service account email for WIF impersonation (WIF mode only) | inference | | Secret | `FULLSEND_GCP_PROJECT_ID` | GCP project identifier (when inference provider is `vertex`) | inference | -| Variable | `FULLSEND_GCP_REGION` | GCP region for Vertex AI (e.g. `us-central1`) | inference | +| Variable | `FULLSEND_GCP_REGION` | GCP region for Vertex AI (e.g. `us-east5`) | inference | - `` is the agent role in **ASCII uppercase** (e.g. `FULLSEND_TRIAGE_APP_PRIVATE_KEY`). - For each role processed in install, if PEM is non-empty, the implementation **must** create/update the secret; if PEM is empty (reuse path), the implementation **must** skip writing that role’s secret. The Client ID variable is **always** written (even on reuse) to ensure it stays current. -- Inference secrets are only created when an inference provider is configured in `config.yaml` (see [ADR 0011](../adr-0011-org-config-yaml/SPEC.md)). When `inference.provider` is `vertex`, the implementation **must** store both `FULLSEND_GCP_SA_KEY_JSON` and `FULLSEND_GCP_PROJECT_ID`. +- Inference secrets are only created when an inference provider is configured in `config.yaml` (see [ADR 0011](../adr-0011-org-config-yaml/SPEC.md)). When `inference.provider` is `vertex`, the implementation **must** store `FULLSEND_GCP_PROJECT_ID` and either the SA key secret (`FULLSEND_GCP_SA_KEY_JSON`) or the WIF secrets (`FULLSEND_GCP_WIF_PROVIDER` and `FULLSEND_GCP_WIF_SA_EMAIL`). The two auth modes are mutually exclusive — WIF secrets and the SA key secret **must not** coexist. The WIF provider name and SA email are stored as secrets (not variables) so their values are masked in GitHub Actions logs. ## 6. Analyze / health semantics for the secrets layer diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 601199fd34..7e44acfd04 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -87,6 +87,8 @@ func newInstallCmd() *cobra.Command { var gcpRegion string var gcpServiceAccount string var gcpCredentialsFile string + var gcpWIFProvider string + var gcpWIFSAEmail string cmd := &cobra.Command{ Use: "install ", @@ -120,40 +122,55 @@ func newInstallCmd() *cobra.Command { } // Validate GCP flag dependencies. - if gcpProject == "" && (gcpServiceAccount != "" || gcpCredentialsFile != "" || gcpRegion != "") { - return fmt.Errorf("--gcp-service-account, --gcp-credentials-file, and --gcp-region require --gcp-project to be set") + if gcpProject == "" && (gcpServiceAccount != "" || gcpCredentialsFile != "" || gcpRegion != "" || gcpWIFProvider != "" || gcpWIFSAEmail != "") { + return fmt.Errorf("--gcp-service-account, --gcp-credentials-file, --gcp-wif-provider, --gcp-wif-sa-email, and --gcp-region require --gcp-project to be set") } if gcpProject != "" && gcpRegion == "" { return fmt.Errorf("--gcp-region is required when --gcp-project is set") } + if gcpWIFProvider != "" && gcpCredentialsFile != "" { + return fmt.Errorf("--gcp-wif-provider and --gcp-credentials-file are mutually exclusive: use WIF or SA key, not both") + } + if gcpWIFProvider != "" && gcpServiceAccount != "" { + return fmt.Errorf("--gcp-wif-provider and --gcp-service-account are mutually exclusive") + } + if (gcpWIFProvider != "") != (gcpWIFSAEmail != "") { + return fmt.Errorf("--gcp-wif-provider and --gcp-wif-sa-email must be provided together") + } // Build inference provider from GCP flags. var inferenceProvider inference.Provider var inferenceProviderName string if gcpProject != "" { - vcfg := vertex.Config{ProjectID: gcpProject, Region: gcpRegion, ServiceAccountName: gcpServiceAccount} - if gcpCredentialsFile != "" { - info, statErr := os.Lstat(gcpCredentialsFile) - if statErr != nil { - return fmt.Errorf("checking credentials file: %w", statErr) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("credentials file %s must be a regular file", gcpCredentialsFile) - } - credData, readErr := os.ReadFile(gcpCredentialsFile) - if readErr != nil { - return fmt.Errorf("reading credentials file: %w", readErr) - } - // Zero credential bytes when done to limit exposure in memory. - defer func() { - for i := range credData { - credData[i] = 0 + vcfg := vertex.Config{ProjectID: gcpProject, Region: gcpRegion} + if gcpWIFProvider != "" { + vcfg.Mode = vertex.AuthModeWIF + vcfg.WIFProvider = gcpWIFProvider + vcfg.WIFServiceAccount = gcpWIFSAEmail + } else { + vcfg.ServiceAccountName = gcpServiceAccount + if gcpCredentialsFile != "" { + info, statErr := os.Lstat(gcpCredentialsFile) + if statErr != nil { + return fmt.Errorf("checking credentials file: %w", statErr) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("credentials file %s must be a regular file", gcpCredentialsFile) + } + credData, readErr := os.ReadFile(gcpCredentialsFile) + if readErr != nil { + return fmt.Errorf("reading credentials file: %w", readErr) + } + defer func() { + for i := range credData { + credData[i] = 0 + } + }() + if err := validateCredentialJSON(credData); err != nil { + return err } - }() - if err := validateCredentialJSON(credData); err != nil { - return err + vcfg.CredentialJSON = credData } - vcfg.CredentialJSON = credData } inferenceProvider = vertex.New(vcfg, vertex.NewLiveGCPClient()) inferenceProviderName = "vertex" @@ -189,6 +206,8 @@ func newInstallCmd() *cobra.Command { cmd.Flags().StringVar(&gcpRegion, "gcp-region", "", "GCP region for Vertex AI (e.g. global, required with --gcp-project)") cmd.Flags().StringVar(&gcpServiceAccount, "gcp-service-account", "", "existing GCP service account name (optional, used with --gcp-project)") cmd.Flags().StringVar(&gcpCredentialsFile, "gcp-credentials-file", "", "path to pre-made GCP service account key JSON (optional, used with --gcp-project)") + cmd.Flags().StringVar(&gcpWIFProvider, "gcp-wif-provider", "", "full Workload Identity Federation provider resource name (e.g. projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER)") + cmd.Flags().StringVar(&gcpWIFSAEmail, "gcp-wif-sa-email", "", "GCP service account email for WIF impersonation (required with --gcp-wif-provider)") return cmd } diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 80a5aee43e..381e36a059 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -45,6 +45,12 @@ func TestInstallCmd_Flags(t *testing.T) { vendorBinaryFlag := cmd.Flags().Lookup("vendor-fullsend-binary") require.NotNil(t, vendorBinaryFlag, "expected --vendor-fullsend-binary flag") assert.Equal(t, "false", vendorBinaryFlag.DefValue) + + wifProviderFlag := cmd.Flags().Lookup("gcp-wif-provider") + require.NotNil(t, wifProviderFlag, "expected --gcp-wif-provider flag") + + wifSAEmailFlag := cmd.Flags().Lookup("gcp-wif-sa-email") + require.NotNil(t, wifSAEmailFlag, "expected --gcp-wif-sa-email flag") } func TestUninstallCmd_RequiresOrg(t *testing.T) { diff --git a/internal/inference/vertex/vertex.go b/internal/inference/vertex/vertex.go index b8b26629b6..345bca94d1 100644 --- a/internal/inference/vertex/vertex.go +++ b/internal/inference/vertex/vertex.go @@ -11,6 +11,17 @@ import ( "fmt" ) +// AuthMode selects between service-account key and Workload Identity Federation. +type AuthMode string + +const ( + // AuthModeSAKey uses a long-lived service account key JSON file. + AuthModeSAKey AuthMode = "sa_key" + + // AuthModeWIF uses Workload Identity Federation with GitHub OIDC. + AuthModeWIF AuthMode = "wif" +) + const ( // SecretCredentials is the repo secret name for the GCP service account key JSON. // Uses the FULLSEND_ prefix to avoid confusion with the GCP SDK env var @@ -24,6 +35,14 @@ const ( // VariableRegion is the repo variable name for the GCP region. VariableRegion = "FULLSEND_GCP_REGION" + // SecretWIFProvider is the repo secret for the full WIF provider resource name. + // Stored as a secret so the value is masked in GitHub Actions logs. + SecretWIFProvider = "FULLSEND_GCP_WIF_PROVIDER" + + // SecretWIFServiceAccount is the repo secret for the SA email used with WIF. + // Stored as a secret so the value is masked in GitHub Actions logs. + SecretWIFServiceAccount = "FULLSEND_GCP_WIF_SA_EMAIL" + // defaultSAName is the service account name created in mode 1. defaultSAName = "fullsend-agent" ) @@ -42,10 +61,13 @@ type GCPClient interface { // Config holds the inputs for Vertex credential provisioning. type Config struct { - ProjectID string // required - Region string // required: GCP region (e.g. global) - ServiceAccountName string // optional: existing SA name (mode 2) - CredentialJSON []byte // optional: pre-made key JSON (mode 3) + ProjectID string // required + Region string // required: GCP region (e.g. global) + Mode AuthMode // "sa_key" (default) or "wif" + ServiceAccountName string // optional: existing SA name (sa_key mode 2) + CredentialJSON []byte // optional: pre-made key JSON (sa_key mode 3) + WIFProvider string // WIF mode: full provider resource name + WIFServiceAccount string // WIF mode: service account email } // Provider implements inference.Provider for Vertex AI. @@ -72,6 +94,9 @@ func (p *Provider) Name() string { // SecretNames returns the secret names this provider manages. func (p *Provider) SecretNames() []string { + if p.cfg.Mode == AuthModeWIF { + return []string{SecretWIFProvider, SecretWIFServiceAccount, SecretProjectID} + } return []string{SecretCredentials, SecretProjectID} } @@ -89,6 +114,27 @@ func (p *Provider) Provision(ctx context.Context) (map[string]string, error) { return nil, fmt.Errorf("GCP project ID is required") } + if p.cfg.Mode == AuthModeWIF { + return p.provisionWIF() + } + return p.provisionSAKey(ctx) +} + +func (p *Provider) provisionWIF() (map[string]string, error) { + if p.cfg.WIFProvider == "" { + return nil, fmt.Errorf("WIF provider resource name is required") + } + if p.cfg.WIFServiceAccount == "" { + return nil, fmt.Errorf("WIF service account email is required") + } + return map[string]string{ + SecretWIFProvider: p.cfg.WIFProvider, + SecretWIFServiceAccount: p.cfg.WIFServiceAccount, + SecretProjectID: p.cfg.ProjectID, + }, nil +} + +func (p *Provider) provisionSAKey(ctx context.Context) (map[string]string, error) { // Mode 3: credential JSON provided directly. if len(p.cfg.CredentialJSON) > 0 { return map[string]string{ diff --git a/internal/inference/vertex/vertex_test.go b/internal/inference/vertex/vertex_test.go index 864af160db..ab26143ce9 100644 --- a/internal/inference/vertex/vertex_test.go +++ b/internal/inference/vertex/vertex_test.go @@ -208,6 +208,56 @@ func TestProvision_AnalyzeOnly(t *testing.T) { assert.Contains(t, err.Error(), "project ID is required") } +func TestProvision_WIF(t *testing.T) { + p := New(Config{ + ProjectID: "my-project", + Region: "global", + Mode: AuthModeWIF, + WIFProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/gh", + WIFServiceAccount: "sa@my-project.iam.gserviceaccount.com", + }, nil) + + secrets, err := p.Provision(context.Background()) + require.NoError(t, err) + + assert.Equal(t, "projects/123/locations/global/workloadIdentityPools/pool/providers/gh", secrets[SecretWIFProvider]) + assert.Equal(t, "sa@my-project.iam.gserviceaccount.com", secrets[SecretWIFServiceAccount]) + assert.Equal(t, "my-project", secrets[SecretProjectID]) + assert.Len(t, secrets, 3) +} + +func TestProvision_WIF_MissingProvider(t *testing.T) { + p := New(Config{ + ProjectID: "my-project", + Region: "global", + Mode: AuthModeWIF, + WIFServiceAccount: "sa@my-project.iam.gserviceaccount.com", + }, nil) + + _, err := p.Provision(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "WIF provider resource name is required") +} + +func TestProvision_WIF_MissingSA(t *testing.T) { + p := New(Config{ + ProjectID: "my-project", + Region: "global", + Mode: AuthModeWIF, + WIFProvider: "projects/123/locations/global/workloadIdentityPools/pool/providers/gh", + }, nil) + + _, err := p.Provision(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "WIF service account email is required") +} + +func TestSecretNames_WIF(t *testing.T) { + p := New(Config{Mode: AuthModeWIF}, nil) + names := p.SecretNames() + assert.Equal(t, []string{SecretWIFProvider, SecretWIFServiceAccount, SecretProjectID}, names) +} + func TestName(t *testing.T) { p := New(Config{}, nil) assert.Equal(t, "vertex", p.Name()) diff --git a/internal/scaffold/fullsend-repo/.github/workflows/code.yml b/internal/scaffold/fullsend-repo/.github/workflows/code.yml index 5765c06b17..076b9dd9b7 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/code.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/code.yml @@ -24,6 +24,7 @@ jobs: permissions: actions: write contents: write + id-token: write issues: write packages: read pull-requests: write @@ -102,6 +103,8 @@ jobs: - name: Authenticate to Google Cloud uses: google-github-actions/auth@v3 with: + workload_identity_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.FULLSEND_GCP_WIF_SA_EMAIL }} credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }} - name: Mask GCP credential file paths @@ -113,6 +116,21 @@ jobs: fi done + - name: Prepare sandbox credentials + run: | + CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" + CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) + if [[ "$CRED_TYPE" == "external_account" ]]; then + OIDC_SRC=$(jq -r '.credential_source.file // empty' "$CRED_CONFIG") + OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" + cp "$OIDC_SRC" "$OIDC_DEST" + SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" + jq '.credential_source.file = "/tmp/workspace/.gcp-oidc-token"' \ + "$CRED_CONFIG" > "$SANDBOX_CREDS" + echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" + echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" + fi + - name: Setup agent environment env: AGENT_PREFIX: CODE_ diff --git a/internal/scaffold/fullsend-repo/.github/workflows/review.yml b/internal/scaffold/fullsend-repo/.github/workflows/review.yml index a574208a25..60734fc370 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/review.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/review.yml @@ -24,6 +24,7 @@ jobs: permissions: actions: write contents: read + id-token: write issues: write pull-requests: write @@ -93,6 +94,8 @@ jobs: - name: Authenticate to Google Cloud uses: google-github-actions/auth@v3 with: + workload_identity_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.FULLSEND_GCP_WIF_SA_EMAIL }} credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }} - name: Mask GCP credential file paths @@ -104,6 +107,21 @@ jobs: fi done + - name: Prepare sandbox credentials + run: | + CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" + CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) + if [[ "$CRED_TYPE" == "external_account" ]]; then + OIDC_SRC=$(jq -r '.credential_source.file // empty' "$CRED_CONFIG") + OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" + cp "$OIDC_SRC" "$OIDC_DEST" + SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" + jq '.credential_source.file = "/tmp/workspace/.gcp-oidc-token"' \ + "$CRED_CONFIG" > "$SANDBOX_CREDS" + echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" + echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" + fi + - name: Setup agent environment env: AGENT_PREFIX: REVIEW_ diff --git a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml index 98f5ea14f2..0897664529 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml @@ -24,6 +24,7 @@ jobs: permissions: actions: write contents: read + id-token: write issues: write steps: @@ -72,6 +73,8 @@ jobs: - name: Authenticate to Google Cloud uses: google-github-actions/auth@v3 with: + workload_identity_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} + service_account: ${{ secrets.FULLSEND_GCP_WIF_SA_EMAIL }} credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }} - name: Mask GCP credential file paths @@ -83,6 +86,21 @@ jobs: fi done + - name: Prepare sandbox credentials + run: | + CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" + CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) + if [[ "$CRED_TYPE" == "external_account" ]]; then + OIDC_SRC=$(jq -r '.credential_source.file // empty' "$CRED_CONFIG") + OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" + cp "$OIDC_SRC" "$OIDC_DEST" + SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" + jq '.credential_source.file = "/tmp/workspace/.gcp-oidc-token"' \ + "$CRED_CONFIG" > "$SANDBOX_CREDS" + echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" + echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" + fi + - name: Setup agent environment env: AGENT_PREFIX: TRIAGE_ diff --git a/internal/scaffold/fullsend-repo/harness/code.yaml b/internal/scaffold/fullsend-repo/harness/code.yaml index b55bb54820..a616116888 100644 --- a/internal/scaffold/fullsend-repo/harness/code.yaml +++ b/internal/scaffold/fullsend-repo/harness/code.yaml @@ -24,6 +24,9 @@ host_files: expand: true - src: ${GOOGLE_APPLICATION_CREDENTIALS} dest: /tmp/workspace/.gcp-credentials.json + - src: ${GCP_OIDC_TOKEN_FILE:-/dev/null} + dest: /tmp/workspace/.gcp-oidc-token + optional: true skills: - skills/code-implementation diff --git a/internal/scaffold/fullsend-repo/harness/review.yaml b/internal/scaffold/fullsend-repo/harness/review.yaml index cacb26887f..54da92f752 100644 --- a/internal/scaffold/fullsend-repo/harness/review.yaml +++ b/internal/scaffold/fullsend-repo/harness/review.yaml @@ -15,6 +15,9 @@ host_files: expand: true - src: ${GOOGLE_APPLICATION_CREDENTIALS} dest: /tmp/workspace/.gcp-credentials.json + - src: ${GCP_OIDC_TOKEN_FILE:-/dev/null} + dest: /tmp/workspace/.gcp-oidc-token + optional: true - src: env/review.env dest: /tmp/workspace/.env.d/review.env expand: true diff --git a/internal/scaffold/fullsend-repo/harness/triage.yaml b/internal/scaffold/fullsend-repo/harness/triage.yaml index 115e56858f..7c8af50274 100644 --- a/internal/scaffold/fullsend-repo/harness/triage.yaml +++ b/internal/scaffold/fullsend-repo/harness/triage.yaml @@ -9,6 +9,9 @@ host_files: expand: true - src: ${GOOGLE_APPLICATION_CREDENTIALS} dest: /tmp/workspace/.gcp-credentials.json + - src: ${GCP_OIDC_TOKEN_FILE:-/dev/null} + dest: /tmp/workspace/.gcp-oidc-token + optional: true - src: env/triage.env dest: /tmp/workspace/.env.d/triage.env expand: true From de0275b783e99ea282b5f13e944f3209888817c5 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Sat, 25 Apr 2026 15:45:54 -0400 Subject: [PATCH 2/9] fix: address review findings in WIF auth migration Split the GCP auth step into two conditional steps (WIF vs SA key) to satisfy google-github-actions/auth@v3 exactlyOneOf constraint. Rewrite the sandbox credential prep step to pre-fetch OIDC tokens via credential_source.url instead of the non-existent .file path. Add Optional field to HostFile struct so the OIDC token host file can be skipped in SA key mode without bash default syntax that Go's os.ExpandEnv does not support. Default AuthMode to sa_key in New() to prevent zero-value fallthrough. Signed-off-by: Wayne Sun --- internal/cli/run.go | 3 ++ internal/harness/harness.go | 10 ++++-- internal/inference/vertex/vertex.go | 11 +++--- .../fullsend-repo/.github/workflows/code.yml | 36 ++++++++++++++++--- .../.github/workflows/review.yml | 33 ++++++++++++++--- .../.github/workflows/triage.yml | 33 ++++++++++++++--- .../scaffold/fullsend-repo/harness/code.yaml | 2 +- .../fullsend-repo/harness/review.yaml | 2 +- .../fullsend-repo/harness/triage.yaml | 2 +- 9 files changed, 107 insertions(+), 25 deletions(-) diff --git a/internal/cli/run.go b/internal/cli/run.go index 0458bada59..d37e9074db 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -635,6 +635,9 @@ func bootstrapEnv(sshConfigPath, sandboxName, repoDir string, h *harness.Harness for _, hf := range h.HostFiles { hostPath := os.ExpandEnv(hf.Src) if hostPath == "" { + if hf.Optional { + continue + } return fmt.Errorf("host_files: src %q expanded to empty string", hf.Src) } diff --git a/internal/harness/harness.go b/internal/harness/harness.go index a9ed856cd3..d196907488 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -26,9 +26,10 @@ var ( // Use this for env files that contain variable references which must be resolved // on the host (because the sandbox does not have those variables set). type HostFile struct { - Src string `yaml:"src"` // host path (may use ${VAR} expansion) - Dest string `yaml:"dest"` // destination path inside the sandbox - Expand bool `yaml:"expand,omitempty"` // expand ${VAR} in file content before copying + Src string `yaml:"src"` // host path (may use ${VAR} expansion) + Dest string `yaml:"dest"` // destination path inside the sandbox + Expand bool `yaml:"expand,omitempty"` // expand ${VAR} in file content before copying + Optional bool `yaml:"optional,omitempty"` // skip if src path is missing or expands to empty } // ProviderDef is a declarative definition of an OpenShell provider. Files in @@ -367,6 +368,9 @@ func (h *Harness) ValidateRunnerEnvWith(expander func(string) string) error { } } for i, hf := range h.HostFiles { + if hf.Optional { + continue + } if err := checkVarRefs(fmt.Sprintf("host_files[%d].src", i), hf.Src); err != nil { return err } diff --git a/internal/inference/vertex/vertex.go b/internal/inference/vertex/vertex.go index 345bca94d1..8cc2874a0c 100644 --- a/internal/inference/vertex/vertex.go +++ b/internal/inference/vertex/vertex.go @@ -1,9 +1,9 @@ // Package vertex implements the inference.Provider interface for Google Cloud -// Vertex AI. It supports three modes of credential provisioning: +// Vertex AI. It supports two auth modes: // -// 1. GCP project ID only → create service account + key -// 2. GCP project ID + SA name → verify SA exists, create key -// 3. GCP project ID + credential JSON → use key directly +// - SA key (default): long-lived service account key JSON, with sub-modes +// for creating, verifying, or using a pre-made key. +// - WIF: Workload Identity Federation with GitHub OIDC — no stored keys. package vertex import ( @@ -78,6 +78,9 @@ type Provider struct { // New creates a Vertex Provider with the given config and GCP client. func New(cfg Config, gcpAPI GCPClient) *Provider { + if cfg.Mode == "" { + cfg.Mode = AuthModeSAKey + } return &Provider{cfg: cfg, gcpAPI: gcpAPI} } diff --git a/internal/scaffold/fullsend-repo/.github/workflows/code.yml b/internal/scaffold/fullsend-repo/.github/workflows/code.yml index 076b9dd9b7..f8080f7f61 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/code.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/code.yml @@ -100,11 +100,17 @@ jobs: GITHUB_ISSUE_URL: ${{ fromJSON(inputs.event_payload).issue.html_url }} run: bash scripts/pre-code.sh - - name: Authenticate to Google Cloud + - name: Authenticate to Google Cloud (WIF) + if: secrets.FULLSEND_GCP_WIF_PROVIDER != '' uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} service_account: ${{ secrets.FULLSEND_GCP_WIF_SA_EMAIL }} + + - name: Authenticate to Google Cloud (SA key) + if: secrets.FULLSEND_GCP_WIF_PROVIDER == '' + uses: google-github-actions/auth@v3 + with: credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }} - name: Mask GCP credential file paths @@ -121,12 +127,32 @@ jobs: CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) if [[ "$CRED_TYPE" == "external_account" ]]; then - OIDC_SRC=$(jq -r '.credential_source.file // empty' "$CRED_CONFIG") + # The auth action writes credential_source.url (not .file) pointing to + # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we + # pre-fetch the OIDC token here and create a file-based credential config. + OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") + OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") + if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then + echo "::error::WIF credential config missing credential_source.url or auth header" + exit 1 + fi + OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" - cp "$OIDC_SRC" "$OIDC_DEST" + curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" + SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" - jq '.credential_source.file = "/tmp/workspace/.gcp-oidc-token"' \ - "$CRED_CONFIG" > "$SANDBOX_CREDS" + jq '{ + type: .type, + audience: .audience, + subject_token_type: .subject_token_type, + token_url: .token_url, + service_account_impersonation_url: .service_account_impersonation_url, + credential_source: { + file: "/tmp/workspace/.gcp-oidc-token", + format: .credential_source.format + } + }' "$CRED_CONFIG" > "$SANDBOX_CREDS" + echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" fi diff --git a/internal/scaffold/fullsend-repo/.github/workflows/review.yml b/internal/scaffold/fullsend-repo/.github/workflows/review.yml index 60734fc370..26db166206 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/review.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/review.yml @@ -91,11 +91,17 @@ jobs: path: target-repo fetch-depth: 1 - - name: Authenticate to Google Cloud + - name: Authenticate to Google Cloud (WIF) + if: secrets.FULLSEND_GCP_WIF_PROVIDER != '' uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} service_account: ${{ secrets.FULLSEND_GCP_WIF_SA_EMAIL }} + + - name: Authenticate to Google Cloud (SA key) + if: secrets.FULLSEND_GCP_WIF_PROVIDER == '' + uses: google-github-actions/auth@v3 + with: credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }} - name: Mask GCP credential file paths @@ -112,12 +118,29 @@ jobs: CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) if [[ "$CRED_TYPE" == "external_account" ]]; then - OIDC_SRC=$(jq -r '.credential_source.file // empty' "$CRED_CONFIG") + OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") + OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") + if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then + echo "::error::WIF credential config missing credential_source.url or auth header" + exit 1 + fi + OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" - cp "$OIDC_SRC" "$OIDC_DEST" + curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" + SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" - jq '.credential_source.file = "/tmp/workspace/.gcp-oidc-token"' \ - "$CRED_CONFIG" > "$SANDBOX_CREDS" + jq '{ + type: .type, + audience: .audience, + subject_token_type: .subject_token_type, + token_url: .token_url, + service_account_impersonation_url: .service_account_impersonation_url, + credential_source: { + file: "/tmp/workspace/.gcp-oidc-token", + format: .credential_source.format + } + }' "$CRED_CONFIG" > "$SANDBOX_CREDS" + echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" fi diff --git a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml index 0897664529..b81eeb330a 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml @@ -70,11 +70,17 @@ jobs: path: target-repo fetch-depth: 1 - - name: Authenticate to Google Cloud + - name: Authenticate to Google Cloud (WIF) + if: secrets.FULLSEND_GCP_WIF_PROVIDER != '' uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }} service_account: ${{ secrets.FULLSEND_GCP_WIF_SA_EMAIL }} + + - name: Authenticate to Google Cloud (SA key) + if: secrets.FULLSEND_GCP_WIF_PROVIDER == '' + uses: google-github-actions/auth@v3 + with: credentials_json: ${{ secrets.FULLSEND_GCP_SA_KEY_JSON }} - name: Mask GCP credential file paths @@ -91,12 +97,29 @@ jobs: CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) if [[ "$CRED_TYPE" == "external_account" ]]; then - OIDC_SRC=$(jq -r '.credential_source.file // empty' "$CRED_CONFIG") + OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") + OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") + if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then + echo "::error::WIF credential config missing credential_source.url or auth header" + exit 1 + fi + OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" - cp "$OIDC_SRC" "$OIDC_DEST" + curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" + SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" - jq '.credential_source.file = "/tmp/workspace/.gcp-oidc-token"' \ - "$CRED_CONFIG" > "$SANDBOX_CREDS" + jq '{ + type: .type, + audience: .audience, + subject_token_type: .subject_token_type, + token_url: .token_url, + service_account_impersonation_url: .service_account_impersonation_url, + credential_source: { + file: "/tmp/workspace/.gcp-oidc-token", + format: .credential_source.format + } + }' "$CRED_CONFIG" > "$SANDBOX_CREDS" + echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" fi diff --git a/internal/scaffold/fullsend-repo/harness/code.yaml b/internal/scaffold/fullsend-repo/harness/code.yaml index a616116888..4b51a339ac 100644 --- a/internal/scaffold/fullsend-repo/harness/code.yaml +++ b/internal/scaffold/fullsend-repo/harness/code.yaml @@ -24,7 +24,7 @@ host_files: expand: true - src: ${GOOGLE_APPLICATION_CREDENTIALS} dest: /tmp/workspace/.gcp-credentials.json - - src: ${GCP_OIDC_TOKEN_FILE:-/dev/null} + - src: ${GCP_OIDC_TOKEN_FILE} dest: /tmp/workspace/.gcp-oidc-token optional: true diff --git a/internal/scaffold/fullsend-repo/harness/review.yaml b/internal/scaffold/fullsend-repo/harness/review.yaml index 54da92f752..71ddc4df28 100644 --- a/internal/scaffold/fullsend-repo/harness/review.yaml +++ b/internal/scaffold/fullsend-repo/harness/review.yaml @@ -15,7 +15,7 @@ host_files: expand: true - src: ${GOOGLE_APPLICATION_CREDENTIALS} dest: /tmp/workspace/.gcp-credentials.json - - src: ${GCP_OIDC_TOKEN_FILE:-/dev/null} + - src: ${GCP_OIDC_TOKEN_FILE} dest: /tmp/workspace/.gcp-oidc-token optional: true - src: env/review.env diff --git a/internal/scaffold/fullsend-repo/harness/triage.yaml b/internal/scaffold/fullsend-repo/harness/triage.yaml index 7c8af50274..11954a0930 100644 --- a/internal/scaffold/fullsend-repo/harness/triage.yaml +++ b/internal/scaffold/fullsend-repo/harness/triage.yaml @@ -9,7 +9,7 @@ host_files: expand: true - src: ${GOOGLE_APPLICATION_CREDENTIALS} dest: /tmp/workspace/.gcp-credentials.json - - src: ${GCP_OIDC_TOKEN_FILE:-/dev/null} + - src: ${GCP_OIDC_TOKEN_FILE} dest: /tmp/workspace/.gcp-oidc-token optional: true - src: env/triage.env From 29577bfdf974f619f27fed492650ceed4c353207 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Sat, 25 Apr 2026 16:10:15 -0400 Subject: [PATCH 3/9] fix: add WIF credential prep comments and attribute-condition security guidance Add explanatory comments to the sandbox credential preparation step in review.yml and triage.yml for consistency with code.yml. Expand the WIF attribute-condition documentation in the installation guide to explain the security trade-offs between org-wide and repo-scoped access. Signed-off-by: Wayne Sun --- docs/guides/admin/installation.md | 7 ++++++- .../scaffold/fullsend-repo/.github/workflows/review.yml | 3 +++ .../scaffold/fullsend-repo/.github/workflows/triage.yml | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/guides/admin/installation.md b/docs/guides/admin/installation.md index 94f5004f2a..daaa485a7b 100644 --- a/docs/guides/admin/installation.md +++ b/docs/guides/admin/installation.md @@ -60,7 +60,12 @@ gcloud iam workload-identity-pools providers create-oidc github \ --project="$GCP_PROJECT" ``` -The `attribute-condition` restricts access to workflows running in your GitHub organization. For tighter control, you can restrict to a specific repository: +The `attribute-condition` restricts which GitHub Actions workflows can exchange OIDC tokens for GCP credentials. + +- **Org-wide** (`repository_owner`): any repo in the org can authenticate. Simpler to maintain but means a compromised or misconfigured workflow in *any* repo could obtain Vertex AI credentials. +- **Repo-scoped** (`repository`): only the `.fullsend` repo can authenticate. Limits blast radius — recommended for orgs where not all repos are equally trusted. + +For repo-scoped access, replace the `attribute-condition` above with: ``` --attribute-condition="assertion.repository == '$ORG_NAME/.fullsend'" diff --git a/internal/scaffold/fullsend-repo/.github/workflows/review.yml b/internal/scaffold/fullsend-repo/.github/workflows/review.yml index 26db166206..d87698287c 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/review.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/review.yml @@ -118,6 +118,9 @@ jobs: CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) if [[ "$CRED_TYPE" == "external_account" ]]; then + # The auth action writes credential_source.url (not .file) pointing to + # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we + # pre-fetch the OIDC token here and create a file-based credential config. OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then diff --git a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml index b81eeb330a..2ecc14783e 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml @@ -97,6 +97,9 @@ jobs: CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) if [[ "$CRED_TYPE" == "external_account" ]]; then + # The auth action writes credential_source.url (not .file) pointing to + # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we + # pre-fetch the OIDC token here and create a file-based credential config. OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then From f45c047889593d25629904e8ee689d2a51cfcc23 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Sat, 25 Apr 2026 16:12:39 -0400 Subject: [PATCH 4/9] fix: complete repo-scoped WIF guidance with attribute-mapping and IAM binding All 4 review agents flagged the repo-scoped WIF guidance as incomplete: changing only the attribute-condition without updating the attribute-mapping and IAM principalSet member would produce a broken or misleadingly-scoped config. Add attribute.repository to the default mapping and document the required IAM binding change for repo-scoped access. Signed-off-by: Wayne Sun --- docs/guides/admin/installation.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/guides/admin/installation.md b/docs/guides/admin/installation.md index daaa485a7b..123f278704 100644 --- a/docs/guides/admin/installation.md +++ b/docs/guides/admin/installation.md @@ -55,7 +55,7 @@ gcloud iam workload-identity-pools providers create-oidc github \ --location=global \ --workload-identity-pool=github-actions \ --issuer-uri="https://token.actions.githubusercontent.com" \ - --attribute-mapping="google.subject=assertion.sub,attribute.repository_owner=assertion.repository_owner" \ + --attribute-mapping="google.subject=assertion.sub,attribute.repository_owner=assertion.repository_owner,attribute.repository=assertion.repository" \ --attribute-condition="assertion.repository_owner == '$ORG_NAME'" \ --project="$GCP_PROJECT" ``` @@ -67,10 +67,16 @@ The `attribute-condition` restricts which GitHub Actions workflows can exchange For repo-scoped access, replace the `attribute-condition` above with: -``` +```bash --attribute-condition="assertion.repository == '$ORG_NAME/.fullsend'" ``` +If you choose repo-scoped access, also update the `--member` in step 1c to match: + +```bash +--member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github-actions/attribute.repository/$ORG_NAME/.fullsend" +``` + **1c. Grant the service account impersonation permission** ```bash From 593973ff1dfac3849c51252ff4fba70fd68f8c83 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Mon, 27 Apr 2026 09:43:23 -0400 Subject: [PATCH 5/9] docs: use global as default gcp-region in installation guide Signed-off-by: Wayne Sun --- docs/guides/admin/installation.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guides/admin/installation.md b/docs/guides/admin/installation.md index 123f278704..ea5f8455c1 100644 --- a/docs/guides/admin/installation.md +++ b/docs/guides/admin/installation.md @@ -17,7 +17,7 @@ This guide walks through installing fullsend in a GitHub organization and enroll - **GCP project** with the [Vertex AI API](https://console.cloud.google.com/apis/library/aiplatform.googleapis.com) enabled -Available regions for Claude on Vertex AI include `us-east5`, `europe-west1`, and `asia-southeast1`. Check the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions) for the latest list. +The default region is `global`. For a list of all available regions, see the [Vertex AI documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions). ## 1. Set up GCP authentication @@ -134,7 +134,7 @@ export REPO_NAME="" fullsend admin install "$ORG_NAME" \ --repo "$REPO_NAME" \ --gcp-project "$GCP_PROJECT" \ - --gcp-region us-east5 \ + --gcp-region global \ --gcp-wif-provider "$WIF_PROVIDER" \ --gcp-wif-sa-email "$WIF_SA_EMAIL" ``` @@ -147,7 +147,7 @@ export REPO_NAME="" fullsend admin install "$ORG_NAME" \ --repo "$REPO_NAME" \ --gcp-project "$GCP_PROJECT" \ - --gcp-region us-east5 \ + --gcp-region global \ --gcp-credentials-file sa-key.json rm sa-key.json ``` @@ -163,7 +163,7 @@ If you already have fullsend installed with a service account key: --repo "$REPO_NAME" \ --skip-app-setup \ --gcp-project "$GCP_PROJECT" \ - --gcp-region us-east5 \ + --gcp-region global \ --gcp-wif-provider "$WIF_PROVIDER" \ --gcp-wif-sa-email "$WIF_SA_EMAIL" ``` From 8a51ba2c5ca61ce4853b2e10715449a9d5cf1bbe Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Mon, 27 Apr 2026 11:22:48 -0400 Subject: [PATCH 6/9] fix: address review findings for WIF auth migration - NewAnalyzeOnly now accepts AuthMode so health checks return correct secret names for WIF-mode orgs (finding 1) - Document OIDC token lifetime limitation in all workflow credential prep steps (finding 2) - Add os.Stat check for optional host files after env var expansion to prevent hard errors when file does not exist (finding 3) - Auto-cleanup of opposing auth secrets deferred to #458 (finding 5) Signed-off-by: Wayne Sun --- internal/cli/admin.go | 8 ++++-- internal/cli/run.go | 5 ++++ internal/inference/vertex/vertex.go | 7 +++-- internal/inference/vertex/vertex_test.go | 28 +++++++++++++------ .../fullsend-repo/.github/workflows/code.yml | 3 ++ .../.github/workflows/review.yml | 3 ++ .../.github/workflows/triage.yml | 3 ++ 7 files changed, 45 insertions(+), 12 deletions(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 7e44acfd04..a8cc7a533a 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -621,10 +621,14 @@ func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, o return fmt.Errorf("getting authenticated user: %w", err) } - // Detect inference provider from existing config. + // Detect inference provider and auth mode from existing config. var inferenceProvider inference.Provider if providerName := loadExistingInferenceProvider(ctx, client, org); providerName != "" { - inferenceProvider = vertex.NewAnalyzeOnly() + mode := vertex.AuthModeSAKey + if wifExists, _ := client.RepoSecretExists(ctx, org, forge.ConfigRepoName, vertex.SecretWIFProvider); wifExists { + mode = vertex.AuthModeWIF + } + inferenceProvider = vertex.NewAnalyzeOnly(mode) } stack := buildLayerStack(org, client, cfg, printer, user, hasPrivate, nil, agentCreds, nil, inferenceProvider, false, nil, nil) diff --git a/internal/cli/run.go b/internal/cli/run.go index d37e9074db..2f477a3131 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -640,6 +640,11 @@ func bootstrapEnv(sshConfigPath, sandboxName, repoDir string, h *harness.Harness } return fmt.Errorf("host_files: src %q expanded to empty string", hf.Src) } + if hf.Optional { + if _, err := os.Stat(hostPath); err != nil { + continue + } + } if hf.Expand { // Read file, expand ${VAR} in content, write expanded version. diff --git a/internal/inference/vertex/vertex.go b/internal/inference/vertex/vertex.go index 8cc2874a0c..cca4da314b 100644 --- a/internal/inference/vertex/vertex.go +++ b/internal/inference/vertex/vertex.go @@ -86,8 +86,11 @@ func New(cfg Config, gcpAPI GCPClient) *Provider { // NewAnalyzeOnly creates a Provider that only supports SecretNames() and Name(). // Calling Provision() on this provider returns an error. -func NewAnalyzeOnly() *Provider { - return &Provider{} +func NewAnalyzeOnly(mode AuthMode) *Provider { + if mode == "" { + mode = AuthModeSAKey + } + return &Provider{cfg: Config{Mode: mode}} } // Name returns "vertex". diff --git a/internal/inference/vertex/vertex_test.go b/internal/inference/vertex/vertex_test.go index ab26143ce9..9baa0bde76 100644 --- a/internal/inference/vertex/vertex_test.go +++ b/internal/inference/vertex/vertex_test.go @@ -198,14 +198,26 @@ func TestProvision_NilGCPClient_Mode3_OK(t *testing.T) { } func TestProvision_AnalyzeOnly(t *testing.T) { - p := NewAnalyzeOnly() - - assert.Equal(t, "vertex", p.Name()) - assert.Equal(t, []string{SecretCredentials, SecretProjectID}, p.SecretNames()) - - _, err := p.Provision(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "project ID is required") + t.Run("sa_key mode", func(t *testing.T) { + p := NewAnalyzeOnly(AuthModeSAKey) + assert.Equal(t, "vertex", p.Name()) + assert.Equal(t, []string{SecretCredentials, SecretProjectID}, p.SecretNames()) + + _, err := p.Provision(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "project ID is required") + }) + + t.Run("wif mode", func(t *testing.T) { + p := NewAnalyzeOnly(AuthModeWIF) + assert.Equal(t, "vertex", p.Name()) + assert.Equal(t, []string{SecretWIFProvider, SecretWIFServiceAccount, SecretProjectID}, p.SecretNames()) + }) + + t.Run("empty defaults to sa_key", func(t *testing.T) { + p := NewAnalyzeOnly("") + assert.Equal(t, []string{SecretCredentials, SecretProjectID}, p.SecretNames()) + }) } func TestProvision_WIF(t *testing.T) { diff --git a/internal/scaffold/fullsend-repo/.github/workflows/code.yml b/internal/scaffold/fullsend-repo/.github/workflows/code.yml index f8080f7f61..e11b62ae88 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/code.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/code.yml @@ -130,6 +130,9 @@ jobs: # The auth action writes credential_source.url (not .file) pointing to # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we # pre-fetch the OIDC token here and create a file-based credential config. + # Note: the OIDC token expires after ~10 min but the GCP access token + # obtained via STS lasts 1 hour. Runs exceeding 1 hour will fail on + # access token refresh since the static OIDC token will have expired. OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then diff --git a/internal/scaffold/fullsend-repo/.github/workflows/review.yml b/internal/scaffold/fullsend-repo/.github/workflows/review.yml index d87698287c..8c79587cd0 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/review.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/review.yml @@ -121,6 +121,9 @@ jobs: # The auth action writes credential_source.url (not .file) pointing to # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we # pre-fetch the OIDC token here and create a file-based credential config. + # Note: the OIDC token expires after ~10 min but the GCP access token + # obtained via STS lasts 1 hour. Runs exceeding 1 hour will fail on + # access token refresh since the static OIDC token will have expired. OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then diff --git a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml index 2ecc14783e..b8c8004ee8 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml @@ -100,6 +100,9 @@ jobs: # The auth action writes credential_source.url (not .file) pointing to # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we # pre-fetch the OIDC token here and create a file-based credential config. + # Note: the OIDC token expires after ~10 min but the GCP access token + # obtained via STS lasts 1 hour. Runs exceeding 1 hour will fail on + # access token refresh since the static OIDC token will have expired. OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then From 77e8ea546412c7824fe594182cf641c50eaa95f2 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Mon, 27 Apr 2026 11:58:10 -0400 Subject: [PATCH 7/9] fix: mask OIDC bearer token and restrict token file permissions - Add ::add-mask:: for OIDC_AUTH to prevent bearer token leaking in workflow logs when ACTIONS_STEP_DEBUG is enabled - chmod 600 the OIDC token file for defense-in-depth Signed-off-by: Wayne Sun --- internal/scaffold/fullsend-repo/.github/workflows/code.yml | 2 ++ internal/scaffold/fullsend-repo/.github/workflows/review.yml | 2 ++ internal/scaffold/fullsend-repo/.github/workflows/triage.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/internal/scaffold/fullsend-repo/.github/workflows/code.yml b/internal/scaffold/fullsend-repo/.github/workflows/code.yml index e11b62ae88..711932b5a0 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/code.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/code.yml @@ -140,8 +140,10 @@ jobs: exit 1 fi + echo "::add-mask::$OIDC_AUTH" OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" + chmod 600 "$OIDC_DEST" SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" jq '{ diff --git a/internal/scaffold/fullsend-repo/.github/workflows/review.yml b/internal/scaffold/fullsend-repo/.github/workflows/review.yml index 8c79587cd0..564e19da72 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/review.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/review.yml @@ -131,8 +131,10 @@ jobs: exit 1 fi + echo "::add-mask::$OIDC_AUTH" OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" + chmod 600 "$OIDC_DEST" SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" jq '{ diff --git a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml index b8c8004ee8..29e2dc50bb 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml @@ -110,8 +110,10 @@ jobs: exit 1 fi + echo "::add-mask::$OIDC_AUTH" OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" + chmod 600 "$OIDC_DEST" SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" jq '{ From 2ebe0b460434b6577aff99b33f4ec71595379f62 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Mon, 27 Apr 2026 12:16:31 -0400 Subject: [PATCH 8/9] refactor: extract credential prep into shared script Move the ~40-line WIF credential preparation bash block from code.yml, review.yml, and triage.yml into scripts/prepare-sandbox-credentials.sh so bug fixes only need to be applied once. Signed-off-by: Wayne Sun --- internal/layers/workflows_test.go | 6 +-- .../fullsend-repo/.github/workflows/code.yml | 39 +-------------- .../.github/workflows/review.yml | 39 +-------------- .../.github/workflows/triage.yml | 39 +-------------- .../scripts/prepare-sandbox-credentials.sh | 48 +++++++++++++++++++ 5 files changed, 54 insertions(+), 117 deletions(-) create mode 100755 internal/scaffold/fullsend-repo/scripts/prepare-sandbox-credentials.sh diff --git a/internal/layers/workflows_test.go b/internal/layers/workflows_test.go index 5b53804e86..d3045e3ff0 100644 --- a/internal/layers/workflows_test.go +++ b/internal/layers/workflows_test.go @@ -112,7 +112,7 @@ func TestWorkflowsLayer_Install_CODEOWNERSOptional(t *testing.T) { require.NoError(t, err) // All scaffold files should have been created (CODEOWNERS excluded since it failed) - assert.Len(t, client.created, 36) + assert.Len(t, client.created, 37) } func TestWorkflowsLayer_Install_Error(t *testing.T) { @@ -160,7 +160,7 @@ func TestWorkflowsLayer_Analyze_AllPresent(t *testing.T) { assert.Equal(t, "workflows", report.Name) assert.Equal(t, StatusInstalled, report.Status) - assert.Len(t, report.Details, 37) + assert.Len(t, report.Details, 38) } func TestWorkflowsLayer_Analyze_NonePresent(t *testing.T) { @@ -174,7 +174,7 @@ func TestWorkflowsLayer_Analyze_NonePresent(t *testing.T) { assert.Equal(t, "workflows", report.Name) assert.Equal(t, StatusNotInstalled, report.Status) - assert.Len(t, report.WouldInstall, 37) + assert.Len(t, report.WouldInstall, 38) } func TestWorkflowsLayer_Analyze_Partial(t *testing.T) { diff --git a/internal/scaffold/fullsend-repo/.github/workflows/code.yml b/internal/scaffold/fullsend-repo/.github/workflows/code.yml index 711932b5a0..969e7ae590 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/code.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/code.yml @@ -123,44 +123,7 @@ jobs: done - name: Prepare sandbox credentials - run: | - CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" - CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) - if [[ "$CRED_TYPE" == "external_account" ]]; then - # The auth action writes credential_source.url (not .file) pointing to - # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we - # pre-fetch the OIDC token here and create a file-based credential config. - # Note: the OIDC token expires after ~10 min but the GCP access token - # obtained via STS lasts 1 hour. Runs exceeding 1 hour will fail on - # access token refresh since the static OIDC token will have expired. - OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") - OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") - if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then - echo "::error::WIF credential config missing credential_source.url or auth header" - exit 1 - fi - - echo "::add-mask::$OIDC_AUTH" - OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" - curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" - chmod 600 "$OIDC_DEST" - - SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" - jq '{ - type: .type, - audience: .audience, - subject_token_type: .subject_token_type, - token_url: .token_url, - service_account_impersonation_url: .service_account_impersonation_url, - credential_source: { - file: "/tmp/workspace/.gcp-oidc-token", - format: .credential_source.format - } - }' "$CRED_CONFIG" > "$SANDBOX_CREDS" - - echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" - echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" - fi + run: bash scripts/prepare-sandbox-credentials.sh - name: Setup agent environment env: diff --git a/internal/scaffold/fullsend-repo/.github/workflows/review.yml b/internal/scaffold/fullsend-repo/.github/workflows/review.yml index 564e19da72..86c67f9fae 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/review.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/review.yml @@ -114,44 +114,7 @@ jobs: done - name: Prepare sandbox credentials - run: | - CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" - CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) - if [[ "$CRED_TYPE" == "external_account" ]]; then - # The auth action writes credential_source.url (not .file) pointing to - # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we - # pre-fetch the OIDC token here and create a file-based credential config. - # Note: the OIDC token expires after ~10 min but the GCP access token - # obtained via STS lasts 1 hour. Runs exceeding 1 hour will fail on - # access token refresh since the static OIDC token will have expired. - OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") - OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") - if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then - echo "::error::WIF credential config missing credential_source.url or auth header" - exit 1 - fi - - echo "::add-mask::$OIDC_AUTH" - OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" - curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" - chmod 600 "$OIDC_DEST" - - SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" - jq '{ - type: .type, - audience: .audience, - subject_token_type: .subject_token_type, - token_url: .token_url, - service_account_impersonation_url: .service_account_impersonation_url, - credential_source: { - file: "/tmp/workspace/.gcp-oidc-token", - format: .credential_source.format - } - }' "$CRED_CONFIG" > "$SANDBOX_CREDS" - - echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" - echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" - fi + run: bash scripts/prepare-sandbox-credentials.sh - name: Setup agent environment env: diff --git a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml index 29e2dc50bb..9922d98d9f 100644 --- a/internal/scaffold/fullsend-repo/.github/workflows/triage.yml +++ b/internal/scaffold/fullsend-repo/.github/workflows/triage.yml @@ -93,44 +93,7 @@ jobs: done - name: Prepare sandbox credentials - run: | - CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" - CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) - if [[ "$CRED_TYPE" == "external_account" ]]; then - # The auth action writes credential_source.url (not .file) pointing to - # GitHub's OIDC endpoint. The sandbox cannot reach that endpoint, so we - # pre-fetch the OIDC token here and create a file-based credential config. - # Note: the OIDC token expires after ~10 min but the GCP access token - # obtained via STS lasts 1 hour. Runs exceeding 1 hour will fail on - # access token refresh since the static OIDC token will have expired. - OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") - OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") - if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then - echo "::error::WIF credential config missing credential_source.url or auth header" - exit 1 - fi - - echo "::add-mask::$OIDC_AUTH" - OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" - curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" - chmod 600 "$OIDC_DEST" - - SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" - jq '{ - type: .type, - audience: .audience, - subject_token_type: .subject_token_type, - token_url: .token_url, - service_account_impersonation_url: .service_account_impersonation_url, - credential_source: { - file: "/tmp/workspace/.gcp-oidc-token", - format: .credential_source.format - } - }' "$CRED_CONFIG" > "$SANDBOX_CREDS" - - echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" - echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" - fi + run: bash scripts/prepare-sandbox-credentials.sh - name: Setup agent environment env: diff --git a/internal/scaffold/fullsend-repo/scripts/prepare-sandbox-credentials.sh b/internal/scaffold/fullsend-repo/scripts/prepare-sandbox-credentials.sh new file mode 100755 index 0000000000..b52a99d25b --- /dev/null +++ b/internal/scaffold/fullsend-repo/scripts/prepare-sandbox-credentials.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Prepare GCP credentials for sandbox environments. +# +# When using Workload Identity Federation (WIF), the google-github-actions/auth +# action creates an external_account credential config that references GitHub's +# OIDC endpoint via credential_source.url. The sandbox cannot reach that +# endpoint, so this script pre-fetches the OIDC token and rewrites the config +# to use a file-based credential source instead. +# +# Note: the OIDC token expires after ~10 min but the GCP access token obtained +# via STS lasts 1 hour. Runs exceeding 1 hour will fail on access token refresh +# since the static OIDC token will have expired. +# +# In SA-key mode (type != external_account), this script is a no-op. + +CRED_CONFIG="$GOOGLE_APPLICATION_CREDENTIALS" +CRED_TYPE=$(jq -r '.type // empty' "$CRED_CONFIG" 2>/dev/null || true) +if [[ "$CRED_TYPE" == "external_account" ]]; then + OIDC_URL=$(jq -r '.credential_source.url // empty' "$CRED_CONFIG") + OIDC_AUTH=$(jq -r '.credential_source.headers.Authorization // empty' "$CRED_CONFIG") + if [[ -z "$OIDC_URL" || -z "$OIDC_AUTH" ]]; then + echo "::error::WIF credential config missing credential_source.url or auth header" + exit 1 + fi + + echo "::add-mask::$OIDC_AUTH" + OIDC_DEST="$RUNNER_TEMP/gcp-oidc-token" + curl -sSf -H "Authorization: $OIDC_AUTH" "$OIDC_URL" > "$OIDC_DEST" + chmod 600 "$OIDC_DEST" + + SANDBOX_CREDS="$RUNNER_TEMP/sandbox-gcp-credentials.json" + jq '{ + type: .type, + audience: .audience, + subject_token_type: .subject_token_type, + token_url: .token_url, + service_account_impersonation_url: .service_account_impersonation_url, + credential_source: { + file: "/tmp/workspace/.gcp-oidc-token", + format: .credential_source.format + } + }' "$CRED_CONFIG" > "$SANDBOX_CREDS" + + echo "GOOGLE_APPLICATION_CREDENTIALS=$SANDBOX_CREDS" >> "$GITHUB_ENV" + echo "GCP_OIDC_TOKEN_FILE=$OIDC_DEST" >> "$GITHUB_ENV" +fi From b4d48061c076f4ad80f1313c490ef75d2270e415 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Mon, 27 Apr 2026 13:25:33 -0400 Subject: [PATCH 9/9] fix: surface WIF secret check error in runAnalyze The RepoSecretExists call for WIF mode detection was silently discarding errors, causing analyze to default to SA key mode without any indication when the API call fails. Signed-off-by: Wayne Sun --- internal/cli/admin.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index a8cc7a533a..28fd80fd50 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -625,7 +625,10 @@ func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, o var inferenceProvider inference.Provider if providerName := loadExistingInferenceProvider(ctx, client, org); providerName != "" { mode := vertex.AuthModeSAKey - if wifExists, _ := client.RepoSecretExists(ctx, org, forge.ConfigRepoName, vertex.SecretWIFProvider); wifExists { + wifExists, err := client.RepoSecretExists(ctx, org, forge.ConfigRepoName, vertex.SecretWIFProvider) + if err != nil { + printer.StepWarn(fmt.Sprintf("Could not check WIF secret: %v (defaulting to SA key mode)", err)) + } else if wifExists { mode = vertex.AuthModeWIF } inferenceProvider = vertex.NewAnalyzeOnly(mode)