75f1732421
* feat: add GitHub token injection for remote executors and Docker session resume - Add global GitHub access token configuration (backend + frontend) - Inject GitHub token into Docker and Sprites executors via env vars and Git credential helper - Transition Docker executor from host mounting to repository cloning - Implement Docker session resume (reconnect to existing containers after backend restart) - Add shell validation using exec.LookPath in agentctl - Add scriptengine providers for agent install and agentctl placeholders * fix: format github-status.tsx with prettier * fix: resolve lint issues (goconst, errcheck, file-length) * fix: address code review findings - Fix resolveGHCLIToken to check both GITHUB_TOKEN and GH_TOKEN guards - Fix credential helper to use \\ fallback - Change /bin/bash to /bin/sh in credential helper for Alpine compat - Strip embedded token from .git/config after clone in prepare scripts - Extract buildLaunchAgentRequest helpers to stay under 80-line limit - Add tests for GitHubAuthProvider, injectTokenIntoURL, reconnect validation - Add GH_TOKEN-only test case for resolveGHCLIToken * refactor(executor): extract GitHub credential resolution to separate file Move injectGitHubToken, injectGitHubTokenFromCLI, resolveRemoteCredentials, resolveAuthSecrets, methodIDToEnvVar, resolveGHCLIToken, and detectGHToken out of executor_execute.go (was 803 logical lines, over the 800-line limit after merging main) into a new executor_credentials.go file. Also adds the nil-guard on secretStore in resolveAuthSecrets that was flagged by review. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
265 lines
6.6 KiB
Go
265 lines
6.6 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// mockSecretManager implements SecretManager for testing.
|
|
type mockSecretManager struct {
|
|
secrets map[string]string // id -> value
|
|
names map[string]string // id -> name
|
|
nextID int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newMockSecretManager() *mockSecretManager {
|
|
return &mockSecretManager{
|
|
secrets: make(map[string]string),
|
|
names: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
func (m *mockSecretManager) Create(_ context.Context, name, value string) (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.nextID++
|
|
id := fmt.Sprintf("secret-%d", m.nextID)
|
|
m.secrets[id] = value
|
|
m.names[id] = name
|
|
return id, nil
|
|
}
|
|
|
|
func (m *mockSecretManager) Update(_ context.Context, id, value string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.secrets[id]; !ok {
|
|
return fmt.Errorf("secret not found: %s", id)
|
|
}
|
|
m.secrets[id] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSecretManager) Delete(_ context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.secrets, id)
|
|
delete(m.names, id)
|
|
return nil
|
|
}
|
|
|
|
// mockSecretProvider implements SecretProvider for testing.
|
|
type mockSecretProvider struct {
|
|
mgr *mockSecretManager
|
|
}
|
|
|
|
func (m *mockSecretProvider) List(_ context.Context) ([]*SecretListItem, error) {
|
|
m.mgr.mu.Lock()
|
|
defer m.mgr.mu.Unlock()
|
|
var items []*SecretListItem
|
|
for id, name := range m.mgr.names {
|
|
items = append(items, &SecretListItem{
|
|
ID: id,
|
|
Name: name,
|
|
HasValue: m.mgr.secrets[id] != "",
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (m *mockSecretProvider) Reveal(_ context.Context, id string) (string, error) {
|
|
m.mgr.mu.Lock()
|
|
defer m.mgr.mu.Unlock()
|
|
if val, ok := m.mgr.secrets[id]; ok {
|
|
return val, nil
|
|
}
|
|
return "", fmt.Errorf("secret not found: %s", id)
|
|
}
|
|
|
|
func TestFindGitHubTokenSecret(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
logger: log,
|
|
}
|
|
|
|
// No secrets - should return empty
|
|
id, found := svc.findGitHubTokenSecret(context.Background())
|
|
if found {
|
|
t.Error("expected no token to be found when secrets are empty")
|
|
}
|
|
if id != "" {
|
|
t.Errorf("expected empty id, got %q", id)
|
|
}
|
|
|
|
// Add a GITHUB_TOKEN secret
|
|
secretID, _ := mgr.Create(context.Background(), "GITHUB_TOKEN", "ghp_test123")
|
|
|
|
id, found = svc.findGitHubTokenSecret(context.Background())
|
|
if !found {
|
|
t.Error("expected token to be found")
|
|
}
|
|
if id != secretID {
|
|
t.Errorf("expected id %q, got %q", secretID, id)
|
|
}
|
|
}
|
|
|
|
func TestFindGitHubTokenSecret_LowerCase(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
logger: log,
|
|
}
|
|
|
|
// Add a github_token (lowercase) secret
|
|
secretID, _ := mgr.Create(context.Background(), "github_token", "ghp_test456")
|
|
|
|
id, found := svc.findGitHubTokenSecret(context.Background())
|
|
if !found {
|
|
t.Error("expected token to be found with lowercase name")
|
|
}
|
|
if id != secretID {
|
|
t.Errorf("expected id %q, got %q", secretID, id)
|
|
}
|
|
}
|
|
|
|
func TestGetStatus_WithTokenConfigured(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
// Add a GITHUB_TOKEN secret
|
|
secretID, _ := mgr.Create(context.Background(), "GITHUB_TOKEN", "ghp_test789")
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
logger: log,
|
|
authMethod: AuthMethodNone,
|
|
}
|
|
|
|
status, err := svc.GetStatus(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetStatus failed: %v", err)
|
|
}
|
|
|
|
if !status.TokenConfigured {
|
|
t.Error("expected TokenConfigured to be true")
|
|
}
|
|
if status.TokenSecretID != secretID {
|
|
t.Errorf("expected TokenSecretID %q, got %q", secretID, status.TokenSecretID)
|
|
}
|
|
if len(status.RequiredScopes) == 0 {
|
|
t.Error("expected RequiredScopes to be populated")
|
|
}
|
|
}
|
|
|
|
func TestGetStatus_WithoutToken(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
logger: log,
|
|
authMethod: AuthMethodNone,
|
|
}
|
|
|
|
status, err := svc.GetStatus(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetStatus failed: %v", err)
|
|
}
|
|
|
|
if status.TokenConfigured {
|
|
t.Error("expected TokenConfigured to be false")
|
|
}
|
|
if status.TokenSecretID != "" {
|
|
t.Errorf("expected empty TokenSecretID, got %q", status.TokenSecretID)
|
|
}
|
|
}
|
|
|
|
func TestClearToken(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
// Add a GITHUB_TOKEN secret
|
|
_, err := mgr.Create(context.Background(), "GITHUB_TOKEN", "ghp_toberemoved")
|
|
if err != nil {
|
|
t.Fatalf("failed to create secret: %v", err)
|
|
}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
secretManager: mgr,
|
|
logger: log,
|
|
authMethod: AuthMethodPAT,
|
|
}
|
|
|
|
// Verify token exists
|
|
_, found := svc.findGitHubTokenSecret(context.Background())
|
|
if !found {
|
|
t.Fatal("expected token to exist before clearing")
|
|
}
|
|
|
|
// Clear the token
|
|
err = svc.ClearToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ClearToken failed: %v", err)
|
|
}
|
|
|
|
// Verify token is gone
|
|
_, found = svc.findGitHubTokenSecret(context.Background())
|
|
if found {
|
|
t.Error("expected token to be removed after clearing")
|
|
}
|
|
|
|
// After ClearToken, authMethod should no longer be "pat" (it resets to "none"
|
|
// then retryClientCreation may set it to "gh_cli" if gh CLI is available).
|
|
if svc.authMethod == AuthMethodPAT {
|
|
t.Error("expected authMethod to no longer be PAT after clearing token")
|
|
}
|
|
}
|
|
|
|
func TestClearToken_NoToken(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
secretManager: mgr,
|
|
logger: log,
|
|
}
|
|
|
|
// Clearing when no token exists should not error
|
|
err := svc.ClearToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ClearToken should not error when no token exists: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClearToken_NoSecretManager(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
|
|
svc := &Service{
|
|
secrets: nil,
|
|
secretManager: nil,
|
|
logger: log,
|
|
}
|
|
|
|
err := svc.ClearToken(context.Background())
|
|
if err == nil {
|
|
t.Error("expected error when secretManager is nil")
|
|
}
|
|
}
|