Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ inputs:
default: ""
github_token:
description: >-
GitHub token for authenticated API calls (avoids 60 req/hour unauthenticated rate limit).
GitHub token used by setup steps (detect version, download binary, clone repos).
Not passed to the fullsend run step, which self-mints a scoped reader token via OIDC.
default: ${{ github.token }}
run-url:
description: URL of the CI/CD run for status comments (optional).
Expand Down Expand Up @@ -354,7 +355,6 @@ runs:
if: inputs.agent != '__install_only__'
shell: bash
env:
GH_TOKEN: ${{ inputs.github_token }}
AGENT: ${{ inputs.agent }}
FULLSEND_DIR: ${{ inputs.fullsend-dir }}
TARGET_REPO: ${{ inputs.target-repo }}
Expand Down
60 changes: 56 additions & 4 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,18 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
}
}

// Resolve mintURL early so it is available for the reader token mint
// before LoadWithBase. The same value is reused for mintAgentToken later.
mintURL := sOpts.mintURL
if mintURL == "" {
mintURL = os.Getenv("FULLSEND_MINT_URL")
}

var composeForgeClient forge.Client
if rFlags.forgeClient != nil {
composeForgeClient = rFlags.forgeClient
} else if token, readerErr := mintReaderToken(ctx, mintURL, printer); readerErr == nil {
composeForgeClient = gh.New(token)
} else if token, tokenErr := resolveToken(); tokenErr == nil {
composeForgeClient = gh.New(token)
}
Expand Down Expand Up @@ -300,6 +309,8 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
if h.HasURLSkills() {
if rFlags.forgeClient != nil {
forgeClient = rFlags.forgeClient
} else if composeForgeClient != nil {
forgeClient = composeForgeClient
} else {
token, tokenErr := resolveToken()
if tokenErr != nil {
Expand Down Expand Up @@ -342,10 +353,6 @@ func runAgent(ctx context.Context, agentName, fullsendDir, outputBase, targetRep
// Mint agent token when a mint URL and harness role are both available.
// Runs before env expansion so minted tokens flow into RunnerEnv and
// host_files via os.Getenv automatically.
mintURL := sOpts.mintURL
if mintURL == "" {
mintURL = os.Getenv("FULLSEND_MINT_URL")
}
minted, mintCleanup, err := mintAgentToken(ctx, h.Role, mintURL, printer)
if err != nil {
return fmt.Errorf("agent token minting failed: %w", err)
Expand Down Expand Up @@ -2127,6 +2134,51 @@ var roleTokenVars = map[string][]tokenVar{
"review": {{Name: "REVIEW_TOKEN"}},
}

// hasOIDCEnv reports whether the GitHub Actions OIDC environment variables
// are present. These are set automatically when the job declares
// id-token: write permission.
func hasOIDCEnv() bool {
return os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") != "" &&
os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") != ""
}

// mintReaderToken mints a minimal-permission token using the "reader" role
// for resolving URL-based harness bases and skill directories before the
// harness role is known. Returns the token string on success, or an error
// if OIDC is unavailable or the mint fails. The token is short-lived and
// used only for ForgeClient construction during LoadWithBase.
func mintReaderToken(ctx context.Context, mintURL string, printer *ui.Printer) (string, error) {
if mintURL == "" || !hasOIDCEnv() {
return "", fmt.Errorf("reader mint unavailable: mint URL and OIDC env vars (ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN) are both required")
}

repos, err := resolveMintRepos()
if err != nil {
return "", fmt.Errorf("resolving mint repos for reader: %w", err)
}

printer.StepStart("Minting reader token (contents:read)")
result, err := statusMintToken(ctx, mintclient.MintRequest{
MintURL: mintURL,
Role: "reader",
Repos: repos,
})
if err != nil {
return "", fmt.Errorf("minting reader token: %w", err)
}

if !mintTokenPattern.MatchString(result.Token) {
return "", fmt.Errorf("minted reader token contains unexpected characters")
}

if os.Getenv("GITHUB_ACTIONS") == "true" {
fmt.Fprintf(os.Stderr, "::add-mask::%s\n", result.Token)
}

printer.StepDone("Reader token minted")
return result.Token, nil
}

// mintAgentToken mints a GitHub App installation token for the agent's role
// and sets the appropriate env vars so RunnerEnv expansion and host_files
// expansion pick them up. Returns (minted bool, cleanup func, err).
Expand Down
162 changes: 162 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2471,3 +2471,165 @@ func TestRunAgent_StatusNotifierSetup(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "openshell")
}

func TestHasOIDCEnv(t *testing.T) {
tests := []struct {
name string
url string
token string
expected bool
}{
{"both set", "https://token.actions.githubusercontent.com", "tok", true},
{"url empty", "", "tok", false},
{"token empty", "https://token.actions.githubusercontent.com", "", false},
{"both empty", "", "", false},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", tc.url)
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", tc.token)
assert.Equal(t, tc.expected, hasOIDCEnv())
})
}
}

func TestMintReaderToken_NoMintURL(t *testing.T) {
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://example.com")
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "tok")

var buf bytes.Buffer
printer := ui.New(&buf)

_, err := mintReaderToken(context.Background(), "", printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "reader mint unavailable")
}

func TestMintReaderToken_NoOIDCEnv(t *testing.T) {
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "")
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "")

var buf bytes.Buffer
printer := ui.New(&buf)

_, err := mintReaderToken(context.Background(), "https://mint.example.com", printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "reader mint unavailable")
}

func TestMintReaderToken_Success(t *testing.T) {
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://oidc.example.com")
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "oidc-tok")
t.Setenv("REPO_FULL_NAME", "org/my-repo")

origMint := statusMintToken
defer func() { statusMintToken = origMint }()

var capturedReq mintclient.MintRequest
statusMintToken = func(_ context.Context, req mintclient.MintRequest) (*mintclient.MintResult, error) {
capturedReq = req
return &mintclient.MintResult{Token: "ghs_reader_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil
}

var buf bytes.Buffer
printer := ui.New(&buf)

token, err := mintReaderToken(context.Background(), "https://mint.example.com", printer)
require.NoError(t, err)
assert.Equal(t, "ghs_reader_token", token)
assert.Equal(t, "reader", capturedReq.Role)
assert.Equal(t, "https://mint.example.com", capturedReq.MintURL)
assert.Equal(t, []string{"my-repo"}, capturedReq.Repos)
assert.Contains(t, buf.String(), "Reader token minted")
}

func TestMintReaderToken_MintError(t *testing.T) {
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://oidc.example.com")
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "oidc-tok")
t.Setenv("REPO_FULL_NAME", "org/my-repo")

origMint := statusMintToken
defer func() { statusMintToken = origMint }()

statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) {
return nil, fmt.Errorf("mint service unavailable")
}

var buf bytes.Buffer
printer := ui.New(&buf)

_, err := mintReaderToken(context.Background(), "https://mint.example.com", printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "minting reader token")
}

func TestMintReaderToken_InvalidTokenPattern(t *testing.T) {
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://oidc.example.com")
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "oidc-tok")
t.Setenv("REPO_FULL_NAME", "org/my-repo")

origMint := statusMintToken
defer func() { statusMintToken = origMint }()

statusMintToken = func(_ context.Context, _ mintclient.MintRequest) (*mintclient.MintResult, error) {
return &mintclient.MintResult{Token: "bad token with spaces!", ExpiresAt: "2026-06-15T12:00:00Z"}, nil
}

var buf bytes.Buffer
printer := ui.New(&buf)

_, err := mintReaderToken(context.Background(), "https://mint.example.com", printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected characters")
}

func TestMintReaderToken_ResolveMintReposError(t *testing.T) {
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://oidc.example.com")
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "oidc-tok")
// Neither MINT_REPOS nor REPO_FULL_NAME is set, so resolveMintRepos fails.
t.Setenv("MINT_REPOS", "")
t.Setenv("REPO_FULL_NAME", "")

var buf bytes.Buffer
printer := ui.New(&buf)

_, err := mintReaderToken(context.Background(), "https://mint.example.com", printer)
require.Error(t, err)
assert.Contains(t, err.Error(), "resolving mint repos for reader")
}

func TestMintReaderToken_SuccessWithMasking(t *testing.T) {
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://oidc.example.com")
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "oidc-tok")
t.Setenv("REPO_FULL_NAME", "org/my-repo")
t.Setenv("GITHUB_ACTIONS", "true")

origMint := statusMintToken
defer func() { statusMintToken = origMint }()

statusMintToken = func(_ context.Context, req mintclient.MintRequest) (*mintclient.MintResult, error) {
return &mintclient.MintResult{Token: "ghs_masked_token", ExpiresAt: "2026-06-15T12:00:00Z"}, nil
}

var buf bytes.Buffer
printer := ui.New(&buf)

// Capture stderr for the ::add-mask:: output.
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w

token, err := mintReaderToken(context.Background(), "https://mint.example.com", printer)

w.Close()
os.Stderr = origStderr

var stderrBuf bytes.Buffer
stderrBuf.ReadFrom(r)

require.NoError(t, err)
assert.Equal(t, "ghs_masked_token", token)
assert.Contains(t, stderrBuf.String(), "::add-mask::ghs_masked_token")
assert.Contains(t, buf.String(), "Reader token minted")
}
1 change: 1 addition & 0 deletions internal/dispatch/gcf/mintsrc/mintcore/github.go.embed
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ var canonicalRolePermissions = map[string]map[string]string{
"organization_administration": "write", "pull_requests": "write",
"secrets": "write", "workflows": "write",
},
"reader": {"contents": "read", "metadata": "read"},
}

// RolePermissions returns a deep copy of the role-to-permissions map,
Expand Down
1 change: 1 addition & 0 deletions internal/mintcore/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ var canonicalRolePermissions = map[string]map[string]string{
"organization_administration": "write", "pull_requests": "write",
"secrets": "write", "workflows": "write",
},
"reader": {"contents": "read", "metadata": "read"},
}

// RolePermissions returns a deep copy of the role-to-permissions map,
Expand Down
10 changes: 9 additions & 1 deletion internal/mintcore/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func TestCreateInstallationToken_UnknownRole(t *testing.T) {
}

func TestRolePermissions_AllRolesPresent(t *testing.T) {
expectedRoles := []string{"triage", "coder", "review", "fix", "retro", "prioritize", "fullsend", "e2e"}
expectedRoles := []string{"triage", "coder", "review", "fix", "retro", "prioritize", "fullsend", "e2e", "reader"}
allPerms := RolePermissions()
for _, role := range expectedRoles {
perms, ok := allPerms[role]
Expand All @@ -131,6 +131,14 @@ func TestRolePermissions_E2e(t *testing.T) {
assert.Equal(t, "write", perms["workflows"])
}

func TestRolePermissions_Reader(t *testing.T) {
perms := RolePermissionsFor("reader")
require.NotNil(t, perms)
assert.Equal(t, "read", perms["contents"])
assert.Equal(t, "read", perms["metadata"])
assert.Len(t, perms, 2, "reader role should have exactly 2 permissions")
}

func TestRolePermissions_ReturnsCopy(t *testing.T) {
// Mutating the returned map must not affect the canonical definitions.
perms := RolePermissions()
Expand Down
Loading