diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f119d0a206..3450c947c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - id: detect-private-key - exclude: "internal/layers/secrets_test\\.go$|internal/security/scanner_test\\.go$|internal/dispatch/gcf/provisioner_test\\.go$|tests/.*test_.*\\.py$" + exclude: "internal/layers/secrets_test\\.go$|internal/security/scanner_test\\.go$|internal/dispatch/gcf/provisioner_test\\.go$|internal/cli/mint_test\\.go$|tests/.*test_.*\\.py$" - id: check-added-large-files args: ["--maxkb=1000"] - id: check-merge-conflict diff --git a/docs/guides/admin/github-setup.md b/docs/guides/admin/github-setup.md index 2f77c92721..fa4f444d9f 100644 --- a/docs/guides/admin/github-setup.md +++ b/docs/guides/admin/github-setup.md @@ -57,6 +57,30 @@ When using the default app set, an **org owner** installs each app from these UR > **Tip:** To verify apps are installed, run `gh api /orgs/{org}/installations --jq '.installations[].app_slug'`. +### Bootstrapping PEMs during mint deploy (optional) + +Most `mint deploy` runs need only `--project` and `--region` — they deploy or update the Cloud Function and GCP infrastructure without touching PEM secrets. + +For **first-time setup only**, the optional `--pem-dir` flag seeds the default app set's PEM secrets during deployment. This allows `mint enroll` to work immediately without running `admin install` first. + +```bash +# Typical deploy (no PEMs needed): +fullsend mint deploy --project= + +# First-time bootstrap with PEMs: +fullsend mint deploy --project= --pem-dir=/path/to/pems +``` + +The `--pem-dir` directory must contain one `{role}.pem` file per agent role (e.g., `fullsend.pem`, `triage.pem`, `coder.pem`, `review.pem`, `retro.pem`, `prioritize.pem`). The CLI auto-discovers each app's numeric ID from the GitHub API by looking up the public app slug (`fullsend-ai-{role}`). + +After deploying with PEMs, enrollment works directly: + +```bash +fullsend mint enroll acme-corp --project= +``` + +> **Note:** PEM bootstrapping requires the GitHub Apps to already exist as public apps. For the default `fullsend-ai` app set, these are the apps maintained by the fullsend-ai organization. If you are using a custom app set with private apps, use `admin install` instead. + ## Per-org setup Per-org mode creates a `.fullsend` config repository, deploys reusable workflows, configures secrets and variables, and enrolls repositories: diff --git a/internal/cli/mint.go b/internal/cli/mint.go index a9bcf05537..f4909ea603 100644 --- a/internal/cli/mint.go +++ b/internal/cli/mint.go @@ -3,13 +3,24 @@ package cli import ( "bufio" "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "errors" "fmt" + "io" + "net/http" "os" + "path/filepath" "sort" "strconv" "strings" + "time" "github.com/spf13/cobra" "golang.org/x/term" @@ -41,6 +52,216 @@ func resolveRole(role string) string { return role } +// githubAPIBaseURL is the base URL for the GitHub API. +// Overridden in tests to use httptest servers. +var githubAPIBaseURL = "https://api.github.com" + +var githubHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// lookupAppID fetches the numeric app ID for a public GitHub App by slug. +// It makes an unauthenticated GET request to the GitHub API. +func lookupAppID(ctx context.Context, slug string) (int, error) { + url := githubAPIBaseURL + "/apps/" + slug + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, fmt.Errorf("creating request for app %s: %w", slug, err) + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := githubHTTPClient.Do(req) + if err != nil { + return 0, fmt.Errorf("looking up app %s: %w", slug, err) + } + defer func() { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + }() + + if resp.StatusCode == http.StatusNotFound { + return 0, fmt.Errorf("GitHub App %q not found — ensure the app exists and is publicly visible", slug) + } + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { + return 0, fmt.Errorf("GitHub API rate limit exceeded for app %s — unauthenticated requests are limited to 60/hour; try again later", slug) + } + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("GitHub API returned %d for app %s", resp.StatusCode, slug) + } + + var app struct { + ID int `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&app); err != nil { + return 0, fmt.Errorf("decoding app %s response: %w", slug, err) + } + if app.ID == 0 { + return 0, fmt.Errorf("GitHub App %s has no numeric ID", slug) + } + return app.ID, nil +} + +// verifyPEMMatchesApp confirms a PEM private key belongs to the given GitHub +// App by generating a JWT and calling GET /app with it. Returns nil on success. +func verifyPEMMatchesApp(ctx context.Context, pemData []byte, appID int, slug string) error { + block, _ := pem.Decode(pemData) + if block == nil { + return fmt.Errorf("failed to decode PEM block") + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + pkcs8Key, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes) + if pkcs8Err != nil { + return fmt.Errorf("parsing private key: %w", pkcs8Err) + } + var ok bool + key, ok = pkcs8Key.(*rsa.PrivateKey) + if !ok { + return fmt.Errorf("key is not RSA") + } + } + + now := time.Now() + headerJSON, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"}) + claimsJSON, _ := json.Marshal(map[string]interface{}{ + "iss": strconv.Itoa(appID), + "iat": now.Add(-60 * time.Second).Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + }) + signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + + base64.RawURLEncoding.EncodeToString(claimsJSON) + hashed := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hashed[:]) + if err != nil { + return fmt.Errorf("signing JWT: %w", err) + } + jwt := signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) + + url := githubAPIBaseURL + "/app" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("creating verify request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := githubHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("verifying PEM against GitHub: %w", err) + } + defer func() { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + }() + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("PEM does not match GitHub App %q (app ID %d) — the key may belong to a different app or have been revoked", slug, appID) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d verifying PEM for app %s", resp.StatusCode, slug) + } + + var respApp struct { + ID int `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&respApp); err != nil { + return fmt.Errorf("decoding verify response for app %s: %w", slug, err) + } + if respApp.ID != appID { + return fmt.Errorf("PEM authenticated as app %d but expected app %d (%s)", respApp.ID, appID, slug) + } + return nil +} + +// listPEMFiles returns the basenames of .pem files in dir, for diagnostics. +func listPEMFiles(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var names []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".pem") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + return names +} + +// validatePEMDir checks that pemDir exists, is a directory, and contains valid +// RSA PEM files for all default mint roles. Returns the validated PEM data keyed +// by role. This is the offline-only portion of PEM validation — no network calls. +func validatePEMDir(pemDir string) (map[string][]byte, error) { + info, err := os.Stat(pemDir) + if err != nil { + return nil, fmt.Errorf("--pem-dir %q: %w", pemDir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("--pem-dir %q is not a directory", pemDir) + } + + roles := defaultMintRoles() + + for _, role := range roles { + pemPath := filepath.Join(pemDir, role+".pem") + if _, err := os.Stat(pemPath); err != nil { + found := listPEMFiles(pemDir) + expected := make([]string, len(roles)) + for i, r := range roles { + expected[i] = r + ".pem" + } + return nil, fmt.Errorf("missing PEM file for role %q: %s\n expected files: %s\n found in dir: %s", + role, pemPath, strings.Join(expected, ", "), strings.Join(found, ", ")) + } + } + + pemsByRole := make(map[string][]byte, len(roles)) + for _, role := range roles { + pemPath := filepath.Join(pemDir, role+".pem") + pemData, err := os.ReadFile(pemPath) + if err != nil { + return nil, fmt.Errorf("reading PEM for role %q: %w", role, err) + } + if err := appsetup.ValidateRSAPEM(pemData); err != nil { + return nil, fmt.Errorf("invalid PEM for role %q (%s): %w", role, pemPath, err) + } + pemsByRole[role] = pemData + } + return pemsByRole, nil +} + +// loadAppSetPEMs reads PEM files from pemDir and discovers app IDs from the +// GitHub API, returning maps ready for gcf.Config. +func loadAppSetPEMs(ctx context.Context, pemDir, appSet string) (map[string][]byte, map[string]string, error) { + if err := appsetup.ValidateAppSet(appSet); err != nil { + return nil, nil, fmt.Errorf("invalid app set: %w", err) + } + + pemsByRole, err := validatePEMDir(pemDir) + if err != nil { + return nil, nil, err + } + + agentPEMs := make(map[string][]byte, len(pemsByRole)) + agentAppIDs := make(map[string]string, len(pemsByRole)) + + for role, pemData := range pemsByRole { + slug := appsetup.AppSlug(appSet, role) + appID, err := lookupAppID(ctx, slug) + if err != nil { + return nil, nil, fmt.Errorf("looking up app ID for %s: %w", slug, err) + } + + if err := verifyPEMMatchesApp(ctx, pemData, appID, slug); err != nil { + return nil, nil, fmt.Errorf("verifying PEM for role %q: %w", role, err) + } + + agentPEMs[role] = pemData + agentAppIDs[role] = strconv.Itoa(appID) + } + + return agentPEMs, agentAppIDs, nil +} + func newMintCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mint", @@ -63,13 +284,18 @@ func newMintDeployCmd() *cobra.Command { var sourceDir string var skipDeploy bool var dryRun bool + var pemDir string cmd := &cobra.Command{ Use: "deploy", Short: "Deploy or update the token mint Cloud Function", Long: `Deploys the fullsend-mint Cloud Function and supporting GCP infrastructure (service account, WIF pool/provider). Does NOT enroll any org — use -'fullsend mint enroll' after deployment.`, +'fullsend mint enroll' after deployment. + +Most runs need only --project and --region. The optional --pem-dir flag is +for first-time bootstrap only: it seeds the default app set's PEM secrets so +that 'mint enroll' can work without running 'admin install' first.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if project == "" { @@ -102,6 +328,12 @@ func newMintDeployCmd() *cobra.Command { if skipDeploy { printer.StepInfo("Would skip code deployment (--skip-deploy)") } + if pemDir != "" { + if _, err := validatePEMDir(pemDir); err != nil { + return err + } + printer.StepInfo(fmt.Sprintf("Would bootstrap app set %q with PEMs from %s (app ID lookup and PEM verification skipped in dry-run)", appsetup.DefaultAppSet, pemDir)) + } return nil } @@ -116,16 +348,33 @@ func newMintDeployCmd() *cobra.Command { deployMode = gcf.DeploySkip } - // Deploy requires at least a placeholder org for the WIF condition. - // The actual orgs are registered via 'mint enroll'. - provisioner := gcf.NewProvisioner(gcf.Config{ + cfg := gcf.Config{ ProjectID: project, Region: region, - GitHubOrgs: []string{gcf.PlaceholderOrg}, - AgentAppIDs: map[string]string{gcf.PlaceholderOrg: "0"}, FunctionSourceDir: sourceDir, DeployMode: deployMode, - }, gcpClient) + } + + if pemDir != "" { + printer.StepStart(fmt.Sprintf("Loading PEMs and discovering app IDs for app set %q", appsetup.DefaultAppSet)) + agentPEMs, agentAppIDs, err := loadAppSetPEMs(ctx, pemDir, appsetup.DefaultAppSet) + if err != nil { + printer.StepFail("Failed to load app set PEMs") + return fmt.Errorf("loading app set PEMs: %w", err) + } + printer.StepDone(fmt.Sprintf("Loaded %d role PEMs for app set %q", len(agentPEMs), appsetup.DefaultAppSet)) + + // The default app set name ("fullsend-ai") doubles as the PEM storage + // key prefix. Custom app sets must use admin install instead. + cfg.GitHubOrgs = []string{appsetup.DefaultAppSet} + cfg.AgentPEMs = agentPEMs + cfg.AgentAppIDs = agentAppIDs + } else { + cfg.GitHubOrgs = []string{gcf.PlaceholderOrg} + cfg.AgentAppIDs = map[string]string{gcf.PlaceholderOrg: "0"} + } + + provisioner := gcf.NewProvisioner(cfg, gcpClient) printer.StepStart("Provisioning mint infrastructure") result, err := provisioner.Provision(ctx) @@ -137,12 +386,17 @@ func newMintDeployCmd() *cobra.Command { mintURL := result["FULLSEND_MINT_URL"] printer.StepDone(fmt.Sprintf("Mint deployed at %s", mintURL)) printer.Blank() - printer.Summary("Deployment complete", []string{ + + summaryLines := []string{ fmt.Sprintf("Project: %s", project), fmt.Sprintf("Region: %s", region), fmt.Sprintf("URL: %s", mintURL), - "Next: fullsend mint enroll --project=" + project, - }) + } + if pemDir != "" { + summaryLines = append(summaryLines, fmt.Sprintf("App set: %s (PEMs bootstrapped)", appsetup.DefaultAppSet)) + } + summaryLines = append(summaryLines, "Next: fullsend mint enroll --project="+project) + printer.Summary("Deployment complete", summaryLines) return nil }, @@ -153,6 +407,7 @@ func newMintDeployCmd() *cobra.Command { cmd.Flags().StringVar(&sourceDir, "source-dir", "", "path to local mint source (default: embedded)") cmd.Flags().BoolVar(&skipDeploy, "skip-deploy", false, "skip code upload, reuse existing function") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") + cmd.Flags().StringVar(&pemDir, "pem-dir", "", "optional: directory containing {role}.pem files to bootstrap the default app set") return cmd } diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go index d31981d0f4..5530ede78b 100644 --- a/internal/cli/mint_test.go +++ b/internal/cli/mint_test.go @@ -2,9 +2,20 @@ package cli import ( "bufio" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "sort" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,6 +24,19 @@ import ( "github.com/fullsend-ai/fullsend/internal/ui" ) +// Tests in this file mutate package-level globals (githubAPIBaseURL, +// githubHTTPClient) via save/restore in defer. Do NOT use t.Parallel(). + +func generateTestPEM(t *testing.T) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + return pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) +} + func TestMintCommand_HasSubcommands(t *testing.T) { cmd := newMintCmd() names := make(map[string]bool) @@ -95,6 +119,345 @@ func TestMintDeployCmd_NoArgs(t *testing.T) { require.Error(t, err) } +func TestMintDeployCmd_PemDirFlag(t *testing.T) { + cmd := newMintDeployCmd() + + pemDirFlag := cmd.Flags().Lookup("pem-dir") + require.NotNil(t, pemDirFlag, "expected --pem-dir flag") + assert.Equal(t, "", pemDirFlag.DefValue) +} + +func TestMintDeployCmd_DryRunWithPemDir(t *testing.T) { + pemDir := t.TempDir() + testPEM := generateTestPEM(t) + for _, role := range defaultMintRoles() { + require.NoError(t, os.WriteFile(filepath.Join(pemDir, role+".pem"), testPEM, 0o600)) + } + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--project=my-project-id", "--dry-run", "--pem-dir=" + pemDir}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestMintDeployCmd_DryRunWithBadPemDir(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--project=my-project-id", "--dry-run", "--pem-dir=/nonexistent"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--pem-dir") +} + +func TestMintDeployCmd_DryRunWithPemDirAsFile(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "notadir.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("dummy"), 0o600)) + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--project=my-project-id", "--dry-run", "--pem-dir=" + tmpFile}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "is not a directory") +} + +func TestMintDeployCmd_DryRunWithInvalidPEM(t *testing.T) { + pemDir := t.TempDir() + testPEM := generateTestPEM(t) + for _, role := range defaultMintRoles() { + require.NoError(t, os.WriteFile(filepath.Join(pemDir, role+".pem"), testPEM, 0o600)) + } + require.NoError(t, os.WriteFile(filepath.Join(pemDir, "coder.pem"), []byte("not-a-pem"), 0o600)) + + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--project=my-project-id", "--dry-run", "--pem-dir=" + pemDir}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid PEM for role") +} + +// --- lookupAppID tests --- + +func TestLookupAppID_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/apps/fullsend-ai-coder", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"id": 12345, "slug": "fullsend-ai-coder", "client_id": "Iv1.abc123"}`) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + appID, err := lookupAppID(context.Background(), "fullsend-ai-coder") + require.NoError(t, err) + assert.Equal(t, 12345, appID) +} + +func TestLookupAppID_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + _, err := lookupAppID(context.Background(), "nonexistent-app") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestLookupAppID_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + _, err := lookupAppID(context.Background(), "some-app") + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestLookupAppID_RateLimit(t *testing.T) { + for _, tc := range []struct { + name string + code int + }{ + {"Forbidden", http.StatusForbidden}, + {"TooManyRequests", http.StatusTooManyRequests}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.code) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + _, err := lookupAppID(context.Background(), "some-app") + require.Error(t, err) + assert.Contains(t, err.Error(), "rate limit") + }) + } +} + +// --- verifyPEMMatchesApp tests --- + +func TestVerifyPEMMatchesApp_Success(t *testing.T) { + testPEM := generateTestPEM(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/app", r.URL.Path) + assert.Contains(t, r.Header.Get("Authorization"), "Bearer ") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"id": 12345, "slug": "test-app"}`) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + err := verifyPEMMatchesApp(context.Background(), testPEM, 12345, "test-app") + require.NoError(t, err) +} + +func TestVerifyPEMMatchesApp_WrongKey(t *testing.T) { + testPEM := generateTestPEM(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + err := verifyPEMMatchesApp(context.Background(), testPEM, 12345, "test-app") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match") +} + +func TestVerifyPEMMatchesApp_AppIDMismatch(t *testing.T) { + testPEM := generateTestPEM(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, `{"id": 99999, "slug": "different-app"}`) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + err := verifyPEMMatchesApp(context.Background(), testPEM, 12345, "test-app") + require.Error(t, err) + assert.Contains(t, err.Error(), "authenticated as app 99999 but expected app 12345") +} + +// --- listPEMFiles tests --- + +func TestListPEMFiles(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "coder.pem"), []byte("x"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "review.pem"), []byte("x"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "other.txt"), []byte("x"), 0o600)) + + files := listPEMFiles(dir) + assert.Equal(t, []string{"coder.pem", "review.pem"}, files) +} + +func TestListPEMFiles_EmptyDir(t *testing.T) { + files := listPEMFiles(t.TempDir()) + assert.Empty(t, files) +} + +func TestListPEMFiles_NonexistentDir(t *testing.T) { + files := listPEMFiles("/nonexistent/path") + assert.Nil(t, files) +} + +// --- loadAppSetPEMs tests --- + +func TestLoadAppSetPEMs_Success(t *testing.T) { + roles := defaultMintRoles() + testPEM := generateTestPEM(t) + + pemDir := t.TempDir() + for _, role := range roles { + err := os.WriteFile(filepath.Join(pemDir, role+".pem"), testPEM, 0o600) + require.NoError(t, err) + } + + appIDCounter := 100 + lastLookedUpID := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/app" { + fmt.Fprintf(w, `{"id": %d, "slug": "test-app"}`, lastLookedUpID) + return + } + appIDCounter++ + lastLookedUpID = appIDCounter + fmt.Fprintf(w, `{"id": %d, "slug": "%s"}`, appIDCounter, r.URL.Path[len("/apps/"):]) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + agentPEMs, agentAppIDs, err := loadAppSetPEMs(context.Background(), pemDir, "fullsend-ai") + require.NoError(t, err) + assert.Len(t, agentPEMs, len(roles)) + assert.Len(t, agentAppIDs, len(roles)) + + for _, role := range roles { + assert.Contains(t, agentPEMs, role, "expected PEM for role %s", role) + assert.NotEmpty(t, agentPEMs[role]) + assert.Contains(t, agentAppIDs, role, "expected app ID for role %s", role) + assert.NotEmpty(t, agentAppIDs[role]) + } +} + +func TestLoadAppSetPEMs_MissingPEM(t *testing.T) { + pemDir := t.TempDir() + // Only write one PEM — the rest will be missing. + err := os.WriteFile(filepath.Join(pemDir, "fullsend.pem"), []byte("fake"), 0o600) + require.NoError(t, err) + + _, _, err = loadAppSetPEMs(context.Background(), pemDir, "fullsend-ai") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing PEM file for role") +} + +func TestLoadAppSetPEMs_InvalidAppSet(t *testing.T) { + _, _, err := loadAppSetPEMs(context.Background(), t.TempDir(), "INVALID CHARS") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid app set") +} + +func TestLoadAppSetPEMs_InvalidPEM(t *testing.T) { + pemDir := t.TempDir() + testPEM := generateTestPEM(t) + roles := defaultMintRoles() + for _, role := range roles { + require.NoError(t, os.WriteFile(filepath.Join(pemDir, role+".pem"), testPEM, 0o600)) + } + // Overwrite one with invalid content. + require.NoError(t, os.WriteFile(filepath.Join(pemDir, "coder.pem"), []byte("not-a-pem"), 0o600)) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/app" { + fmt.Fprintln(w, `{"id": 1, "slug": "test-app"}`) + return + } + fmt.Fprintln(w, `{"id": 999, "slug": "test-app"}`) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + _, _, err := loadAppSetPEMs(context.Background(), pemDir, "fullsend-ai") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid PEM for role") +} + +func TestLoadAppSetPEMs_BadDir(t *testing.T) { + _, _, err := loadAppSetPEMs(context.Background(), "/nonexistent/path", "fullsend-ai") + require.Error(t, err) + assert.Contains(t, err.Error(), "--pem-dir") +} + +func TestLoadAppSetPEMs_FileNotDir(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "notadir.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("dummy"), 0o600)) + + _, _, err := loadAppSetPEMs(context.Background(), tmpFile, "fullsend-ai") + require.Error(t, err) + assert.Contains(t, err.Error(), "is not a directory") +} + +func TestGitHubHTTPClient_HasTimeout(t *testing.T) { + assert.Equal(t, 30*time.Second, githubHTTPClient.Timeout) +} + +func TestLoadAppSetPEMs_AppNotFound(t *testing.T) { + roles := defaultMintRoles() + testPEM := generateTestPEM(t) + pemDir := t.TempDir() + for _, role := range roles { + err := os.WriteFile(filepath.Join(pemDir, role+".pem"), testPEM, 0o600) + require.NoError(t, err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + defer func() { githubAPIBaseURL = orig }() + + _, _, err := loadAppSetPEMs(context.Background(), pemDir, "fullsend-ai") + require.Error(t, err) + assert.Contains(t, err.Error(), "looking up app ID") + assert.Contains(t, err.Error(), "not found") +} + // --- enroll command tests --- func TestMintEnrollCmd_Flags(t *testing.T) { diff --git a/skills/mint-enroll/SKILL.md b/skills/mint-enroll/SKILL.md index 1aa7ce084b..ef0b5da879 100644 --- a/skills/mint-enroll/SKILL.md +++ b/skills/mint-enroll/SKILL.md @@ -85,6 +85,12 @@ The fullsend-ai org maintains public GitHub Apps shared across orgs. PEM keys are tied to the app, not the org. Enrolling a new org copies PEMs from the app set (e.g., `fullsend-ai`). +**PEM bootstrapping (first-time only):** On a fresh mint with no existing +PEM secrets, the optional `--pem-dir` flag on `mint deploy` seeds the +default app set's PEMs during deployment. App IDs are auto-discovered from +the GitHub API. After this, `mint enroll ` copies from the bootstrapped +app set. Most `mint deploy` runs do not need `--pem-dir`. + Apps must be installed on the target org before the mint can produce tokens. An org admin installs via `https://github.com/apps/{slug}/installations/new` or by running `fullsend admin install`.