diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index aa80d46960..d353bfb3f9 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -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. diff --git a/apps/backend/internal/agent/agents/claude_code.go b/apps/backend/internal/agent/agents/claude_code.go index 41605424d2..435ba41c42 100644 --- a/apps/backend/internal/agent/agents/claude_code.go +++ b/apps/backend/internal/agent/agents/claude_code.go @@ -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" < "${HOME}/.claude.json" <<'JSON' +{"hasCompletedOnboarding":true} +JSON +chmod 600 "${HOME}/.claude/.credentials.json" +chmod 600 "${HOME}/.claude.json"`, }, }, } diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites.go b/apps/backend/internal/agent/lifecycle/executor_sprites.go index 0058116783..3ca912f7d5 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites.go @@ -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 { diff --git a/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go b/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go index 455f42590b..1d38a45655 100644 --- a/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go +++ b/apps/backend/internal/agent/lifecycle/executor_sprites_e2e_test.go @@ -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 { diff --git a/apps/backend/internal/agent/remoteauth/catalog.go b/apps/backend/internal/agent/remoteauth/catalog.go index 57882c28af..07ff2c3eb1 100644 --- a/apps/backend/internal/agent/remoteauth/catalog.go +++ b/apps/backend/internal/agent/remoteauth/catalog.go @@ -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