feat: claude code auth setup for remote executors (#285)
release / build-web (push) Has been cancelled
release / build-bundles (aarch64-linux-gnu-gcc, arm64, linux, ubuntu-latest, linux-arm64) (push) Has been cancelled
release / build-bundles (amd64, darwin, macos-14, macos-x64) (push) Has been cancelled
release / build-bundles (amd64, linux, ubuntu-latest, linux-x64) (push) Has been cancelled
release / build-bundles (arm64, darwin, macos-14, macos-arm64) (push) Has been cancelled
release / build-bundles (x86_64-w64-mingw32-gcc, amd64, windows, ubuntu-latest, windows-x64) (push) Has been cancelled
release / publish-release (push) Has been cancelled

This commit is contained in:
Carlos Florêncio
2026-02-25 21:48:06 +00:00
committed by GitHub
parent 9f24b9664c
commit cc9574ffcc
5 changed files with 270 additions and 62 deletions
@@ -250,6 +250,10 @@ type RemoteAuthMethod struct {
TargetRelDir string `json:"target_rel_dir,omitempty"`
// Label is a UI label for the file copy option (for type="files").
Label string `json:"label,omitempty"`
// SetupScript is an optional shell script that runs on the remote after the
// env var is resolved. Used to bootstrap credential files from env vars.
// Only meaningful for type="env". Can reference the env var by name.
SetupScript string `json:"setup_script,omitempty"`
}
// Command is a domain value type representing a CLI command with arguments.
@@ -132,6 +132,15 @@ func (a *ClaudeCode) RemoteAuth() *RemoteAuth {
Type: "env",
EnvVar: "CLAUDE_CODE_OAUTH_TOKEN",
SetupHint: "Run `claude setup-token` to generate a long-lived OAuth token",
SetupScript: `mkdir -p "${HOME}/.claude"
cat > "${HOME}/.claude/.credentials.json" <<CREDS
{"claudeAiOauth":{"accessToken":"${CLAUDE_CODE_OAUTH_TOKEN}","expiresAt":4102444800000}}
CREDS
cat > "${HOME}/.claude.json" <<'JSON'
{"hasCompletedOnboarding":true}
JSON
chmod 600 "${HOME}/.claude/.credentials.json"
chmod 600 "${HOME}/.claude.json"`,
},
},
}
@@ -210,7 +210,10 @@ func (r *SpritesExecutor) CreateInstance(ctx context.Context, req *ExecutorCreat
if reconnectRequired {
completeStepSkipped(&step)
} else {
if err := r.uploadCredentials(ctx, sprite, req); err != nil {
if err := r.uploadCredentials(ctx, sprite, req, func(output string) {
step.Output = output
report(step, 2)
}); err != nil {
r.logger.Warn("failed to upload credentials (non-fatal)", zap.Error(err))
completeStepSkipped(&step)
} else {
@@ -511,14 +514,22 @@ func (r *SpritesExecutor) createAgentInstance(
}
// uploadCredentials reads the remote_credentials metadata and uploads the selected
// credential files to the sprite. Also handles gh_cli_token auto-detect and
// secret-based auth via remote_auth_secrets.
func (r *SpritesExecutor) uploadCredentials(ctx context.Context, sprite *sprites.Sprite, req *ExecutorCreateRequest) error {
// credential files to the sprite. Also handles gh_cli_token auto-detect,
// secret-based auth via remote_auth_secrets, and agent auth setup scripts.
func (r *SpritesExecutor) uploadCredentials(
ctx context.Context,
sprite *sprites.Sprite,
req *ExecutorCreateRequest,
onOutput func(string),
) error {
catalog := r.buildRemoteAuthCatalog()
// Handle secret-based auth (e.g., GITHUB_TOKEN from a stored secret)
r.resolveAuthSecrets(ctx, req, catalog)
// Run auth setup scripts for env-type methods (e.g., Claude Code credential files)
r.runAuthSetupScripts(ctx, sprite, req, catalog, onOutput)
credsJSON, _ := req.Metadata["remote_credentials"].(string)
if credsJSON == "" {
return nil
@@ -563,6 +574,51 @@ func (r *SpritesExecutor) uploadCredentials(ctx context.Context, sprite *sprites
return UploadCredentialFiles(stepCtx, uploader, fileMethods, targetHomeDir, r.logger)
}
// runAuthSetupScripts executes setup scripts for env-type auth methods whose env var
// is present in req.Env. This handles both secret-store-resolved and directly-injected env vars.
// The optional onOutput callback streams output to the caller (e.g., progress UI).
func (r *SpritesExecutor) runAuthSetupScripts(
ctx context.Context,
sprite *sprites.Sprite,
req *ExecutorCreateRequest,
catalog remoteauth.Catalog,
onOutput func(string),
) {
for _, spec := range catalog.Specs {
for _, method := range spec.Methods {
if method.Type != "env" || method.SetupScript == "" || method.EnvVar == "" {
continue
}
if req.Env[method.EnvVar] == "" {
continue
}
if onOutput != nil {
onOutput(fmt.Sprintf("Setting up %s credentials...", spec.DisplayName))
}
stepCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
cmd := sprite.CommandContext(stepCtx, "sh", "-c", method.SetupScript)
cmd.Env = r.buildSpriteEnv(req.Env)
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
r.logger.Warn("auth setup script failed",
zap.String("method_id", method.MethodID),
zap.String("output", strings.TrimSpace(string(out))),
zap.Error(err))
if onOutput != nil {
onOutput(fmt.Sprintf("Warning: %s credential setup failed", spec.DisplayName))
}
} else {
r.logger.Debug("auth setup script completed",
zap.String("method_id", method.MethodID))
}
}
}
}
// resolveGHToken handles the gh_cli_token credential: detects the token locally
// and injects it as GITHUB_TOKEN in the request env. Returns filtered credential IDs.
func (r *SpritesExecutor) resolveGHToken(credentialIDs []string, req *ExecutorCreateRequest) []string {
@@ -12,6 +12,7 @@ import (
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"sync"
"testing"
@@ -504,70 +505,106 @@ func TestSpritesE2E_CredentialsAndAuth(t *testing.T) {
client := instance.Client
t.Run("GitIdentityCopiedFromHost", func(t *testing.T) {
gotName := strings.TrimSpace(runSpriteCmd(t, client, localPort, log,
"git config --global --get user.name 2>&1", 30*time.Second))
gotEmail := strings.TrimSpace(runSpriteCmd(t, client, localPort, log,
"git config --global --get user.email 2>&1", 30*time.Second))
res := runSpriteCmd(t, client, localPort, log,
"git config --global --get user.name 2>&1", 30*time.Second)
require.Equal(t, 0, res.ExitCode, "git config user.name should succeed")
require.Equal(t, gitUserName, strings.TrimSpace(res.Output), "sprite git user.name mismatch")
require.Equal(t, gitUserName, gotName, "sprite git user.name mismatch")
require.Equal(t, gitUserEmail, gotEmail, "sprite git user.email mismatch")
res = runSpriteCmd(t, client, localPort, log,
"git config --global --get user.email 2>&1", 30*time.Second)
require.Equal(t, 0, res.ExitCode, "git config user.email should succeed")
require.Equal(t, gitUserEmail, strings.TrimSpace(res.Output), "sprite git user.email mismatch")
})
t.Run("GhCliAuth", func(t *testing.T) {
// GITHUB_TOKEN is injected via gh_cli_token auto-detect — verify gh can use it
output := runSpriteCmd(t, client, localPort, log, "gh auth status 2>&1", 30*time.Second)
t.Logf("gh auth status output:\n%s", output)
if !hasCredential(credIDs, "gh_cli_token") {
t.Skip("gh_cli_token not selected")
}
// GITHUB_TOKEN is injected via gh_cli_token auto-detect — verify gh can use it.
// gh auth status makes a network call that can be slow on sprites; retry with short timeout.
var res spriteCmdResult
for attempt := 1; attempt <= 2; attempt++ {
res = runSpriteCmdNoFail(t, client, localPort, log, "gh auth status 2>&1", 10*time.Second)
if res.Output != "" {
break
}
t.Logf("gh auth status attempt %d timed out, retrying...", attempt)
}
t.Logf("gh auth status output:\n%s", res.Output)
require.NotEmpty(t, res.Output, "gh auth status should produce output")
lower := strings.ToLower(res.Output)
require.True(t,
strings.Contains(output, "Logged in") || strings.Contains(output, "logged in") ||
strings.Contains(output, "GITHUB_TOKEN"),
"gh auth status should indicate logged in or GITHUB_TOKEN active, got: %s", output)
strings.Contains(lower, "logged in") || strings.Contains(lower, "github_token"),
"gh auth status should indicate logged in or GITHUB_TOKEN active, got: %s", res.Output)
})
// t.Run("AuggieAuth", func(t *testing.T) {
// if !hasCredentialPrefix(credIDs, "agent:auggie:files:") {
// t.Skip("auggie file auth credential not available on host")
// }
// output := runSpriteCmd(t, client, localPort, log,
// "npx -y @augmentcode/auggie -p hello 2>&1", 180*time.Second)
// t.Logf("auggie output:\n%s", output)
// require.NotContains(t, strings.ToLower(output), "authentication",
// "auggie should not show auth errors")
// })
t.Run("AuggieAuth", func(t *testing.T) {
if !hasCredentialPrefix(credIDs, "agent:auggie:files:") {
t.Skip("auggie file auth credential not available on host")
}
res := runSpriteCmd(t, client, localPort, log,
"npx -y @augmentcode/auggie -p hello 2>&1", 180*time.Second)
t.Logf("auggie output:\n%s", res.Output)
require.Equal(t, 0, res.ExitCode, "auggie should exit with code 0")
require.NotContains(t, strings.ToLower(res.Output), "authentication",
"auggie should not show auth errors")
})
// t.Run("CodexAuth", func(t *testing.T) {
// if !hasAgentFileCredential(credIDs, "codex") {
// t.Skip("codex file auth credential not available on host")
// }
// output := runSpriteCmd(t, client, localPort, log,
// "npx -y @openai/codex exec \"hello\" 2>&1", 180*time.Second)
// t.Logf("codex output:\n%s", output)
// require.NotContains(t, strings.ToLower(output), "unauthorized",
// "codex should not show auth errors")
// })
t.Run("CodexAuth", func(t *testing.T) {
if !hasAgentFileCredential(credIDs, "codex") {
t.Skip("codex file auth credential not available on host")
}
res := runSpriteCmd(t, client, localPort, log,
"npx -y @openai/codex exec \"hello\" 2>&1", 180*time.Second)
t.Logf("codex output:\n%s", res.Output)
require.Equal(t, 0, res.ExitCode, "codex should exit with code 0")
require.NotContains(t, strings.ToLower(res.Output), "unauthorized",
"codex should not show auth errors")
})
// t.Run("GeminiAuth", func(t *testing.T) {
// if !hasAgentFileCredential(credIDs, "gemini") {
// t.Skip("gemini file auth credential not available on host")
// }
// output := runSpriteCmd(t, client, localPort, log,
// "npx -y @google/gemini-cli --model flash -p hello 2>&1", 180*time.Second)
// t.Logf("gemini output:\n%s", output)
// require.NotContains(t, strings.ToLower(output), "unauthenticated",
// "gemini should not show auth errors")
// })
t.Run("GeminiAuth", func(t *testing.T) {
if !hasAgentFileCredential(credIDs, "gemini") {
t.Skip("gemini file auth credential not available on host")
}
res := runSpriteCmd(t, client, localPort, log,
"npx -y @google/gemini-cli --model flash -p hello 2>&1", 180*time.Second)
t.Logf("gemini output:\n%s", res.Output)
// require.Equal(t, 0, res.ExitCode, "gemini should exit with code 0")
require.NotContains(t, strings.ToLower(res.Output), "unauthenticated",
"gemini should not show auth errors")
})
t.Run("ClaudeCodeAuth", func(t *testing.T) {
if os.Getenv("CLAUDE_CODE_OAUTH_TOKEN") == "" {
t.Skip("CLAUDE_CODE_OAUTH_TOKEN not set, skipping Claude Code auth test")
}
// Verify credential files were created by the auth setup script
res := runSpriteCmd(t, client, localPort, log,
`test -f ~/.claude/.credentials.json && echo CREDS_OK || echo CREDS_MISSING`, 30*time.Second)
require.Contains(t, res.Output, "CREDS_OK", "credential file should exist")
res = runSpriteCmd(t, client, localPort, log,
`test -f ~/.claude.json && echo ONBOARD_OK || echo ONBOARD_MISSING`, 30*time.Second)
require.Contains(t, res.Output, "ONBOARD_OK", "onboarding file should exist")
// Token is injected via ExecutorCreateRequest.Env and inherited by sprite processes.
// Keep command text free of secrets so logs do not expose token values.
output := runSpriteCmd(t, client, localPort, log,
res = runSpriteCmd(t, client, localPort, log,
"npx -y @anthropic-ai/claude-code --verbose -p hello 2>&1",
180*time.Second)
t.Logf("claude-code output:\n%s", output)
require.NotContains(t, strings.ToLower(output), "invalid_api_key",
"claude-code should not show auth errors")
t.Logf("claude-code output:\n%s", res.Output)
require.Equal(t, 0, res.ExitCode, "claude-code should exit with code 0")
lower := strings.ToLower(res.Output)
for _, errPattern := range []string{
"authentication_error",
"invalid_api_key",
"invalid bearer token",
"failed to authenticate",
} {
require.NotContains(t, lower, errPattern,
"claude-code should not show auth errors")
}
})
// Interactive mode
@@ -582,7 +619,13 @@ func TestSpritesE2E_CredentialsAndAuth(t *testing.T) {
}
}
// runSpriteCmd runs a command on the sprite and returns its combined output.
// spriteCmdResult holds the output and exit code of a command run on a sprite.
type spriteCmdResult struct {
Output string
ExitCode int
}
// runSpriteCmd runs a command on the sprite and returns its combined output and exit code.
// It uses a workspace stream with an end marker to reliably capture all output.
func runSpriteCmd(
t *testing.T,
@@ -591,7 +634,7 @@ func runSpriteCmd(
log *logger.Logger,
cmd string,
timeout time.Duration,
) string {
) spriteCmdResult {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
@@ -622,8 +665,8 @@ func runSpriteCmd(
t.Fatal("workspace stream connection timed out")
}
// Wrap command to echo end marker when done
wrappedCmd := fmt.Sprintf("(%s); echo %s", cmd, marker)
// Wrap command to capture exit code and echo end marker when done
wrappedCmd := fmt.Sprintf("(%s); _ec=$?; echo \"%s EXIT_CODE=$_ec\"", cmd, marker)
_, err = client.StartProcess(ctx, agentctl.StartProcessRequest{
SessionID: "e2e-creds-session",
Command: wrappedCmd,
@@ -641,12 +684,106 @@ func runSpriteCmd(
fullOutput := output.String()
mu.Unlock()
// Strip the end marker from output
// Parse exit code and strip the marker line from output
exitCode := -1
idx := strings.Index(fullOutput, marker)
if idx >= 0 {
return strings.TrimSpace(fullOutput[:idx])
markerLine := fullOutput[idx:]
if ecIdx := strings.Index(markerLine, "EXIT_CODE="); ecIdx >= 0 {
ecStr := strings.TrimSpace(markerLine[ecIdx+len("EXIT_CODE="):])
if n, err := strconv.Atoi(strings.Split(ecStr, "\n")[0]); err == nil {
exitCode = n
}
}
return spriteCmdResult{
Output: strings.TrimSpace(fullOutput[:idx]),
ExitCode: exitCode,
}
}
return spriteCmdResult{
Output: strings.TrimSpace(fullOutput),
ExitCode: exitCode,
}
}
// runSpriteCmdNoFail is like runSpriteCmd but returns an empty result on timeout instead of failing.
func runSpriteCmdNoFail(
t *testing.T,
client *agentctl.Client,
localPort int,
log *logger.Logger,
cmd string,
timeout time.Duration,
) spriteCmdResult {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
marker := "END_MARKER_" + randomSuffix()
var output strings.Builder
var mu sync.Mutex
connected := make(chan struct{})
streamClient := agentctl.NewClient("127.0.0.1", localPort, log)
defer streamClient.Close()
stream, err := streamClient.StreamWorkspace(ctx, agentctl.WorkspaceStreamCallbacks{
OnConnected: func() { close(connected) },
OnProcessOutput: func(o *agentctl.ProcessOutput) {
mu.Lock()
output.WriteString(o.Data)
mu.Unlock()
},
})
if err != nil {
return spriteCmdResult{ExitCode: -1}
}
defer stream.Close()
select {
case <-connected:
case <-ctx.Done():
return spriteCmdResult{ExitCode: -1}
}
wrappedCmd := fmt.Sprintf("(%s); _ec=$?; echo \"%s EXIT_CODE=$_ec\"", cmd, marker)
if _, err = client.StartProcess(ctx, agentctl.StartProcessRequest{
SessionID: "e2e-creds-session",
Command: wrappedCmd,
}); err != nil {
return spriteCmdResult{ExitCode: -1}
}
// Wait for marker — return empty on timeout
deadline := time.After(timeout)
for {
select {
case <-deadline:
return spriteCmdResult{ExitCode: -1}
case <-time.After(500 * time.Millisecond):
mu.Lock()
if strings.Contains(output.String(), marker) {
fullOutput := output.String()
mu.Unlock()
exitCode := -1
idx := strings.Index(fullOutput, marker)
if idx >= 0 {
markerLine := fullOutput[idx:]
if ecIdx := strings.Index(markerLine, "EXIT_CODE="); ecIdx >= 0 {
ecStr := strings.TrimSpace(markerLine[ecIdx+len("EXIT_CODE="):])
if n, parseErr := strconv.Atoi(strings.Split(ecStr, "\n")[0]); parseErr == nil {
exitCode = n
}
}
return spriteCmdResult{Output: strings.TrimSpace(fullOutput[:idx]), ExitCode: exitCode}
}
return spriteCmdResult{Output: strings.TrimSpace(fullOutput), ExitCode: exitCode}
}
mu.Unlock()
}
}
return strings.TrimSpace(fullOutput)
}
func hasCredentialPrefix(credIDs []string, prefix string) bool {
@@ -29,6 +29,7 @@ type Method struct {
TargetRelDir string `json:"target_rel_dir,omitempty"`
Label string `json:"label,omitempty"`
HasLocalFiles bool `json:"has_local_files,omitempty"`
SetupScript string `json:"setup_script,omitempty"`
}
// Spec groups auth methods for one integration or agent.
@@ -110,11 +111,12 @@ func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string
continue
}
entry := Method{
MethodID: fmt.Sprintf("agent:%s:env:%s", ag.ID(), method.EnvVar),
Type: "env",
EnvVar: method.EnvVar,
SetupHint: method.SetupHint,
Label: method.Label,
MethodID: fmt.Sprintf("agent:%s:env:%s", ag.ID(), method.EnvVar),
Type: "env",
EnvVar: method.EnvVar,
SetupHint: method.SetupHint,
Label: method.Label,
SetupScript: method.SetupScript,
}
spec.Methods = append(spec.Methods, entry)
methodsByID[entry.MethodID] = entry