fix: default new workspaces to host GitHub access (#2156)

This commit is contained in:
Carlos Florêncio
2026-08-02 14:13:04 +01:00
committed by GitHub
parent 5b12a37b0c
commit 190eeddbc4
23 changed files with 1103 additions and 38 deletions
@@ -6,6 +6,7 @@ import (
"fmt"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
@@ -141,6 +142,13 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
taskSvc.SetRemoteBranchLister(githubBranchListerAdapter{svc: githubSvc})
taskSvc.SetPRTaskResolver(githubSvc)
githubSvc.SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess)
taskSvc.SetWorkspaceDefaultsInitializer(githubSvc)
startupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := githubSvc.InitializeFreshWorkspaceDefaults(startupCtx)
cancel()
if err != nil {
log.Warn("GitHub fresh workspace defaults initialization failed", zap.Error(err))
}
}
// Initialize Automation service
+2
View File
@@ -135,6 +135,8 @@ type Service struct {
ghAccountLister func(context.Context) ([]GHAccount, error)
mockAuth *MockAuthState
workspaceAuthorizer func(context.Context, string) error
freshDefaultsMu sync.Mutex
freshDefaultsDone bool
// cleanupFailureMu guards cleanupFailureCounts; the cleanup loop is the
// only writer but the global sweep + per-watch sweep can run concurrently
@@ -95,10 +95,10 @@ type connectionSecretIDLister interface {
ListIDs(context.Context) ([]string, error)
}
// handleWorkspaceDeleted removes encrypted GitHub credentials after the
// workspace-owned metadata rows have been deleted by the database cascade.
// Secret IDs are namespaced, so future multi-user connections are covered
// without reading deleted user-connection rows.
// handleWorkspaceDeleted removes workspace-owned GitHub metadata and encrypted
// credentials after the task repository deletes the workspace row. Secret IDs
// are namespaced, so future multi-user connections are covered without reading
// deleted user-connection rows.
func (s *Service) handleWorkspaceDeleted(ctx context.Context, event *bus.Event) error {
workspaceID := workspaceIDFromEvent(event)
if workspaceID == "" {
@@ -110,6 +110,11 @@ func (s *Service) handleWorkspaceDeleted(ctx context.Context, event *bus.Event)
if broker != nil {
broker.RevokeWorkspace(workspaceID)
}
if s.store != nil {
if err := s.store.DeleteWorkspaceSettings(ctx, workspaceID); err != nil {
return fmt.Errorf("delete GitHub workspace settings: %w", err)
}
}
if s.connectionSecrets == nil {
return nil
}
@@ -152,15 +152,30 @@ func TestHandleTaskEvents_MalformedPayloadIsNoop(t *testing.T) {
func TestHandleWorkspaceDeletedRemovesOnlyOwnedConnectionSecrets(t *testing.T) {
svc, secrets := newWorkspaceConnectionService(t, "octocat")
ctx := context.Background()
if err := svc.store.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
TaskGitCredentialsMode: TaskGitCredentialsModeExecutor,
}); err != nil {
t.Fatalf("seed workspace settings: %v", err)
}
secrets.values[WorkspacePATSecretKey("ws-1")] = "pat"
secrets.values[UserAccessTokenSecretKey("ws-1", "user-1")] = "access"
secrets.values[UserRefreshTokenSecretKey("ws-1", "user-1")] = "refresh"
secrets.values[WorkspacePATSecretKey("ws-2")] = "other"
event := bus.NewEvent(events.WorkspaceDeleted, "task-service", map[string]interface{}{"id": "ws-1"})
if err := svc.handleWorkspaceDeleted(context.Background(), event); err != nil {
if err := svc.handleWorkspaceDeleted(ctx, event); err != nil {
t.Fatalf("handleWorkspaceDeleted: %v", err)
}
var settingsCount int
if err := svc.store.db.GetContext(ctx, &settingsCount,
`SELECT COUNT(1) FROM github_workspace_settings WHERE workspace_id = ?`, "ws-1"); err != nil {
t.Fatalf("count workspace settings: %v", err)
}
if settingsCount != 0 {
t.Fatalf("workspace settings rows = %d, want 0", settingsCount)
}
for _, id := range []string{
WorkspacePATSecretKey("ws-1"),
UserAccessTokenSecretKey("ws-1", "user-1"),
+46
View File
@@ -21,6 +21,7 @@ import (
type Store struct {
db *sqlx.DB // writer
ro *sqlx.DB // reader
freshInstall bool
deploymentAppPersistenceMu sync.Mutex
appLifecycleLocksMu sync.Mutex
appLifecycleLocks map[string]*appRegistrationLifecycleLock
@@ -43,6 +44,8 @@ func NewStore(writer, reader *sqlx.DB) (*Store, error) {
db: writer, ro: reader,
appLifecycleLocks: make(map[string]*appRegistrationLifecycleLock),
}
s.freshInstall = !s.tableExists("github_workspace_settings") &&
!s.tableExists("github_workspace_connections")
legacyUpgrade := s.tableExists("github_workspace_settings") &&
!s.tableExists("github_workspace_connections")
if err := s.initSchema(legacyUpgrade); err != nil {
@@ -2972,6 +2975,49 @@ func (s *Store) GetWorkspaceSettings(ctx context.Context, workspaceID string) (*
return normalizeWorkspaceSettings(settings), nil
}
// EnsureWorkspaceExecutorDefaults creates the initial task Git policy without
// rewriting any settings that already exist. This is intentionally separate
// from GetWorkspaceSettings' managed fallback so upgrades do not change the
// behavior of existing workspaces that have never stored a settings row.
func (s *Store) EnsureWorkspaceExecutorDefaults(ctx context.Context, workspaceID string) error {
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return fmt.Errorf("workspace_id is required")
}
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `
INSERT OR IGNORE INTO github_workspace_settings (
workspace_id, task_git_credentials_mode, repo_scope_mode, repo_scope_orgs, repo_scope_repos,
saved_presets, default_query_presets, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
workspaceID, TaskGitCredentialsModeExecutor, RepoScopeModeAll, "[]", "[]", "[]", nil, now, now)
return err
}
// DeleteWorkspaceSettings removes the non-secret GitHub settings owned by a
// workspace after the task repository has deleted the workspace row.
func (s *Store) DeleteWorkspaceSettings(ctx context.Context, workspaceID string) error {
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return fmt.Errorf("workspace_id is required")
}
_, err := s.db.ExecContext(ctx,
`DELETE FROM github_workspace_settings WHERE workspace_id = ?`, workspaceID)
return err
}
func (s *Store) listWorkspaceIDs(ctx context.Context) ([]string, error) {
if !s.tableExists("workspaces") {
return nil, nil
}
var ids []string
if err := s.ro.SelectContext(ctx, &ids, `
SELECT id FROM workspaces WHERE TRIM(id) <> '' ORDER BY id`); err != nil {
return nil, err
}
return ids, nil
}
// UpsertWorkspaceSettings stores per-workspace GitHub settings.
func (s *Store) UpsertWorkspaceSettings(ctx context.Context, settings *WorkspaceSettings) error {
settings = normalizeWorkspaceSettings(settings)
@@ -0,0 +1,153 @@
package github
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
)
var errWorkspaceDefaultsUnavailable = errors.New("workspace defaults unavailable")
const (
workspaceDefaultsCLIValidationTimeout = 5 * time.Second
freshWorkspaceDefaultsTimeout = 30 * time.Second
)
// InitializeWorkspaceDefaults persists the task Git policy for a newly
// created workspace and, when the caller is allowed to use the deployment
// host identity, snapshots the active gh CLI account as a named connection.
// Account discovery and validation are deliberately best-effort: a fresh
// install without an authenticated gh CLI still gets executor credential
// inheritance and can be configured later from workspace settings.
func (s *Service) InitializeWorkspaceDefaults(ctx context.Context, workspaceID string) error {
if s == nil || s.store == nil {
return errWorkspaceDefaultsUnavailable
}
workspaceID = strings.TrimSpace(workspaceID)
if workspaceID == "" {
return fmt.Errorf("workspace_id is required")
}
if err := s.authorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return err
}
if err := s.store.EnsureWorkspaceExecutorDefaults(ctx, workspaceID); err != nil {
return fmt.Errorf("persist workspace GitHub defaults: %w", err)
}
connection, err := s.store.GetWorkspaceConnection(ctx, workspaceID)
if err != nil {
return fmt.Errorf("load existing workspace GitHub connection: %w", err)
}
if connection != nil {
return nil
}
// An authenticated member may create a workspace, but must not receive the
// operator's host gh identity. Keep the authorization boundary before any
// account listing so member requests cannot observe operator accounts.
if err := requireGHCLIOperator(ctx); err != nil {
if errors.Is(err, ErrGHCLIOperatorRequired) {
return nil
}
return err
}
accounts, err := s.ListGHAccounts(ctx)
if err != nil {
s.logWorkspaceDefaultsFallback("unable to discover host gh account", workspaceID, err)
return nil
}
account, ok := activeGHAccount(accounts)
if !ok {
return nil
}
if s.connectionSecrets == nil {
s.logWorkspaceDefaultsFallback("connection secret store is unavailable", workspaceID, errWorkspaceDefaultsUnavailable)
return nil
}
bindCtx, cancel := context.WithTimeout(ctx, workspaceDefaultsCLIValidationTimeout)
defer cancel()
if _, err := s.SetWorkspaceConnection(bindCtx, workspaceID, SetWorkspaceConnectionRequest{
Source: ConnectionSourceGHCLI,
Host: account.Host,
Login: account.Login,
}); err != nil {
s.logWorkspaceDefaultsFallback("unable to bind host gh account", workspaceID, err)
}
return nil
}
// InitializeFreshWorkspaceDefaults handles the repository-seeded initial
// workspace. The store records whether its GitHub schema was absent before
// startup; existing installations are intentionally left untouched.
func (s *Service) InitializeFreshWorkspaceDefaults(ctx context.Context) error {
if s == nil || s.store == nil || !s.store.freshInstall {
return nil
}
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, freshWorkspaceDefaultsTimeout)
defer cancel()
}
s.freshDefaultsMu.Lock()
defer s.freshDefaultsMu.Unlock()
if s.freshDefaultsDone {
return nil
}
workspaceIDs, err := s.store.listWorkspaceIDs(ctx)
if err != nil {
return fmt.Errorf("list initial workspaces: %w", err)
}
for _, workspaceID := range workspaceIDs {
if err := s.InitializeWorkspaceDefaults(ctx, workspaceID); err != nil {
return err
}
}
s.freshDefaultsDone = true
return nil
}
func activeGHAccount(accounts []GHAccount) (GHAccount, bool) {
// Prefer the canonical GitHub.com account when gh reports multiple active
// hosts. Other active hosts are passed through the existing connection
// validation path, where unsupported hosts degrade to disconnected access.
for _, account := range accounts {
if validActiveGHAccount(account) && strings.EqualFold(strings.TrimSpace(account.Host), defaultGitHubHost) {
account.Host = strings.TrimSpace(account.Host)
account.Login = strings.TrimSpace(account.Login)
return account, true
}
}
for _, account := range accounts {
if validActiveGHAccount(account) {
account.Host = strings.TrimSpace(account.Host)
account.Login = strings.TrimSpace(account.Login)
return account, true
}
}
return GHAccount{}, false
}
func validActiveGHAccount(account GHAccount) bool {
if !account.Active || strings.TrimSpace(account.Host) == "" || strings.TrimSpace(account.Login) == "" {
return false
}
switch strings.ToLower(strings.TrimSpace(account.State)) {
case "active", checkStatusSuccess:
return true
default:
return false
}
}
func (s *Service) logWorkspaceDefaultsFallback(message, workspaceID string, err error) {
if s == nil || s.logger == nil {
return
}
s.logger.Warn(message, zap.String("workspace_id", workspaceID), zap.Error(err))
}
@@ -0,0 +1,257 @@
package github
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/auth/authn"
)
func TestInitializeWorkspaceDefaultsPersistsExecutorAndBindsActiveCLI(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "bob-cli")
service.ghAccountLister = func(context.Context) ([]GHAccount, error) {
return []GHAccount{
{Host: "github.com", Login: "bob-cli", Active: true, State: "active"},
}, nil
}
service.resolver.ghToken = func(context.Context, string, string) (string, error) {
return "mock-gh-token", nil
}
if err := service.InitializeWorkspaceDefaults(context.Background(), "ws-1"); err != nil {
t.Fatalf("InitializeWorkspaceDefaults: %v", err)
}
settings, err := service.store.GetWorkspaceSettings(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceSettings: %v", err)
}
if settings.TaskGitCredentialsMode != TaskGitCredentialsModeExecutor {
t.Fatalf("task Git mode = %q, want executor", settings.TaskGitCredentialsMode)
}
connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceConnection: %v", err)
}
if connection == nil || connection.Source != ConnectionSourceGHCLI || connection.GitHubHost != "github.com" || connection.Login != "bob-cli" {
t.Fatalf("connection = %+v, want exact active CLI account", connection)
}
if len(secrets.values) != 0 {
t.Fatalf("workspace CLI token was persisted in secrets: %#v", secrets.values)
}
}
func TestInitializeWorkspaceDefaultsBoundsCLIValidationContext(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "operator")
service.ghAccountLister = func(context.Context) ([]GHAccount, error) {
return []GHAccount{{Host: "github.com", Login: "operator", Active: true, State: "active"}}, nil
}
var hasDeadline bool
service.resolver.ghToken = func(ctx context.Context, _, _ string) (string, error) {
_, hasDeadline = ctx.Deadline()
return "mock-gh-token", nil
}
if err := service.InitializeWorkspaceDefaults(context.Background(), "ws-1"); err != nil {
t.Fatalf("InitializeWorkspaceDefaults: %v", err)
}
if !hasDeadline {
t.Fatal("CLI validation context has no deadline")
}
}
func TestInitializeWorkspaceDefaultsDoesNotBindOperatorCLIForMember(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "operator")
listCalls := 0
service.ghAccountLister = func(context.Context) ([]GHAccount, error) {
listCalls++
return []GHAccount{{Host: "github.com", Login: "operator", Active: true, State: "active"}}, nil
}
memberCtx := authn.WithIdentity(context.Background(), authn.Identity{
UserID: "member-1",
Role: authn.RoleMember,
})
if err := service.InitializeWorkspaceDefaults(memberCtx, "ws-1"); err != nil {
t.Fatalf("InitializeWorkspaceDefaults(member): %v", err)
}
settings, err := service.store.GetWorkspaceSettings(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceSettings: %v", err)
}
if settings.TaskGitCredentialsMode != TaskGitCredentialsModeExecutor {
t.Fatalf("task Git mode = %q, want executor", settings.TaskGitCredentialsMode)
}
connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceConnection: %v", err)
}
if connection != nil {
t.Fatalf("member-created workspace inherited operator connection: %+v", connection)
}
if listCalls != 0 {
t.Fatalf("CLI accounts listed %d times for member-created workspace, want 0", listCalls)
}
}
func TestInitializeWorkspaceDefaultsSoftFailsWhenCLIUnavailable(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "operator")
service.ghAccountLister = func(context.Context) ([]GHAccount, error) {
return nil, errors.New("gh is not authenticated")
}
if err := service.InitializeWorkspaceDefaults(context.Background(), "ws-1"); err != nil {
t.Fatalf("InitializeWorkspaceDefaults: %v", err)
}
settings, err := service.store.GetWorkspaceSettings(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceSettings: %v", err)
}
if settings.TaskGitCredentialsMode != TaskGitCredentialsModeExecutor {
t.Fatalf("task Git mode = %q, want executor", settings.TaskGitCredentialsMode)
}
connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceConnection: %v", err)
}
if connection != nil {
t.Fatalf("unavailable CLI created a connection: %+v", connection)
}
}
func TestInitializeWorkspaceDefaultsIgnoresInvalidActiveCLI(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "operator")
service.ghAccountLister = func(context.Context) ([]GHAccount, error) {
return []GHAccount{{Host: "github.com", Login: "operator", Active: true, State: "unexpected"}}, nil
}
service.resolver.ghToken = func(context.Context, string, string) (string, error) {
return "mock-gh-token", nil
}
if err := service.InitializeWorkspaceDefaults(context.Background(), "ws-1"); err != nil {
t.Fatalf("InitializeWorkspaceDefaults: %v", err)
}
connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceConnection: %v", err)
}
if connection != nil {
t.Fatalf("invalid active CLI account created a connection: %+v", connection)
}
}
func TestInitializeWorkspaceDefaultsPreservesExistingWorkspaceState(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "operator")
ctx := context.Background()
if err := service.store.UpsertWorkspaceSettings(ctx, &WorkspaceSettings{
WorkspaceID: "ws-1",
TaskGitCredentialsMode: TaskGitCredentialsModeManaged,
RepoScopeMode: RepoScopeModeRepos,
}); err != nil {
t.Fatalf("UpsertWorkspaceSettings: %v", err)
}
if err := service.store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{
WorkspaceID: "ws-1",
Source: ConnectionSourcePAT,
GitHubHost: defaultGitHubHost,
Login: "existing-user",
Status: ConnectionStatusActive,
CredentialGeneration: 1,
}); err != nil {
t.Fatalf("UpsertWorkspaceConnection: %v", err)
}
service.ghAccountLister = func(context.Context) ([]GHAccount, error) {
return []GHAccount{{Host: "github.com", Login: "operator", Active: true, State: "active"}}, nil
}
service.resolver.ghToken = func(context.Context, string, string) (string, error) {
return "mock-gh-token", nil
}
if err := service.InitializeWorkspaceDefaults(ctx, "ws-1"); err != nil {
t.Fatalf("InitializeWorkspaceDefaults: %v", err)
}
settings, err := service.store.GetWorkspaceSettings(ctx, "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceSettings: %v", err)
}
if settings.TaskGitCredentialsMode != TaskGitCredentialsModeManaged || settings.RepoScopeMode != RepoScopeModeRepos {
t.Fatalf("existing settings changed: %+v", settings)
}
connection, err := service.store.GetWorkspaceConnection(ctx, "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceConnection: %v", err)
}
if connection == nil || connection.Source != ConnectionSourcePAT || connection.Login != "existing-user" {
t.Fatalf("existing connection changed: %+v", connection)
}
}
func TestInitializeFreshWorkspaceDefaultsSeedsExistingInitialWorkspace(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "operator")
service.ghAccountLister = func(context.Context) ([]GHAccount, error) {
return []GHAccount{{Host: "github.com", Login: "operator", Active: true, State: "active"}}, nil
}
service.resolver.ghToken = func(context.Context, string, string) (string, error) {
return "mock-gh-token", nil
}
if err := service.InitializeFreshWorkspaceDefaults(context.Background()); err != nil {
t.Fatalf("InitializeFreshWorkspaceDefaults: %v", err)
}
settings, err := service.store.GetWorkspaceSettings(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceSettings: %v", err)
}
if settings.TaskGitCredentialsMode != TaskGitCredentialsModeExecutor {
t.Fatalf("task Git mode = %q, want executor", settings.TaskGitCredentialsMode)
}
connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceConnection: %v", err)
}
if connection == nil || connection.Source != ConnectionSourceGHCLI || connection.Login != "operator" {
t.Fatalf("connection = %+v, want initial active CLI account", connection)
}
if len(secrets.values) != 0 {
t.Fatalf("initial workspace CLI token was persisted: %#v", secrets.values)
}
}
func TestInitializeFreshWorkspaceDefaultsBoundsStartupContext(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "operator")
service.ghAccountLister = func(ctx context.Context) ([]GHAccount, error) {
if _, ok := ctx.Deadline(); !ok {
t.Error("fresh workspace defaults context has no deadline")
}
return nil, errors.New("gh is not authenticated")
}
if err := service.InitializeFreshWorkspaceDefaults(context.Background()); err != nil {
t.Fatalf("InitializeFreshWorkspaceDefaults: %v", err)
}
}
func TestInitializeFreshWorkspaceDefaultsSkipsExistingGitHubStore(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "operator")
service.store.freshInstall = false
service.ghAccountLister = func(context.Context) ([]GHAccount, error) {
return []GHAccount{{Host: "github.com", Login: "operator", Active: true, State: "active"}}, nil
}
if err := service.InitializeFreshWorkspaceDefaults(context.Background()); err != nil {
t.Fatalf("InitializeFreshWorkspaceDefaults: %v", err)
}
settings, err := service.store.GetWorkspaceSettings(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceSettings: %v", err)
}
if settings.TaskGitCredentialsMode != TaskGitCredentialsModeManaged {
t.Fatalf("existing store task Git mode = %q, want managed fallback", settings.TaskGitCredentialsMode)
}
connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil {
t.Fatalf("GetWorkspaceConnection: %v", err)
}
if connection != nil {
t.Fatalf("existing store was rebound to host CLI: %+v", connection)
}
}
@@ -131,6 +131,12 @@ type WorkspaceBootstrapper interface {
CreateWorkspaceWithKanban(ctx context.Context, workspace *models.Workspace) (*models.Workflow, error)
}
// WorkspaceDefaultsInitializer persists integration defaults after a
// workspace row exists and before its creation event is published.
type WorkspaceDefaultsInitializer interface {
InitializeWorkspaceDefaults(ctx context.Context, workspaceID string) error
}
// WorkflowStepGetter retrieves workflow step information.
type WorkflowStepGetter interface {
GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error)
@@ -256,6 +262,8 @@ type Service struct {
comments CommentRepository
baseBranchPusher AgentBaseBranchPusher
runtimeOverridesMu sync.Mutex
workspaceDefaultsInitializer WorkspaceDefaultsInitializer
// foregroundActivity resolves the live fine-grained busy substate of a RUNNING
// session (satisfied by the orchestrator). Used to compute the task-level
// MOST-ACTIVE-WINS activity aggregate carried on task.updated events. Optional.
@@ -376,6 +384,13 @@ func (s *Service) SetWorkspaceBootstrapper(bootstrapper WorkspaceBootstrapper) {
s.workspaceBootstrapper = bootstrapper
}
// SetWorkspaceDefaultsInitializer wires the integration initializer used by
// workspace creation. It is optional so the task service remains usable in
// deployments without GitHub.
func (s *Service) SetWorkspaceDefaultsInitializer(initializer WorkspaceDefaultsInitializer) {
s.workspaceDefaultsInitializer = initializer
}
// SetWorkflowStepGetter wires the workflow step getter for MoveTask.
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
s.workflowStepGetter = getter
@@ -103,6 +103,12 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque
return nil, err
}
if s.workspaceDefaultsInitializer != nil {
if err := s.workspaceDefaultsInitializer.InitializeWorkspaceDefaults(ctx, workspace.ID); err != nil {
s.logger.Warn("failed to initialize workspace defaults", zap.String("workspace_id", workspace.ID), zap.Error(err))
}
}
s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace)
s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name))
if kanbanWorkflow != nil {
@@ -0,0 +1,38 @@
package service
import (
"context"
"testing"
)
type recordingWorkspaceDefaultsInitializer struct {
eventBus *MockEventBus
calls []string
seen []int
}
func (r *recordingWorkspaceDefaultsInitializer) InitializeWorkspaceDefaults(_ context.Context, workspaceID string) error {
r.calls = append(r.calls, workspaceID)
r.seen = append(r.seen, len(r.eventBus.GetPublishedEvents()))
return nil
}
func TestService_CreateWorkspaceInitializesDefaultsBeforePublication(t *testing.T) {
svc, eventBus, _ := createTestService(t)
initializer := &recordingWorkspaceDefaultsInitializer{eventBus: eventBus}
svc.SetWorkspaceDefaultsInitializer(initializer)
workspace, err := svc.CreateWorkspace(context.Background(), &CreateWorkspaceRequest{Name: "Defaults"})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if initializer.calls == nil || len(initializer.calls) != 1 || initializer.calls[0] != workspace.ID {
t.Fatalf("initializer calls = %#v, want workspace %q", initializer.calls, workspace.ID)
}
if len(initializer.seen) != 1 || initializer.seen[0] != 0 {
t.Fatalf("events visible during initialization = %#v, want none", initializer.seen)
}
if events := eventBus.GetPublishedEvents(); len(events) == 0 {
t.Fatal("workspace.created was not published after initialization")
}
}
@@ -11,21 +11,16 @@ test.describe("GitHub workspace authentication", () => {
apiClient,
seedData,
}, testInfo) => {
await apiClient.mockGitHubSetCLIAccounts([
{ host: "github.com", login: "alice-automation", active: false, state: "active" },
{ host: "github.com", login: "bob-cli", active: true, state: "active" },
]);
const workspaceB = await apiClient.createWorkspace("GitHub Workspace B");
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
source: "pat",
status: "active",
login: "alice-automation",
});
await apiClient.mockGitHubSetWorkspaceConnection(workspaceB.id, {
source: "gh_cli",
status: "active",
login: "bob-cli",
});
await apiClient.mockGitHubSetCLIAccounts([
{ host: "github.com", login: "alice-automation", active: true, state: "active" },
{ host: "github.com", login: "bob-cli", active: false, state: "active" },
]);
let releaseWorkspaceBStatus: () => void = () => {};
const workspaceBStatusGate = new Promise<void>((resolve) => {
releaseWorkspaceBStatus = resolve;
@@ -68,6 +63,9 @@ test.describe("GitHub workspace authentication", () => {
timeout: 15_000,
});
await expect(automation.getByText("GitHub CLI", { exact: true })).toBeVisible();
await expect(automation.getByTestId("github-task-access-summary")).toContainText(
"Inherit executor Git credentials",
);
await expect(testPage.getByText("My GitHub identity", { exact: true })).toHaveCount(0);
await expect(
automation.getByText("This account also powers My GitHub and user-triggered actions.", {
@@ -72,21 +72,22 @@ test.describe("GitHub workspace settings", () => {
test("configures task Git access from the workspace connection dialog", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
const workspace = await apiClient.createWorkspace("GitHub task defaults workspace");
const workspaceId = workspace.id;
await apiClient.mockGitHubSetWorkspaceConnection(workspaceId, {
source: "legacy_shared",
status: "active",
});
await apiClient.mockGitHubSetCLIAccounts([
{ host: "github.com", login: "workspace-cli", active: true, state: "active" },
]);
await stubGitHubRateLimits(testPage, seedData.workspaceId);
await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
await stubGitHubRateLimits(testPage, workspaceId);
await testPage.goto(`/settings/workspace/${workspaceId}/integrations/github`);
const automation = testPage.getByTestId("github-workspace-automation");
await expect(automation.getByTestId("github-task-access-summary")).toContainText(
"Managed workspace credentials",
"Inherit executor Git credentials",
);
await expect(testPage.getByRole("heading", { name: "My GitHub identity" })).toHaveCount(0);
await expect(testPage.getByRole("heading", { name: "Task Git credentials" })).toHaveCount(0);
@@ -206,13 +207,13 @@ test.describe("GitHub workspace settings", () => {
const response = await apiClient.rawRequest(
"GET",
`/api/v1/github/workspace-settings?workspace_id=${seedData.workspaceId}`,
`/api/v1/github/workspace-settings?workspace_id=${workspaceId}`,
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ task_git_credentials_mode: "executor" });
const statusResponse = await apiClient.rawRequest(
"GET",
`/api/v1/github/status?workspace_id=${seedData.workspaceId}`,
`/api/v1/github/status?workspace_id=${workspaceId}`,
);
expect(await statusResponse.json()).toMatchObject({
automation: { source: "gh_cli", login: "workspace-cli" },
@@ -5,21 +5,22 @@ test.describe("GitHub workspace settings on mobile", () => {
test("configures task Git access in the connection drawer", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
await apiClient.mockGitHubSetWorkspaceConnection(seedData.workspaceId, {
const workspace = await apiClient.createWorkspace("Mobile GitHub task defaults workspace");
const workspaceId = workspace.id;
await apiClient.mockGitHubSetWorkspaceConnection(workspaceId, {
source: "legacy_shared",
status: "active",
});
await apiClient.mockGitHubSetCLIAccounts([
{ host: "github.com", login: "mobile-cli", active: true, state: "active" },
]);
await stubGitHubRateLimits(testPage, seedData.workspaceId);
await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`);
await stubGitHubRateLimits(testPage, workspaceId);
await testPage.goto(`/settings/workspace/${workspaceId}/integrations/github`);
const automation = testPage.getByTestId("github-workspace-automation");
await expect(automation.getByTestId("github-task-access-summary")).toContainText(
"Managed workspace credentials",
"Inherit executor Git credentials",
);
await expect(testPage.getByRole("heading", { name: "My GitHub identity" })).toHaveCount(0);
await expect(testPage.getByRole("heading", { name: "Task Git credentials" })).toHaveCount(0);
@@ -143,12 +144,12 @@ test.describe("GitHub workspace settings on mobile", () => {
const response = await apiClient.rawRequest(
"GET",
`/api/v1/github/workspace-settings?workspace_id=${seedData.workspaceId}`,
`/api/v1/github/workspace-settings?workspace_id=${workspaceId}`,
);
expect(await response.json()).toMatchObject({ task_git_credentials_mode: "executor" });
const statusResponse = await apiClient.rawRequest(
"GET",
`/api/v1/github/status?workspace_id=${seedData.workspaceId}`,
`/api/v1/github/status?workspace_id=${workspaceId}`,
);
expect(await statusResponse.json()).toMatchObject({
automation: { source: "gh_cli", login: "mobile-cli" },
@@ -10,6 +10,12 @@
> workspace automation identity and task Git credential routing are separate settings. Background
> automation still uses the workspace connection; tasks may explicitly inherit executor
> credentials instead of receiving the managed broker contract.
>
> Amended by
> [ADR-2026-08-02-new-workspace-github-access-defaults](2026-08-02-new-workspace-github-access-defaults.md):
> operator-authorized workspace creation may snapshot the host's active authenticated `gh`
> host/login as that new workspace's explicit named CLI automation connection. Member-created
> workspaces never receive the operator identity automatically.
- Status: accepted
- Date: 2026-07-19
@@ -1,5 +1,10 @@
# ADR-2026-07-27-task-git-credential-policy: Separate GitHub Automation From Task Git Credential Policy
> Amended by
> [ADR-2026-08-02-new-workspace-github-access-defaults](2026-08-02-new-workspace-github-access-defaults.md):
> newly created workspaces persist `executor` as their initial task Git policy, while existing
> persisted and legacy fallback behavior remains unchanged.
**Status:** accepted
**Date:** 2026-07-27
**Area:** backend, frontend, security
@@ -19,10 +24,12 @@ control process can start successfully while leaving the child Git helper unable
Workspace GitHub automation identity and task Git credential policy are separate workspace-owned
settings:
- `managed` is the default. Kandev injects task/repository-bound broker leases for attached GitHub
- `managed` is the backward-compatible fallback for existing missing or invalid settings. Kandev
injects task/repository-bound broker leases for attached GitHub
repositories. PAT, named GitHub CLI, GitHub App, and migration-only legacy connections remain
automation methods rather than task-policy values.
- `executor` is the explicit inheritance mode. Kandev injects no GitHub broker helper or `gh` shim.
- `executor` is the initial mode persisted for newly created workspaces and remains an explicit
inheritance mode. Kandev injects no GitHub broker helper or `gh` shim.
Local and Worktree tasks use credentials visible on the Kandev host; Docker, SSH, Sprites, and
other remote tasks use credentials configured in that executor.
- An explicit executor-profile `GITHUB_TOKEN` or `GH_TOKEN` remains an unmanaged override. It takes
@@ -0,0 +1,85 @@
# ADR-2026-08-02-new-workspace-github-access-defaults: Bootstrap New Workspaces From Host GitHub Access
**Status:** accepted
**Date:** 2026-08-02
**Area:** backend, frontend, security
## Context
New workspaces currently start with managed task Git credentials but no workspace GitHub
connection. Launching a task with an attached GitHub repository therefore asks the credential
broker for a lease that cannot be issued, even when the Kandev host already has an authenticated
`gh` account and usable local Git credentials. The first task on a fresh self-hosted installation
can fail before the agent starts.
The host `gh` account is an operator credential. Kandev already prevents non-admin members from
selecting it, so workspace bootstrapping must preserve that boundary instead of granting the host
identity to every authenticated creator.
## Decision
Every newly created workspace attempts to persist **Inherit executor Git credentials** (`executor`)
as its initial task Git access policy. When that persistence succeeds, Local and Worktree tasks use
host-visible Git or SSH credentials; remote tasks use credentials configured in their executor. If
the settings write fails, workspace creation remains available and the existing `managed`
compatibility fallback applies until the workspace is configured or retried.
When workspace creation is performed by an internal trusted caller, an auth-disabled synthetic
administrator, or a real administrator, Kandev also snapshots the host's active authenticated
`gh` account as a named workspace automation connection. It stores the exact host/login selection,
not a token and not a live reference to whichever account becomes active later. If `gh` is absent,
unauthenticated, or cannot validate the selected account, workspace creation still succeeds with
executor task access and disconnected GitHub automation.
A non-admin member-created workspace receives executor task access but never inherits the server
operator's `gh` account automatically. The member or an administrator must configure an allowed
workspace automation identity explicitly.
The initial workspace seeded for a brand-new database follows the same rule. Upgrades do not
rewrite any existing workspace connection, persisted task policy, or legacy missing/invalid policy
fallback. Existing missing or invalid stored values continue to normalize to `managed` for
backward compatibility.
This decision amends
[ADR-2026-07-27-task-git-credential-policy](2026-07-27-task-git-credential-policy.md) only for the
default assigned to newly created workspaces, and amends
[ADR 0047](0047-github-authentication-ownership.md) only to allow an operator-authorized creation
flow to make an explicit named CLI selection automatically.
## Consequences
- A fresh local installation can launch its first task without configuring Kandev's credential
broker first.
- Background GitHub automation works immediately when the host `gh` account is authenticated and
the creator is allowed to use operator credentials.
- Workspace task access remains explicit and persisted; changing the host's active `gh` account
later does not silently change an existing workspace.
- A settings persistence failure is logged and leaves the workspace on the existing managed
fallback, so a caller must configure or retry task access before relying on executor inheritance.
- Remote executors do not inherit host credentials merely because the workspace default is
`executor`; they still require executor-specific credentials.
- Member-created workspaces remain isolated from the server operator's host identity.
- Upgrade behavior is stable, at the cost of different defaults for pre-existing missing settings
and newly created workspaces.
## Alternatives Considered
### Keep managed task access as the default
Rejected. A new disconnected workspace cannot issue the lease managed mode requires, so ordinary
task creation fails before the agent starts.
### Auto-bind the host account to member-created workspaces
Rejected. It bypasses the existing operator-only CLI binding rule and can expose repositories the
member was not intended to access.
### Resolve the currently active host account on every task launch
Rejected. A later `gh auth switch` would silently change workspace identity and recreate the
cross-workspace ambient-credential coupling that ADR 0047 removed.
### Migrate existing unconfigured workspaces
Rejected. Existing installations may rely on managed routing or deliberate disconnection. The new
defaults apply prospectively without changing saved or legacy behavior.
+1
View File
@@ -98,3 +98,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-01-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 |
| 2026-08-01-global-run-scheduler-ownership | [Separate Global Run Dispatch from Office Maintenance](2026-08-01-global-run-scheduler-ownership.md) | accepted | backend, workflow | 2026-08-01 |
| 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 |
| 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 |
@@ -0,0 +1,192 @@
---
spec: docs/specs/integrations/github-authentication.md
created: 2026-08-02
status: completed
---
# Implementation Plan: New Workspace GitHub Access Defaults
## Overview
The confirmed root cause is that `github_workspace_settings` resolves a missing task policy to
`managed`, while a new workspace has no `github_workspace_connections` row. Task launch therefore
requests a broker lease that fails with `ErrGitHubNotConfigured` before the agent starts. The fix
will persist executor task access for each new workspace, optionally snapshot an operator-authorized
active host `gh` account as a named CLI connection, and leave every existing installation unchanged.
The implementation begins with failing backend and browser regressions, then adds the smallest
workspace-initialization seam and GitHub persistence behavior required to make them pass. Public
documentation follows after the behavior is green.
---
## Backend
### GitHub workspace default initializer
- Add a focused workspace-default initialization path under
`apps/backend/internal/github/` (prefer a new `workspace_defaults.go` with sibling tests rather
than growing `service_connections.go`).
- Persist `TaskGitCredentialsModeExecutor` for a workspace created after this feature is active.
Keep `defaultWorkspaceSettings`, `normalizeTaskGitCredentialsMode`, existing schema defaults, and
upgrade fallbacks on `managed`; those are the backward-compatibility path for existing missing or
invalid state.
- Discover the currently active account through the service's injectable CLI account source. Only
an internal caller, synthetic administrator, or real administrator may receive the host account;
reuse the existing `requireGHCLIOperator` boundary for member denial.
- Validate and store the exact `github.com` host/login as a `gh_cli` connection without persisting a
bearer token. No active/valid CLI account is a soft condition: executor settings are still saved
and workspace creation succeeds disconnected.
- Record whether the GitHub schema belongs to a brand-new database before `NewStore` initializes
its tables. Expose a one-shot startup method that initializes the already-seeded default
workspace only in that fresh-database case. Existing databases, including workspaces with no
settings row, are not backfilled or rebound.
### Workspace creation seam
- Add a narrow `WorkspaceDefaultsInitializer` interface and setter in
`apps/backend/internal/task/service/service.go`.
- Call it in `CreateWorkspace` after the workspace transaction succeeds and before
`workspace.created` is published, so an immediately created task observes executor mode rather
than racing an event subscriber. The initializer owns optional-CLI degradation; a returned
persistence error is logged as a workspace-default initialization failure.
- Wire the GitHub service into this seam in `apps/backend/internal/backendapp/services.go` after
workspace authorization and connection-secret infrastructure are installed. Invoke the
fresh-database bootstrap at the same composition point for the repository-seeded initial
workspace.
- Do not use a `workspace.created` subscription as the correctness boundary: the NATS-backed bus
need not finish a subscriber before the create request can be followed by task launch.
### Upgrade and security boundaries
- Do not modify existing `github_workspace_connections` or
`github_workspace_settings.task_git_credentials_mode` rows.
- Do not change the current `managed` normalization for legacy missing/invalid values.
- Do not auto-bind host `gh` for a non-admin member context.
- Do not copy or persist the resolved CLI token, and do not follow later host active-account
changes for an already-created workspace.
---
## Frontend
No React component, route, API client, state slice, copy, or responsive composition changes are
required. The existing Workspace GitHub access summary and connection dialog already render the
backend's saved `executor` and `gh_cli` values.
### Mobile parity contract
The nearest shipped mobile exemplar remains
`apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts`: the existing full-height,
single-scroll-owner connection drawer and 44px task-access controls are unchanged. Update that
scenario to prove the initial summary reads **Inherit executor Git credentials**. Because this is a
persisted backend-default change with no layout, navigation, scroll, touch, or composition change,
the focused existing mobile scenario satisfies mobile parity without a new mobile surface.
---
## Tests
- **What:** a new workspace always receives persisted executor task access; an internal/admin
creator with a valid active CLI account also receives an exact named CLI connection.
**File:** `apps/backend/internal/github/workspace_defaults_test.go`.
**How:** SQLite-backed service tests with injectable CLI account/token validation boundaries.
- **What:** member creation never receives the server operator's CLI identity; unavailable,
unauthenticated, or invalid CLI discovery degrades to disconnected automation without losing
executor task access.
**File:** `apps/backend/internal/github/workspace_defaults_test.go`.
**How:** table-driven service tests using real store reads and synthetic/admin/member contexts.
- **What:** a database with pre-existing GitHub schema/settings is not treated as fresh and its
missing or managed behavior is not rewritten, while a genuinely fresh schema initializes the
seeded workspace once.
**Files:** `apps/backend/internal/github/workspace_defaults_test.go`,
`apps/backend/internal/github/workspace_settings_test.go`.
**How:** fresh and replayed SQLite store setup with row-level assertions.
- **What:** task-service workspace creation runs default initialization before publishing the
created event.
**File:** `apps/backend/internal/task/service/service_resources_test.go`.
**How:** a recording initializer and event bus assert call order and error handling.
- **What:** backend composition wires runtime workspace creation and fresh-install startup to the
GitHub initializer.
**File:** `apps/backend/internal/backendapp/services_github_defaults_test.go` (or the nearest
existing service-composition test if a smaller harness is available during RED).
**How:** real SQLite repositories with injected GitHub account resolution and stored-state
assertions.
- **What:** executor policy skips broker lease injection.
**File:** `apps/backend/internal/orchestrator/executor/executor_credentials_test.go`.
**How:** retain the existing `executor inheritance` regression as downstream contract evidence;
add no duplicate test unless the new integration test cannot prove the no-lease outcome.
Targeted backend verification:
```bash
cd apps/backend && go test -tags fts5 ./internal/github ./internal/task/service ./internal/backendapp ./internal/orchestrator/executor -run 'Test.*Workspace.*Defaults|Test.*Workspace.*Initializ|TestTaskGitCredentialModeDefaultsManaged|TestApplyGitCredentialSnapshotUsesExecutorSources' -count=1
```
## E2E Tests
- **Scenario:** GIVEN a new workspace and an active mock CLI account, WHEN an administrator creates
the workspace and opens Workspace GitHub access, THEN the UI shows that exact CLI automation
identity and **Inherit executor Git credentials**.
**File:** `apps/web/e2e/tests/integrations/github-authentication.spec.ts`.
**What to verify:** automation source/login and task-access summary through the rendered settings
page, with backend seeding only for the CLI-account precondition.
- **Scenario:** GIVEN the initial fresh E2E workspace, WHEN desktop and mobile users open Workspace
GitHub access, THEN the existing summary and dialog/drawer show executor inheritance as the
initial task policy.
**Files:** `apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts`,
`apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts`.
**What to verify:** desktop and mobile report the same saved policy; existing mobile geometry,
scroll ownership, safe-area containment, and touch targets remain green.
Targeted browser verification after the fresh-worktree install:
```bash
cd apps && pnpm install --frozen-lockfile
cd apps && pnpm --filter @kandev/web run e2e:run -- tests/integrations/github-authentication.spec.ts tests/integrations/github-workspace-settings.spec.ts
cd apps && pnpm --filter @kandev/web run e2e:run -- --project mobile-chrome tests/integrations/mobile-github-workspace-settings.spec.ts
```
---
## Public Documentation
Update `docs/public/integrations.md` so **Inherit executor Git credentials** is documented as the
new-workspace default, managed credentials are described as an opt-in workspace policy, active host
`gh` auto-binding is limited to operator-authorized creation, remote executors remain responsible
for their own credentials, and upgrades are unchanged.
Validate with:
```bash
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
---
## Verification Results
- `cd apps/backend && go test -tags fts5 ./internal/github ./internal/task/service ./internal/backendapp ./internal/orchestrator/executor -run 'Test.*Workspace.*Defaults|Test.*Workspace.*Initializ|TestTaskGitCredentialModeDefaultsManaged|TestApplyGitCredentialSnapshotUsesExecutorSources' -count=1` — 12 passed in 4 packages.
- `cd apps/backend && go test -tags fts5 ./internal/github ./internal/task/service ./internal/backendapp ./internal/orchestrator/executor -count=1` — 2402 passed in 4 packages.
- `cd apps && pnpm --filter @kandev/web run e2e:run -- tests/integrations/github-authentication.spec.ts tests/integrations/github-workspace-settings.spec.ts` — 9 passed.
- `cd apps && pnpm --filter @kandev/web run e2e:run -- --project mobile-chrome tests/integrations/mobile-github-workspace-settings.spec.ts` — 2 passed.
- `cd apps/backend && make lint` — 0 issues.
- `cd apps && pnpm --filter @kandev/web run typecheck` — passed.
- `cd apps && pnpm --filter @kandev/web run lint` — passed.
- `cd apps && pnpm --filter @kandev/web run i18n:check && pnpm --filter @kandev/web run i18n:ratchet` — passed.
- `node --test scripts/validate-public-docs.test.mjs` — 58 passed.
- `node scripts/validate-public-docs.mjs` — 41 published docs pages validated.
- `cd apps && pnpm run format:check` and `git diff --check` — passed.
---
## Implementation Waves And Parallel Candidates
Execution is sequential in the primary conversation; no task is marked parallel-safe because the
RED tests define the contracts consumed by the backend implementation and documentation must match
the final green behavior.
- [x] [Task 01: Add failing default-behavior regressions](task-01-default-behavior-regressions.md)
- [x] [Task 02: Implement workspace GitHub defaults](task-02-workspace-github-defaults.md)
- [x] [Task 03: Update public integration documentation](task-03-public-integration-documentation.md)
@@ -0,0 +1,69 @@
---
id: "01-default-behavior-regressions"
title: "Default behavior regressions"
status: completed
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/integrations/github-authentication.md"
---
# Task 01: Default behavior regressions
- **Acceptance:** Backend tests reproduce that new workspaces must persist executor task access,
bind a valid active CLI account only for internal/admin creators, and preserve existing-install
state.
- **Acceptance:** Task-service/composition tests prove initialization occurs before workspace-created
publication and covers the repository-seeded initial workspace on a fresh database.
- **Acceptance:** Desktop and existing mobile Playwright scenarios assert the rendered executor
default and exact auto-bound CLI identity; each new expectation fails for the intended current
behavior before production code changes.
- **Verification:** Run the focused Go command below and record the expected assertion failures (not
compile failures). After adding the Playwright expectations and installing dependencies, run the
focused desktop/mobile commands and record their expected failures.
```bash
cd apps/backend && go test -tags fts5 ./internal/github ./internal/task/service ./internal/backendapp -run 'Test.*Workspace.*Defaults|Test.*Workspace.*Initializ' -count=1
cd apps && pnpm install --frozen-lockfile
cd apps && pnpm --filter @kandev/web run e2e:run -- tests/integrations/github-authentication.spec.ts tests/integrations/github-workspace-settings.spec.ts
cd apps && pnpm --filter @kandev/web run e2e:run -- --project mobile-chrome tests/integrations/mobile-github-workspace-settings.spec.ts
```
- **Files likely touched:**
- `apps/backend/internal/github/workspace_defaults_test.go`
- `apps/backend/internal/github/workspace_settings_test.go`
- `apps/backend/internal/task/service/service_resources_test.go`
- `apps/backend/internal/backendapp/services_github_defaults_test.go`
- `apps/web/e2e/tests/integrations/github-authentication.spec.ts`
- `apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts`
- `apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts`
- **Dependencies:** None.
- **Parallelism:** sequential; these RED contracts must exist before Task 02 changes production.
- **Inputs:** spec `What`, `Task Git credential resolution`, `Failure Modes`, `Scenarios`, and plan
`Tests`/`E2E Tests`; existing `requireGHCLIOperator`, mock GitHub account APIs, task-service
workspace event ordering, and executor inheritance tests.
- **Risks:** The E2E mock must exercise injected CLI discovery rather than the developer host's real
`gh` configuration. A test that passes without the production default initializer must be revised
before leaving RED.
- **Output contract:** Summarize files changed, every RED command and expected failure, unexpected
blockers, and risks; mark this task and its plan checkbox complete while leaving production code
untouched.
## Results
- Added backend regressions for executor persistence, exact active CLI binding without token
storage, member isolation, soft unavailable/invalid CLI behavior, existing-state preservation,
fresh-install bootstrap, and task-service initialization-before-publication ordering.
- Added desktop and mobile expectations that create a new workspace and verify executor inheritance;
the GitHub authentication scenario now proves an active mock CLI account is auto-bound.
- The initial RED backend command failed at the expected undefined initializer seam before
production changes; its first failure was the missing `WorkspaceDefaultsInitializer` seam, and
the final focused suite is green.
- The first desktop command was run after the production edit and exposed that the E2E reset fixture
intentionally deletes workspace settings, restoring the legacy managed fallback: five GitHub
authentication tests and three workspace-settings tests passed, while the executor-summary test
received `Managed workspace credentials`. The scenarios were corrected to create a genuinely new
workspace for default assertions, and the final desktop command passed nine tests.
- The first valid mobile command was run after the fixture correction and passed two tests; no
separate pre-production mobile RED assertion was captured because the shared reset behavior made
the original expectation invalid before the implementation could be evaluated.
@@ -0,0 +1,69 @@
---
id: "02-workspace-github-defaults"
title: "Workspace GitHub defaults"
status: completed
wave: 2
depends_on: ["01-default-behavior-regressions"]
plan: "plan.md"
spec: "../../specs/integrations/github-authentication.md"
---
# Task 02: Workspace GitHub defaults
- **Acceptance:** Every newly created workspace and the initial workspace on a genuinely fresh
database persist executor task access before they can launch a task; existing installations are
not backfilled or rewritten.
- **Acceptance:** Internal/synthetic-admin/real-admin creation stores a validated active CLI
host/login without a token, while member creation and absent/invalid CLI state remain disconnected
and still succeed.
- **Acceptance:** All Task 01 backend, desktop, and mobile regressions pass, and executor mode issues
no managed credential lease.
- **Verification:** Run the targeted backend and managed desktop/mobile E2E commands exactly as
listed below after the final production edit.
```bash
cd apps/backend && go test -tags fts5 ./internal/github ./internal/task/service ./internal/backendapp ./internal/orchestrator/executor -run 'Test.*Workspace.*Defaults|Test.*Workspace.*Initializ|TestTaskGitCredentialModeDefaultsManaged|TestApplyGitCredentialSnapshotUsesExecutorSources' -count=1
cd apps && pnpm --filter @kandev/web run e2e:run -- tests/integrations/github-authentication.spec.ts tests/integrations/github-workspace-settings.spec.ts
cd apps && pnpm --filter @kandev/web run e2e:run -- --project mobile-chrome tests/integrations/mobile-github-workspace-settings.spec.ts
```
- **Files likely touched:**
- `apps/backend/internal/github/store.go`
- `apps/backend/internal/github/workspace_defaults.go`
- `apps/backend/internal/github/workspace_defaults_test.go`
- `apps/backend/internal/github/workspace_settings_test.go`
- `apps/backend/internal/task/service/service.go`
- `apps/backend/internal/task/service/service_resources.go`
- `apps/backend/internal/task/service/service_resources_test.go`
- `apps/backend/internal/backendapp/services.go`
- `apps/backend/internal/backendapp/services_github_defaults_test.go`
- `apps/web/e2e/tests/integrations/github-authentication.spec.ts`
- `apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts`
- `apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts`
- **Dependencies:** Task 01.
- **Parallelism:** sequential; it changes the shared service-composition and GitHub workspace
persistence path.
- **Inputs:** Task 01 RED evidence; ADR-2026-08-02-new-workspace-github-access-defaults; existing
GitHub connection validation/commit functions, mock account injection, workspace authorization,
and task-service workspace creation ordering.
- **Risks:** Do not use ambient host `gh` in E2E, do not auto-bind members, do not reinterpret legacy
missing settings as executor, and do not make CLI unavailability fail workspace creation.
- **Output contract:** Summarize implementation, actual files changed, exact GREEN command results
and Playwright artifact paths, security/external-side-effect boundaries, and remaining risks;
update this task and `plan.md` statuses/results.
## Results
- Added `Store.EnsureWorkspaceExecutorDefaults` and fresh-schema detection; existing missing or
invalid rows still resolve through the managed compatibility fallback.
- Added the GitHub workspace-default initializer, operator/admin boundary, injectable CLI account
discovery, token-free named connection commit, soft CLI degradation, and one-shot seeded-workspace
bootstrap.
- Added the task-service initializer seam and wired it before `workspace.created`; backend composition
wires the GitHub service and invokes fresh-install bootstrap after authorization/secret setup.
- Focused Go suite: `12 passed in 4 packages`.
- Scoped Go suite: `2402 passed in 4 packages`.
- Desktop GitHub authentication/settings E2E: `9 passed` in the combined run after the fixture
correction; focused settings rerun `4 passed`.
- Mobile GitHub settings E2E: `2 passed`.
- No CLI bearer token is persisted; member-created workspaces never list or bind operator accounts.
@@ -0,0 +1,41 @@
---
id: "03-public-integration-documentation"
title: "Public integration documentation"
status: completed
wave: 3
depends_on: ["02-workspace-github-defaults"]
plan: "plan.md"
spec: "../../specs/integrations/github-authentication.md"
---
# Task 03: Public integration documentation
- **Acceptance:** Public docs identify executor credentials as the new-workspace default and
managed workspace credentials as an explicit later choice.
- **Acceptance:** The docs explain operator-only active host `gh` auto-binding, disconnected fallback,
remote-executor responsibility, and unchanged upgrade behavior without implying that a token is
stored.
- **Verification:** Run both public-doc validators.
```bash
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
- **Files likely touched:** `docs/public/integrations.md`.
- **Dependencies:** Task 02.
- **Parallelism:** sequential; documentation must reflect the final green behavior.
- **Inputs:** amended GitHub-authentication spec, ADR-2026-08-02-new-workspace-github-access-defaults,
and Task 02 results.
- **Risks:** Avoid saying Local/Worktree behavior also applies to Docker/SSH/cloud executors, and do
not describe existing workspaces as migrated.
- **Output contract:** Summarize the doc changes and exact validator results; mark this task and the
plan complete and synchronize `## Verification Results`.
## Results
- Updated `docs/public/integrations.md` to document executor inheritance as the new-workspace
default, managed credentials as opt-in, operator/admin-only active `gh` auto-binding, soft
disconnected fallback, remote-executor credential responsibility, and unchanged upgrades.
- Public-doc test suite: `58 passed`.
- Public-doc validator: `Validated 41 published docs pages.`
+6 -3
View File
@@ -70,6 +70,8 @@ Open the workspace GitHub settings. **Workspace automation** offers three connec
A workspace has one active automation connection at a time. Replacing it changes the identity used by repository discovery, watches, background work, and task GitHub access in that workspace only. Disconnecting a CLI connection does not sign the host out of `gh`; disconnecting an App connection removes only the workspace binding and does not uninstall the App from GitHub.
New workspaces created by an operator or an internal trusted flow start with **Inherit executor Git credentials** for task Git access when the settings write succeeds. When the backend host has an authenticated active `gh` account, the new workspace also snapshots that exact host/login as its named GitHub CLI automation connection; Kandev stores the selection, never the CLI token. If `gh` is unavailable or unauthenticated, creation still succeeds with executor inheritance and no automation connection. If the settings write itself fails, workspace creation remains available but the existing managed compatibility fallback applies until the workspace is configured or retried. Authenticated non-admin members receive the executor default but never the operator's host identity.
Workspaces migrated from an older Kandev release may temporarily use a compatibility connection
named `legacy_shared`. It continues using the deployment's existing authenticated `gh` account,
`GITHUB_TOKEN`/`GH_TOKEN`, or legacy stored PAT for both GitHub API calls and workspace-isolated
@@ -114,14 +116,15 @@ select **Save changes** once. GitHub App creation, import, and installation rema
GitHub workflows. The help control beside **Task Git access** explains the effective credential
path on desktop hover or focus and in a touch-accessible drawer on mobile.
- **Managed workspace credentials** (the default) uses the selected workspace PAT, named GitHub
- **Managed workspace credentials** (an opt-in policy) uses the selected workspace PAT, named GitHub
CLI account, or GitHub App through Kandev's short-lived, task/repository-scoped broker. Kandev
configures `agentctl` as Git's credential helper so an attached repository can redeem its
matching lease on demand; the returned credential is not written to the repository or Git
configuration. A separate broker-aware shim handles `gh`. The task receives neither the stored
PAT nor an App private key. An executor-profile `GH_TOKEN` or `GITHUB_TOKEN` deliberately takes
precedence for that task.
- **Inherit executor Git credentials** does not install Kandev's broker helper or `gh` shim. Local
- **Inherit executor Git credentials** is the default for newly created workspaces and does not
install Kandev's broker helper or `gh` shim. Local
and Worktree tasks use credentials already visible to the host Git process (including SSH).
Docker, SSH, and cloud tasks use only credentials intentionally configured in that executor.
For Kandev-managed GitHub checkouts, Local and Worktree preparation also updates `origin` to the
@@ -252,7 +255,7 @@ it.
### Upgrade and recovery
Workspaces that existed when workspace authentication was introduced receive a **Legacy shared** connection so upgrades do not immediately lose GitHub access. It preserves the previous installation-wide resolution behavior while the workspace is migrated. New workspaces start disconnected. After a legacy workspace selects a PAT, named CLI account, or App installation, it cannot return to legacy mode. Copying a workspace never copies authentication or App installation bindings.
Workspaces that existed when workspace authentication was introduced receive a **Legacy shared** connection so upgrades do not immediately lose GitHub access. It preserves the previous installation-wide resolution behavior while the workspace is migrated. Existing workspaces and their saved task-access policies are not rewritten by the new-workspace defaults. After a legacy workspace selects a PAT, named CLI account, or App installation, it cannot return to legacy mode. Copying a workspace never copies authentication or App installation bindings.
Legacy shared resolution checks an authenticated host `gh` CLI first, then backend `GITHUB_TOKEN`, backend `GH_TOKEN`, and finally the old stored `GITHUB_TOKEN`/`github_token` secret. Those ambient sources are migration compatibility only; configure an explicit workspace connection to make identity and access deterministic.
@@ -1,7 +1,7 @@
---
status: building
created: 2026-07-19
amended: 2026-08-01
amended: 2026-08-02
owner: Kandev
---
@@ -50,6 +50,18 @@ automation under different GitHub Apps without operating separate Kandev deploym
workspace automation connection. **Inherit executor Git credentials** injects no GitHub broker
helper or `gh` shim: Local and Worktree tasks use host-visible Git/SSH credentials, while remote
tasks use credentials configured in that executor.
- Every newly created workspace attempts to persist **Inherit executor Git credentials** as its
initial task policy. After a successful settings write, if creation is performed by an internal
trusted caller, an auth-disabled synthetic administrator, or a real administrator while host
`gh` has an authenticated active account, Kandev also stores that exact host/login as the new
workspace's named CLI automation connection. Non-admin member-created workspaces never receive
the server operator's CLI identity automatically.
- If host `gh` is absent, unauthenticated, or cannot validate its active account, workspace creation
still succeeds with executor task access and disconnected GitHub automation. If the executor
settings write fails, creation still succeeds but the existing managed compatibility fallback
remains until the workspace is configured or retried. Existing workspaces are never migrated to
these defaults; their connection, persisted policy, and legacy missing/invalid policy fallback
remain unchanged.
- Workspace settings present the automation identity and task Git credential routing as one
**Workspace GitHub access** group. The page shows a compact read-only summary of both effective
choices; the existing **Change GitHub connection** dialog contains the full controls for the
@@ -106,8 +118,11 @@ automation under different GitHub Apps without operating separate Kandev deploym
- The unpublished `KANDEV_GITHUB_APP_*` configuration introduced on this branch is removed. Setting
those variables does not create or configure a registration; operators use the guided import
flow for an App they already own.
- Existing released workspaces migrate to `legacy_shared`; new workspaces start disconnected.
Once a workspace leaves legacy mode it cannot return. The unpublished singleton registration
- Existing released workspaces migrate to `legacy_shared`; upgrades do not rewrite their
connection or task policy. New operator-authorized workspaces use the active authenticated host
`gh` account when one can be validated, while member-created workspaces and workspaces created
without usable host `gh` remain disconnected. Once a workspace leaves legacy mode it cannot
return. The unpublished singleton registration
schema on this branch is rewritten directly and receives no compatibility migration. A valid
legacy connection supplies both the existing API client abstraction and an in-memory Git
transport credential so provider-backed repositories can be rematerialized from legacy shared
@@ -218,8 +233,8 @@ The non-secret operational settings row adds `task_git_credentials_mode`, with a
| Value | Behavior |
| --- | --- |
| `managed` | Default. Inject the workspace broker contract for attached GitHub repositories unless an explicit executor-profile token overrides it. |
| `executor` | Inject no Kandev GitHub helper or `gh` shim; use credentials available where the selected executor runs. |
| `managed` | Inject the workspace broker contract for attached GitHub repositories unless an explicit executor-profile token overrides it. Existing missing/invalid values continue to normalize here for upgrade compatibility. |
| `executor` | Default persisted for newly created workspaces. Inject no Kandev GitHub helper or `gh` shim; use credentials available where the selected executor runs. |
Missing or invalid persisted values normalize to `managed`. Workspace-settings copy includes this
policy because it is operational configuration, not authentication material.
@@ -359,6 +374,11 @@ post-signature processing failures produce `failing`; a later valid successful d
### Task Git credential resolution
- Missing settings start at `managed`.
- New workspace creation attempts to persist `executor` before the workspace is exposed for task
launch; a persistence failure leaves the existing managed compatibility fallback in place.
- Operator-authorized creation snapshots a validated active host `gh` host/login as a named CLI
connection when available; member creation and unavailable/invalid host CLI leave automation
disconnected.
- `managed + explicit profile GITHUB_TOKEN/GH_TOKEN -> executor_profile`.
- `managed + attached GitHub repository + active workspace connection -> workspace broker`.
- `executor -> executor-visible credentials`, regardless of PAT/CLI/App automation method.
@@ -432,6 +452,12 @@ post-signature processing failures produce `failing`; a later valid successful d
truncating, partially merging, or executing a different block.
- If executor inheritance is selected but no usable credential exists in that executor, Git/SSH
reports its normal authentication failure. Kandev does not probe or guess the actor.
- If host `gh` is absent, unauthenticated, or fails active-account validation while a workspace is
created, the workspace remains disconnected for automation, retains executor task access, and
creation succeeds.
- If executor-default persistence fails while a workspace is created, the workspace remains
available with the existing managed task-access fallback and a warning for retry/configuration;
Kandev does not claim executor inheritance was applied.
- If Kandev cannot reconcile a managed checkout's `origin` with the selected task policy, Local and
Worktree preparation fails before the agent starts instead of silently using the other policy's
transport.
@@ -519,6 +545,23 @@ registration and never creates a global default.
## Scenarios
- **GIVEN** a brand-new Kandev database and an authenticated active host `gh` account, **WHEN** the
initial workspace is seeded, **THEN** its automation connection records that exact CLI
host/login, its task access is executor inheritance, and its first task does not request a
managed credential lease.
- **GIVEN** an administrator or internal trusted caller and an authenticated active host `gh`
account, **WHEN** a workspace is created, **THEN** the workspace records that exact named CLI
automation connection and executor task access.
- **GIVEN** a non-admin member and an authenticated server-operator `gh` account, **WHEN** the
member creates a workspace, **THEN** the workspace has executor task access but no automatic
GitHub automation connection.
- **GIVEN** no usable authenticated host `gh` account, **WHEN** an authorized caller creates a
workspace, **THEN** workspace creation succeeds, task access is executor inheritance, and
GitHub automation remains disconnected.
- **GIVEN** an existing installation with managed, disconnected, or legacy missing workspace
settings, **WHEN** Kandev upgrades, **THEN** those connections and task policies remain
unchanged.
- **GIVEN** a workspace GitHub status is visible, **WHEN** the user refreshes it and the status
request is still pending, **THEN** the existing workspace content remains visible and usable
while the refresh control is busy, on both desktop and mobile.
@@ -697,11 +740,15 @@ and the
[managed GitHub login-shell repair plan](../../plans/github-managed-tools-login-shell/plan.md),
and the
[system-service identity guardrails repair plan](../../plans/system-service-identity-guardrails/plan.md).
The new-workspace default repair is tracked in
[the new workspace GitHub access defaults plan](../../plans/new-workspace-github-access-defaults/plan.md).
## Decision
See [ADR-2026-07-21-workspace-selectable-github-app-registrations](../../decisions/2026-07-21-workspace-selectable-github-app-registrations.md)
and
[ADR-2026-07-27-task-git-credential-policy](../../decisions/2026-07-27-task-git-credential-policy.md).
New-workspace defaults are defined by
[ADR-2026-08-02-new-workspace-github-access-defaults](../../decisions/2026-08-02-new-workspace-github-access-defaults.md).
The system-service ownership boundary is defined by
[ADR-2026-07-31-system-service-user-continuity](../../decisions/2026-07-31-system-service-user-continuity.md).