From 7c96621de1773dae1b0cc467f2bad2361f42659f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 16 Aug 2026 21:13:41 +0000 Subject: [PATCH] feat: add executor parity safeguards (#2704) --- apps/backend/internal/agent/agents/agent.go | 26 + .../internal/agent/agents/claude_acp.go | 17 + .../internal/agent/agents/codex_acp.go | 20 +- apps/backend/internal/agent/agents/mock.go | 50 +- .../internal/agent/agents/opencode_acp.go | 27 +- .../agent/agents/portable_config_test.go | 65 ++ .../internal/agent/remoteconfig/catalog.go | 115 ++++ .../agent/remoteconfig/catalog_test.go | 91 +++ .../internal/agent/runtime/agentctl/agent.go | 16 +- .../runtime/agentctl/agent_session_test.go | 11 +- .../internal/agent/runtime/agentctl/client.go | 44 ++ .../runtime/lifecycle/agent_session_seeder.go | 81 ++- .../lifecycle/agent_session_seeder_test.go | 31 +- .../agent/runtime/lifecycle/event_types.go | 19 + .../agent/runtime/lifecycle/events.go | 1 + .../runtime/lifecycle/executor_backend.go | 5 +- .../runtime/lifecycle/executor_docker.go | 31 +- .../lifecycle/executor_sprites_credentials.go | 39 +- .../lifecycle/executor_ssh_credentials.go | 36 +- .../runtime/lifecycle/manager_interaction.go | 127 +++- .../lifecycle/manager_interaction_test.go | 72 +- .../lifecycle/manager_workspace_rebind.go | 27 +- .../lifecycle/portable_config_uploader.go | 249 +++++++ .../portable_config_uploader_test.go | 244 +++++++ .../runtime/lifecycle/safe_file_writer.go | 20 + .../lifecycle/safe_file_writer_unix.go | 120 ++++ .../lifecycle/safe_file_writer_windows.go | 57 ++ .../agent/runtime/lifecycle/session.go | 87 ++- .../agent/runtime/lifecycle/session_test.go | 43 +- .../agent/runtime/lifecycle/start_model.go | 195 +++--- .../start_model_executor_authority_test.go | 137 ++++ .../runtime/lifecycle/start_model_test.go | 233 ------- .../agentctl/server/adapter/adapter.go | 8 + .../server/adapter/transport/acp/adapter.go | 43 ++ .../internal/agentctl/server/api/agent.go | 37 +- .../server/api/agent_adapter_actions_test.go | 13 + .../internal/agentctl/types/streams/agent.go | 31 + apps/backend/internal/events/types.go | 23 +- .../websocket/session_notifications.go | 1 + .../orchestrator/event_handlers_streaming.go | 174 +++++ .../event_handlers_streaming_test.go | 81 +++ .../orchestrator/executor/executor_state.go | 6 +- .../handlers/executor_profile_handlers.go | 13 + .../executor_profile_handlers_test.go | 38 ++ .../task/handlers/route_registration_test.go | 1 + apps/backend/pkg/websocket/actions.go | 139 ++-- .../settings/executors/[profileId]/page.tsx | 77 +-- .../settings/executors/new/[type]/page.tsx | 71 +- .../executor-profile-baselines.ts | 4 + .../profile-edit/portable-config-bundles.tsx | 189 ++++++ .../profile-edit/profile-runtime-sections.tsx | 12 + .../profile-edit/remote-auth-section.tsx | 340 ++++++++++ .../remote-credentials-card.test.tsx | 248 ++++++- .../profile-edit/remote-credentials-card.tsx | 633 +++++------------- .../serialize-executor-config.test.ts | 43 ++ .../profile-edit/serialize-executor-config.ts | 62 ++ .../task-create-dialog-options.test.tsx | 23 +- .../components/task-create-dialog-options.tsx | 46 +- .../chat/messages/status-message.test.tsx | 45 ++ .../task/chat/messages/status-message.tsx | 117 +++- apps/web/e2e/helpers/api-client.ts | 17 + .../chat/agent-message-comments-helpers.ts | 20 + .../mobile-agent-message-comments.spec.ts | 7 +- .../chat/mobile-message-queue-reorder.spec.ts | 12 +- .../tests/docker/agent-config-copy.spec.ts | 121 ++++ .../hide-disabled-integrations-nav.spec.ts | 43 +- .../mobile-github-workspace-settings.spec.ts | 28 +- .../plugins/bitbucket-plugin-contract.spec.ts | 2 +- .../tests/plugins/composer-actions.spec.ts | 1 - .../web/e2e/tests/pr/pr-multi-popover.spec.ts | 8 +- .../pr/pr-watcher-merged-cleanup.spec.ts | 11 +- apps/web/e2e/tests/search/shared.ts | 13 +- .../task-workspace-content-search.spec.ts | 2 + .../mobile-model-mismatch-warning.spec.ts | 64 ++ .../session/model-mismatch-warning-helpers.ts | 27 + .../session/model-mismatch-warning.spec.ts | 76 +++ .../tests/session/port-forward-dialog.spec.ts | 1 + .../settings/executor-agent-config-helpers.ts | 45 ++ .../settings/executor-agent-config.spec.ts | 92 +++ .../mobile-executor-agent-config.spec.ts | 57 ++ .../mobile-no-silent-model-fallback.spec.ts | 141 +--- .../settings/no-silent-model-fallback.spec.ts | 215 +----- .../task/repository-selector-scroll.spec.ts | 5 +- apps/web/e2e/tests/task/task-list.spec.ts | 6 +- .../e2e/tests/terminal/terminal-agent.spec.ts | 5 + .../tests/terminal/terminal-keyboard.spec.ts | 16 +- .../use-workspace-content-search.test.ts | 32 + .../lib/api/domains/agent-config-api.test.ts | 26 + apps/web/lib/api/domains/agent-config-api.ts | 29 + apps/web/lib/types/session-events.ts | 5 + .../web/lib/types/session-runtime-payloads.ts | 18 + apps/web/lib/ws/handlers/messages.test.ts | 69 +- apps/web/lib/ws/handlers/messages.ts | 34 +- apps/web/src/locales/en/executors.json | 17 + apps/web/src/locales/en/settings.json | 1 + apps/web/src/locales/en/task.json | 20 + apps/web/src/locales/pseudo/executors.json | 17 + apps/web/src/locales/pseudo/settings.json | 1 + apps/web/src/locales/pseudo/task.json | 20 + docs/audits/settings-typography/README.md | 2 +- ...-executor-authoritative-model-selection.md | 118 ++++ ...15-portable-agent-configuration-bundles.md | 167 +++++ docs/decisions/INDEX.md | 2 + .../executor-model-mismatch-fallback/plan.md | 200 ++++++ .../task-01-executor-model-decision.md | 72 ++ .../task-02-persist-model-warning.md | 78 +++ .../task-03-remove-host-model-gates.md | 84 +++ .../task-04-model-mismatch-evidence.md | 77 +++ .../portable-agent-configuration/plan.md | 199 ++++++ .../task-01-portable-config-catalog.md | 77 +++ .../task-02-configuration-transfer.md | 81 +++ .../task-03-executor-config-controls.md | 85 +++ .../task-04-executor-config-evidence.md | 79 +++ docs/public/agents-and-profiles.md | 18 + docs/public/executors.md | 38 ++ docs/public/security.md | 21 + docs/specs/INDEX.md | 3 +- docs/specs/no-silent-model-fallback/spec.md | 319 ++++++--- .../portable-agent-configuration/spec.md | 231 +++++++ 119 files changed, 6491 insertions(+), 1638 deletions(-) create mode 100644 apps/backend/internal/agent/agents/portable_config_test.go create mode 100644 apps/backend/internal/agent/remoteconfig/catalog.go create mode 100644 apps/backend/internal/agent/remoteconfig/catalog_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/portable_config_uploader.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/portable_config_uploader_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/safe_file_writer.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/safe_file_writer_unix.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/safe_file_writer_windows.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/start_model_executor_authority_test.go create mode 100644 apps/web/components/settings/profile-edit/portable-config-bundles.tsx create mode 100644 apps/web/components/settings/profile-edit/remote-auth-section.tsx create mode 100644 apps/web/components/settings/profile-edit/serialize-executor-config.test.ts create mode 100644 apps/web/components/settings/profile-edit/serialize-executor-config.ts create mode 100644 apps/web/components/task/chat/messages/status-message.test.tsx create mode 100644 apps/web/e2e/tests/docker/agent-config-copy.spec.ts create mode 100644 apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts create mode 100644 apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts create mode 100644 apps/web/e2e/tests/session/model-mismatch-warning.spec.ts create mode 100644 apps/web/e2e/tests/settings/executor-agent-config-helpers.ts create mode 100644 apps/web/e2e/tests/settings/executor-agent-config.spec.ts create mode 100644 apps/web/e2e/tests/settings/mobile-executor-agent-config.spec.ts create mode 100644 apps/web/lib/api/domains/agent-config-api.test.ts create mode 100644 apps/web/lib/api/domains/agent-config-api.ts create mode 100644 docs/decisions/2026-08-15-executor-authoritative-model-selection.md create mode 100644 docs/decisions/2026-08-15-portable-agent-configuration-bundles.md create mode 100644 docs/plans/executor-model-mismatch-fallback/plan.md create mode 100644 docs/plans/executor-model-mismatch-fallback/task-01-executor-model-decision.md create mode 100644 docs/plans/executor-model-mismatch-fallback/task-02-persist-model-warning.md create mode 100644 docs/plans/executor-model-mismatch-fallback/task-03-remove-host-model-gates.md create mode 100644 docs/plans/executor-model-mismatch-fallback/task-04-model-mismatch-evidence.md create mode 100644 docs/plans/portable-agent-configuration/plan.md create mode 100644 docs/plans/portable-agent-configuration/task-01-portable-config-catalog.md create mode 100644 docs/plans/portable-agent-configuration/task-02-configuration-transfer.md create mode 100644 docs/plans/portable-agent-configuration/task-03-executor-config-controls.md create mode 100644 docs/plans/portable-agent-configuration/task-04-executor-config-evidence.md create mode 100644 docs/specs/portable-agent-configuration/spec.md diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 902a021c59..0be4c416ee 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -461,6 +461,32 @@ type RemoteAuthMethod struct { SetupScript string `json:"setup_script,omitempty"` } +// PortableConfigAgent is an optional capability for agents that have a small, +// explicitly allowlisted set of user configuration files that can be copied +// into an isolated executor. It is intentionally separate from RemoteAuth. +type PortableConfigAgent interface { + PortableConfig() *PortableConfig +} + +// PortableConfig declares safe configuration bundles for an agent. +type PortableConfig struct { + Bundles []PortableConfigBundle `json:"bundles"` +} + +// PortableConfigBundle is one stable, user-selectable configuration unit. +type PortableConfigBundle struct { + ID string `json:"id"` + Label string `json:"label"` + Files []PortableConfigFile `json:"files"` +} + +// PortableConfigFile maps an OS-specific host path relative to the host home +// directory to a relative path below the executor user's home directory. +type PortableConfigFile struct { + SourcePaths map[string]string `json:"source_paths"` + TargetPath string `json:"target_path"` +} + // Command is a domain value type representing a CLI command with arguments. // Serialize to []string only at system boundaries (process exec, Docker API, JSON DTOs). type Command struct { diff --git a/apps/backend/internal/agent/agents/claude_acp.go b/apps/backend/internal/agent/agents/claude_acp.go index 00c7cc77d7..7833769b62 100644 --- a/apps/backend/internal/agent/agents/claude_acp.go +++ b/apps/backend/internal/agent/agents/claude_acp.go @@ -136,6 +136,7 @@ func (a *ClaudeACP) Runtime() *RuntimeConfig { NativeSessionResume: true, CanRecover: &canRecover, SessionDirTemplate: "{home}/.claude", + SessionDirTarget: "/root/.claude", }, } } @@ -161,6 +162,22 @@ chmod 600 "${HOME}/.claude.json"`, } } +func (a *ClaudeACP) PortableConfig() *PortableConfig { + return &PortableConfig{Bundles: []PortableConfigBundle{ + { + ID: "claude.settings", + Label: "Copy Claude settings", + Files: []PortableConfigFile{ + {SourcePaths: map[string]string{ + "darwin": ".claude/settings.json", + "linux": ".claude/settings.json", + "windows": ".claude/settings.json", + }, TargetPath: ".claude/settings.json"}, + }, + }, + }} +} + // Verified: `claude --help` documents `claude auth login` as the dedicated // sign-in subcommand (also `claude auth logout` / `claude auth status`). // Source: https://code.claude.com/docs/en/cli-reference diff --git a/apps/backend/internal/agent/agents/codex_acp.go b/apps/backend/internal/agent/agents/codex_acp.go index 787cdfb18e..c3759db105 100644 --- a/apps/backend/internal/agent/agents/codex_acp.go +++ b/apps/backend/internal/agent/agents/codex_acp.go @@ -140,8 +140,8 @@ func (a *CodexACP) RemoteAuth() *RemoteAuth { Type: "files", Label: "Copy auth files", SourceFiles: map[string][]string{ - "darwin": {".codex/auth.json", ".codex/config.toml"}, - "linux": {".codex/auth.json", ".codex/config.toml"}, + "darwin": {".codex/auth.json"}, + "linux": {".codex/auth.json"}, }, TargetRelDir: ".codex", }, @@ -153,6 +153,22 @@ func (a *CodexACP) RemoteAuth() *RemoteAuth { } } +func (a *CodexACP) PortableConfig() *PortableConfig { + return &PortableConfig{Bundles: []PortableConfigBundle{ + { + ID: "codex.config", + Label: "Copy Codex configuration", + Files: []PortableConfigFile{ + {SourcePaths: map[string]string{ + "darwin": ".codex/config.toml", + "linux": ".codex/config.toml", + "windows": ".codex/config.toml", + }, TargetPath: ".codex/config.toml"}, + }, + }, + }} +} + // Verified against `codex --help`: `codex login --device-auth` is the // dedicated sign-in subcommand. Device-auth prints a code + URL that works // even when the kandev process can't open a browser (containers, SSH, diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index ec4d8a8b9d..95148ea997 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -173,15 +173,55 @@ func (a *MockAgent) Runtime() *RuntimeConfig { ProjectSkillDir: DefaultProjectSkillDir, UserSkillDir: ".mock-agent/skills", SessionConfig: SessionConfig{ - CanRecover: &canRecover, + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.mock-agent", + SessionDirTarget: "/root/.mock-agent", }, } } -// RemoteAuth returns a non-nil empty spec so the mock agent counts as a -// remote-capable agent (it's runnable inside Docker for E2E tests) but -// requires no credentials. The empty methods list signals "no auth needed". -func (a *MockAgent) RemoteAuth() *RemoteAuth { return &RemoteAuth{} } +// RemoteAuth returns a Codex-shaped auth spec for the mock Codex alias so the +// settings E2E can prove auth and configuration remain independent. The base +// mock agent still requires no credentials. +func (a *MockAgent) RemoteAuth() *RemoteAuth { + if a.ID() != "codex-acp" { + return &RemoteAuth{} + } + return &RemoteAuth{Methods: []RemoteAuthMethod{ + { + Type: "files", + Label: "Copy auth files", + SourceFiles: map[string][]string{ + "darwin": {".codex/auth.json"}, + "linux": {".codex/auth.json"}, + }, + TargetRelDir: ".codex", + }, + {Type: "env", EnvVar: "OPENAI_API_KEY"}, + }} +} + +// PortableConfig exposes one disposable settings file so the Docker E2E +// agent can exercise the same isolated-home transfer path as a real ACP +// provider without requiring provider credentials. +func (a *MockAgent) PortableConfig() *PortableConfig { + bundleID := "mock.settings" + if a.ID() != mockAgentDefaultID { + bundleID = a.ID() + ".settings" + } + return &PortableConfig{Bundles: []PortableConfigBundle{ + { + ID: bundleID, + Label: "Copy mock-agent settings", + Files: []PortableConfigFile{ + { + SourcePaths: map[string]string{"": ".mock-agent/settings.json"}, + TargetPath: ".mock-agent/settings.json", + }, + }, + }, + }} +} // InstallScript returns a deterministic short-lived shell script so e2e tests // can exercise the install streaming endpoint without depending on npm. diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index 1af18bbed2..b91c4faa08 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -114,7 +114,11 @@ func (a *OpenCodeACP) Runtime() *RuntimeConfig { NativeSessionResume: true, NewSessionOnWorkspaceRebind: true, CanRecover: &canRecover, - SessionDirTemplate: "{home}/.opencode", + // Auth lives under .local/share/opencode and configuration under + // .config/opencode. Mount the isolated executor home so both trees + // remain visible without mounting the host home. + SessionDirTemplate: "{home}", + SessionDirTarget: "/root", }, } } @@ -135,6 +139,27 @@ func (a *OpenCodeACP) RemoteAuth() *RemoteAuth { } } +func (a *OpenCodeACP) PortableConfig() *PortableConfig { + return &PortableConfig{Bundles: []PortableConfigBundle{ + { + ID: "opencode.config", + Label: "Copy OpenCode configuration", + Files: []PortableConfigFile{ + {SourcePaths: map[string]string{ + "darwin": ".config/opencode/opencode.json", + "linux": ".config/opencode/opencode.json", + "windows": ".config/opencode/opencode.json", + }, TargetPath: ".config/opencode/opencode.json"}, + {SourcePaths: map[string]string{ + "darwin": ".config/opencode/opencode.jsonc", + "linux": ".config/opencode/opencode.jsonc", + "windows": ".config/opencode/opencode.jsonc", + }, TargetPath: ".config/opencode/opencode.jsonc"}, + }, + }, + }} +} + func (a *OpenCodeACP) InstallScript() string { return "npm install -g " + opencodeACPPackage } diff --git a/apps/backend/internal/agent/agents/portable_config_test.go b/apps/backend/internal/agent/agents/portable_config_test.go new file mode 100644 index 0000000000..cba85bbc87 --- /dev/null +++ b/apps/backend/internal/agent/agents/portable_config_test.go @@ -0,0 +1,65 @@ +package agents + +import "testing" + +func TestInitialPortableConfigDeclarations(t *testing.T) { + tests := []struct { + name string + agent Agent + bundleID string + filePaths []string + }{ + {name: "claude", agent: NewClaudeACP(), bundleID: "claude.settings", filePaths: []string{".claude/settings.json"}}, + {name: "codex", agent: NewCodexACP(), bundleID: "codex.config", filePaths: []string{".codex/config.toml"}}, + {name: "opencode", agent: NewOpenCodeACP(), bundleID: "opencode.config", filePaths: []string{".config/opencode/opencode.json", ".config/opencode/opencode.jsonc"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.agent.(PortableConfigAgent).PortableConfig() + if config == nil || len(config.Bundles) != 1 { + t.Fatalf("portable config = %+v, want one bundle", config) + } + bundle := config.Bundles[0] + if bundle.ID != tt.bundleID { + t.Fatalf("bundle ID = %q, want %q", bundle.ID, tt.bundleID) + } + if len(bundle.Files) != len(tt.filePaths) { + t.Fatalf("bundle files = %+v, want %v", bundle.Files, tt.filePaths) + } + for index, file := range bundle.Files { + if file.TargetPath != tt.filePaths[index] { + t.Errorf("file %d target = %q, want %q", index, file.TargetPath, tt.filePaths[index]) + } + } + }) + } +} + +func TestCodexRemoteAuthContainsAuthOnly(t *testing.T) { + auth := NewCodexACP().RemoteAuth() + if auth == nil || len(auth.Methods) == 0 { + t.Fatal("Codex RemoteAuth must declare auth") + } + for _, method := range auth.Methods { + for _, paths := range method.SourceFiles { + for _, path := range paths { + if path == ".codex/config.toml" { + t.Fatal("Codex config.toml must be independent from authentication") + } + } + } + } +} + +func TestMockPortableConfigUsesUniqueBundleIDsForAliases(t *testing.T) { + defaultBundle := NewMockAgent().PortableConfig().Bundles[0] + aliasBundle := NewMockAgentWithID("codex-acp", "Mock Codex", "Mock Codex").PortableConfig().Bundles[0] + + if defaultBundle.ID != "mock.settings" { + t.Fatalf("default mock bundle ID = %q, want mock.settings", defaultBundle.ID) + } + if aliasBundle.ID != "codex-acp.settings" { + t.Fatalf("aliased mock bundle ID = %q, want codex-acp.settings", aliasBundle.ID) + } +} diff --git a/apps/backend/internal/agent/remoteconfig/catalog.go b/apps/backend/internal/agent/remoteconfig/catalog.go new file mode 100644 index 0000000000..56ca8f7688 --- /dev/null +++ b/apps/backend/internal/agent/remoteconfig/catalog.go @@ -0,0 +1,115 @@ +// Package remoteconfig owns the backend allowlist for small agent +// configuration files that may be copied into isolated executors. +package remoteconfig + +import ( + "os" + "path/filepath" + "runtime" + "sort" + + "github.com/kandev/kandev/internal/agent/agents" +) + +// File is safe metadata for one declared configuration file. It contains no +// file contents and never contains an absolute host path. +type File struct { + SourcePath string `json:"source_path"` + TargetPath string `json:"target_path"` + Available bool `json:"available"` +} + +// Bundle is one stable, selectable configuration bundle. +type Bundle struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + DisplayName string `json:"display_name"` + Label string `json:"label"` + Files []File `json:"files"` + Available bool `json:"available"` +} + +// Catalog is the host-aware catalog plus an internal lookup for transfer. +type Catalog struct { + Bundles []Bundle + bundlesByID map[string]Bundle +} + +// BuildCatalog creates a catalog for the current host home and OS. +func BuildCatalog(enabledAgents []agents.Agent) Catalog { + homeDir, _ := os.UserHomeDir() + return BuildCatalogForHost(enabledAgents, runtime.GOOS, homeDir) +} + +// BuildCatalogForHost creates a catalog for an explicit host OS and home. +// Only agents with a PortableConfig declaration contribute bundles. +func BuildCatalogForHost(enabledAgents []agents.Agent, currentOS, homeDir string) Catalog { + bundles := make([]Bundle, 0) + byID := make(map[string]Bundle) + for _, ag := range enabledAgents { + capability, ok := ag.(agents.PortableConfigAgent) + if !ok || capability.PortableConfig() == nil { + continue + } + config := capability.PortableConfig() + for _, declared := range config.Bundles { + if declared.ID == "" || len(declared.Files) == 0 { + continue + } + if _, duplicate := byID[declared.ID]; duplicate { + continue + } + bundle := Bundle{ + ID: declared.ID, + AgentID: ag.ID(), + DisplayName: ag.DisplayName(), + Label: declared.Label, + Files: make([]File, 0, len(declared.Files)), + } + for _, declaredFile := range declared.Files { + sourcePath := sourcePathForOS(declaredFile.SourcePaths, currentOS) + if sourcePath == "" || declaredFile.TargetPath == "" { + continue + } + available := localRegularFileExists(homeDir, sourcePath) + bundle.Files = append(bundle.Files, File{ + SourcePath: sourcePath, + TargetPath: declaredFile.TargetPath, + Available: available, + }) + bundle.Available = bundle.Available || available + } + if len(bundle.Files) == 0 { + continue + } + bundles = append(bundles, bundle) + byID[bundle.ID] = bundle + } + } + sort.Slice(bundles, func(i, j int) bool { return bundles[i].ID < bundles[j].ID }) + return Catalog{Bundles: bundles, bundlesByID: byID} +} + +// FindBundle returns a declared bundle by its stable ID. +func (c Catalog) FindBundle(id string) (Bundle, bool) { + bundle, ok := c.bundlesByID[id] + return bundle, ok +} + +func sourcePathForOS(paths map[string]string, currentOS string) string { + if path := paths[currentOS]; path != "" { + return path + } + if path := paths["linux"]; path != "" { + return path + } + return paths[""] +} + +func localRegularFileExists(homeDir, relativePath string) bool { + if homeDir == "" { + return false + } + info, err := os.Lstat(filepath.Join(homeDir, relativePath)) + return err == nil && info.Mode().IsRegular() +} diff --git a/apps/backend/internal/agent/remoteconfig/catalog_test.go b/apps/backend/internal/agent/remoteconfig/catalog_test.go new file mode 100644 index 0000000000..deebb38cb1 --- /dev/null +++ b/apps/backend/internal/agent/remoteconfig/catalog_test.go @@ -0,0 +1,91 @@ +package remoteconfig + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" +) + +func TestBuildCatalogForHostUsesStableBundleIDsAndAvailability(t *testing.T) { + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("model = \"private\""), 0o600); err != nil { + t.Fatal(err) + } + + first := BuildCatalogForHost([]agents.Agent{agents.NewOpenCodeACP(), agents.NewCodexACP(), agents.NewClaudeACP()}, "linux", home) + second := BuildCatalogForHost([]agents.Agent{agents.NewClaudeACP(), agents.NewCodexACP(), agents.NewOpenCodeACP()}, "linux", home) + + firstIDs := bundleIDs(first.Bundles) + secondIDs := bundleIDs(second.Bundles) + slices.Sort(firstIDs) + slices.Sort(secondIDs) + if !slices.Equal(firstIDs, secondIDs) { + t.Fatalf("bundle IDs depend on declaration order: %v vs %v", firstIDs, secondIDs) + } + + codex, ok := first.FindBundle("codex.config") + if !ok || !codex.Available { + t.Fatalf("codex bundle = %+v, want available stable codex.config bundle", codex) + } + if len(codex.Files) != 1 || codex.Files[0].SourcePath != ".codex/config.toml" { + t.Fatalf("codex bundle files = %+v, want config.toml only", codex.Files) + } + + claude, ok := first.FindBundle("claude.settings") + if !ok || claude.Available { + t.Fatalf("claude bundle = %+v, want unavailable without settings.json", claude) + } +} + +func TestBuildCatalogForHostDoesNotExposeFileData(t *testing.T) { + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o700); err != nil { + t.Fatal(err) + } + secret := "host-only-secret" + if err := os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte(secret), 0o600); err != nil { + t.Fatal(err) + } + + catalog := BuildCatalogForHost([]agents.Agent{agents.NewCodexACP()}, "linux", home) + encoded, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), secret) { + t.Fatalf("catalog leaked file data: %s", encoded) + } + if strings.Contains(string(encoded), home) { + t.Fatalf("catalog leaked host home path: %s", encoded) + } +} + +func TestBuildCatalogForHostSkipsAgentsWithoutDeclarations(t *testing.T) { + catalog := BuildCatalogForHost([]agents.Agent{ + agents.NewTUIAgent(agents.TUIAgentConfig{ + AgentID: "plain-tui", + AgentName: "Plain TUI", + Command: "plain-tui", + Desc: "test agent without portable configuration", + }), + }, "linux", "") + if len(catalog.Bundles) != 0 { + t.Fatalf("bundles = %+v, want empty catalog", catalog.Bundles) + } +} + +func bundleIDs(bundles []Bundle) []string { + ids := make([]string, 0, len(bundles)) + for _, bundle := range bundles { + ids = append(ids, bundle.ID) + } + return ids +} diff --git a/apps/backend/internal/agent/runtime/agentctl/agent.go b/apps/backend/internal/agent/runtime/agentctl/agent.go index bf58e52760..cd23ad4bca 100644 --- a/apps/backend/internal/agent/runtime/agentctl/agent.go +++ b/apps/backend/internal/agent/runtime/agentctl/agent.go @@ -73,9 +73,10 @@ func (c *Client) Initialize(ctx context.Context, clientName, clientVersion strin // NewSessionResponse from agentctl type NewSessionResponse struct { - Success bool `json:"success"` - SessionID string `json:"session_id,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + SessionID string `json:"session_id,omitempty"` + ModelState *streams.SessionModelState `json:"model_state,omitempty"` + Error string `json:"error,omitempty"` } // createSessionRequest sends a session creation request and parses the response. @@ -86,6 +87,7 @@ func (c *Client) createSessionRequest(ctx context.Context, action, cwd string, m McpServers []types.McpServer `json:"mcp_servers,omitempty"` }{Cwd: cwd, McpServers: mcpServers} + c.setLastSessionModelState(nil) resp, err := c.sendStreamRequest(ctx, action, payload) if err != nil { return "", fmt.Errorf("%s request failed: %w", action, err) @@ -106,6 +108,7 @@ func (c *Client) createSessionRequest(ctx context.Context, action, cwd string, m if !result.Success { return "", fmt.Errorf("%s failed: %s", action, result.Error) } + c.setLastSessionModelState(result.ModelState) return result.SessionID, nil } @@ -129,6 +132,7 @@ func (c *Client) LoadSession(ctx context.Context, sessionID string, mcpServers [ McpServers []types.McpServer `json:"mcp_servers,omitempty"` }{SessionID: sessionID, McpServers: mcpServers} + c.setLastSessionModelState(nil) resp, err := c.sendStreamRequest(ctx, "agent.session.load", payload) if err != nil { return fmt.Errorf("load session request failed: %w", err) @@ -143,8 +147,9 @@ func (c *Client) LoadSession(ctx context.Context, sessionID string, mcpServers [ } var result struct { - Success bool `json:"success"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + ModelState *streams.SessionModelState `json:"model_state,omitempty"` + Error string `json:"error,omitempty"` } if err := resp.ParsePayload(&result); err != nil { return fmt.Errorf("failed to parse load session response: %w", err) @@ -152,6 +157,7 @@ func (c *Client) LoadSession(ctx context.Context, sessionID string, mcpServers [ if !result.Success { return fmt.Errorf("load session failed: %s", result.Error) } + c.setLastSessionModelState(result.ModelState) return nil } diff --git a/apps/backend/internal/agent/runtime/agentctl/agent_session_test.go b/apps/backend/internal/agent/runtime/agentctl/agent_session_test.go index 11cce5b0aa..1e0afacd4f 100644 --- a/apps/backend/internal/agent/runtime/agentctl/agent_session_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/agent_session_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -52,8 +53,12 @@ func okResponse(payload map[string]any) func(ws.Message) *ws.Message { } func TestResetSession_UsesResetActionAndReturnsNewSessionID(t *testing.T) { + modelState := &streams.SessionModelState{ + CurrentModelID: "mock-fast", + Models: []streams.SessionModelInfo{{ModelID: "mock-fast"}, {ModelID: "mock-smart"}}, + } c, captured := captureStreamRequest(t, okResponse(map[string]any{ - "success": true, "session_id": "sess-reset-1", + "success": true, "session_id": "sess-reset-1", "model_state": modelState, })) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -67,6 +72,10 @@ func TestResetSession_UsesResetActionAndReturnsNewSessionID(t *testing.T) { if sessionID != "sess-reset-1" { t.Errorf("session ID = %q, want sess-reset-1", sessionID) } + state := c.GetLastSessionModelState() + if state == nil || state.CurrentModelID != "mock-fast" || len(state.Models) != 2 { + t.Fatalf("model state = %+v, want the synchronous reset catalog", state) + } sent := captured() // Reset must NOT reuse agent.session.new — that restarts the subprocess diff --git a/apps/backend/internal/agent/runtime/agentctl/client.go b/apps/backend/internal/agent/runtime/agentctl/client.go index 3fc355fc98..fb2ddd467c 100644 --- a/apps/backend/internal/agent/runtime/agentctl/client.go +++ b/apps/backend/internal/agent/runtime/agentctl/client.go @@ -14,6 +14,7 @@ import ( "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/mcp/plugintools" ws "github.com/kandev/kandev/pkg/websocket" @@ -59,6 +60,49 @@ type Client struct { pendingRequests map[string]chan *ws.Message pendingRequestConns map[string]*websocket.Conn pendingMu sync.Mutex + + // lastSessionModelState is populated synchronously by session/new, + // session/reset, or session/load responses. Lifecycle policy evaluation can + // use it before the corresponding session_models event reaches its handler. + lastSessionModelState *streams.SessionModelState +} + +func (c *Client) setLastSessionModelState(state *streams.SessionModelState) { + c.mu.Lock() + defer c.mu.Unlock() + c.lastSessionModelState = cloneSessionModelState(state) +} + +// GetLastSessionModelState returns the model catalog included in the most +// recent session creation or load response. +func (c *Client) GetLastSessionModelState() *streams.SessionModelState { + c.mu.RLock() + defer c.mu.RUnlock() + return cloneSessionModelState(c.lastSessionModelState) +} + +func cloneSessionModelState(state *streams.SessionModelState) *streams.SessionModelState { + if state == nil { + return nil + } + cloned := &streams.SessionModelState{ + CurrentModelID: state.CurrentModelID, + Models: append([]streams.SessionModelInfo(nil), state.Models...), + ConfigOptions: append([]streams.ConfigOption(nil), state.ConfigOptions...), + } + for i, model := range cloned.Models { + if model.Meta == nil { + continue + } + cloned.Models[i].Meta = make(map[string]any, len(model.Meta)) + for key, value := range model.Meta { + cloned.Models[i].Meta[key] = value + } + } + for i, option := range cloned.ConfigOptions { + cloned.ConfigOptions[i].Options = append([]streams.ConfigOptionValue(nil), option.Options...) + } + return cloned } // ClientOption configures optional Client settings. diff --git a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go index 24697e19ac..ce2bbfb6e6 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go +++ b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go @@ -64,6 +64,9 @@ func SessionDirHostPath(kandevHomeDir, instanceID, sessionDirTemplate string) st return "" } rel := strings.TrimPrefix(sessionDirTemplate, "{home}/") + if sessionDirTemplate == "{home}" { + rel = "." + } return filepath.Join(root, rel) } @@ -78,14 +81,7 @@ type localFileUploader struct { } func (u localFileUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error { - cleanPath, err := containedPath(u.root, path) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(cleanPath), 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", filepath.Dir(cleanPath), err) - } - return os.WriteFile(cleanPath, data, mode) + return writeFileWithinRoot(u.root, path, data, mode) } // containedPath returns a cleaned absolute path guaranteed to be inside root, @@ -108,8 +104,8 @@ func containedPath(root, path string) (string, error) { } // SeedAgentSessionDir copies the agent's RemoteAuth.SourceFiles from the host -// home into a per-container session dir, populating just the auth-relevant -// files (typically auth.json + config.toml). Stale per-host state — codex's +// home into a per-container session dir. Optional configuration bundles are +// copied only when selectedIDs is provided. Stale per-host state — codex's // state.db with absolute macOS paths, sessions/ subdirectories, lock files — // is intentionally NOT copied; the agent rebuilds them inside the container // with container-local Linux paths. @@ -122,6 +118,22 @@ func SeedAgentSessionDir( ag agents.Agent, instanceSessionRoot string, log *logger.Logger, + selectedIDs ...[]string, +) error { + var selected []string + if len(selectedIDs) > 0 { + selected = selectedIDs[0] + } + return seedAgentSessionDir(ctx, ag, instanceSessionRoot, log, selected, nil) +} + +func seedAgentSessionDir( + ctx context.Context, + ag agents.Agent, + instanceSessionRoot string, + log *logger.Logger, + selectedIDs []string, + onWarnings func([]PortableConfigWarning), ) error { if ag == nil || instanceSessionRoot == "" { return nil @@ -134,33 +146,52 @@ func SeedAgentSessionDir( return fmt.Errorf("mkdir %s: %w", instanceSessionRoot, err) } + authErr := seedAgentAuthFiles(ctx, ag, instanceSessionRoot, log) + if authErr != nil && log != nil { + log.Warn("failed to seed agent authentication files; continuing with configuration bundles", zap.Error(authErr)) + } + if len(selectedIDs) > 0 { + warnings := UploadPortableConfigBundles( + ctx, localFileUploader{root: instanceSessionRoot}, ag, selectedIDs, instanceSessionRoot, log, + ) + if onWarnings != nil { + onWarnings(warnings) + } + } + return authErr +} + +func seedAgentAuthFiles(ctx context.Context, ag agents.Agent, instanceSessionRoot string, log *logger.Logger) error { auth := ag.RemoteAuth() if auth == nil { return nil } - hostOS := runtime.GOOS - methods := make([]remoteauth.Method, 0, len(auth.Methods)) - for _, m := range auth.Methods { - if m.Type != authMethodTypeFiles || len(m.SourceFiles) == 0 { + methods := authMethodsForHost(auth.Methods, runtime.GOOS) + if len(methods) == 0 { + return nil + } + return UploadCredentialFiles(ctx, localFileUploader{root: instanceSessionRoot}, methods, instanceSessionRoot, log) +} + +func authMethodsForHost(methods []agents.RemoteAuthMethod, hostOS string) []remoteauth.Method { + selected := make([]remoteauth.Method, 0, len(methods)) + for _, method := range methods { + if method.Type != authMethodTypeFiles || len(method.SourceFiles) == 0 { continue } // Resolve the OS-specific source list into a flat one keyed by host // OS, falling back to "linux" for cross-platform agents. - srcs := selectAuthSourceFiles(m, hostOS) - if len(srcs) == 0 { + sources := selectAuthSourceFiles(method, hostOS) + if len(sources) == 0 { continue } - methods = append(methods, remoteauth.Method{ - Type: m.Type, - SourceFiles: srcs, - TargetRelDir: m.TargetRelDir, + selected = append(selected, remoteauth.Method{ + Type: method.Type, + SourceFiles: sources, + TargetRelDir: method.TargetRelDir, }) } - if len(methods) == 0 { - return nil - } - - return UploadCredentialFiles(ctx, localFileUploader{root: instanceSessionRoot}, methods, instanceSessionRoot, log) + return selected } // selectAuthSourceFiles picks the SourceFiles list to use for the host OS, diff --git a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go index da063044f5..3b74c37c5f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go @@ -47,7 +47,7 @@ func writeFile(t *testing.T, root, rel string, contents []byte) { } // TestSeedAgentSessionDir_OnlyCopiesAuthFiles is the load-bearing check for -// the codex bug: the seeder must copy auth.json/config.toml from the host +// the codex boundary: the seeder must copy auth.json from the host // home into the per-container session dir but must NOT bring along state.db, // sessions/, or other host-state caches that contain absolute host paths. func TestSeedAgentSessionDir_OnlyCopiesAuthFiles(t *testing.T) { @@ -69,14 +69,14 @@ func TestSeedAgentSessionDir_OnlyCopiesAuthFiles(t *testing.T) { dotdir := filepath.Join(instanceRoot, ".codex") - mustExist := []string{"auth.json", "config.toml"} + mustExist := []string{"auth.json"} for _, name := range mustExist { if _, err := os.Stat(filepath.Join(dotdir, name)); err != nil { t.Fatalf("expected %s in session dir: %v", name, err) } } - mustNotExist := []string{"state.db", "state.db-wal", "junk.txt", "sessions"} + mustNotExist := []string{"config.toml", "state.db", "state.db-wal", "junk.txt", "sessions"} for _, name := range mustNotExist { if _, err := os.Stat(filepath.Join(dotdir, name)); !os.IsNotExist(err) { t.Fatalf("unexpected %s leaked into session dir (err=%v)", name, err) @@ -84,6 +84,30 @@ func TestSeedAgentSessionDir_OnlyCopiesAuthFiles(t *testing.T) { } } +func TestSeedAgentSessionDir_CopiesSelectedPortableConfig(t *testing.T) { + hostHome := seedTestHostHome(t) + writeFile(t, hostHome, ".codex/auth.json", []byte(`{"token":"abc"}`)) + writeFile(t, hostHome, ".codex/config.toml", []byte(`model = "gpt"`)) + + kandevHome := t.TempDir() + instanceRoot := InstanceSessionRoot(kandevHome, "selected-config") + if err := SeedAgentSessionDir( + context.Background(), agents.NewCodexACP(), instanceRoot, newSeederTestLogger(t), []string{"codex.config"}, + ); err != nil { + t.Fatalf("SeedAgentSessionDir: %v", err) + } + if _, err := os.Stat(filepath.Join(instanceRoot, ".codex", "config.toml")); err != nil { + t.Fatalf("selected config missing: %v", err) + } + info, err := os.Stat(filepath.Join(instanceRoot, ".codex", "config.toml")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("selected config mode = %o, want 600", info.Mode().Perm()) + } +} + // TestSeedAgentSessionDir_PerInstanceUnique ensures two containers running // the same agent get independent session dirs. Without this the codex state // DB from one task would clobber another's, defeating the whole isolation @@ -240,6 +264,7 @@ func TestDockerStopInstance_PreservesSessionDirOnPlainStop(t *testing.T) { {reason: "stopped via API", expectExists: true}, {reason: "agent crashed", expectExists: true}, {reason: "user requested", expectExists: true}, + {reason: stopReasonStaleExecutionCleanup, expectExists: true}, {reason: "task archived", expectExists: false}, {reason: "task deleted", expectExists: false}, {reason: "session archived", expectExists: false}, diff --git a/apps/backend/internal/agent/runtime/lifecycle/event_types.go b/apps/backend/internal/agent/runtime/lifecycle/event_types.go index fd3e7dd20e..54479c6a3b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/event_types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/event_types.go @@ -202,6 +202,10 @@ type AgentStreamEventData struct { // model was unavailable. FallbackModel string `json:"fallback_model,omitempty"` + // ModelSelectionWarning explains an executor-authoritative default or + // explicit fallback decision. + ModelSelectionWarning *streams.ModelSelectionWarning `json:"model_selection_warning,omitempty"` + // Session info (from "session_info" event) SessionTitle string `json:"session_title,omitempty"` SessionUpdatedAt string `json:"session_updated_at,omitempty"` @@ -566,6 +570,21 @@ func (p SessionModelFallbackEventPayload) GetSessionID() string { return p.SessionID } +// SessionModelSelectionWarningEventPayload is the provider-neutral live +// payload for an executor-authoritative model decision. +type SessionModelSelectionWarningEventPayload struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + Warning streams.ModelSelectionWarning `json:"warning"` + Timestamp string `json:"timestamp"` +} + +// GetSessionID returns the task-session ID used by the gateway for routing. +func (p SessionModelSelectionWarningEventPayload) GetSessionID() string { + return p.SessionID +} + // SessionModelsSnapshot is the persisted provider-derived state needed to // hydrate the task model selector before live session events reconnect. type SessionModelsSnapshot struct { diff --git a/apps/backend/internal/agent/runtime/lifecycle/events.go b/apps/backend/internal/agent/runtime/lifecycle/events.go index 39cc9a152f..293c4812d4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/events.go @@ -207,6 +207,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even AuthMethods: event.AuthMethods, CurrentModelID: event.CurrentModelID, FallbackModel: event.FallbackModel, + ModelSelectionWarning: event.ModelSelectionWarning, SessionModels: event.SessionModels, ConfigOptions: event.ConfigOptions, ConfigBaselineCandidate: event.ConfigBaselineCandidate, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 21d430130a..d5167625fd 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -153,6 +153,8 @@ const ( MetadataKeyContributionDestinations = "contribution_destinations" MetadataKeyIsRemote = "is_remote" MetadataKeyRemoteAuthHome = "remote_auth_target_home" + MetadataKeyAgentConfigBundles = "agent_config_bundles" + MetadataKeyExecutorProfileID = "executor_profile_id" MetadataKeyGitUserName = "git_user_name" MetadataKeyGitUserEmail = "git_user_email" MetadataKeyImageTagOverride = "image_tag_override" @@ -230,13 +232,14 @@ var persistentMetadataKeys = map[string]bool{ MetadataKeyCleanupScript: true, MetadataKeyRepoSetupScript: true, MetadataKeyRemoteAuthHome: true, + MetadataKeyAgentConfigBundles: true, MetadataKeyGitUserName: true, MetadataKeyGitUserEmail: true, "remote_credentials": true, "remote_auth_secrets": true, "executor_mcp_policy": true, "sprites_network_policy_rules": true, - "executor_profile_id": true, + MetadataKeyExecutorProfileID: true, MetadataKeyImageTagOverride: true, MetadataKeyContainerID: true, MetadataKeyWorktreeBranch: true, diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index 374771a208..91431999e1 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -235,8 +235,8 @@ func (r *DockerExecutor) tryReconnect(ctx context.Context, dockerClient *docker. return nil, false } -// seedSessionDir copies the agent's auth files (auth.json / config.toml / -// etc.) into the per-container session dir. Replaces the older pattern of +// seedSessionDir copies the agent's auth files and selected configuration +// bundles into the per-container session dir. Replaces the older pattern of // bind-mounting the host's whole ~/., which leaked absolute host // paths into agent state DBs and broke resume on codex. func (r *DockerExecutor) seedSessionDir(ctx context.Context, req *ExecutorCreateRequest) { @@ -244,7 +244,17 @@ func (r *DockerExecutor) seedSessionDir(ctx context.Context, req *ExecutorCreate return } instanceRoot := InstanceSessionRoot(r.kandevHomeDir, req.InstanceID) - if err := SeedAgentSessionDir(ctx, req.AgentConfig, instanceRoot, r.logger); err != nil { + selectedBundles := selectedPortableConfigBundleIDs(req.Metadata) + if err := seedAgentSessionDir( + ctx, + req.AgentConfig, + instanceRoot, + r.logger, + selectedBundles, + func(warnings []PortableConfigWarning) { + reportPortableConfigWarnings(req.OnProgress, warnings) + }, + ); err != nil { r.logger.Warn("failed to seed agent session dir (continuing)", zap.String("instance_id", req.InstanceID), zap.String("agent_id", req.AgentConfig.ID()), @@ -663,10 +673,11 @@ func (r *DockerExecutor) StopInstance(ctx context.Context, instance *ExecutorIns // On destructive stop reasons (task/session deleted/archived), clean up // the kandev-managed per-container session dir so we don't leak GBs of - // agent state on disk. Plain stops preserve the dir so resume re-attaches - // to the same agent state, mirroring the Sprites preserve-on-stop rule. + // agent state on disk. Plain stops and stale execution cleanup preserve the + // dir because a resume may re-attach to the same container and its bind + // mounts. teardownContainer := shouldTeardownDockerContainer(instance.StopReason) - if teardownContainer && r.kandevHomeDir != "" && instance.InstanceID != "" { + if shouldRunExecutorCleanup(instance.StopReason) && r.kandevHomeDir != "" && instance.InstanceID != "" { CleanupAgentSessionDir(InstanceSessionRoot(r.kandevHomeDir, instance.InstanceID), r.logger) } @@ -726,10 +737,10 @@ func (r *DockerExecutor) StopInstance(ctx context.Context, instance *ExecutorIns } // shouldTeardownDockerContainer extends terminal cleanup with stale execution -// cleanup for Docker only. A stale Docker execution owns a local container and -// per-instance session dir that become untracked before retry/resume launches a -// replacement. Sprites intentionally keep stale sandboxes; see -// shouldRunExecutorCleanup for that shared runtime policy. +// cleanup for Docker only. Stale cleanup must stop the old container before a +// retry/resume launch, but its per-instance session dir must remain available +// because the stopped container still references its bind mounts. Destructive +// task/session lifecycle reasons own removal; see shouldRunExecutorCleanup. func shouldTeardownDockerContainer(reason string) bool { if shouldRunExecutorCleanup(reason) { return true diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go index 90825a246f..626dc8be38 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go @@ -30,21 +30,17 @@ func (r *SpritesExecutor) uploadCredentials( // 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 - } - var selectedMethodIDs []string - if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil { - return fmt.Errorf("failed to parse remote_credentials: %w", err) + if credsJSON, _ := req.Metadata["remote_credentials"].(string); credsJSON != "" { + if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil { + return fmt.Errorf("failed to parse remote_credentials: %w", err) + } + // Ignore the retired host-global gh token method in stale profiles. GitHub + // automation credentials are supplied by the workspace credential broker. + selectedMethodIDs = r.resolveGHToken(selectedMethodIDs) } - - // Ignore the retired host-global gh token method in stale profiles. GitHub - // automation credentials are supplied by the workspace credential broker. - selectedMethodIDs = r.resolveGHToken(selectedMethodIDs) - - if len(selectedMethodIDs) == 0 { + selectedBundles := selectedPortableConfigBundleIDs(req.Metadata) + if len(selectedMethodIDs) == 0 && len(selectedBundles) == 0 { return nil } @@ -60,10 +56,6 @@ func (r *SpritesExecutor) uploadCredentials( } fileMethods = append(fileMethods, method) } - if len(fileMethods) == 0 { - return nil - } - stepCtx, cancel := context.WithTimeout(ctx, spriteUploadTimeout) defer cancel() @@ -72,7 +64,18 @@ func (r *SpritesExecutor) uploadCredentials( if err != nil { return err } - return UploadCredentialFiles(stepCtx, uploader, fileMethods, targetHomeDir, r.logger) + var uploadErr error + if len(fileMethods) > 0 { + uploadErr = UploadCredentialFiles(stepCtx, uploader, fileMethods, targetHomeDir, r.logger) + if uploadErr != nil { + r.logger.Warn("sprites executor: credential files failed; continuing with configuration bundles", zap.Error(uploadErr)) + } + } + if len(selectedBundles) > 0 && req.AgentConfig != nil { + warnings := UploadPortableConfigBundles(stepCtx, uploader, req.AgentConfig, selectedBundles, targetHomeDir, r.logger) + reportPortableConfigWarnings(req.OnProgress, warnings) + } + return uploadErr } // runAuthSetupScripts executes setup scripts for env-type auth methods whose env var diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go index e834042401..2ddb985930 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go @@ -36,20 +36,17 @@ func (r *SSHExecutor) uploadCredentials( r.resolveAuthSecrets(ctx, req, catalog) r.runAuthSetupScripts(ctx, client, req, catalog, platform) - credsJSON := getMetadataString(req.Metadata, "remote_credentials") - if credsJSON == "" { - return nil + var fileMethods []remoteauth.Method + if credsJSON := getMetadataString(req.Metadata, "remote_credentials"); credsJSON != "" { + var selectedMethodIDs []string + if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil { + return fmt.Errorf("failed to parse remote_credentials: %w", err) + } + selectedMethodIDs = r.resolveGHToken(selectedMethodIDs) + fileMethods = selectFileMethods(catalog, selectedMethodIDs, r.logger) } - - var selectedMethodIDs []string - if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil { - return fmt.Errorf("failed to parse remote_credentials: %w", err) - } - - selectedMethodIDs = r.resolveGHToken(selectedMethodIDs) - - fileMethods := selectFileMethods(catalog, selectedMethodIDs, r.logger) - if len(fileMethods) == 0 { + selectedBundles := selectedPortableConfigBundleIDs(req.Metadata) + if len(fileMethods) == 0 && len(selectedBundles) == 0 { return nil } @@ -58,7 +55,18 @@ func (r *SSHExecutor) uploadCredentials( if err != nil { return err } - return UploadCredentialFiles(ctx, uploader, fileMethods, homeDir, r.logger) + var uploadErr error + if len(fileMethods) > 0 { + uploadErr = UploadCredentialFiles(ctx, uploader, fileMethods, homeDir, r.logger) + if uploadErr != nil { + r.logger.Warn("ssh executor: credential files failed; continuing with configuration bundles", zap.Error(uploadErr)) + } + } + if len(selectedBundles) > 0 && req.AgentConfig != nil { + warnings := UploadPortableConfigBundles(ctx, uploader, req.AgentConfig, selectedBundles, homeDir, r.logger) + reportPortableConfigWarnings(req.OnProgress, warnings) + } + return uploadErr } // runAuthSetupScripts executes setup scripts for env-type auth methods whose diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index dc98627097..711c18cce6 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -15,11 +15,17 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/routingerr" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) +const ( + freshSessionModelStateWait = 2 * time.Second + freshSessionModelStatePoll = 10 * time.Millisecond +) + // WasSessionInitialized reports whether the execution completed ACP session setup. // Returns false if the execution is not found or init hasn't completed. func (m *Manager) WasSessionInitialized(executionID string) bool { @@ -441,12 +447,8 @@ func (m *Manager) reapplySessionModeAfterReset(ctx context.Context, execution *A zap.String("mode", mode)) } -// reapplySessionModelAfterReset re-applies the effective model to a freshly -// initialized ACP session under the no-silent-model-fallback policy: a model -// that is gone in the fresh session's advertised list fails the reset -// explicitly (returning an error) unless the profile opted into a fallback -// model or the legacy auto-fallback toggle. Previously the re-apply was -// best-effort, silently leaving the fresh session on the provider default. +// reapplySessionModelAfterReset applies the executor-authoritative model +// decision to a freshly initialized ACP session. func (m *Manager) reapplySessionModelAfterReset( ctx context.Context, execution *AgentExecution, @@ -457,7 +459,7 @@ func (m *Manager) reapplySessionModelAfterReset( } policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID) policy.Model = modelID - appliedModel, usingFallback, err := applyStartModelPolicy( + decision, err := applyStartModelPolicy( ctx, m.logger, execution.agentctl, execution.GetModelState(), policy, ) if err != nil { @@ -467,20 +469,79 @@ func (m *Manager) reapplySessionModelAfterReset( zap.Error(err)) return err } - if appliedModel != "" { + if decision.Warning && m.sessionManager != nil { + m.sessionManager.publishModelSelectionWarningEvent(execution, newSessionID, decision) + } + if decision.EffectiveModel != "" && + (decision.Outcome == ModelSelectionOutcomeApplied || decision.Outcome == ModelSelectionOutcomeExplicitFallback) { m.logger.Info("re-applied session model after context reset", zap.String("execution_id", execution.ID), zap.String("session_id", execution.SessionID), zap.String("new_acp_session_id", newSessionID), - zap.String("model", appliedModel), - zap.Bool("using_fallback", usingFallback)) - if usingFallback && m.sessionManager != nil { - m.sessionManager.publishModelFallbackEvent(execution, newSessionID, appliedModel) - } + zap.String("model", decision.EffectiveModel), + zap.Bool("using_fallback", decision.Outcome == ModelSelectionOutcomeExplicitFallback)) } return nil } +func cacheFreshSessionModelState(execution *AgentExecution) bool { + if execution == nil || execution.agentctl == nil { + return false + } + state := execution.agentctl.GetLastSessionModelState() + if state == nil { + return false + } + execution.SetModelState(&CachedModelState{ + CurrentModelID: state.CurrentModelID, + Models: state.Models, + ConfigOptions: state.ConfigOptions, + }) + return true +} + +// waitForFreshSessionModelState gives the agent stream a chance to deliver the +// new session's model catalog before the reset/restart policy runs. ACP +// session/new returns before its session_models notification is dispatched to +// the lifecycle manager. This is the fallback for transports that do not +// include a synchronous snapshot in their session response. Reading the old +// nil/empty cache at that boundary would incorrectly treat an advertised model +// as unavailable and leave the agent on its provider default. A missing +// notification still follows the executor-authoritative default path after the +// bounded wait. +func waitForFreshSessionModelState(ctx context.Context, log *logger.Logger, execution *AgentExecution) bool { + if execution == nil { + return false + } + if freshSessionModelCatalogReady(execution.GetModelState()) { + return true + } + + waitCtx, cancel := context.WithTimeout(ctx, freshSessionModelStateWait) + defer cancel() + ticker := time.NewTicker(freshSessionModelStatePoll) + defer ticker.Stop() + for { + select { + case <-waitCtx.Done(): + if log != nil { + log.Debug("fresh session model catalog was not reported before policy evaluation", + zap.String("execution_id", execution.ID), + zap.Error(waitCtx.Err())) + } + return false + case <-ticker.C: + if freshSessionModelCatalogReady(execution.GetModelState()) { + return true + } + } + } +} + +func freshSessionModelCatalogReady(state *CachedModelState) bool { + return state != nil && len(state.Models) > 0 +} + func (m *Manager) effectiveSessionModelForReset(ctx context.Context, execution *AgentExecution) string { modelFallback := "" if previous := execution.GetModelState(); previous != nil { @@ -518,6 +579,13 @@ func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) err prevMode := execution.GetModeState() effectiveModel := m.effectiveSessionModelForReset(ctx, execution) + // Clear the old catalog before creating the fresh session. The new + // session_models event is delivered asynchronously and must be the only + // catalog consulted by the reset decision. + _ = m.executionStore.WithLock(executionID, func(exec *AgentExecution) { + exec.SetModelState(nil) + }) + // Resolve agent config and MCP servers for session reset agentConfig, err := m.getAgentConfigForExecution(execution) if err != nil { @@ -550,13 +618,6 @@ func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) err m.resetStreamingStateWithHistory(exec) - // The cached model state describes the OLD ACP session. The fresh - // session has not advertised its models yet, so evaluating the start - // model against the stale list could reject a model the new session - // accepts (or vice versa). Clear it: reapplySessionModelAfterReset - // then relies on the fresh session's own SetModel result. - exec.SetModelState(nil) - // Drain any stale prompt completion signal select { case <-exec.promptDoneCh: @@ -568,6 +629,9 @@ func (m *Manager) ResetAgentContext(ctx context.Context, executionID string) err // onto the fresh ACP session. A strict-mode model that is gone in the new // session fails the reset explicitly instead of silently dropping to the // provider default. + if !cacheFreshSessionModelState(execution) && effectiveModel != "" { + waitForFreshSessionModelState(ctx, m.logger, execution) + } if err := m.reapplySessionModelAfterReset(ctx, execution, newSessionID, effectiveModel); err != nil { // The reset already committed the execution as Ready; a model // re-application failure must not leave it there, or later prompts @@ -791,6 +855,14 @@ func (m *Manager) RestartAgentProcess(ctx context.Context, executionID string) e return fmt.Errorf("failed to initialize ACP session after restart: %w", err) } + if !cacheFreshSessionModelState(execution) && preparation.previousModel != "" { + waitForFreshSessionModelState(ctx, m.logger, execution) + if err := m.reapplySessionModelAfterReset(ctx, execution, execution.ACPSessionID, preparation.previousModel); err != nil { + m.updateExecutionError(executionID, "failed to restore session model after restart: "+err.Error()) + return fmt.Errorf("failed to restore session model after restart: %w", err) + } + } + // Restore the user's session permission mode onto the fresh ACP session. m.reapplySessionModeAfterReset(ctx, execution, execution.ACPSessionID, preparation.previousMode) @@ -804,9 +876,10 @@ func (m *Manager) RestartAgentProcess(ctx context.Context, executionID string) e } type agentRestartPreparation struct { - agentConfig agents.Agent - commands agentCommands - previousMode *CachedModeState + agentConfig agents.Agent + commands agentCommands + previousModel string + previousMode *CachedModeState } // prepareAgentRestart builds and validates the replacement before touching the current process. @@ -825,9 +898,10 @@ func (m *Manager) prepareAgentRestart(ctx context.Context, execution *AgentExecu return agentRestartPreparation{}, fmt.Errorf("failed to rebuild agent command for restart: %w", err) } return agentRestartPreparation{ - agentConfig: agentConfig, - commands: commands, - previousMode: execution.GetModeState(), + agentConfig: agentConfig, + commands: commands, + previousModel: m.effectiveSessionModelForReset(ctx, execution), + previousMode: execution.GetModeState(), }, nil } @@ -839,6 +913,7 @@ func (m *Manager) resetAgentRestartState(executionID string, commands agentComma exec.needsResumeContext = false exec.resumeContextInjected = false exec.setSessionInitialized(false) + exec.SetModelState(nil) exec.AgentCommand = commands.initial exec.ContinueCommand = commands.continue_ exec.AgentArgs = commands.args diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go index ed9c4f08f0..81265c9ae9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go @@ -46,6 +46,7 @@ type restartMockAgentctlServer struct { failStop bool failSessionNew bool + modelState *streams.SessionModelState } func TestStopAgentWithReason_MissingExecutionIsClassified(t *testing.T) { @@ -56,6 +57,21 @@ func TestStopAgentWithReason_MissingExecutionIsClassified(t *testing.T) { require.ErrorIs(t, err, ErrExecutionNotFound) } +func TestWaitForFreshSessionModelStateWaitsForAdvertisedCatalog(t *testing.T) { + execution := &AgentExecution{ID: "exec-model-catalog"} + execution.SetModelState(&CachedModelState{CurrentModelID: "mock-fast"}) + + go func() { + time.Sleep(20 * time.Millisecond) + execution.SetModelState(&CachedModelState{ + CurrentModelID: "mock-fast", + Models: []streams.SessionModelInfo{{ModelID: "mock-fast"}}, + }) + }() + + require.True(t, waitForFreshSessionModelState(context.Background(), newTestLogger(), execution)) +} + func newRestartMockAgentctlServer(t *testing.T, failStop, failSessionNew bool) *restartMockAgentctlServer { t.Helper() @@ -135,10 +151,14 @@ func newRestartMockAgentctlServer(t *testing.T, failStop, failSessionNew bool) * }) } case "agent.session.reset": - resp, _ = ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + payload := map[string]interface{}{ "success": true, "session_id": "reset-session-456", - }) + } + if m.modelState != nil { + payload["model_state"] = m.modelState + } + resp, _ = ws.NewResponse(msg.ID, msg.Action, payload) case "agent.session.set_mode": resp, _ = ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ "success": true, @@ -758,10 +778,52 @@ func TestManager_ResetAgentContext_ReappliesSessionModel(t *testing.T) { resetIndex := slices.Index(actions, "agent.session.reset") modelIndex := slices.Index(actions, "agent.session.set_model") require.GreaterOrEqual(t, resetIndex, 0) - require.Greater(t, modelIndex, resetIndex, - "the model must be reapplied after the fresh ACP session is created") + require.Equal(t, -1, modelIndex, + "an empty fresh-session model catalog must not receive a model-selection request") + require.Empty(t, mock.getSetModelIDs(), + "reset must continue on the fresh-session provider default when no model is advertised") +} + +func TestManager_ResetAgentContext_UsesSynchronousSessionModelCatalog(t *testing.T) { + mgr := newTestManager(t) + mgr.workspaceInfoProvider = &mockWorkspaceInfoProvider{ + infos: map[string]*WorkspaceInfo{ + "session-1": {SessionID: "session-1", RuntimeModel: "mock-smart"}, + }, + } + mock := newRestartMockAgentctlServer(t, false, false) + mock.modelState = &streams.SessionModelState{ + CurrentModelID: "mock-fast", + Models: []streams.SessionModelInfo{{ModelID: "mock-fast"}, {ModelID: "mock-smart"}}, + } + + client := createTestClient(t, mock.server.URL) + t.Cleanup(client.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + require.NoError(t, client.StreamUpdates(ctx, func(agentctl.AgentEvent) {}, nil, nil)) + + exec := &AgentExecution{ + ID: "exec-model-reset-response", + TaskID: "task-1", + SessionID: "session-1", + AgentProfileID: "profile-1", + ACPSessionID: "old-session", + AgentCommand: "auggie --model test", + Status: v1.AgentStatusRunning, + WorkspacePath: "/workspace", + sessionInitialized: true, + agentctl: client, + promptDoneCh: make(chan PromptCompletionSignal, 1), + } + exec.SetModelState(&CachedModelState{CurrentModelID: "mock-fast"}) + require.NoError(t, mgr.executionStore.Add(exec)) + + require.NoError(t, mgr.ResetAgentContext(ctx, exec.ID)) + require.Equal(t, []string{"mock-smart"}, mock.getSetModelIDs(), - "reset must restore the persisted effective model, not the fresh-session default") + "reset must use the synchronous session catalog before the stream event is dispatched") } // TestManager_RestartAgentProcess_PrefersPersistedModeOverStaleCache is the diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go index ec06ef4bd8..c3277d42da 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go @@ -140,8 +140,11 @@ func (m *Manager) createReboundACPSession(ctx context.Context, execution *AgentE } previousMode := execution.GetModeState() previousModel := execution.GetModelState() + previousModelID := m.effectiveSessionModelForReset(ctx, execution) + execution.SetModelState(nil) newSessionID, err := execution.agentctl.NewSession(ctx, execution.WorkspacePath, mcpServers) if err != nil { + execution.SetModelState(previousModel) return fmt.Errorf("create ACP session in rebound workspace: %w", err) } execution.ACPSessionID = newSessionID @@ -149,7 +152,12 @@ func (m *Manager) createReboundACPSession(ctx context.Context, execution *AgentE execution.resumeContextInjected = false execution.needsResumeContext = m.historyManager != nil && m.historyManager.HasHistory(execution.SessionID) - m.reapplyReboundSessionConfig(ctx, execution, newSessionID, previousModel, previousMode) + if !cacheFreshSessionModelState(execution) && previousModelID != "" { + waitForFreshSessionModelState(ctx, m.logger, execution) + } + if err := m.reapplyReboundSessionConfig(ctx, execution, newSessionID, previousModel, previousModelID, previousMode); err != nil { + return err + } if m.eventPublisher != nil { m.eventPublisher.PublishACPSessionCreated(execution, newSessionID) } @@ -161,14 +169,22 @@ func (m *Manager) reapplyReboundSessionConfig( execution *AgentExecution, sessionID string, model *CachedModelState, + modelID string, mode *CachedModeState, -) { - if model != nil && model.CurrentModelID != "" { - if err := execution.agentctl.SetModel(ctx, model.CurrentModelID); err != nil { +) error { + if modelID != "" { + policy := m.resolveStartModelPolicy(ctx, execution.AgentProfileID) + policy.Model = modelID + decision, err := applyStartModelPolicy(ctx, m.logger, execution.agentctl, execution.GetModelState(), policy) + if err != nil { m.logger.Warn("failed to re-apply model after workspace rebind", zap.String("execution_id", execution.ID), - zap.String("model", model.CurrentModelID), + zap.String("model", modelID), zap.Error(err)) + return err + } + if decision.Warning && m.sessionManager != nil { + m.sessionManager.publishModelSelectionWarningEvent(execution, sessionID, decision) } } if model != nil { @@ -187,6 +203,7 @@ func (m *Manager) reapplyReboundSessionConfig( } } m.reapplySessionModeAfterReset(ctx, execution, sessionID, mode) + return nil } func waitForReboundAgentReady(ctx context.Context, execution *AgentExecution) error { diff --git a/apps/backend/internal/agent/runtime/lifecycle/portable_config_uploader.go b/apps/backend/internal/agent/runtime/lifecycle/portable_config_uploader.go new file mode 100644 index 0000000000..684d16c495 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/portable_config_uploader.go @@ -0,0 +1,249 @@ +package lifecycle + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/remoteconfig" + "github.com/kandev/kandev/internal/common/logger" +) + +// selectedPortableConfigBundleIDs decodes the executor-profile value without +// accepting file paths or file data from task metadata. +func selectedPortableConfigBundleIDs(metadata map[string]interface{}) []string { + if metadata == nil { + return nil + } + raw, ok := metadata[MetadataKeyAgentConfigBundles] + if !ok { + return nil + } + if value, ok := raw.(string); ok { + var ids []string + if json.Unmarshal([]byte(value), &ids) == nil { + return ids + } + return nil + } + if values, ok := raw.([]string); ok { + return append([]string(nil), values...) + } + values, ok := raw.([]interface{}) + if !ok { + return nil + } + ids := make([]string, 0, len(values)) + for _, value := range values { + if id, ok := value.(string); ok && id != "" { + ids = append(ids, id) + } + } + return ids +} + +const ( + portableConfigMaxFileBytes int64 = 1 << 20 + portableConfigMaxLaunchBytes int64 = 4 << 20 + portableConfigReasonUnsafeSourcePath = "unsafe_source_path" +) + +const portableConfigFileMode os.FileMode = 0o600 + +// PortableConfigWarning describes an optional configuration copy that was +// skipped. It deliberately contains paths relative to the host home only. +type PortableConfigWarning struct { + BundleID string `json:"bundle_id"` + SourcePath string `json:"source_path,omitempty"` + TargetPath string `json:"target_path,omitempty"` + Reason string `json:"reason"` +} + +func reportPortableConfigWarnings(onProgress PrepareProgressCallback, warnings []PortableConfigWarning) { + if onProgress == nil || len(warnings) == 0 { + return + } + step := beginStep("Copy agent configuration") + details := make([]string, 0, len(warnings)) + for _, warning := range warnings { + details = append(details, fmt.Sprintf("%s: %s", warning.SourcePath, warning.Reason)) + } + step.Warning = details[0] + if len(details) > 1 { + step.WarningDetail = strings.Join(details, "\n") + } + completeStepSuccess(&step) + onProgress(step, 0, 0) +} + +// UploadPortableConfigBundles copies selected, backend-declared configuration +// files from the host home into an executor home. Every copy is optional: +// invalid, missing, oversized, or failed files produce warnings and the next +// file continues. The raw data never enters metadata or the browser. +func UploadPortableConfigBundles( + ctx context.Context, + uploader FileUploader, + ag agents.Agent, + selectedIDs []string, + targetHomeDir string, + log *logger.Logger, +) []PortableConfigWarning { + if ag == nil || uploader == nil || len(selectedIDs) == 0 || targetHomeDir == "" { + return nil + } + hostHome, err := os.UserHomeDir() + if err != nil || hostHome == "" { + return []PortableConfigWarning{{Reason: "host_home_unavailable"}} + } + catalog := remoteconfig.BuildCatalogForHost([]agents.Agent{ag}, runtime.GOOS, hostHome) + warnings := make([]PortableConfigWarning, 0) + var copiedBytes int64 + seen := make(map[string]struct{}, len(selectedIDs)) + for _, bundleID := range selectedIDs { + if _, exists := seen[bundleID]; exists { + continue + } + seen[bundleID] = struct{}{} + bundle, ok := catalog.FindBundle(bundleID) + if !ok { + warnings = append(warnings, PortableConfigWarning{BundleID: bundleID, Reason: "unknown_bundle"}) + continue + } + for _, file := range bundle.Files { + warning, copied, size := uploadPortableConfigFile( + ctx, uploader, hostHome, targetHomeDir, bundle.ID, file, copiedBytes, + ) + if warning.Reason != "" { + warnings = append(warnings, warning) + if log != nil { + log.Warn("portable agent configuration copy skipped", + zap.String("bundle_id", warning.BundleID), + zap.String("source_path", warning.SourcePath), + zap.String("reason", warning.Reason)) + } + } + if copied { + copiedBytes += size + } + } + } + return warnings +} + +func uploadPortableConfigFile( + ctx context.Context, + uploader FileUploader, + hostHome, targetHome, bundleID string, + file remoteconfig.File, + copiedBytes int64, +) (PortableConfigWarning, bool, int64) { + warning := PortableConfigWarning{ + BundleID: bundleID, + SourcePath: file.SourcePath, + TargetPath: file.TargetPath, + } + if !isSafePortableRelativePath(file.SourcePath) { + warning.Reason = portableConfigReasonUnsafeSourcePath + return warning, false, 0 + } + if !isSafePortableRelativePath(file.TargetPath) { + warning.Reason = "unsafe_target_path" + return warning, false, 0 + } + sourcePath, err := containedPath(hostHome, filepath.Join(hostHome, filepath.FromSlash(file.SourcePath))) + if err != nil { + warning.Reason = portableConfigReasonUnsafeSourcePath + return warning, false, 0 + } + targetPath, err := containedPath(targetHome, filepath.Join(targetHome, filepath.FromSlash(file.TargetPath))) + if err != nil { + warning.Reason = "unsafe_target_path" + return warning, false, 0 + } + info, reason := portableConfigSourceInfo(hostHome, sourcePath) + if reason != "" { + warning.Reason = reason + return warning, false, 0 + } + if info.Size() > portableConfigMaxFileBytes { + warning.Reason = "file_too_large" + return warning, false, 0 + } + if copiedBytes > portableConfigMaxLaunchBytes-info.Size() { + warning.Reason = "launch_size_limit" + return warning, false, 0 + } + data, err := readPortableConfigFile(sourcePath, info) + if err != nil { + warning.Reason = "source_read_failed" + return warning, false, 0 + } + if err := uploader.WriteFile(ctx, targetPath, data, portableConfigFileMode); err != nil { + warning.Reason = "target_write_failed" + return warning, false, 0 + } + return PortableConfigWarning{}, true, int64(len(data)) +} + +func portableConfigSourceInfo(root, path string) (os.FileInfo, string) { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, portableConfigReasonUnsafeSourcePath + } + + current := filepath.Clean(root) + var info os.FileInfo + for _, part := range strings.Split(rel, string(filepath.Separator)) { + if part == "" || part == "." { + continue + } + current = filepath.Join(current, part) + info, err = os.Lstat(current) + if err != nil { + return nil, "source_missing" + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, "source_symlink" + } + } + if info == nil || !info.Mode().IsRegular() { + return nil, "source_not_regular" + } + return info, "" +} + +func readPortableConfigFile(path string, expectedInfo os.FileInfo) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + actualInfo, err := file.Stat() + if err != nil || !actualInfo.Mode().IsRegular() || !os.SameFile(expectedInfo, actualInfo) { + return nil, fmt.Errorf("file changed while opening") + } + data, err := io.ReadAll(io.LimitReader(file, portableConfigMaxFileBytes+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > portableConfigMaxFileBytes || int64(len(data)) != expectedInfo.Size() { + return nil, fmt.Errorf("file changed while reading") + } + return data, nil +} + +func isSafePortableRelativePath(path string) bool { + if path == "" || filepath.IsAbs(filepath.FromSlash(path)) { + return false + } + clean := filepath.Clean(filepath.FromSlash(path)) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator)) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/portable_config_uploader_test.go b/apps/backend/internal/agent/runtime/lifecycle/portable_config_uploader_test.go new file mode 100644 index 0000000000..f721275a6f --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/portable_config_uploader_test.go @@ -0,0 +1,244 @@ +package lifecycle + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" +) + +type recordingPortableConfigUploader struct { + writes []portableConfigWrite +} + +type portableConfigWrite struct { + path string + data []byte + mode os.FileMode +} + +func (u *recordingPortableConfigUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error { + u.writes = append(u.writes, portableConfigWrite{path: path, data: append([]byte(nil), data...), mode: mode}) + return nil +} + +type portableConfigTestAgent struct { + agents.Agent + config *agents.PortableConfig +} + +func (a portableConfigTestAgent) PortableConfig() *agents.PortableConfig { return a.config } + +func TestUploadPortableConfigBundlesCopiesSelectedFilesWithPrivateMode(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFile(t, home, ".agent/settings.json", []byte("settings")) + writeFile(t, home, ".agent/optional.json", []byte("optional")) + + ag := portableConfigTestAgent{ + Agent: agents.NewMockAgent(), + config: &agents.PortableConfig{Bundles: []agents.PortableConfigBundle{{ + ID: "test.config", Label: "Test config", Files: []agents.PortableConfigFile{ + {SourcePaths: map[string]string{"linux": ".agent/settings.json"}, TargetPath: ".agent/settings.json"}, + {SourcePaths: map[string]string{"linux": ".agent/optional.json"}, TargetPath: ".agent/optional.json"}, + }, + }}}, + } + uploader := &recordingPortableConfigUploader{} + + warnings := UploadPortableConfigBundles( + context.Background(), uploader, ag, []string{"test.config"}, filepath.Join(t.TempDir(), "remote-home"), newSeederTestLogger(t), + ) + if len(warnings) != 0 { + t.Fatalf("warnings = %+v, want none", warnings) + } + if len(uploader.writes) != 2 { + t.Fatalf("writes = %+v, want two selected files", uploader.writes) + } + for _, write := range uploader.writes { + if write.mode != 0o600 { + t.Errorf("mode for %s = %o, want 600", write.path, write.mode) + } + } +} + +func TestUploadPortableConfigBundlesRejectsUnsafeAndOversizedFiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFile(t, home, ".agent/link.json", []byte("link")) + if err := os.Symlink(filepath.Join(home, ".agent", "link.json"), filepath.Join(home, ".agent", "symlink.json")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + writeFile(t, home, ".agent/large.json", make([]byte, portableConfigMaxFileBytes+1)) + + ag := portableConfigTestAgent{ + Agent: agents.NewMockAgent(), + config: &agents.PortableConfig{Bundles: []agents.PortableConfigBundle{{ + ID: "unsafe.config", Label: "Unsafe config", Files: []agents.PortableConfigFile{ + {SourcePaths: map[string]string{"linux": ".agent/symlink.json"}, TargetPath: ".agent/symlink.json"}, + {SourcePaths: map[string]string{"linux": ".agent/large.json"}, TargetPath: ".agent/large.json"}, + {SourcePaths: map[string]string{"linux": ".agent/link.json"}, TargetPath: "../escape.json"}, + }, + }}}, + } + uploader := &recordingPortableConfigUploader{} + warnings := UploadPortableConfigBundles( + context.Background(), uploader, ag, []string{"unsafe.config"}, filepath.Join(t.TempDir(), "remote-home"), newSeederTestLogger(t), + ) + if len(uploader.writes) != 0 { + t.Fatalf("unsafe files were copied: %+v", uploader.writes) + } + if len(warnings) != 3 { + t.Fatalf("warnings = %+v, want one for each rejected file", warnings) + } +} + +func TestUploadPortableConfigBundlesWarnsAndContinuesForMissingFiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFile(t, home, ".agent/present.json", []byte("present")) + ag := portableConfigTestAgent{ + Agent: agents.NewMockAgent(), + config: &agents.PortableConfig{Bundles: []agents.PortableConfigBundle{{ + ID: "partial.config", Label: "Partial config", Files: []agents.PortableConfigFile{ + {SourcePaths: map[string]string{"linux": ".agent/present.json"}, TargetPath: ".agent/present.json"}, + {SourcePaths: map[string]string{"linux": ".agent/missing.json"}, TargetPath: ".agent/missing.json"}, + }, + }}}, + } + uploader := &recordingPortableConfigUploader{} + warnings := UploadPortableConfigBundles( + context.Background(), uploader, ag, []string{"partial.config"}, filepath.Join(t.TempDir(), "remote-home"), newSeederTestLogger(t), + ) + if len(uploader.writes) != 1 { + t.Fatalf("writes = %+v, want present file copied", uploader.writes) + } + if len(warnings) != 1 { + t.Fatalf("warnings = %+v, want missing-file warning", warnings) + } +} + +func TestReadPortableConfigFileRejectsPathReplacement(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "settings.json") + writeFile(t, root, "settings.json", []byte("original")) + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("lstat source: %v", err) + } + replacement := filepath.Join(root, "replacement.json") + writeFile(t, root, "replacement.json", []byte("replacement")) + if err := os.Remove(path); err != nil { + t.Fatalf("remove source: %v", err) + } + if err := os.Symlink(replacement, path); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + if _, err := readPortableConfigFile(path, info); err == nil { + t.Fatal("expected replaced source path to be rejected") + } +} + +func TestUploadPortableConfigBundlesRejectsSymlinkedDestinations(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeFile(t, home, ".agent/settings.json", []byte("settings")) + + for _, tc := range []struct { + name string + targetPath string + prepare func(t *testing.T, root, outside string) string + }{ + { + name: "final file", + targetPath: ".agent/settings.json", + prepare: func(t *testing.T, root, outside string) string { + t.Helper() + target := filepath.Join(root, ".agent", "settings.json") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir target parent: %v", err) + } + outsideTarget := filepath.Join(outside, "settings.json") + writeFile(t, outside, "settings.json", []byte("outside")) + if err := os.Symlink(outsideTarget, target); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + return outsideTarget + }, + }, + { + name: "parent directory", + targetPath: ".redirect/settings.json", + prepare: func(t *testing.T, root, outside string) string { + t.Helper() + parent := filepath.Join(root, ".redirect") + outsideTarget := filepath.Join(outside, "settings.json") + writeFile(t, outside, "settings.json", []byte("outside")) + if err := os.Symlink(outside, parent); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + return outsideTarget + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + outsideTarget := tc.prepare(t, root, outside) + ag := portableConfigTestAgent{ + Agent: agents.NewMockAgent(), + config: &agents.PortableConfig{Bundles: []agents.PortableConfigBundle{{ + ID: "test.config", Label: "Test config", Files: []agents.PortableConfigFile{{ + SourcePaths: map[string]string{ + "linux": ".agent/settings.json", + "darwin": ".agent/settings.json", + "windows": ".agent/settings.json", + }, + TargetPath: tc.targetPath, + }}, + }}}, + } + + warnings := UploadPortableConfigBundles( + context.Background(), localFileUploader{root: root}, ag, + []string{"test.config"}, root, newSeederTestLogger(t), + ) + if len(warnings) != 1 || warnings[0].Reason != "target_write_failed" { + t.Fatalf("warnings = %+v, want target_write_failed", warnings) + } + contents, err := os.ReadFile(outsideTarget) + if err != nil { + t.Fatalf("read outside target: %v", err) + } + if string(contents) != "outside" { + t.Fatalf("outside target = %q, want unchanged content", contents) + } + }) + } +} + +func TestReportPortableConfigWarningsPublishesOptionalCopyWarning(t *testing.T) { + var got PrepareStep + reportPortableConfigWarnings(func(step PrepareStep, _, _ int) { + got = step + }, []PortableConfigWarning{ + {BundleID: "test.config", SourcePath: ".agent/missing.json", Reason: "source_missing"}, + {BundleID: "test.config", SourcePath: ".agent/large.json", Reason: "file_too_large"}, + }) + + if got.Name != "Copy agent configuration" { + t.Fatalf("step name = %q, want copy step", got.Name) + } + if got.Status != PrepareStepCompleted { + t.Fatalf("step status = %q, want completed", got.Status) + } + if got.Warning == "" || got.WarningDetail == "" { + t.Fatalf("step = %+v, want warning and warning detail", got) + } + if got.Output != "" { + t.Fatalf("step output = %q, must not contain copied file data", got.Output) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer.go b/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer.go new file mode 100644 index 0000000000..4f1073dba7 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer.go @@ -0,0 +1,20 @@ +package lifecycle + +import ( + "io" + "os" +) + +func writeAllFile(file *os.File, data []byte) error { + for len(data) > 0 { + written, err := file.Write(data) + if err != nil { + return err + } + if written == 0 { + return io.ErrShortWrite + } + data = data[written:] + } + return nil +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer_unix.go b/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer_unix.go new file mode 100644 index 0000000000..345cca7755 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer_unix.go @@ -0,0 +1,120 @@ +//go:build aix || android || darwin || dragonfly || freebsd || illumos || ios || linux || netbsd || openbsd || solaris + +package lifecycle + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +const safeFileDirectoryFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC | unix.O_NOFOLLOW + +// writeFileWithinRoot walks the destination from an opened root directory. +// O_NOFOLLOW is applied to every directory and the final file so an executor +// cannot redirect a managed session write through a stale symlink. +func writeFileWithinRoot(root, path string, data []byte, mode os.FileMode) error { + _, parts, err := safeFilePathParts(root, path) + if err != nil { + return err + } + currentFD, directoryFDs, err := openSafeFileDirectories(root, parts) + if err != nil { + return err + } + defer func() { + for index := len(directoryFDs) - 1; index >= 0; index-- { + _ = unix.Close(directoryFDs[index]) + } + }() + return writeSafeFile(currentFD, path, parts[len(parts)-1], data, mode) +} + +func safeFilePathParts(root, path string) (string, []string, error) { + cleanPath, err := containedPath(root, path) + if err != nil { + return "", nil, err + } + rel, err := filepath.Rel(filepath.Clean(root), cleanPath) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", nil, fmt.Errorf("path %q is not a file below session root %q", path, root) + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) == 0 || parts[len(parts)-1] == "" { + return "", nil, fmt.Errorf("path %q has no file name", path) + } + return cleanPath, parts, nil +} + +func openSafeFileDirectories(root string, parts []string) (currentFD int, directoryFDs []int, err error) { + rootFD, err := unix.Open(filepath.Clean(root), safeFileDirectoryFlags, 0) + if err != nil { + return 0, nil, fmt.Errorf("open session root %q: %w", root, err) + } + directoryFDs = []int{rootFD} + currentFD = rootFD + defer func() { + if err == nil { + return + } + for index := len(directoryFDs) - 1; index >= 0; index-- { + _ = unix.Close(directoryFDs[index]) + } + }() + for _, part := range parts[:len(parts)-1] { + nextFD, openErr := openSafeFileDirectory(currentFD, part) + if openErr != nil { + return 0, nil, openErr + } + directoryFDs = append(directoryFDs, nextFD) + currentFD = nextFD + } + return currentFD, directoryFDs, nil +} + +func openSafeFileDirectory(parentFD int, name string) (int, error) { + nextFD, err := unix.Openat(parentFD, name, safeFileDirectoryFlags, 0) + if errors.Is(err, unix.ENOENT) { + if mkdirErr := unix.Mkdirat(parentFD, name, 0o755); mkdirErr != nil && !errors.Is(mkdirErr, unix.EEXIST) { + return 0, fmt.Errorf("create session directory %q: %w", name, mkdirErr) + } + nextFD, err = unix.Openat(parentFD, name, safeFileDirectoryFlags, 0) + } + if err != nil { + return 0, fmt.Errorf("open session directory %q: %w", name, err) + } + return nextFD, nil +} + +func writeSafeFile(parentFD int, path, name string, data []byte, mode os.FileMode) error { + fileFD, err := unix.Openat( + parentFD, + name, + unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC|unix.O_CLOEXEC|unix.O_NOFOLLOW, + uint32(mode.Perm()), + ) + if err != nil { + return fmt.Errorf("open session file %q: %w", path, err) + } + file := os.NewFile(uintptr(fileFD), "") + if file == nil { + _ = unix.Close(fileFD) + return fmt.Errorf("open session file %q: invalid file", path) + } + if err := unix.Fchmod(fileFD, uint32(mode.Perm())); err != nil { + _ = file.Close() + return fmt.Errorf("chmod session file %q: %w", path, err) + } + if err := writeAllFile(file, data); err != nil { + _ = file.Close() + return fmt.Errorf("write session file %q: %w", path, err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close session file %q: %w", path, err) + } + return nil +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer_windows.go b/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer_windows.go new file mode 100644 index 0000000000..26dc0281cf --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/safe_file_writer_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package lifecycle + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func writeFileWithinRoot(root, path string, data []byte, mode os.FileMode) error { + cleanPath, err := containedPath(root, path) + if err != nil { + return err + } + if err := rejectExistingSymlinkComponents(root, cleanPath); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(cleanPath), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(cleanPath), err) + } + file, err := os.OpenFile(cleanPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm()) + if err != nil { + return fmt.Errorf("open session file %q: %w", path, err) + } + defer func() { _ = file.Close() }() + if err := writeAllFile(file, data); err != nil { + return fmt.Errorf("write session file %q: %w", path, err) + } + return nil +} + +func rejectExistingSymlinkComponents(root, path string) error { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return err + } + current := filepath.Clean(root) + for _, part := range strings.Split(rel, string(filepath.Separator)) { + if part == "" || part == "." { + continue + } + current = filepath.Join(current, part) + info, statErr := os.Lstat(current) + if os.IsNotExist(statErr) { + continue + } + if statErr != nil { + return statErr + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("session path component %q is a symlink", current) + } + } + return nil +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go index 21666b6091..58c9c4be67 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session.go @@ -2,6 +2,8 @@ package lifecycle import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "sort" @@ -366,24 +368,24 @@ func (sm *SessionManager) InitializeAndPromptWithLayers( } execution.ACPSessionID = result.SessionID + if !cacheFreshSessionModelState(execution) && (profileModel != "" || runtimeModel != "") { + waitForFreshSessionModelState(ctx, sm.logger, execution) + } providerDefaultConfig := execution.GetModelState() - // No-silent-model-fallback: decide the effective model up front under the - // profile's policy (a gone model fails the launch explicitly unless a - // fallback model or the legacy auto-fallback toggle is configured), then - // hand the decided model to the layers so it is applied exactly once. - // Previously SetModel failures were best-effort — the session continued - // on the provider default, which is the implicit switch this feature - // eliminates. The gate is the effective model (which includes the - // persisted runtime override), not the profile's start model: a profile - // with no model and a session with a runtime model must still enforce - // strict failure / the configured fallback. + // Decide the effective model up front under the executor-authoritative + // policy, then hand the decided model to the layers so it is applied once. + // The gate is the effective model (which includes the persisted runtime + // override), not only the profile's start model. effectiveModel := sm.applyStartModelPolicyToEffectiveModel( ctx, execution, result.SessionID, profileModel, runtimeModel, startModelPolicy, ) if effectiveModel.err != nil { return effectiveModel.err } + if effectiveModel.decision.Warning { + sm.publishModelSelectionWarningEvent(execution, result.SessionID, effectiveModel.decision) + } // The layers apply the policy-decided model; the runtime override is // neutralized because the decision already accounted for it. When the // policy ran it already applied the model via SetModel — the layers must @@ -426,13 +428,8 @@ func (sm *SessionManager) InitializeAndPromptWithLayers( // applyStartModelPolicyToEffectiveModel resolves the effective model (profile // start model, overridden by the persisted runtime model) and applies the -// profile's no-silent-model-fallback policy to it: strict profiles fail a gone -// model explicitly, fallback-model profiles switch to the configured fallback -// (publishing the fallback event), and auto-fallback keeps legacy behavior. -// Returns the decided model (fallback when one was applied), the model -// actually applied by the policy, whether the policy handled model selection, -// or the policy error. The caller neutralizes the runtime override afterwards -// because the decision already accounted for it. +// executor-authoritative policy. The caller neutralizes the runtime override +// afterwards because the decision already accounted for it. func (sm *SessionManager) applyStartModelPolicyToEffectiveModel( ctx context.Context, execution *AgentExecution, @@ -443,6 +440,7 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel( model string appliedModel string handled bool + decision ModelSelectionDecision err error }) { effective.model = profileModel @@ -457,7 +455,7 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel( // profile layers must not retry an outcome that was already handled. effective.handled = true startModelPolicy.Model = effective.model - appliedModel, usingFallback, policyErr := applyStartModelPolicy( + decision, policyErr := applyStartModelPolicy( ctx, sm.logger, execution.agentctl, execution.GetModelState(), startModelPolicy, ) @@ -468,10 +466,13 @@ func (sm *SessionManager) applyStartModelPolicyToEffectiveModel( effective.err = policyErr return effective } - effective.appliedModel = appliedModel - if usingFallback { - sm.publishModelFallbackEvent(execution, acpSessionID, appliedModel) - effective.model = appliedModel + effective.decision = decision + if decision.Outcome == ModelSelectionOutcomeApplied || + decision.Outcome == ModelSelectionOutcomeExplicitFallback { + effective.appliedModel = decision.EffectiveModel + } + if decision.Outcome == ModelSelectionOutcomeExplicitFallback { + effective.model = decision.EffectiveModel } return effective } @@ -700,6 +701,48 @@ func (sm *SessionManager) publishModelFallbackEvent( }) } +// publishModelSelectionWarningEvent emits the provider-neutral decision and +// keeps the legacy fallback event as a compatibility projection for clients +// that still listen for session_model_fallback. +func (sm *SessionManager) publishModelSelectionWarningEvent( + execution *AgentExecution, + acpSessionID string, + decision ModelSelectionDecision, +) { + if sm.eventPublisher == nil || execution == nil || !decision.Warning { + return + } + decisionIDBytes := sha256.Sum256([]byte(strings.Join([]string{ + execution.ID, + acpSessionID, + decision.RequestedModel, + decision.Reason, + decision.EffectiveModel, + }, "|"))) + warning := &streams.ModelSelectionWarning{ + Kind: "model_selection_warning", + DecisionID: hex.EncodeToString(decisionIDBytes[:]), + Reason: decision.Reason, + RequestedModel: decision.RequestedModel, + EffectiveModel: decision.EffectiveModel, + AgentID: execution.AgentID, + ExecutorType: string(execution.RuntimeName), + ExecutorProfileID: execution.metadataString(MetadataKeyExecutorProfileID), + } + if decision.Outcome == ModelSelectionOutcomeExplicitFallback { + warning.FallbackModel = decision.EffectiveModel + } + sm.eventPublisher.PublishAgentStreamEvent(execution, agentctl.AgentEvent{ + Type: streams.EventTypeSessionModelSelectionWarning, + SessionID: acpSessionID, + CurrentModelID: warning.EffectiveModel, + ModelSelectionWarning: warning, + }) + if warning.FallbackModel != "" { + sm.publishModelFallbackEvent(execution, acpSessionID, warning.FallbackModel) + } +} + func (sm *SessionManager) publishWorkflowSessionConfigFailures( execution *AgentExecution, acpSessionID string, diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_test.go index 7881553003..4def3e339d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session_test.go @@ -239,14 +239,10 @@ func createTestClient(t *testing.T, serverURL string) *agentctl.Client { // --- Tests --- -// TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel verifies the -// start-model policy is applied to the EFFECTIVE model (profile start model or -// persisted runtime override) and a strict profile fails the launch explicitly -// when that model is unavailable. This is the regression guard for the -// no-silent model-fallback gate: an unavailable model must never silently -// continue on the provider default, and a failed launch must not look -// initialized. -func TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel(t *testing.T) { +// TestInitializeAndPromptWithLayers_UnadvertisedModelUsesProviderDefault +// verifies the executor-authoritative policy for both the profile model and a +// persisted runtime override. +func TestInitializeAndPromptWithLayers_UnadvertisedModelUsesProviderDefault(t *testing.T) { for _, tc := range []struct { name string profileModel string @@ -266,7 +262,8 @@ func TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel(t *testing.T) OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {}, }, nil, stopCh) cleanupStreamManager(t, stopCh, streamMgr) - sm.SetDependencies(NewEventPublisher(&MockEventBusWithTracking{}, log), streamMgr, nil, nil) + eventBus := &MockEventBusWithTracking{} + sm.SetDependencies(NewEventPublisher(eventBus, log), streamMgr, nil, nil) client := createTestClient(t, mock.server.URL) defer client.Close() @@ -300,14 +297,29 @@ func TestInitializeAndPromptWithLayers_StrictPolicyFailsGoneModel(t *testing.T) tc.runtimeModel, "", nil, StartModelPolicy{}, ) - if err == nil { - t.Fatal("expected InitializeAndPromptWithLayers to fail for an unavailable model") + if err != nil { + t.Fatalf("unadvertised model should not fail launch: %v", err) } - if !strings.Contains(err.Error(), "claude-gone") { - t.Errorf("error should name the unavailable model, got %q", err.Error()) + if !execution.sessionInitialized { + t.Error("provider-default continuation must be marked initialized") } - if execution.sessionInitialized { - t.Error("a failed launch must not be marked initialized") + for _, action := range mock.getActionLog() { + if action == "agent.session.set_model" { + t.Error("unadvertised model must not send a SetModel request") + } + } + var warning *AgentStreamEventPayload + for _, event := range eventBus.getStreamEvents() { + if event.Data != nil && event.Data.ModelSelectionWarning != nil { + warning = &event + break + } + } + if warning == nil { + t.Fatal("expected a model-selection warning event") + } + if warning.Data.ModelSelectionWarning.RequestedModel != "claude-gone" { + t.Errorf("warning requested model = %q, want claude-gone", warning.Data.ModelSelectionWarning.RequestedModel) } }) } @@ -594,7 +606,6 @@ func TestInitializeAndPrompt_AppliesProfileConfigOptions(t *testing.T) { actions := mock.getActionLog() for _, want := range []string{ - "agent.session.set_model", "agent.session.set_mode", "agent.session.set_config_option", } { diff --git a/apps/backend/internal/agent/runtime/lifecycle/start_model.go b/apps/backend/internal/agent/runtime/lifecycle/start_model.go index 960bae235c..4606acec5f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/start_model.go +++ b/apps/backend/internal/agent/runtime/lifecycle/start_model.go @@ -2,30 +2,60 @@ package lifecycle import ( "context" - "errors" "fmt" - "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/agentctl/sessionmodel" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) // StartModelPolicy carries the profile's model-selection settings for the -// no-silent-model-fallback policy applied at session start (and when a -// context reset re-applies the effective model to a fresh ACP session). +// executor-authoritative policy applied at session start and when a fresh ACP +// session is created. type StartModelPolicy struct { // Model is the configured start model (ACP model ID). Empty means "use - // the agent's default" — nothing is applied. + // the agent's default" and does not produce a selection decision. Model string // FallbackModel is the optional single model to switch to when Model is - // unavailable. Ignored when AutoFallback is enabled. + // not advertised. It is used only when the executor advertises it. FallbackModel string - // AutoFallback restores the legacy best-effort behavior: SetModel - // failures log and the session continues on the provider default. + // AutoFallback keeps selection best-effort after an advertised model + // rejects SetModel. It does not bypass the executor catalog. AutoFallback bool } +// ModelSelectionOutcome describes the executor-authoritative result. +type ModelSelectionOutcome string + +const ( + ModelSelectionOutcomeNone ModelSelectionOutcome = "" + ModelSelectionOutcomeApplied ModelSelectionOutcome = "applied" + ModelSelectionOutcomeExplicitFallback ModelSelectionOutcome = "explicit_fallback" + ModelSelectionOutcomeProviderDefault ModelSelectionOutcome = "provider_default" +) + +// Reasons are stable values stored in warning metadata and consumed by the +// frontend. Keep them provider-neutral. +const ( + ModelSelectionReasonRequestedNotAdvertised = "requested_not_advertised" + ModelSelectionReasonFallbackNotAdvertised = "fallback_not_advertised" + ModelSelectionReasonCatalogEmpty = "catalog_empty" + ModelSelectionReasonSelectionUnsupported = "selection_unsupported" + ModelSelectionReasonSelectionFailedAutoFallback = "selection_failed_auto_fallback" +) + +// ModelSelectionDecision is the single model-selection result shared by +// initial launch, context reset, and workspace rebind. +type ModelSelectionDecision struct { + RequestedModel string `json:"requested_model,omitempty"` + FallbackModel string `json:"fallback_model,omitempty"` + EffectiveModel string `json:"effective_model,omitempty"` + Outcome ModelSelectionOutcome `json:"outcome"` + Reason string `json:"reason,omitempty"` + Warning bool `json:"warning"` + SetModelCalled bool `json:"set_model_called"` +} + // modelApplier abstracts the agentctl surface applyStartModelPolicy needs so // tests can script SetModel without a live agentctl server. type modelApplier interface { @@ -33,7 +63,7 @@ type modelApplier interface { } // advertisedModelIDs returns the session's currently advertised model IDs. -// An empty slice means the agent has not advertised a model list (unknown). +// An empty slice means the executor has no usable model catalog. func advertisedModelIDs(state *CachedModelState) []string { if state == nil { return nil @@ -45,8 +75,6 @@ func advertisedModelIDs(state *CachedModelState) []string { return ids } -// containsModel reports whether ids contains id. - func containsModel(ids []string, id string) bool { for _, existing := range ids { if existing == id { @@ -56,91 +84,104 @@ func containsModel(ids []string, id string) bool { return false } -// applyFallbackModel applies the policy's fallback model after the start -// model proved unavailable (gone from the advertised list or rejected by -// SetModel). Shared by both failure branches so the log line, error -// wrapping, and return shape stay in sync. reason ("is unavailable" / -// "failed") distinguishes the failure context in the error text. -func applyFallbackModel( - ctx context.Context, - log *logger.Logger, - applier modelApplier, - policy StartModelPolicy, - reason string, -) (string, bool, error) { - if err := applier.SetModel(ctx, policy.FallbackModel); err != nil { - return "", false, fmt.Errorf( - "start model %q %s and fallback model %q failed to apply: %w", - policy.Model, reason, policy.FallbackModel, err) +func providerDefaultDecision(state *CachedModelState, policy StartModelPolicy, reason string) ModelSelectionDecision { + decision := ModelSelectionDecision{ + RequestedModel: policy.Model, + FallbackModel: policy.FallbackModel, + Outcome: ModelSelectionOutcomeProviderDefault, + Reason: reason, + Warning: policy.Model != "", } - log.Info("start model unavailable, using fallback model", - zap.String("start_model", policy.Model), - zap.String("fallback_model", policy.FallbackModel)) - return policy.FallbackModel, true, nil + if state != nil { + decision.EffectiveModel = state.CurrentModelID + } + return decision } -// applyStartModelPolicy enforces the no-silent-model-fallback rules when -// applying the profile's start model to a session: -// -// - AutoFallback: legacy best-effort (attempt SetModel; on failure warn -// and continue on the provider default). -// - Start model gone (absent from the advertised list, when known): -// apply FallbackModel when set (returning usingFallback=true), else -// fail with the actionable model-unavailable message. -// - Start model present / list unknown: apply it; a method-not-found -// error means the agent has no model selection — continue silently; -// any other error fails explicitly. -// -// Returns the model actually applied ("" when none was applied / the agent -// default remains) and whether the fallback model was used. +// applyStartModelPolicy applies the profile model only when the executor's +// ACP catalog advertises it. If the requested model is absent, it may apply an +// explicitly configured fallback only when that fallback is also advertised. +// All other mismatches continue on the agent's current/default model and +// return a warning decision. Errors from an advertised model remain explicit, +// except for method-not-supported and legacy auto-fallback mode. func applyStartModelPolicy( ctx context.Context, log *logger.Logger, applier modelApplier, state *CachedModelState, policy StartModelPolicy, -) (applied string, usingFallback bool, err error) { +) (ModelSelectionDecision, error) { if policy.Model == "" { - return "", false, nil - } - if policy.AutoFallback { - if err := applier.SetModel(ctx, policy.Model); err != nil { - log.Warn("failed to set profile model via ACP (auto-fallback)", - zap.String("model", policy.Model), zap.Error(err)) - return "", false, nil - } - return policy.Model, false, nil + return ModelSelectionDecision{Outcome: ModelSelectionOutcomeNone}, nil } + decision := ModelSelectionDecision{ + RequestedModel: policy.Model, + FallbackModel: policy.FallbackModel, + } advertised := advertisedModelIDs(state) - if len(advertised) > 0 && !containsModel(advertised, policy.Model) { - if policy.FallbackModel != "" { - return applyFallbackModel(ctx, log, applier, policy, "is unavailable") - } - return "", false, fmt.Errorf("%s", routingerr.ModelUnavailableMessage(policy.Model)) + if len(advertised) == 0 { + return providerDefaultDecision(state, policy, ModelSelectionReasonCatalogEmpty), nil } + if !containsModel(advertised, policy.Model) { + if policy.FallbackModel != "" && containsModel(advertised, policy.FallbackModel) { + return applyAdvertisedFallback(ctx, log, applier, state, policy, decision) + } + reason := ModelSelectionReasonRequestedNotAdvertised + if policy.FallbackModel != "" { + reason = ModelSelectionReasonFallbackNotAdvertised + } + return providerDefaultDecision(state, policy, reason), nil + } + + decision.SetModelCalled = true if err := applier.SetModel(ctx, policy.Model); err != nil { if sessionmodel.IsMethodNotFound(err) { - // The agent declares no model-selection support — nothing to - // enforce; the session continues on the agent default. - log.Debug("agent does not support model selection, continuing", + log.Debug("agent does not support model selection, continuing on provider default", zap.String("model", policy.Model), zap.Error(err)) - return "", false, nil + decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported) + decision.SetModelCalled = true + return decision, nil } - // SetModel failed even though the model appeared available (the - // advertised list may not have arrived yet at session start, or the - // agent rejected the model itself). The explicit fallback exists - // exactly for this: switch to it instead of failing — but never - // fall through to the provider default. A transport or cancellation - // failure is NOT evidence the model is unavailable — switching - // models would mask a broken connection, so those fail explicitly - // like any other non-availability error. - if policy.FallbackModel != "" && - !isTransportDeadErr(err) && !errors.Is(err, context.Canceled) { - return applyFallbackModel(ctx, log, applier, policy, "failed") + if policy.AutoFallback { + log.Warn("failed to set profile model via ACP (auto-fallback)", + zap.String("model", policy.Model), zap.Error(err)) + decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionFailedAutoFallback) + decision.SetModelCalled = true + return decision, nil } - return "", false, fmt.Errorf("failed to set start model %q: %w", policy.Model, err) + return decision, fmt.Errorf("failed to set start model %q: %w", policy.Model, err) } - return policy.Model, false, nil + + decision.EffectiveModel = policy.Model + decision.Outcome = ModelSelectionOutcomeApplied + return decision, nil +} + +func applyAdvertisedFallback( + ctx context.Context, + log *logger.Logger, + applier modelApplier, + state *CachedModelState, + policy StartModelPolicy, + decision ModelSelectionDecision, +) (ModelSelectionDecision, error) { + decision.SetModelCalled = true + if err := applier.SetModel(ctx, policy.FallbackModel); err != nil { + if sessionmodel.IsMethodNotFound(err) { + decision = providerDefaultDecision(state, policy, ModelSelectionReasonSelectionUnsupported) + decision.SetModelCalled = true + return decision, nil + } + return decision, fmt.Errorf("failed to set fallback model %q: %w", policy.FallbackModel, err) + } + decision.EffectiveModel = policy.FallbackModel + decision.Outcome = ModelSelectionOutcomeExplicitFallback + decision.Reason = ModelSelectionReasonRequestedNotAdvertised + decision.Warning = true + log.Info("start model unavailable, using advertised fallback model", + zap.String("start_model", policy.Model), + zap.String("fallback_model", policy.FallbackModel)) + return decision, nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/start_model_executor_authority_test.go b/apps/backend/internal/agent/runtime/lifecycle/start_model_executor_authority_test.go new file mode 100644 index 0000000000..02a8514769 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/start_model_executor_authority_test.go @@ -0,0 +1,137 @@ +package lifecycle + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" +) + +func TestApplyStartModelPolicyExecutorAuthority(t *testing.T) { + tests := []struct { + name string + state *CachedModelState + policy StartModelPolicy + applierErrors []error + wantCalls []string + wantOutcome ModelSelectionOutcome + wantReason string + wantEffective string + wantWarning bool + wantErr string + }{ + { + name: "requested model absent does not call executor", + state: modelState("executor-default"), + policy: StartModelPolicy{Model: "host-only-model"}, + wantOutcome: ModelSelectionOutcomeProviderDefault, + wantReason: ModelSelectionReasonRequestedNotAdvertised, + wantWarning: true, + }, + { + name: "empty catalog does not call executor", + state: &CachedModelState{}, + policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback"}, + wantOutcome: ModelSelectionOutcomeProviderDefault, + wantReason: ModelSelectionReasonCatalogEmpty, + wantWarning: true, + }, + { + name: "advertised fallback is applied", + state: modelState("fallback"), + policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback"}, + wantCalls: []string{"fallback"}, + wantOutcome: ModelSelectionOutcomeExplicitFallback, + wantReason: ModelSelectionReasonRequestedNotAdvertised, + wantEffective: "fallback", + wantWarning: true, + }, + { + name: "advertised fallback method not supported keeps provider default", + state: modelState("fallback"), + policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "fallback"}, + applierErrors: []error{methodNotFoundErr()}, + wantCalls: []string{"fallback"}, + wantOutcome: ModelSelectionOutcomeProviderDefault, + wantReason: ModelSelectionReasonSelectionUnsupported, + wantWarning: true, + }, + { + name: "unadvertised fallback keeps provider default", + state: modelState("executor-default"), + policy: StartModelPolicy{Model: "host-only-model", FallbackModel: "host-fallback"}, + wantOutcome: ModelSelectionOutcomeProviderDefault, + wantReason: ModelSelectionReasonFallbackNotAdvertised, + wantWarning: true, + }, + { + name: "advertised requested model is applied", + state: modelState("requested"), + policy: StartModelPolicy{Model: "requested"}, + wantCalls: []string{"requested"}, + wantOutcome: ModelSelectionOutcomeApplied, + wantEffective: "requested", + }, + { + name: "method not supported keeps provider default", + state: modelState("requested"), + policy: StartModelPolicy{Model: "requested"}, + applierErrors: []error{methodNotFoundErr()}, + wantCalls: []string{"requested"}, + wantOutcome: ModelSelectionOutcomeProviderDefault, + wantReason: ModelSelectionReasonSelectionUnsupported, + wantWarning: true, + }, + { + name: "advertised apply error is explicit", + state: modelState("requested"), + policy: StartModelPolicy{Model: "requested", FallbackModel: "fallback"}, + applierErrors: []error{errors.New("rejected")}, + wantCalls: []string{"requested"}, + wantErr: `failed to set start model "requested"`, + }, + { + name: "auto fallback warns after advertised apply error", + state: modelState("requested"), + policy: StartModelPolicy{Model: "requested", AutoFallback: true}, + applierErrors: []error{errors.New("rejected")}, + wantCalls: []string{"requested"}, + wantOutcome: ModelSelectionOutcomeProviderDefault, + wantReason: ModelSelectionReasonSelectionFailedAutoFallback, + wantWarning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + applier := &fakeModelApplier{errs: tt.applierErrors} + decision, err := applyStartModelPolicy( + context.Background(), newPolicyTestLogger(), applier, tt.state, tt.policy, + ) + if tt.wantErr == "" && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tt.wantErr) + } + } + if !reflect.DeepEqual(applier.calls, tt.wantCalls) { + t.Errorf("SetModel calls = %v, want %v", applier.calls, tt.wantCalls) + } + if decision.Outcome != tt.wantOutcome { + t.Errorf("outcome = %q, want %q", decision.Outcome, tt.wantOutcome) + } + if decision.Reason != tt.wantReason { + t.Errorf("reason = %q, want %q", decision.Reason, tt.wantReason) + } + if decision.EffectiveModel != tt.wantEffective { + t.Errorf("effective model = %q, want %q", decision.EffectiveModel, tt.wantEffective) + } + if decision.Warning != tt.wantWarning { + t.Errorf("warning = %v, want %v", decision.Warning, tt.wantWarning) + } + }) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/start_model_test.go b/apps/backend/internal/agent/runtime/lifecycle/start_model_test.go index b4830cb156..3ecfaf21ee 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/start_model_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/start_model_test.go @@ -2,24 +2,17 @@ package lifecycle import ( "context" - "errors" - "strings" - "testing" acp "github.com/coder/acp-go-sdk" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" ) -// fakeModelApplier records SetModel calls and returns scripted errors, one -// per call; the last error repeats for any further calls. type fakeModelApplier struct { calls []string errs []error } -// SetModel records the applied model so tests can assert what the policy sent. - func (f *fakeModelApplier) SetModel(_ context.Context, modelID string) error { f.calls = append(f.calls, modelID) if len(f.errs) == 0 { @@ -32,15 +25,11 @@ func (f *fakeModelApplier) SetModel(_ context.Context, modelID string) error { return err } -// newPolicyTestLogger returns a discard logger for policy tests. - func newPolicyTestLogger() *logger.Logger { log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) return log } -// modelState builds a cached model state advertising the given model ids. - func modelState(models ...string) *CachedModelState { infos := make([]streams.SessionModelInfo, 0, len(models)) for _, id := range models { @@ -49,228 +38,6 @@ func modelState(models ...string) *CachedModelState { return &CachedModelState{Models: infos} } -// methodNotFoundErr simulates the agent declaring no model-selection support. - func methodNotFoundErr() error { return &acp.RequestError{Code: -32601, Message: "method not found"} } - -// TestApplyStartModelPolicy_StrictGoneModelFails verifies a gone start model fails the session with the actionable message. - -func TestApplyStartModelPolicy_StrictGoneModelFails(t *testing.T) { - applier := &fakeModelApplier{} - _, usingFallback, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("gpt-5"), StartModelPolicy{Model: "claude-gone"}, - ) - if err == nil { - t.Fatal("expected error for gone start model in strict mode") - } - if !strings.Contains(err.Error(), "claude-gone") { - t.Errorf("error should name the gone model, got %q", err.Error()) - } - if usingFallback { - t.Error("usingFallback should be false") - } - if len(applier.calls) != 0 { - t.Errorf("SetModel should not be called for a gone model in strict mode, got %v", applier.calls) - } -} - -// TestApplyStartModelPolicy_StrictGoneModelNoSession verifies no model is applied when the strict policy rejects a gone start model. - -func TestApplyStartModelPolicy_StrictGoneModelNoSession(t *testing.T) { - // Empty advertised list = "unknown" — fall back to SetModel result. - applier := &fakeModelApplier{errs: []error{errors.New("model rejected")}} - _, _, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - &CachedModelState{}, StartModelPolicy{Model: "claude-sonnet"}, - ) - if err == nil { - t.Fatal("expected error when SetModel fails and list is unknown") - } -} - -// TestApplyStartModelPolicy_StrictGoneModelUnsupportedContinues verifies a method-not-found SetModel error continues silently. - -func TestApplyStartModelPolicy_StrictGoneModelUnsupportedContinues(t *testing.T) { - applier := &fakeModelApplier{errs: []error{methodNotFoundErr()}} - applied, usingFallback, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - &CachedModelState{}, StartModelPolicy{Model: "claude-sonnet"}, - ) - if err != nil { - t.Fatalf("method-not-found should continue silently, got %v", err) - } - if applied != "" || usingFallback { - t.Errorf("expected no applied model, got applied=%q fallback=%v", applied, usingFallback) - } -} - -// TestApplyStartModelPolicy_StrictModelPresentApplies verifies an advertised start model is applied. - -func TestApplyStartModelPolicy_StrictModelPresentApplies(t *testing.T) { - applier := &fakeModelApplier{} - applied, usingFallback, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("claude-sonnet"), StartModelPolicy{Model: "claude-sonnet"}, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if applied != "claude-sonnet" || usingFallback { - t.Errorf("expected applied=claude-sonnet fallback=false, got applied=%q fallback=%v", applied, usingFallback) - } - if len(applier.calls) != 1 || applier.calls[0] != "claude-sonnet" { - t.Errorf("SetModel calls = %v, want [claude-sonnet]", applier.calls) - } -} - -// TestApplyStartModelPolicy_FallbackModelUsedWhenGone verifies the configured fallback replaces a gone start model. - -func TestApplyStartModelPolicy_FallbackModelUsedWhenGone(t *testing.T) { - applier := &fakeModelApplier{} - applied, usingFallback, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"}, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if applied != "gpt-5" || !usingFallback { - t.Errorf("expected applied=gpt-5 fallback=true, got applied=%q fallback=%v", applied, usingFallback) - } - if len(applier.calls) != 1 || applier.calls[0] != "gpt-5" { - t.Errorf("SetModel calls = %v, want [gpt-5]", applier.calls) - } -} - -// TestApplyStartModelPolicy_FallbackWhenSetModelRejectsGoneModel verifies the fallback applies when SetModel rejects the start model even without an advertised list. - -func TestApplyStartModelPolicy_FallbackWhenSetModelRejectsGoneModel(t *testing.T) { - // Regression: at session start the advertised model list may not have - // arrived yet (empty = "unknown"). The adapter still rejects the gone - // start model — the fallback must be applied then too, not only when - // the list is known. - applier := &fakeModelApplier{errs: []error{ - errors.New(`model "claude-gone" is not in the agent's 2 available models`), - nil, - }} - applied, usingFallback, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - &CachedModelState{}, StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"}, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if applied != "gpt-5" || !usingFallback { - t.Errorf("expected applied=gpt-5 fallback=true, got applied=%q fallback=%v", applied, usingFallback) - } - if len(applier.calls) != 2 || applier.calls[0] != "claude-gone" || applier.calls[1] != "gpt-5" { - t.Errorf("SetModel calls = %v, want [claude-gone gpt-5]", applier.calls) - } -} - -// TestApplyStartModelPolicy_FallbackModelFailureFails verifies a failed fallback SetModel fails the session explicitly. - -func TestApplyStartModelPolicy_FallbackModelFailureFails(t *testing.T) { - applier := &fakeModelApplier{errs: []error{errors.New("rejected")}} - _, _, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"}, - ) - if err == nil { - t.Fatal("expected error when the fallback model itself fails to apply") - } - if !strings.Contains(err.Error(), "gpt-5") { - t.Errorf("error should name the fallback model, got %q", err.Error()) - } -} - -// TestApplyStartModelPolicy_AutoFallbackLegacyBestEffort verifies auto-fallback logs and continues on SetModel failure. - -func TestApplyStartModelPolicy_AutoFallbackLegacyBestEffort(t *testing.T) { - // Gone model + auto-fallback: legacy behavior — try, warn, continue. - applier := &fakeModelApplier{errs: []error{errors.New("not in available models")}} - applied, usingFallback, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("gpt-5"), StartModelPolicy{Model: "claude-gone", AutoFallback: true}, - ) - if err != nil { - t.Fatalf("auto-fallback should never fail: %v", err) - } - if applied != "" || usingFallback { - t.Errorf("expected applied=\"\" fallback=false, got applied=%q fallback=%v", applied, usingFallback) - } - if len(applier.calls) != 1 { - t.Errorf("legacy path should still attempt SetModel, calls=%v", applier.calls) - } -} - -// TestApplyStartModelPolicy_AutoFallbackPresentApplies verifies auto-fallback still applies an available start model. - -func TestApplyStartModelPolicy_AutoFallbackPresentApplies(t *testing.T) { - applier := &fakeModelApplier{} - applied, usingFallback, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("claude-sonnet"), StartModelPolicy{Model: "claude-sonnet", AutoFallback: true}, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if applied != "claude-sonnet" || usingFallback { - t.Errorf("expected applied=claude-sonnet fallback=false, got applied=%q fallback=%v", applied, usingFallback) - } -} - -// TestApplyStartModelPolicy_EmptyModelNoOp verifies an empty start model is a no-op. - -func TestApplyStartModelPolicy_EmptyModelNoOp(t *testing.T) { - applier := &fakeModelApplier{} - applied, usingFallback, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("gpt-5"), StartModelPolicy{}, - ) - if err != nil || applied != "" || usingFallback { - t.Errorf("empty model should no-op, got applied=%q fallback=%v err=%v", applied, usingFallback, err) - } - if len(applier.calls) != 0 { - t.Errorf("SetModel should not be called for empty model, got %v", applier.calls) - } -} - -// TestApplyStartModelPolicy_TransportErrorDoesNotFallback verifies a -// transport/protocol SetModel failure fails explicitly instead of switching -// to the fallback: a broken connection is not evidence the model is -// unavailable, so applying the fallback would mask the real fault. -func TestApplyStartModelPolicy_TransportErrorDoesNotFallback(t *testing.T) { - applier := &fakeModelApplier{errs: []error{ - errors.New(`websocket: connection closed: peer disconnected`), - }} - _, _, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("claude-gone"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"}, - ) - if err == nil { - t.Fatal("expected explicit failure for a transport error") - } - if len(applier.calls) != 1 || applier.calls[0] != "claude-gone" { - t.Errorf("SetModel calls = %v, want only [claude-gone] (no fallback)", applier.calls) - } -} - -// TestApplyStartModelPolicy_CancelErrorDoesNotFallback verifies a cancelled -// SetModel context does not switch models either. -func TestApplyStartModelPolicy_CancelErrorDoesNotFallback(t *testing.T) { - applier := &fakeModelApplier{errs: []error{context.Canceled}} - _, _, err := applyStartModelPolicy( - context.Background(), newPolicyTestLogger(), applier, - modelState("claude-gone"), StartModelPolicy{Model: "claude-gone", FallbackModel: "gpt-5"}, - ) - if err == nil { - t.Fatal("expected explicit failure for a cancelled SetModel") - } - if len(applier.calls) != 1 || applier.calls[0] != "claude-gone" { - t.Errorf("SetModel calls = %v, want only [claude-gone] (no fallback)", applier.calls) - } -} diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 13e41e156a..56e0d83953 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -116,6 +116,14 @@ type ModelSettableAdapter interface { SetModel(ctx context.Context, modelID string) error } +// SessionModelStateProvider exposes the model catalog returned by the most +// recent session creation or load. The agent stream still emits the same +// session_models event, but this synchronous snapshot prevents lifecycle +// decisions from racing that event's downstream dispatch. +type SessionModelStateProvider interface { + GetSessionModelState() *streams.SessionModelState +} + // AuthenticatableAdapter is an optional interface implemented by adapters that // support ACP authentication (session/authenticate). type AuthenticatableAdapter interface { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 7e14c7dfd6..4dde3418bf 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -533,6 +533,49 @@ func (a *Adapter) GetSessionID() string { return a.sessionID } +// GetSessionModelState returns the latest session model snapshot while holding +// the adapter lock. The snapshot is used in the synchronous session response; +// the normal session_models stream event remains the long-lived cache update. +func (a *Adapter) GetSessionModelState() *streams.SessionModelState { + a.mu.RLock() + defer a.mu.RUnlock() + if len(a.availableModels) == 0 && len(a.availableConfigOptions) == 0 { + return nil + } + return &streams.SessionModelState{ + CurrentModelID: currentModelFromConfig(a.availableConfigOptions), + Models: cloneSessionModels(convertSessionModels(a.availableModels)), + ConfigOptions: cloneConfigOptions(a.availableConfigOptions), + } +} + +func cloneSessionModels(models []streams.SessionModelInfo) []streams.SessionModelInfo { + if len(models) == 0 { + return nil + } + cloned := append([]streams.SessionModelInfo(nil), models...) + for i, model := range cloned { + if model.Meta != nil { + cloned[i].Meta = make(map[string]any, len(model.Meta)) + for key, value := range model.Meta { + cloned[i].Meta[key] = value + } + } + } + return cloned +} + +func cloneConfigOptions(options []streams.ConfigOption) []streams.ConfigOption { + if len(options) == 0 { + return nil + } + cloned := append([]streams.ConfigOption(nil), options...) + for i, option := range cloned { + cloned[i].Options = append([]streams.ConfigOptionValue(nil), option.Options...) + } + return cloned +} + // GetOperationID returns the current operation/turn ID. // ACP protocol doesn't have explicit turn/operation IDs, so this returns empty string. func (a *Adapter) GetOperationID() string { diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index 738b64cb4c..1e0e26690a 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -59,9 +59,10 @@ type NewSessionRequest struct { // NewSessionResponse is the response to a new session call type NewSessionResponse struct { - Success bool `json:"success"` - SessionID string `json:"session_id,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + SessionID string `json:"session_id,omitempty"` + ModelState *streams.SessionModelState `json:"model_state,omitempty"` + Error string `json:"error,omitempty"` } // LoadSessionRequest is a request to load an existing ACP session @@ -72,9 +73,10 @@ type LoadSessionRequest struct { // LoadSessionResponse is the response to a load session call type LoadSessionResponse struct { - Success bool `json:"success"` - SessionID string `json:"session_id,omitempty"` - Error string `json:"error,omitempty"` + Success bool `json:"success"` + SessionID string `json:"session_id,omitempty"` + ModelState *streams.SessionModelState `json:"model_state,omitempty"` + Error string `json:"error,omitempty"` } // PromptRequest is a request to send a prompt to the agent @@ -469,8 +471,9 @@ func (s *Server) handleWSNewSession(ctx context.Context, msg *ws.Message) *ws.Me } resp, _ := ws.NewResponse(msg.ID, msg.Action, NewSessionResponse{ - Success: true, - SessionID: sessionID, + Success: true, + SessionID: sessionID, + ModelState: sessionModelState(adapter), }) return resp } @@ -519,8 +522,9 @@ func (s *Server) handleWSLoadSession(ctx context.Context, msg *ws.Message) *ws.M s.publishMCPAttachmentResult(attachmentContext.Attempt.AttemptID, mcpServers, nil) resp, _ := ws.NewResponse(msg.ID, msg.Action, LoadSessionResponse{ - Success: true, - SessionID: req.SessionID, + Success: true, + SessionID: req.SessionID, + ModelState: sessionModelState(adapter), }) return resp } @@ -766,12 +770,21 @@ func (s *Server) handleWSResetSession(ctx context.Context, msg *ws.Message) *ws. } resp, _ := ws.NewResponse(msg.ID, msg.Action, NewSessionResponse{ - Success: true, - SessionID: sessionID, + Success: true, + SessionID: sessionID, + ModelState: sessionModelState(agentAdapter), }) return resp } +func sessionModelState(agentAdapter adapter.AgentAdapter) *streams.SessionModelState { + provider, ok := agentAdapter.(adapter.SessionModelStateProvider) + if !ok { + return nil + } + return provider.GetSessionModelState() +} + // promptOrSteer routes a prompt to the steering path when the caller asked for it // and the adapter can actually do it. An adapter that does not implement // SteerablePrompter, or a connected agent that never advertised the capability, diff --git a/apps/backend/internal/agentctl/server/api/agent_adapter_actions_test.go b/apps/backend/internal/agentctl/server/api/agent_adapter_actions_test.go index 5219c1df33..bd6cfead38 100644 --- a/apps/backend/internal/agentctl/server/api/agent_adapter_actions_test.go +++ b/apps/backend/internal/agentctl/server/api/agent_adapter_actions_test.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/adapter" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/agentctl/types/streams" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -56,6 +57,7 @@ type capableAgentAdapter struct { resetServers []types.McpServer resetErr error failWith error + modelState *streams.SessionModelState } func (a *capableAgentAdapter) SetMode(_ context.Context, modeID string) error { @@ -73,6 +75,10 @@ func (a *capableAgentAdapter) Authenticate(_ context.Context, methodID string) e return a.failWith } +func (a *capableAgentAdapter) GetSessionModelState() *streams.SessionModelState { + return a.modelState +} + func (a *capableAgentAdapter) ResetSession(_ context.Context, servers []types.McpServer) (string, error) { a.resetServers = servers if a.resetErr != nil { @@ -275,6 +281,10 @@ func TestHandleWSResetSession_ReturnsNewSessionID(t *testing.T) { s := newTestServer(t) agentAdapter := &capableAgentAdapter{} agentAdapter.sessionID = "session-before-reset" + agentAdapter.modelState = &streams.SessionModelState{ + CurrentModelID: "mock-fast", + Models: []streams.SessionModelInfo{{ModelID: "mock-fast"}, {ModelID: "mock-smart"}}, + } s.procMgr.SetAdapterForTest(agentAdapter) msg, err := ws.NewRequest("req-1", "agent.session.reset", NewSessionRequest{ @@ -294,6 +304,9 @@ func TestHandleWSResetSession_ReturnsNewSessionID(t *testing.T) { if !payload.Success || payload.SessionID != "session-after-reset" { t.Errorf("payload = %+v, want the adapter's new session id", payload) } + if payload.ModelState == nil || payload.ModelState.CurrentModelID != "mock-fast" || len(payload.ModelState.Models) != 2 { + t.Errorf("model state = %+v, want the adapter's synchronous catalog", payload.ModelState) + } if len(agentAdapter.resetServers) != 1 || agentAdapter.resetServers[0].Name != "external" { t.Errorf("adapter received %+v, want the caller's MCP servers", agentAdapter.resetServers) } diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index 2929ddcdd9..ac2012741f 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -70,6 +70,10 @@ const ( // unavailable. Data carries {"fallback_model": }. EventTypeSessionModelFallback = "session_model_fallback" + // EventTypeSessionModelSelectionWarning explains an executor-authoritative + // default or explicit fallback decision. + EventTypeSessionModelSelectionWarning = "session_model_selection_warning" + // EventTypeSessionInfo indicates ACP session metadata such as title changed. EventTypeSessionInfo = "session_info" @@ -303,6 +307,10 @@ type AgentEvent struct { // events). FallbackModel string `json:"fallback_model,omitempty"` + // ModelSelectionWarning carries the provider-neutral model decision made + // before the session's first prompt. + ModelSelectionWarning *ModelSelectionWarning `json:"model_selection_warning,omitempty"` + // SessionModels lists models available in the ACP session. SessionModels []SessionModelInfo `json:"session_models,omitempty"` @@ -335,6 +343,20 @@ type AgentEvent struct { Usage *PromptUsage `json:"usage,omitempty"` } +// ModelSelectionWarning is the structured, provider-neutral explanation for +// continuing with an executor model other than the saved profile request. +type ModelSelectionWarning struct { + Kind string `json:"kind"` + DecisionID string `json:"decision_id,omitempty"` + Reason string `json:"reason"` + RequestedModel string `json:"requested_model,omitempty"` + EffectiveModel string `json:"effective_model,omitempty"` + FallbackModel string `json:"fallback_model,omitempty"` + AgentID string `json:"agent_id,omitempty"` + ExecutorType string `json:"executor_type,omitempty"` + ExecutorProfileID string `json:"executor_profile_id,omitempty"` +} + // PlanEntry represents an entry in the agent's execution plan. type PlanEntry struct { // Description is the content/description of the task. @@ -420,6 +442,15 @@ type SessionModelInfo struct { Meta map[string]any `json:"meta,omitempty"` } +// SessionModelState is the synchronous model snapshot returned with a newly +// created or loaded session. It lets lifecycle callers apply a saved model +// before the asynchronous session_models stream event is dispatched. +type SessionModelState struct { + CurrentModelID string `json:"current_model_id,omitempty"` + Models []SessionModelInfo `json:"models,omitempty"` + ConfigOptions []ConfigOption `json:"config_options,omitempty"` +} + // AuthMethodInfo represents an authentication method from ACP initialize. type AuthMethodInfo struct { // ID is the auth method identifier. diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 8117e1d2bb..33593ba7f5 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -267,11 +267,12 @@ const ( // Event types for ACP capabilities and models const ( - AgentCapabilitiesUpdated = "agent_capabilities.updated" // Agent capabilities received - SessionModelsUpdated = "session_models.updated" // Session models received - SessionModelFallbackUpdated = "session_model_fallback.updated" // Session started on the profile's fallback model - SessionInfoUpdated = "session_info.updated" // ACP session info received - SessionMCPStatusUpdated = "session_mcp_status.updated" // MCP attachment evidence changed + AgentCapabilitiesUpdated = "agent_capabilities.updated" // Agent capabilities received + SessionModelsUpdated = "session_models.updated" // Session models received + SessionModelFallbackUpdated = "session_model_fallback.updated" // Session started on the profile's fallback model + SessionModelSelectionWarningUpdated = "session_model_selection_warning.updated" // Executor-authoritative model decision warning + SessionInfoUpdated = "session_info.updated" // ACP session info received + SessionMCPStatusUpdated = "session_mcp_status.updated" // MCP attachment evidence changed ) // Event types for session todos (ACP plan entries) @@ -486,6 +487,18 @@ func BuildSessionModelFallbackWildcardSubject() string { return SessionModelFallbackUpdated + ".*" } +// BuildSessionModelSelectionWarningSubject creates a session-specific model +// selection warning subject. +func BuildSessionModelSelectionWarningSubject(sessionID string) string { + return SessionModelSelectionWarningUpdated + "." + sessionID +} + +// BuildSessionModelSelectionWarningWildcardSubject creates a wildcard +// subscription for model selection warnings. +func BuildSessionModelSelectionWarningWildcardSubject() string { + return SessionModelSelectionWarningUpdated + ".*" +} + // BuildSessionMCPStatusSubject creates a session-specific MCP status subject. func BuildSessionMCPStatusSubject(sessionID string) string { return SessionMCPStatusUpdated + "." + sessionID diff --git a/apps/backend/internal/gateway/websocket/session_notifications.go b/apps/backend/internal/gateway/websocket/session_notifications.go index bca39d1f0f..34de44503d 100644 --- a/apps/backend/internal/gateway/websocket/session_notifications.go +++ b/apps/backend/internal/gateway/websocket/session_notifications.go @@ -52,6 +52,7 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB b.subscribe(eventBus, events.BuildAgentCapabilitiesWildcardSubject(), ws.ActionSessionAgentCapabilities) b.subscribe(eventBus, events.BuildSessionModelsWildcardSubject(), ws.ActionSessionModelsUpdated) b.subscribe(eventBus, events.BuildSessionModelFallbackWildcardSubject(), ws.ActionSessionModelFallback) + b.subscribe(eventBus, events.BuildSessionModelSelectionWarningWildcardSubject(), ws.ActionSessionModelSelectionWarning) b.subscribe(eventBus, events.BuildSessionMCPStatusWildcardSubject(), ws.ActionSessionMCPStatusUpdated) b.subscribe(eventBus, events.BuildSessionInfoWildcardSubject(), ws.ActionSessionInfoUpdated) b.subscribe(eventBus, events.BuildSessionTodosWildcardSubject(), ws.ActionSessionTodosUpdated) diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index c943ea2e1b..a3fa035b33 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -143,6 +143,9 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle case "session_model_fallback": s.handleSessionModelFallbackEvent(ctx, payload) + case streams.EventTypeSessionModelSelectionWarning: + s.handleSessionModelSelectionWarningEvent(ctx, payload) + case streams.EventTypeMCPAttachment: s.handleSessionMCPAttachmentEvent(ctx, payload) @@ -2936,6 +2939,177 @@ func (s *Service) handleSessionModelFallbackEvent(ctx context.Context, payload * _ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.SessionModelFallbackUpdated, "orchestrator", eventPayload)) } +// handleSessionModelSelectionWarningEvent persists one structured status +// message for an executor-authoritative model decision and publishes the same +// data to live WebSocket subscribers. Persistence is best-effort and never +// blocks the task launch. +func (s *Service) handleSessionModelSelectionWarningEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) { + warning, sessionID, ok := s.modelSelectionWarningEvent(ctx, payload) + if !ok { + return + } + var releaseClaim func() + if s.messageCreator != nil { + var claimed bool + releaseClaim, claimed = s.claimModelSelectionWarning(ctx, sessionID, warning.DecisionID) + if !claimed { + return + } + } + if err := s.persistModelSelectionWarningMessage(ctx, payload.TaskID, sessionID, warning); err != nil { + releaseClaim() + } + s.publishModelSelectionWarning(ctx, payload.TaskID, sessionID, warning) +} + +func (s *Service) modelSelectionWarningEvent( + ctx context.Context, + payload *lifecycle.AgentStreamEventPayload, +) (streams.ModelSelectionWarning, string, bool) { + if payload == nil || payload.Data == nil || payload.Data.ModelSelectionWarning == nil { + return streams.ModelSelectionWarning{}, "", false + } + sessionID := payload.SessionID + if sessionID == "" && payload.TaskID != "" && s.repo != nil { + if sess, err := s.repo.GetActiveTaskSessionByTaskID(ctx, payload.TaskID); err == nil && sess != nil { + sessionID = sess.ID + } + } + if sessionID == "" { + return streams.ModelSelectionWarning{}, "", false + } + return *payload.Data.ModelSelectionWarning, sessionID, true +} + +func (s *Service) claimModelSelectionWarning(ctx context.Context, sessionID, decisionID string) (func(), bool) { + if s.repo == nil || decisionID == "" { + return func() {}, true + } + // A decision ID is created by lifecycle and is stable across event replay. + // Use the structured metadata key as an atomic claim so two deliveries cannot + // create duplicate status messages after a reconnect or restart. + claimCtx := context.WithoutCancel(ctx) + key := "model_selection_warning:" + decisionID + if claimer, ok := s.repo.(failedSessionMetadataClaimer); ok { + return s.claimModelSelectionWarningWithState(claimCtx, sessionID, key, claimer) + } + claimed, err := s.repo.SetSessionMetadataKeyIfAbsent(claimCtx, sessionID, key, true) + if err != nil { + s.logger.Warn("failed to claim model selection warning persistence", + zap.String("session_id", sessionID), zap.Error(err)) + return func() {}, false + } + return func() {}, claimed +} + +func (s *Service) claimModelSelectionWarningWithState( + ctx context.Context, + sessionID, key string, + claimer failedSessionMetadataClaimer, +) (func(), bool) { + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil || session == nil { + s.logger.Warn("failed to load session for model selection warning claim", + zap.String("session_id", sessionID), zap.Error(err)) + return func() {}, false + } + claimed, err := claimer.SetSessionMetadataKeyIfAbsentIfState(ctx, sessionID, key, true, session.State) + if err != nil { + s.logger.Warn("failed to claim model selection warning persistence", + zap.String("session_id", sessionID), zap.Error(err)) + return func() {}, false + } + if !claimed { + return func() {}, false + } + return func() { + s.releaseModelSelectionWarningClaim(ctx, sessionID, key, session.State) + }, true +} + +func (s *Service) releaseModelSelectionWarningClaim( + ctx context.Context, + sessionID, key string, + expectedState models.TaskSessionState, +) { + releaser, ok := s.repo.(failedSessionMetadataClaimReleaser) + if !ok { + s.logger.Warn("session repository cannot release model selection warning claim", + zap.String("session_id", sessionID)) + return + } + if _, err := releaser.RemoveSessionMetadataKeyIfState(ctx, sessionID, key, expectedState); err != nil { + s.logger.Warn("failed to release model selection warning claim", + zap.String("session_id", sessionID), zap.Error(err)) + } +} + +func modelSelectionWarningMetadata(warning streams.ModelSelectionWarning) map[string]interface{} { + metadata := map[string]interface{}{ + "variant": "warning", + "kind": warning.Kind, + "reason": warning.Reason, + "requested_model": warning.RequestedModel, + "effective_model": warning.EffectiveModel, + "agent_id": warning.AgentID, + "executor_type": warning.ExecutorType, + "executor_profile_id": warning.ExecutorProfileID, + "decision_id": warning.DecisionID, + "remediation": []string{"executor_credentials", "copied_agent_configuration", "agent_version"}, + } + if warning.FallbackModel != "" { + metadata["fallback_model"] = warning.FallbackModel + } + return metadata +} + +func (s *Service) persistModelSelectionWarningMessage( + ctx context.Context, + taskID, sessionID string, + warning streams.ModelSelectionWarning, +) error { + if s.messageCreator == nil { + return nil + } + if err := s.messageCreator.CreateSessionMessage( + ctx, + taskID, + "The executor could not use the saved model selection.", + sessionID, + string(v1.MessageTypeStatus), + s.getActiveTurnID(sessionID), + modelSelectionWarningMetadata(warning), + false, + ); err != nil { + s.logger.Warn("failed to persist model selection warning", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), zap.Error(err)) + return err + } + return nil +} + +func (s *Service) publishModelSelectionWarning( + ctx context.Context, + taskID, sessionID string, + warning streams.ModelSelectionWarning, +) { + if s.eventBus == nil { + return + } + eventPayload := lifecycle.SessionModelSelectionWarningEventPayload{ + TaskID: taskID, + SessionID: sessionID, + AgentID: warning.AgentID, + Warning: warning, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + subject := events.BuildSessionModelSelectionWarningSubject(sessionID) + _ = s.eventBus.Publish(ctx, subject, bus.NewEvent( + events.SessionModelSelectionWarningUpdated, "orchestrator", eventPayload, + )) +} + func workflowSessionConfigFailures(raw any) []string { data, ok := raw.(map[string]any) if !ok || data == nil { diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 18c1e52eff..d8d0e2ebfe 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -3455,6 +3455,87 @@ func TestHandleSessionModelFallbackEventResolvesSessionFromTask(t *testing.T) { require.Equal(t, "gpt-5", payload.FallbackModel) } +func TestHandleSessionModelSelectionWarningPersistsIdempotently(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + messages := &mockMessageCreator{} + eb := &recordingEventBus{} + svc := &Service{logger: testLogger(), repo: repo, eventBus: eb, messageCreator: messages} + warning := &streams.ModelSelectionWarning{ + Kind: "model_selection_warning", + DecisionID: "decision-1", + Reason: "requested_not_advertised", + RequestedModel: "host-only-model", + EffectiveModel: "executor-default", + AgentID: "codex-acp", + ExecutorType: "ssh", + ExecutorProfileID: "executor-profile-1", + } + payload := &lifecycle.AgentStreamEventPayload{ + TaskID: "t1", + SessionID: "s1", + AgentID: "codex-acp", + Data: &lifecycle.AgentStreamEventData{ + Type: streams.EventTypeSessionModelSelectionWarning, + ModelSelectionWarning: warning, + }, + } + + svc.handleAgentStreamEvent(ctx, payload) + svc.handleAgentStreamEvent(ctx, payload) + + require.Len(t, messages.sessionMessages, 1) + require.Equal(t, "s1", messages.sessionMessages[0].sessionID) + require.Equal(t, string(v1.MessageTypeStatus), messages.sessionMessages[0].messageType) + require.Equal(t, "model_selection_warning", messages.sessionMessages[0].metadata["kind"]) + require.Equal(t, "host-only-model", messages.sessionMessages[0].metadata["requested_model"]) + require.Equal(t, "executor-default", messages.sessionMessages[0].metadata["effective_model"]) + require.Equal(t, []string{"executor_credentials", "copied_agent_configuration", "agent_version"}, messages.sessionMessages[0].metadata["remediation"]) + require.Len(t, eb.events, 1) + require.Equal(t, events.BuildSessionModelSelectionWarningSubject("s1"), eb.events[0].subject) + + updated, err := repo.GetTaskSession(ctx, "s1") + require.NoError(t, err) + require.Equal(t, true, updated.Metadata["model_selection_warning:decision-1"]) +} + +func TestHandleSessionModelSelectionWarningReleasesClaimAfterPersistenceFailure(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + messages := &mockMessageCreator{sessionMessageErr: errors.New("transient message failure")} + svc := &Service{logger: testLogger(), repo: repo, messageCreator: messages} + warning := &streams.ModelSelectionWarning{ + Kind: "model_selection_warning", + DecisionID: "decision-retry", + Reason: "requested_not_advertised", + RequestedModel: "host-only-model", + EffectiveModel: "executor-default", + } + payload := &lifecycle.AgentStreamEventPayload{ + TaskID: "t1", + SessionID: "s1", + Data: &lifecycle.AgentStreamEventData{ + Type: streams.EventTypeSessionModelSelectionWarning, + ModelSelectionWarning: warning, + }, + } + + svc.handleAgentStreamEvent(ctx, payload) + failed, err := repo.GetTaskSession(ctx, "s1") + require.NoError(t, err) + require.NotContains(t, failed.Metadata, "model_selection_warning:decision-retry") + require.Empty(t, messages.sessionMessages) + + messages.sessionMessageErr = nil + svc.handleAgentStreamEvent(ctx, payload) + require.Len(t, messages.sessionMessages, 1) + completed, err := repo.GetTaskSession(ctx, "s1") + require.NoError(t, err) + require.Equal(t, true, completed.Metadata["model_selection_warning:decision-retry"]) +} + func TestHandleSessionModelsEventStoresBaselineCandidateAndPublishesLiveState(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/executor/executor_state.go b/apps/backend/internal/orchestrator/executor/executor_state.go index 6b906d4157..3af03d0155 100644 --- a/apps/backend/internal/orchestrator/executor/executor_state.go +++ b/apps/backend/internal/orchestrator/executor/executor_state.go @@ -221,8 +221,9 @@ func (e *Executor) applyProfile(ctx context.Context, profileID string, cfg *exec // Hoisted to constants so the table below doesn't duplicate string // literals that exist elsewhere in the package. const ( - profileKeyRemoteCredentials = "remote_credentials" - profileKeyRemoteAuthSecrets = "remote_auth_secrets" + profileKeyRemoteCredentials = "remote_credentials" + profileKeyRemoteAuthSecrets = "remote_auth_secrets" + profileKeyAgentConfigBundles = "agent_config_bundles" ) // profileConfigPassthroughKeys are profile.Config keys copied verbatim @@ -232,6 +233,7 @@ var profileConfigPassthroughKeys = []string{ "sprites_network_policy_rules", profileKeyRemoteCredentials, profileKeyRemoteAuthSecrets, + profileKeyAgentConfigBundles, "remote_auth_target_home", "git_user_name", "git_user_email", diff --git a/apps/backend/internal/task/handlers/executor_profile_handlers.go b/apps/backend/internal/task/handlers/executor_profile_handlers.go index f91f7e2c2d..120e36226a 100644 --- a/apps/backend/internal/task/handlers/executor_profile_handlers.go +++ b/apps/backend/internal/task/handlers/executor_profile_handlers.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/remoteauth" + "github.com/kandev/kandev/internal/agent/remoteconfig" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/subproc" @@ -45,6 +46,7 @@ func (h *ExecutorProfileHandlers) registerHTTP(router *gin.Engine) { api.GET("/executor-profiles", h.httpListAllProfiles) api.GET("/executor-profiles/default-script", h.httpGetDefaultScript) api.GET("/remote-credentials", h.httpListRemoteCredentials) + api.GET("/agent-config-bundles", h.httpListAgentConfigBundles) api.GET("/git/identity", h.httpGetGitIdentity) api.GET("/script-placeholders", h.httpListScriptPlaceholders) api.GET("/executors/:id/profiles", h.httpListProfiles) @@ -118,6 +120,10 @@ func (h *ExecutorProfileHandlers) httpListRemoteCredentials(c *gin.Context) { }) } +func (h *ExecutorProfileHandlers) httpListAgentConfigBundles(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"bundles": h.buildAgentConfigBundles()}) +} + func (h *ExecutorProfileHandlers) httpGetGitIdentity(c *gin.Context) { name := firstNonEmptyGitConfig("user.name") email := firstNonEmptyGitConfig("user.email") @@ -153,6 +159,13 @@ func (h *ExecutorProfileHandlers) buildRemoteAuthSpecs() []remoteauth.Spec { return remoteauth.BuildCatalog(h.agentList.ListEnabled()).Specs } +func (h *ExecutorProfileHandlers) buildAgentConfigBundles() []remoteconfig.Bundle { + if h.agentList == nil { + return remoteconfig.BuildCatalog(nil).Bundles + } + return remoteconfig.BuildCatalog(h.agentList.ListEnabled()).Bundles +} + func (h *ExecutorProfileHandlers) httpListProfiles(c *gin.Context) { executorID := c.Param("id") profiles, err := h.service.ListExecutorProfiles(c.Request.Context(), executorID) diff --git a/apps/backend/internal/task/handlers/executor_profile_handlers_test.go b/apps/backend/internal/task/handlers/executor_profile_handlers_test.go index d13ad4b735..c670920864 100644 --- a/apps/backend/internal/task/handlers/executor_profile_handlers_test.go +++ b/apps/backend/internal/task/handlers/executor_profile_handlers_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "path/filepath" "testing" "github.com/gin-gonic/gin" @@ -28,6 +29,12 @@ func (l *recordingAgentLister) ListEnabled() []agents.Agent { return nil } +type fixedAgentLister struct { + agents []agents.Agent +} + +func (l fixedAgentLister) ListEnabled() []agents.Agent { return l.agents } + func newProfileHandlers(t *testing.T, repo *executorRepo, lister AgentLister) *ExecutorProfileHandlers { t.Helper() return NewExecutorProfileHandlers(newExecutorService(t, repo), lister, newTestLogger(t)) @@ -156,6 +163,37 @@ func TestHTTPListRemoteCredentialsFallsBackWithoutAnAgentLister(t *testing.T) { require.Contains(t, rec.Body.String(), `"auth_specs"`) } +func TestHTTPListAgentConfigBundlesReturnsMetadataOnly(t *testing.T) { + h := newProfileHandlers(t, executorFixture(), fixedAgentLister{ + agents: []agents.Agent{agents.NewClaudeACP(), agents.NewCodexACP(), agents.NewOpenCodeACP()}, + }) + c, rec := newWorkflowRequest(t, http.MethodGet, "/api/v1/agent-config-bundles", "") + + h.httpListAgentConfigBundles(c) + + require.Equal(t, http.StatusOK, rec.Code) + var body struct { + Bundles []struct { + ID string `json:"id"` + Files []struct { + SourcePath string `json:"source_path"` + TargetPath string `json:"target_path"` + } `json:"files"` + } `json:"bundles"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Len(t, body.Bundles, 3) + for _, bundle := range body.Bundles { + require.NotEmpty(t, bundle.ID) + for _, file := range bundle.Files { + require.NotEmpty(t, file.SourcePath) + require.NotEmpty(t, file.TargetPath) + require.NotContains(t, file.SourcePath, string(filepath.Separator)+"root"+string(filepath.Separator)) + } + } + require.NotContains(t, rec.Body.String(), "host-only-secret") +} + // TestHTTPGetGitIdentityReportsDetectionConsistently shells out to the real // git, so the values depend on the host. The invariant that must hold either // way is that "detected" agrees with the two fields it summarizes. diff --git a/apps/backend/internal/task/handlers/route_registration_test.go b/apps/backend/internal/task/handlers/route_registration_test.go index c033fe55f8..88bc563895 100644 --- a/apps/backend/internal/task/handlers/route_registration_test.go +++ b/apps/backend/internal/task/handlers/route_registration_test.go @@ -99,6 +99,7 @@ func TestRegisterExecutorProfileRoutesWiresBothDeleteRoutes(t *testing.T) { "GET /api/v1/executor-profiles", "GET /api/v1/executor-profiles/default-script", "GET /api/v1/remote-credentials", + "GET /api/v1/agent-config-bundles", "GET /api/v1/git/identity", "GET /api/v1/script-placeholders", "GET /api/v1/executors/:id/profiles", diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index d9e2ae6dcd..ec5d390db5 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -179,75 +179,76 @@ const ( // Native code review. The *.run / *.cancel / *.get / *.finding.update / // *.clear actions are client requests; the rest are server notifications. - ActionTaskReviewRun = "task.review.run" - ActionTaskReviewCancel = "task.review.cancel" - ActionTaskReviewGet = "task.review.get" - ActionTaskReviewFindingUpdate = "task.review.finding.update" - ActionTaskReviewClear = "task.review.clear" - ActionTaskReviewRunUpdated = "task.review.run_updated" - ActionTaskReviewFindingsPublished = "task.review.findings_published" - ActionTaskReviewFindingUpdated = "task.review.finding_updated" - ActionTaskReviewCleared = "task.review.cleared" - ActionAgentUpdated = "agent.updated" - ActionAgentAvailableUpdated = "agent.available.updated" - ActionAgentInstallStarted = "agent.install.started" - ActionAgentInstallOutput = "agent.install.output" - ActionAgentInstallFinished = "agent.install.finished" - ActionAgentUpdateStarted = "agent.update.started" - ActionAgentUpdateOutput = "agent.update.output" - ActionAgentUpdateFinished = "agent.update.finished" - ActionWorkspaceCreated = "workspace.created" - ActionWorkspaceUpdated = "workspace.updated" - ActionWorkspaceDeleted = "workspace.deleted" - ActionWorkflowCreated = "workflow.created" - ActionWorkflowUpdated = "workflow.updated" - ActionWorkflowDeleted = "workflow.deleted" - ActionWorkflowStepCreated = "workflow.step.created" - ActionWorkflowStepUpdated = "workflow.step.updated" - ActionWorkflowStepDeleted = "workflow.step.deleted" - ActionSessionMessageAdded = "session.message.added" - ActionSessionMessageUpdated = "session.message.updated" - ActionSessionMessageDeleted = "session.message.deleted" - ActionSessionStateChanged = "session.state_changed" - ActionSessionActivityChanged = "session.activity_changed" - ActionSessionCancellationChanged = "session.cancellation_changed" - ActionTaskStatusSummaryUpdated = "task.status_summary.updated" - ActionSessionAgentctlStarting = "session.agentctl_starting" - ActionSessionAgentctlReady = "session.agentctl_ready" - ActionSessionAgentctlError = "session.agentctl_error" - ActionSessionTurnStarted = "session.turn.started" - ActionSessionTurnCompleted = "session.turn.completed" - ActionSessionAvailableCommands = "session.available_commands" - ActionSessionModeChanged = "session.mode_changed" - ActionSessionAgentCapabilities = "session.agent_capabilities" - ActionSessionModelsUpdated = "session.models_updated" - ActionSessionModelFallback = "session.model_fallback" - ActionSessionMCPStatusUpdated = "session.mcp_status_updated" - ActionSessionInfoUpdated = "session.info_updated" - ActionSessionSetMode = "session.set_mode" - ActionSessionTodosUpdated = "session.todos_updated" - ActionSessionPromptUsage = "session.prompt_usage" - ActionSessionPollModeChanged = "session.poll_mode_changed" - ActionInputRequested = "input.requested" - ActionRepositoryCreated = "repository.created" - ActionRepositoryUpdated = "repository.updated" - ActionRepositoryDeleted = "repository.deleted" - ActionRepositoryScriptCreated = "repository.script.created" - ActionRepositoryScriptUpdated = "repository.script.updated" - ActionRepositoryScriptDeleted = "repository.script.deleted" - ActionExecutorCreated = "executor.created" - ActionExecutorUpdated = "executor.updated" - ActionExecutorDeleted = "executor.deleted" - ActionEnvironmentCreated = "environment.created" - ActionEnvironmentUpdated = "environment.updated" - ActionEnvironmentDeleted = "environment.deleted" - ActionExecutorProfileCreated = "executor.profile.created" - ActionExecutorProfileUpdated = "executor.profile.updated" - ActionExecutorProfileDeleted = "executor.profile.deleted" - ActionExecutorPrepareProgress = "executor.prepare.progress" - ActionExecutorPrepareCompleted = "executor.prepare.completed" - ActionSystemMetricsUpdated = "system.metrics.updated" - ActionUpdateAvailable = "system.update_available" + ActionTaskReviewRun = "task.review.run" + ActionTaskReviewCancel = "task.review.cancel" + ActionTaskReviewGet = "task.review.get" + ActionTaskReviewFindingUpdate = "task.review.finding.update" + ActionTaskReviewClear = "task.review.clear" + ActionTaskReviewRunUpdated = "task.review.run_updated" + ActionTaskReviewFindingsPublished = "task.review.findings_published" + ActionTaskReviewFindingUpdated = "task.review.finding_updated" + ActionTaskReviewCleared = "task.review.cleared" + ActionAgentUpdated = "agent.updated" + ActionAgentAvailableUpdated = "agent.available.updated" + ActionAgentInstallStarted = "agent.install.started" + ActionAgentInstallOutput = "agent.install.output" + ActionAgentInstallFinished = "agent.install.finished" + ActionAgentUpdateStarted = "agent.update.started" + ActionAgentUpdateOutput = "agent.update.output" + ActionAgentUpdateFinished = "agent.update.finished" + ActionWorkspaceCreated = "workspace.created" + ActionWorkspaceUpdated = "workspace.updated" + ActionWorkspaceDeleted = "workspace.deleted" + ActionWorkflowCreated = "workflow.created" + ActionWorkflowUpdated = "workflow.updated" + ActionWorkflowDeleted = "workflow.deleted" + ActionWorkflowStepCreated = "workflow.step.created" + ActionWorkflowStepUpdated = "workflow.step.updated" + ActionWorkflowStepDeleted = "workflow.step.deleted" + ActionSessionMessageAdded = "session.message.added" + ActionSessionMessageUpdated = "session.message.updated" + ActionSessionMessageDeleted = "session.message.deleted" + ActionSessionStateChanged = "session.state_changed" + ActionSessionActivityChanged = "session.activity_changed" + ActionSessionCancellationChanged = "session.cancellation_changed" + ActionTaskStatusSummaryUpdated = "task.status_summary.updated" + ActionSessionAgentctlStarting = "session.agentctl_starting" + ActionSessionAgentctlReady = "session.agentctl_ready" + ActionSessionAgentctlError = "session.agentctl_error" + ActionSessionTurnStarted = "session.turn.started" + ActionSessionTurnCompleted = "session.turn.completed" + ActionSessionAvailableCommands = "session.available_commands" + ActionSessionModeChanged = "session.mode_changed" + ActionSessionAgentCapabilities = "session.agent_capabilities" + ActionSessionModelsUpdated = "session.models_updated" + ActionSessionModelFallback = "session.model_fallback" + ActionSessionModelSelectionWarning = "session.model_selection_warning" + ActionSessionMCPStatusUpdated = "session.mcp_status_updated" + ActionSessionInfoUpdated = "session.info_updated" + ActionSessionSetMode = "session.set_mode" + ActionSessionTodosUpdated = "session.todos_updated" + ActionSessionPromptUsage = "session.prompt_usage" + ActionSessionPollModeChanged = "session.poll_mode_changed" + ActionInputRequested = "input.requested" + ActionRepositoryCreated = "repository.created" + ActionRepositoryUpdated = "repository.updated" + ActionRepositoryDeleted = "repository.deleted" + ActionRepositoryScriptCreated = "repository.script.created" + ActionRepositoryScriptUpdated = "repository.script.updated" + ActionRepositoryScriptDeleted = "repository.script.deleted" + ActionExecutorCreated = "executor.created" + ActionExecutorUpdated = "executor.updated" + ActionExecutorDeleted = "executor.deleted" + ActionEnvironmentCreated = "environment.created" + ActionEnvironmentUpdated = "environment.updated" + ActionEnvironmentDeleted = "environment.deleted" + ActionExecutorProfileCreated = "executor.profile.created" + ActionExecutorProfileUpdated = "executor.profile.updated" + ActionExecutorProfileDeleted = "executor.profile.deleted" + ActionExecutorPrepareProgress = "executor.prepare.progress" + ActionExecutorPrepareCompleted = "executor.prepare.completed" + ActionSystemMetricsUpdated = "system.metrics.updated" + ActionUpdateAvailable = "system.update_available" ActionAgentProfileDeleted = "agent.profile.deleted" ActionAgentProfileCreated = "agent.profile.created" diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index 53a68b565c..89415f1e57 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -54,6 +54,7 @@ import { serializeSettingsRevision } from "@/components/settings/settings-save-r import { deriveSpritesSecretId, getGitIdentityBaseline, + parseAgentConfigBundles, parseNetworkPolicyRules, parseRemoteAuthSecrets, parseRemoteCredentials, @@ -61,6 +62,7 @@ import { import type { Executor, ExecutorProfile, ExecutorType, ProfileEnvVar } from "@/lib/types/http"; import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; import { executorProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets"; +import { buildSaveConfig } from "@/components/settings/profile-edit/serialize-executor-config"; const EXECUTORS_ROUTE = "/settings/executors"; const SPRITES_TOKEN_KEY = "SPRITES_API_TOKEN"; @@ -98,6 +100,9 @@ function useRemoteAuthState(profile: ExecutorProfile) { const [remoteCredentials, setRemoteCredentials] = useState(() => parseRemoteCredentials(profile.config), ); + const [configBundleIds, setConfigBundleIds] = useState(() => + parseAgentConfigBundles(profile.config), + ); const [agentEnvVars, setAgentEnvVars] = useState>(() => parseRemoteAuthSecrets(profile.config), ); @@ -111,6 +116,8 @@ function useRemoteAuthState(profile: ExecutorProfile) { setNetworkPolicyRules, remoteCredentials, setRemoteCredentials, + configBundleIds, + setConfigBundleIds, agentEnvVars, handleAgentEnvVarChange, }; @@ -327,6 +334,8 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { setNetworkPolicyRules: remoteAuth.setNetworkPolicyRules, remoteCredentials: remoteAuth.remoteCredentials, setRemoteCredentials: remoteAuth.setRemoteCredentials, + configBundleIds: remoteAuth.configBundleIds, + setConfigBundleIds: remoteAuth.setConfigBundleIds, agentEnvVars: remoteAuth.agentEnvVars, handleAgentEnvVarChange: remoteAuth.handleAgentEnvVarChange, localGitIdentity: gitIdentity.localGitIdentity, @@ -347,70 +356,6 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) { }; } -function buildSaveConfig( - form: ReturnType, - baseConfig?: Record, -): Record { - const config: Record = { ...baseConfig }; - if (form.isSprites && form.networkPolicyRules.length > 0) { - config.sprites_network_policy_rules = JSON.stringify(form.networkPolicyRules); - } else { - delete config.sprites_network_policy_rules; - } - if (form.isRemote && form.remoteCredentials.length > 0) { - config.remote_credentials = JSON.stringify(form.remoteCredentials); - } else { - delete config.remote_credentials; - } - const nonNullEnvVars = Object.fromEntries( - Object.entries(form.agentEnvVars).filter(([, v]) => v != null), - ); - if (form.isRemote && Object.keys(nonNullEnvVars).length > 0) { - config.remote_auth_secrets = JSON.stringify(nonNullEnvVars); - } else { - delete config.remote_auth_secrets; - } - const effectiveName = - form.gitIdentityMode === "local" - ? form.localGitIdentity.userName.trim() - : form.gitUserName.trim(); - const effectiveEmail = - form.gitIdentityMode === "local" - ? form.localGitIdentity.userEmail.trim() - : form.gitUserEmail.trim(); - if (form.isRemote && effectiveName) { - config.git_user_name = effectiveName; - } else { - delete config.git_user_name; - } - if (form.isRemote && effectiveEmail) { - config.git_user_email = effectiveEmail; - } else { - delete config.git_user_email; - } - applyDockerConfig(config, form); - if (form.isSSH && form.sshShell.trim()) config.ssh_shell = form.sshShell.trim(); - else delete config.ssh_shell; - return config; -} - -function applyDockerConfig( - config: Record, - form: ReturnType, -): void { - if (!form.isDocker) return; - if (form.dockerfile.trim()) { - config.dockerfile = form.dockerfile; - } else { - delete config.dockerfile; - } - if (form.imageTag.trim()) { - config.image_tag = form.imageTag.trim(); - } else { - delete config.image_tag; - } -} - type ProfileEditSectionsProps = { executor: Executor; profile: ExecutorProfile; @@ -458,6 +403,10 @@ function ExecutorSpecificSections({ executor, profile, form, secrets }: ProfileE remoteCredentials={form.remoteCredentials} baselineRemoteCredentials={parseRemoteCredentials(profile.config)} onRemoteCredentialsChange={form.setRemoteCredentials} + configBundleIds={form.configBundleIds} + baselineConfigBundleIds={parseAgentConfigBundles(profile.config)} + onConfigBundleChange={form.setConfigBundleIds} + isSSH={form.isSSH} agentEnvVars={form.agentEnvVars} baselineAgentEnvVars={parseRemoteAuthSecrets(profile.config)} onAgentEnvVarChange={form.handleAgentEnvVarChange} diff --git a/apps/web/app/settings/executors/new/[type]/page.tsx b/apps/web/app/settings/executors/new/[type]/page.tsx index 4ab2595387..40a88791ca 100644 --- a/apps/web/app/settings/executors/new/[type]/page.tsx +++ b/apps/web/app/settings/executors/new/[type]/page.tsx @@ -125,6 +125,7 @@ type BuildProfileConfigInput = { isDocker: boolean; networkPolicyRules: NetworkPolicyRule[]; remoteCredentials: string[]; + configBundleIds: string[]; agentEnvVars: Record; gitIdentityMode: GitIdentityMode; localGitIdentity: GitIdentityState; @@ -141,6 +142,7 @@ function buildProfileConfig(input: BuildProfileConfigInput): Record 0) { config.remote_credentials = JSON.stringify(remoteCredentials); } + if (isRemote && configBundleIds.length > 0) { + config.agent_config_bundles = JSON.stringify(configBundleIds); + } const nonNullEnvVars = Object.fromEntries( Object.entries(agentEnvVars).filter(([, v]) => v != null), ); @@ -217,6 +222,7 @@ function useCreateRemoteFlags(executorType: ExecutorType) { function useCreateRemoteAuthState() { const [remoteCredentials, setRemoteCredentials] = useState([]); + const [configBundleIds, setConfigBundleIds] = useState([]); const [agentEnvVars, setAgentEnvVars] = useState>({}); const [networkPolicyRules, setNetworkPolicyRules] = useState([]); @@ -227,6 +233,8 @@ function useCreateRemoteAuthState() { return { remoteCredentials, setRemoteCredentials, + configBundleIds, + setConfigBundleIds, agentEnvVars, handleAgentEnvVarChange, networkPolicyRules, @@ -344,6 +352,8 @@ function useCreateProfileFormState(executorType: ExecutorType) { setNetworkPolicyRules: remoteAuth.setNetworkPolicyRules, remoteCredentials: remoteAuth.remoteCredentials, setRemoteCredentials: remoteAuth.setRemoteCredentials, + configBundleIds: remoteAuth.configBundleIds, + setConfigBundleIds: remoteAuth.setConfigBundleIds, agentEnvVars: remoteAuth.agentEnvVars, handleAgentEnvVarChange: remoteAuth.handleAgentEnvVarChange, localGitIdentity: gitIdentity.localGitIdentity, @@ -433,28 +443,7 @@ function CreateProfileSections({ onBuildSuccess={form.recordDockerBuildSuccess} /> )} - {form.isRemote && ( - - )} + {form.isSprites && ( ; + secrets: ReturnType["items"]; +}) { + if (!form.isRemote) return null; + return ( + + ); +} + // Returns a catalog key (or null when the form is submittable) so the reason // resolves at render rather than at module load. function getCreateDisabledReasonKey( @@ -531,6 +557,7 @@ function buildCreateProfilePayload(form: ReturnType): string[ return parseJsonSetting(config?.remote_credentials, []); } +export function parseAgentConfigBundles(config?: Record): string[] { + return parseJsonSetting(config?.agent_config_bundles, []); +} + export function parseRemoteAuthSecrets( config?: Record, ): Record { diff --git a/apps/web/components/settings/profile-edit/portable-config-bundles.tsx b/apps/web/components/settings/profile-edit/portable-config-bundles.tsx new file mode 100644 index 0000000000..90598cc485 --- /dev/null +++ b/apps/web/components/settings/profile-edit/portable-config-bundles.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; +import type { AgentConfigBundle } from "@/lib/api/domains/agent-config-api"; + +type AgentConfigOptionsProps = { + agentId: string; + bundles: AgentConfigBundle[]; + selectedIds: string[]; + baselineSelectedIds: string[]; + onChange: (ids: string[]) => void; + isSSH?: boolean; +}; + +const PORTABLE_CONFIG_LABEL_KEYS: Record = { + "claude.settings": "executors:portableConfigBundleClaudeSettings", + "codex.config": "executors:portableConfigBundleCodexConfig", + "opencode.config": "executors:portableConfigBundleOpenCodeConfig", + "mock.settings": "executors:portableConfigBundleMockSettings", +}; + +export function AgentConfigOptions({ + agentId, + bundles, + selectedIds, + baselineSelectedIds, + onChange, + isSSH = false, +}: AgentConfigOptionsProps) { + const { t } = useTranslation(); + if (bundles.length === 0) return null; + + const selected = new Set(selectedIds); + const baseline = new Set(baselineSelectedIds); + const bundleIds = new Set(bundles.map((bundle) => bundle.id)); + const isDirty = [...bundleIds].some( + (bundleId) => selected.has(bundleId) !== baseline.has(bundleId), + ); + + const toggle = (bundleId: string, checked: boolean) => { + const next = new Set(selected); + if (checked) next.add(bundleId); + else next.delete(bundleId); + onChange([...next]); + }; + + return ( +
+ {bundles.map((bundle, index) => { + const labelKey = PORTABLE_CONFIG_LABEL_KEYS[bundle.id]; + const label = labelKey + ? t(labelKey) + : t("executors:portableConfigBundleGeneric", { id: bundle.id }); + const bundleIsDirty = selected.has(bundle.id) !== baseline.has(bundle.id); + return ( +
+ + {index === 0 && ( + + )} +
+ ); + })} +
+ ); +} + +function PortableConfigInfo({ isSSH, testId }: { isSSH: boolean; testId: string }) { + const { t } = useTranslation(); + const usesTouchDrawer = useTouchDrawer(); + const [open, setOpen] = useState(false); + const button = ( + + ); + const content = ( +
+

{t("executors:portableConfigInfoCopyTiming")}

+

{t("executors:portableConfigInfoErrors")}

+

{t("executors:portableConfigInfoScope")}

+ {isSSH &&

{t("executors:portableConfigInfoSsh")}

} +
+ ); + const trigger = usesTouchDrawer ? ( + {button} + ) : ( + + + {button} + + {content} + + + + ); + + return ( + + {trigger} + + + + + {t("executors:portableConfigInfoLabel")} + +
+ {content} +
+
+
+ ); +} diff --git a/apps/web/components/settings/profile-edit/profile-runtime-sections.tsx b/apps/web/components/settings/profile-edit/profile-runtime-sections.tsx index 94884463e4..467950b5ab 100644 --- a/apps/web/components/settings/profile-edit/profile-runtime-sections.tsx +++ b/apps/web/components/settings/profile-edit/profile-runtime-sections.tsx @@ -55,6 +55,10 @@ type SpritesSectionsProps = { remoteCredentials: string[]; baselineRemoteCredentials?: string[]; onRemoteCredentialsChange: (ids: string[]) => void; + configBundleIds: string[]; + baselineConfigBundleIds?: string[]; + onConfigBundleChange: (ids: string[]) => void; + isSSH?: boolean; agentEnvVars: Record; baselineAgentEnvVars?: Record; onAgentEnvVarChange: (agentId: string, secretId: string | null) => void; @@ -81,6 +85,10 @@ export function SpritesSections({ remoteCredentials, baselineRemoteCredentials, onRemoteCredentialsChange, + configBundleIds, + baselineConfigBundleIds, + onConfigBundleChange, + isSSH, agentEnvVars, baselineAgentEnvVars, onAgentEnvVarChange, @@ -104,6 +112,10 @@ export function SpritesSections({ selectedIds={remoteCredentials} baselineSelectedIds={baselineRemoteCredentials} onChange={onRemoteCredentialsChange} + configBundleIds={configBundleIds} + baselineConfigBundleIds={baselineConfigBundleIds} + onConfigBundleChange={onConfigBundleChange} + isSSH={isSSH} agentEnvVars={agentEnvVars} baselineAgentEnvVars={baselineAgentEnvVars} onAgentEnvVarChange={onAgentEnvVarChange} diff --git a/apps/web/components/settings/profile-edit/remote-auth-section.tsx b/apps/web/components/settings/profile-edit/remote-auth-section.tsx new file mode 100644 index 0000000000..97019cc50d --- /dev/null +++ b/apps/web/components/settings/profile-edit/remote-auth-section.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { Trans, useTranslation } from "react-i18next"; +import ReactMarkdown from "react-markdown"; +import type { ReactNode } from "react"; +import { Badge } from "@kandev/ui/badge"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { AccordionContent, AccordionItem, AccordionTrigger } from "@kandev/ui/accordion"; +import { AgentLogo } from "@/components/agent-logo"; +import { InlineSecretSelect } from "@/components/settings/profile-edit/inline-secret-select"; +import type { RemoteAuthMethod, RemoteAuthSpec } from "@/lib/api/domains/settings-api"; +import type { AgentConfigBundle } from "@/lib/api/domains/agent-config-api"; +import type { SecretListItem } from "@/lib/types/http-secrets"; +import { AgentConfigOptions } from "./portable-config-bundles"; + +const OPTION_LABEL_BASE = + "flex min-h-11 w-full cursor-pointer items-start gap-3 rounded-md border p-3 text-left transition-colors"; +const SELECTED_BORDER = "border-primary bg-primary/5"; +const DEFAULT_BORDER = "border-border"; +const AGENT_LOGO_IDS = new Set(["claude_code", "auggie", "codex", "gemini", "copilot", "amp"]); + +export function getSpecMethods(spec: RemoteAuthSpec): RemoteAuthMethod[] { + return Array.isArray(spec.methods) ? spec.methods : []; +} + +type AuthSectionProps = { + spec: RemoteAuthSpec; + selectedIds: Set; + baselineSelectedIds: Set; + onCredentialsChange: (ids: string[]) => void; + envSecretId: string | null; + baselineEnvSecretId: string | null; + onMethodSecretChange: (methodId: string, secretId: string | null) => void; + secrets: SecretListItem[]; + configBundles: AgentConfigBundle[]; + configBundleIds: string[]; + baselineConfigBundleIds: string[]; + onConfigBundleChange: (ids: string[]) => void; + isSSH: boolean; +}; + +export function AuthSection({ + spec, + selectedIds, + baselineSelectedIds, + onCredentialsChange, + envSecretId, + baselineEnvSecretId, + onMethodSecretChange, + secrets, + configBundles, + configBundleIds, + baselineConfigBundleIds, + onConfigBundleChange, + isSSH, +}: AuthSectionProps) { + const methods = getSpecMethods(spec); + const envMethod = methods.find((m) => m.type === "env"); + const fileMethod = methods.find((m) => m.type === "files"); + const fileSelected = fileMethod ? selectedIds.has(fileMethod.method_id) : false; + const envSelected = Boolean(envMethod && (selectedIds.has(envMethod.method_id) || envSecretId)); + const baselineFileSelected = fileMethod ? baselineSelectedIds.has(fileMethod.method_id) : false; + const baselineEnvSelected = Boolean( + envMethod && (baselineSelectedIds.has(envMethod.method_id) || baselineEnvSecretId), + ); + const isDirty = + fileSelected !== baselineFileSelected || + envSelected !== baselineEnvSelected || + envSecretId !== baselineEnvSecretId; + const configIsDirty = configBundles.some( + (bundle) => configBundleIds.includes(bundle.id) !== baselineConfigBundleIds.includes(bundle.id), + ); + + const handleMethodToggle = (methodId: string, checked: boolean) => { + const nextSelectedIds = new Set(selectedIds); + setMethodSelected(nextSelectedIds, methodId, checked); + if (envMethod?.method_id === methodId && !checked) { + onMethodSecretChange(methodId, null); + } + onCredentialsChange([...nextSelectedIds]); + }; + + return ( + + +
+ {AGENT_LOGO_IDS.has(spec.id) && } + {spec.display_name} + +
+
+ +
+ { + if (envMethod) onMethodSecretChange(envMethod.method_id, sid); + }} + secrets={secrets} + /> + {configBundles.length > 0 && ( + + )} +
+
+
+ ); +} + +function AuthOptions({ + fileMethod, + envMethod, + fileSelected, + envSelected, + baselineFileSelected, + baselineEnvSelected, + secretId, + baselineSecretId, + onMethodToggle, + onSecretIdChange, + secrets, +}: { + fileMethod?: RemoteAuthMethod; + envMethod?: RemoteAuthMethod; + fileSelected: boolean; + envSelected: boolean; + baselineFileSelected: boolean; + baselineEnvSelected: boolean; + secretId: string | null; + baselineSecretId: string | null; + onMethodToggle: (methodId: string, checked: boolean) => void; + onSecretIdChange: (id: string | null) => void; + secrets: SecretListItem[]; +}) { + const { t } = useTranslation(); + return ( +
+ {fileMethod && ( + onMethodToggle(fileMethod.method_id, checked)} + /> + )} + {envMethod?.env_var && ( + onMethodToggle(envMethod.method_id, checked)} + /> + )} +
+ ); +} + +function FileOption({ + method, + isSelected, + isDirty, + filesAvailable, + onCheckedChange, +}: { + method: RemoteAuthMethod; + isSelected: boolean; + isDirty: boolean; + filesAvailable: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + const { t } = useTranslation(); + const filesLabel = method.source_files?.join(", ") ?? ""; + return ( + +
+ {method.label ?? t("executors:copyAuthFiles")} + + {filesAvailable + ? filesLabel + : t("executors:authFilesNotFoundOnThisMachine", { files: filesLabel })} + +
+
+ ); +} + +function EnvOption({ + method, + isSelected, + isDirty, + secretId, + baselineSecretId, + onSecretIdChange, + secrets, + onCheckedChange, +}: { + method: RemoteAuthMethod; + isSelected: boolean; + isDirty: boolean; + secretId: string | null; + baselineSecretId: string | null; + onSecretIdChange: (id: string | null) => void; + secrets: SecretListItem[]; + onCheckedChange: (checked: boolean) => void; +}) { + const { t } = useTranslation(); + return ( +
+ +
+ {t("executors:provideSecret")} + + + Set {method.env_var} via a + stored secret + + + {method.setup_hint && ( +
+ {method.setup_hint} +
+ )} +
+
+ {isSelected && ( +
+ +
+ )} +
+ ); +} + +function AuthOption({ + selected, + isDirty, + onCheckedChange, + label, + children, +}: { + selected: boolean; + isDirty: boolean; + onCheckedChange: (checked: boolean) => void; + label: string; + children: ReactNode; +}) { + return ( + + ); +} + +function setMethodSelected(selectedIds: Set, methodId: string, selected: boolean) { + if (selected) { + selectedIds.add(methodId); + return; + } + selectedIds.delete(methodId); +} + +function AuthStatusBadge({ + fileSelected, + envSelected, + hasSecret, +}: { + fileSelected: boolean; + envSelected: boolean; + hasSecret: boolean; +}) { + const { t } = useTranslation(); + if (envSelected && hasSecret) { + return ( + + {t("executors:configured")} + + ); + } + if (fileSelected) { + return ( + + {t("executors:filesSelected")} + + ); + } + return ( + + {t("executors:notConfigured")} + + ); +} diff --git a/apps/web/components/settings/profile-edit/remote-credentials-card.test.tsx b/apps/web/components/settings/profile-edit/remote-credentials-card.test.tsx index b93a6aa882..9dd4159d43 100644 --- a/apps/web/components/settings/profile-edit/remote-credentials-card.test.tsx +++ b/apps/web/components/settings/profile-edit/remote-credentials-card.test.tsx @@ -1,22 +1,55 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { useState } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RemoteCredentialsCard } from "./remote-credentials-card"; +import { AgentConfigOptions } from "./portable-config-bundles"; import { listRemoteCredentials } from "@/lib/api/domains/settings-api"; +import { listAgentConfigBundles, type AgentConfigBundle } from "@/lib/api/domains/agent-config-api"; vi.mock("@/lib/api/domains/settings-api", () => ({ listRemoteCredentials: vi.fn(), })); -const codexFileMethodId = "agent:codex-acp:files:0"; -const copyAuthFilesLabel = "Copy auth files"; +vi.mock("@/lib/api/domains/agent-config-api", () => ({ + listAgentConfigBundles: vi.fn(), +})); -function renderRemoteCredentialsCard(onChange = vi.fn()) { - render(); +const codexFileMethodId = "agent:codex-acp:files:0"; +const codexAgentName = "Codex"; +const copyAuthFilesLabel = "Copy auth files"; +const provideSecretLabel = "Provide secret"; +const codexConfigBundleId = "codex.config"; +const copyCodexConfigLabel = "Copy Codex configuration"; +const CHECKED_STATE = "checked"; +const UNCHECKED_STATE = "unchecked"; +const TRUE_ATTRIBUTE = "true"; +const DATA_STATE_ATTRIBUTE = "data-state"; + +function renderRemoteCredentialsCard( + onChange = vi.fn(), + onConfigChange = vi.fn(), + initialConfigBundleIds: string[] = [], +) { + render( + , + ); } -function RemoteCredentialsHarness({ onChange }: { onChange: (ids: string[]) => void }) { +function RemoteCredentialsHarness({ + onChange, + onConfigChange, + initialConfigBundleIds, +}: { + onChange: (ids: string[]) => void; + onConfigChange: (ids: string[]) => void; + initialConfigBundleIds: string[]; +}) { const [selectedIds, setSelectedIds] = useState([]); + const [configBundleIds, setConfigBundleIds] = useState(initialConfigBundleIds); return ( v setSelectedIds(ids); onChange(ids); }} + configBundleIds={configBundleIds} + onConfigBundleChange={(ids) => { + setConfigBundleIds(ids); + onConfigChange(ids); + }} agentEnvVars={{}} onAgentEnvVarChange={() => {}} secrets={[]} @@ -40,11 +78,12 @@ function RemoteCredentialsHarness({ onChange }: { onChange: (ids: string[]) => v } beforeEach(() => { + vi.mocked(listAgentConfigBundles).mockResolvedValue({ bundles: [] }); vi.mocked(listRemoteCredentials).mockResolvedValue({ auth_specs: [ { id: "codex-acp", - display_name: "Codex", + display_name: codexAgentName, methods: [ { method_id: codexFileMethodId, @@ -90,34 +129,37 @@ describe("RemoteCredentialsCard", () => { const onChange = vi.fn(); renderRemoteCredentialsCard(onChange); - fireEvent.click(await screen.findByText("Codex")); - const fileOption = screen.getByRole("radio", { name: copyAuthFilesLabel }); - expect(fileOption.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(await screen.findByText(codexAgentName)); + const fileOption = screen.getByRole("checkbox", { name: copyAuthFilesLabel }); + expect(fileOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(UNCHECKED_STATE); fireEvent.click(fileOption); expect(onChange).toHaveBeenCalledWith([codexFileMethodId]); await waitFor(() => { - const selectedOption = screen.getByRole("radio", { name: copyAuthFilesLabel }); - expect(selectedOption.getAttribute("aria-checked")).toBe("true"); + expect( + screen + .getByRole("checkbox", { name: copyAuthFilesLabel }) + .getAttribute(DATA_STATE_ATTRIBUTE), + ).toBe(CHECKED_STATE); }); }); it("marks the selected credential and owning card dirty", async () => { renderRemoteCredentialsCard(); - fireEvent.click(await screen.findByText("Codex")); - const fileOption = screen.getByRole("radio", { name: copyAuthFilesLabel }); + fireEvent.click(await screen.findByText(codexAgentName)); + const fileOption = screen.getByRole("checkbox", { name: copyAuthFilesLabel }); fireEvent.click(fileOption); await waitFor(() => { - expect(fileOption.getAttribute("data-settings-dirty")).toBe("true"); + expect(fileOption.closest("label")?.getAttribute("data-settings-dirty")).toBe(TRUE_ATTRIBUTE); expect( screen .getByText("Remote Credentials") .closest("[data-settings-dirty]") ?.getAttribute("data-settings-dirty"), - ).toBe("true"); + ).toBe(TRUE_ATTRIBUTE); }); }); @@ -127,20 +169,184 @@ describe("RemoteCredentialsCard", () => { it("renders the env-var secret hint as one sentence with the variable inline", async () => { renderRemoteCredentialsCard(); - fireEvent.click(await screen.findByText("Codex")); - const envOption = screen.getByRole("radio", { name: "Provide secret" }); + fireEvent.click(await screen.findByText(codexAgentName)); + const envOption = screen.getByRole("checkbox", { name: provideSecretLabel }); - expect(envOption.textContent).toContain("Set OPENAI_API_KEY via a stored secret"); - expect(envOption.querySelector("code")?.textContent).toBe("OPENAI_API_KEY"); + const envOptionLabel = envOption.closest("label"); + expect(envOptionLabel?.textContent).toContain("Set OPENAI_API_KEY via a stored secret"); + expect(envOptionLabel?.querySelector("code")?.textContent).toBe("OPENAI_API_KEY"); }); it("appends the not-found note to the auth file list as one string", async () => { renderRemoteCredentialsCard(); - fireEvent.click(await screen.findByText("Codex")); + fireEvent.click(await screen.findByText(codexAgentName)); expect( screen.getByText(".codex/auth.json, .codex/config.toml; files not found on this machine"), ).toBeTruthy(); }); + + it("places configuration choices inside the matching agent accordion", async () => { + vi.mocked(listAgentConfigBundles).mockResolvedValueOnce({ + bundles: [PORTABLE_BUNDLES[1]], + }); + + renderRemoteCredentialsCard(); + + await screen.findByText(codexAgentName); + await waitFor(() => { + expect(screen.queryByRole("checkbox", { name: copyCodexConfigLabel })).toBeNull(); + }); + + fireEvent.click(screen.getByText(codexAgentName)); + + const options = await screen.findByTestId("agent-config-options-codex-acp"); + expect(options).toBeTruthy(); + expect(screen.queryByText("Agent configuration")).toBeNull(); + expect(options.contains(screen.getByRole("checkbox", { name: copyCodexConfigLabel }))).toBe( + true, + ); + }); + + it("keeps authentication and configuration selections independent inside one agent", async () => { + vi.mocked(listAgentConfigBundles).mockResolvedValueOnce({ + bundles: [{ ...PORTABLE_BUNDLES[1], available: true }], + }); + const onAuthChange = vi.fn(); + const onConfigChange = vi.fn(); + + renderRemoteCredentialsCard(onAuthChange, onConfigChange); + fireEvent.click(await screen.findByText(codexAgentName)); + + const authOption = screen.getByRole("checkbox", { name: copyAuthFilesLabel }); + fireEvent.click(authOption); + const configOptions = await screen.findByTestId("agent-config-options-codex-acp"); + fireEvent.click(within(configOptions).getByRole("checkbox", { name: copyCodexConfigLabel })); + + expect(authOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE); + expect(onAuthChange).toHaveBeenCalledWith([codexFileMethodId]); + expect(onConfigChange).toHaveBeenCalledWith([codexConfigBundleId]); + }); +}); + +describe("RemoteCredentialsCard auth selections", () => { + it("allows file and secret authentication methods at the same time", async () => { + const onAuthChange = vi.fn(); + renderRemoteCredentialsCard(onAuthChange); + + fireEvent.click(await screen.findByText(codexAgentName)); + const fileOption = screen.getByRole("checkbox", { name: copyAuthFilesLabel }); + const secretOption = screen.getByRole("checkbox", { name: provideSecretLabel }); + + fireEvent.click(fileOption); + fireEvent.click(secretOption); + + expect(fileOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE); + expect(secretOption.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE); + expect(onAuthChange).toHaveBeenLastCalledWith([ + codexFileMethodId, + "agent:codex-acp:env:OPENAI_API_KEY", + ]); + }); +}); + +const PORTABLE_BUNDLES: AgentConfigBundle[] = [ + { + id: "claude.settings", + agent_id: "claude-acp", + display_name: "Claude settings", + label: "Claude settings", + available: true, + files: [ + { + source_path: ".claude/settings.json", + target_path: ".claude/settings.json", + available: true, + }, + ], + }, + { + id: codexConfigBundleId, + agent_id: "codex-acp", + display_name: "Codex configuration", + label: "Codex configuration", + available: false, + files: [ + { source_path: ".codex/config.toml", target_path: ".codex/config.toml", available: false }, + ], + }, +]; + +describe("AgentConfigOptions", () => { + it("keeps configuration selection independent from authentication", () => { + const onChange = vi.fn(); + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("checkbox", { name: "Copy Claude settings" })); + rerender( + , + ); + + expect(onChange).toHaveBeenCalledWith(["claude.settings"]); + expect( + screen.getByTestId("agent-config-options-codex-acp").getAttribute("data-settings-dirty"), + ).toBe(TRUE_ATTRIBUTE); + }); + + it("disables new unavailable bundles but keeps saved ones removable", () => { + const onChange = vi.fn(); + const { rerender } = render( + , + ); + + const checkbox = screen.getByRole("checkbox", { name: copyCodexConfigLabel }); + expect((checkbox as HTMLInputElement).disabled).toBe(true); + expect(screen.getByText("Not found")).toBeTruthy(); + expect(screen.getByText(/editing hint/i)).toBeTruthy(); + + rerender( + , + ); + const savedCheckbox = screen.getByRole("checkbox", { name: copyCodexConfigLabel }); + expect((savedCheckbox as HTMLInputElement).disabled).toBe(false); + fireEvent.click(savedCheckbox); + expect(onChange).toHaveBeenCalledWith([]); + rerender( + , + ); + }); }); diff --git a/apps/web/components/settings/profile-edit/remote-credentials-card.tsx b/apps/web/components/settings/profile-edit/remote-credentials-card.tsx index 4c717cd490..114d65ffb7 100644 --- a/apps/web/components/settings/profile-edit/remote-credentials-card.tsx +++ b/apps/web/components/settings/profile-edit/remote-credentials-card.tsx @@ -1,43 +1,31 @@ "use client"; -import { useEffect, useState, type ReactNode } from "react"; -import { Trans } from "react-i18next"; -import ReactMarkdown from "react-markdown"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { IconLoader2 } from "@tabler/icons-react"; -import { Badge } from "@kandev/ui/badge"; import { CardContent, CardHeader, CardTitle, CardDescription } from "@kandev/ui/card"; -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@kandev/ui/accordion"; -import { AgentLogo } from "@/components/agent-logo"; -import { InlineSecretSelect } from "@/components/settings/profile-edit/inline-secret-select"; +import { Accordion } from "@kandev/ui/accordion"; import { SettingsCard } from "@/components/settings/settings-card"; import { GitIdentityAccordionItem, type GitIdentityMode, type GitIdentityState, } from "./git-identity-fields"; -import { - listRemoteCredentials, - type RemoteAuthSpec, - type RemoteAuthMethod, -} from "@/lib/api/domains/settings-api"; +import { listRemoteCredentials, type RemoteAuthSpec } from "@/lib/api/domains/settings-api"; +import { listAgentConfigBundles, type AgentConfigBundle } from "@/lib/api/domains/agent-config-api"; import type { SecretListItem } from "@/lib/types/http-secrets"; -import { useTranslation } from "react-i18next"; - -type AuthChoice = "files" | "env" | "none"; +import { AuthSection, getSpecMethods } from "./remote-auth-section"; export type { GitIdentityMode, GitIdentityState } from "./git-identity-fields"; -const RADIO_LABEL_BASE = - "flex w-full items-start gap-3 rounded-md border p-3 text-left cursor-pointer transition-colors"; -const SELECTED_BORDER = "border-primary bg-primary/5"; -const DEFAULT_BORDER = "border-border"; -const OPTION_DOT_BASE = - "mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border"; - type RemoteCredentialsCardProps = { isRemote: boolean; selectedIds: string[]; baselineSelectedIds?: string[]; onChange: (ids: string[]) => void; + configBundleIds: string[]; + baselineConfigBundleIds?: string[]; + onConfigBundleChange: (ids: string[]) => void; + isSSH?: boolean; agentEnvVars: Record; baselineAgentEnvVars?: Record; onAgentEnvVarChange: (methodId: string, secretId: string | null) => void; @@ -59,6 +47,10 @@ export function RemoteCredentialsCard({ selectedIds, baselineSelectedIds = [], onChange, + configBundleIds, + baselineConfigBundleIds = [], + onConfigBundleChange, + isSSH = false, agentEnvVars, baselineAgentEnvVars = {}, onAgentEnvVarChange, @@ -74,26 +66,24 @@ export function RemoteCredentialsCard({ onGitUserEmailChange, localGitIdentity, }: RemoteCredentialsCardProps) { - const { t } = useTranslation(); - const [authSpecs, setAuthSpecs] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - listRemoteCredentials() - .then((res) => setAuthSpecs(res.auth_specs ?? [])) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - + const { authSpecs, configBundles, loading } = useRemoteCredentialCatalog(); + const isDirty = hasRemoteCredentialsChanges({ + selectedIds, + baselineSelectedIds, + configBundleIds, + baselineConfigBundleIds, + agentEnvVars, + baselineAgentEnvVars, + gitIdentityMode, + baselineGitIdentityMode, + gitUserName, + gitUserEmail, + baselineGitUserName, + baselineGitUserEmail, + }); const selectedSet = new Set(selectedIds); const baselineSelectedSet = new Set(baselineSelectedIds); - const credentialsDirty = !sameStringSet(selectedSet, baselineSelectedSet); - const agentEnvVarsDirty = JSON.stringify(agentEnvVars) !== JSON.stringify(baselineAgentEnvVars); - const gitIdentityDirty = - gitIdentityMode !== baselineGitIdentityMode || - (gitIdentityMode === "override" && - (gitUserName !== baselineGitUserName || gitUserEmail !== baselineGitUserEmail)); - const isDirty = credentialsDirty || agentEnvVarsDirty || gitIdentityDirty; + const agentSections = mergeAgentSections(authSpecs, configBundles); if (loading) { return ; @@ -101,57 +91,76 @@ export function RemoteCredentialsCard({ return ( - - {t("executors:remoteCredentials")} - {t("executors:configureAuthenticationForToolsAndAgents")} - + - {authSpecs.length > 0 || isRemote ? ( - - {isRemote && ( - - )} - {authSpecs.map((spec) => { - const methods = getSpecMethods(spec); - const envMethod = methods.find((m) => m.type === "env"); - return ( - 0 || isRemote ? ( + <> + + {isRemote && ( + - ); - })} - + )} + {agentSections.map(({ spec, configBundles: agentConfigBundles }) => { + const methods = getSpecMethods(spec); + const envMethod = methods.find((m) => m.type === "env"); + return ( + + ); + })} + + ) : ( -

- {t("executors:noTransferableCredentialsFound")} -

+ )}
); } +function RemoteCredentialsHeader() { + const { t } = useTranslation(); + return ( + + {t("executors:remoteCredentials")} + {t("executors:configureAuthenticationForToolsAndAgents")} + + ); +} + +function NoTransferableCredentials() { + const { t } = useTranslation(); + return ( +

{t("executors:noTransferableCredentialsFound")}

+ ); +} + function RemoteCredentialsLoading({ isDirty }: { isDirty: boolean }) { const { t } = useTranslation(); return ( @@ -169,372 +178,100 @@ function RemoteCredentialsLoading({ isDirty }: { isDirty: boolean }) { ); } +function useRemoteCredentialCatalog() { + const [authSpecs, setAuthSpecs] = useState([]); + const [configBundles, setConfigBundles] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + void Promise.allSettled([listRemoteCredentials(), listAgentConfigBundles()]).then( + ([authResult, configResult]) => { + if (authResult.status === "fulfilled") setAuthSpecs(authResult.value.auth_specs ?? []); + if (configResult.status === "fulfilled") setConfigBundles(configResult.value.bundles ?? []); + setLoading(false); + }, + ); + }, []); + + return { authSpecs, configBundles, loading }; +} + +type RemoteAgentSection = { + spec: RemoteAuthSpec; + configBundles: AgentConfigBundle[]; +}; + +function mergeAgentSections( + authSpecs: RemoteAuthSpec[], + configBundles: AgentConfigBundle[], +): RemoteAgentSection[] { + const sections = new Map(); + + for (const spec of authSpecs) { + sections.set(spec.id, { spec, configBundles: [] }); + } + + for (const bundle of configBundles) { + const section = sections.get(bundle.agent_id); + if (section) { + section.configBundles.push(bundle); + continue; + } + + sections.set(bundle.agent_id, { + spec: { + id: bundle.agent_id, + display_name: bundle.display_name, + methods: [], + }, + configBundles: [bundle], + }); + } + + return [...sections.values()]; +} + +function hasRemoteCredentialsChanges({ + selectedIds, + baselineSelectedIds, + configBundleIds, + baselineConfigBundleIds, + agentEnvVars, + baselineAgentEnvVars, + gitIdentityMode, + baselineGitIdentityMode, + gitUserName, + gitUserEmail, + baselineGitUserName, + baselineGitUserEmail, +}: Pick< + RemoteCredentialsCardProps, + | "selectedIds" + | "baselineSelectedIds" + | "configBundleIds" + | "baselineConfigBundleIds" + | "agentEnvVars" + | "baselineAgentEnvVars" + | "gitIdentityMode" + | "baselineGitIdentityMode" + | "gitUserName" + | "gitUserEmail" + | "baselineGitUserName" + | "baselineGitUserEmail" +>): boolean { + const credentialsDirty = !sameStringSet(new Set(selectedIds), new Set(baselineSelectedIds)); + const configBundlesDirty = !sameStringSet( + new Set(configBundleIds), + new Set(baselineConfigBundleIds), + ); + const agentEnvVarsDirty = JSON.stringify(agentEnvVars) !== JSON.stringify(baselineAgentEnvVars); + const gitIdentityDirty = + gitIdentityMode !== baselineGitIdentityMode || + (gitIdentityMode === "override" && + (gitUserName !== baselineGitUserName || gitUserEmail !== baselineGitUserEmail)); + return credentialsDirty || configBundlesDirty || agentEnvVarsDirty || gitIdentityDirty; +} + function sameStringSet(left: Set, right: Set): boolean { return left.size === right.size && [...left].every((value) => right.has(value)); } - -function getSpecMethods(spec: RemoteAuthSpec): RemoteAuthMethod[] { - return Array.isArray(spec.methods) ? spec.methods : []; -} - -type InitialChoiceOpts = { - fileMethod: RemoteAuthMethod | undefined; - envMethod: RemoteAuthMethod | undefined; - selectedIds: Set; - envSecretId: string | null; -}; - -function initialChoice(opts: InitialChoiceOpts): AuthChoice { - if (opts.fileMethod && opts.selectedIds.has(opts.fileMethod.method_id)) return "files"; - // Treat env as selected either when the user just clicked the radio (its - // method id is in selectedIds, no secret picked yet) or when a secret is - // already persisted. Without the selectedIds branch, first-time env setup - // for an agent that exposes both `files` and `env` methods is broken: the - // radio click never updates state, `choice` re-derives to "none", the env - // option deselects, and the secret dropdown disappears before the user can - // pick anything. - if (opts.envMethod && (opts.selectedIds.has(opts.envMethod.method_id) || opts.envSecretId)) - return "env"; - return "none"; -} - -const AGENT_LOGO_IDS = new Set(["claude_code", "auggie", "codex", "gemini", "copilot", "amp"]); - -function AuthSection({ - spec, - selectedIds, - baselineSelectedIds, - onCredentialsChange, - envSecretId, - baselineEnvSecretId, - onMethodSecretChange, - secrets, -}: { - spec: RemoteAuthSpec; - selectedIds: Set; - baselineSelectedIds: Set; - onCredentialsChange: (ids: string[]) => void; - envSecretId: string | null; - baselineEnvSecretId: string | null; - onMethodSecretChange: (methodId: string, secretId: string | null) => void; - secrets: SecretListItem[]; -}) { - const methods = getSpecMethods(spec); - const envMethod = methods.find((m) => m.type === "env"); - const fileMethod = methods.find((m) => m.type === "files"); - const hasOnlyEnv = envMethod && !fileMethod; - - // `choice` is derived from props so the configured-status badge updates live - // when the user picks a secret in the dropdown (which only flows back through - // `envSecretId`). Holding it in useState would freeze the badge to its initial - // value until a full page reload. - const choice: AuthChoice = initialChoice({ - fileMethod, - envMethod, - selectedIds, - envSecretId, - }); - const baselineChoice = initialChoice({ - fileMethod, - envMethod, - selectedIds: baselineSelectedIds, - envSecretId: baselineEnvSecretId, - }); - const isDirty = choice !== baselineChoice || envSecretId !== baselineEnvSecretId; - - const handleChoice = (value: AuthChoice) => { - const nextSelectedIds = new Set(selectedIds); - if (fileMethod) { - setMethodSelected(nextSelectedIds, fileMethod.method_id, value === "files"); - } - if (envMethod) { - // Track env in selectedIds so `initialChoice` stays "env" while the - // user is still picking a secret. Switching away clears the secret too. - setMethodSelected(nextSelectedIds, envMethod.method_id, value === "env"); - if (value !== "env") { - onMethodSecretChange(envMethod.method_id, null); - } - } - onCredentialsChange([...nextSelectedIds]); - }; - - const showLogo = AGENT_LOGO_IDS.has(spec.id); - - return ( - - -
- {showLogo && } - {spec.display_name} - -
-
- -
- {hasOnlyEnv && envMethod ? ( - onMethodSecretChange(envMethod.method_id, sid)} - secrets={secrets} - /> - ) : ( - { - if (envMethod) onMethodSecretChange(envMethod.method_id, sid); - }} - secrets={secrets} - /> - )} -
-
-
- ); -} - -function EnvOnlySection({ - envMethod, - secretId, - baselineSecretId, - onSecretIdChange, - secrets, -}: { - envMethod: RemoteAuthMethod; - secretId: string | null; - baselineSecretId: string | null; - onSecretIdChange: (id: string | null) => void; - secrets: SecretListItem[]; -}) { - const { t } = useTranslation(); - return ( - <> - {envMethod.setup_hint && ( -
- {envMethod.setup_hint} -
- )} - - - ); -} - -function FileOption({ - method, - isSelected, - isDirty, - filesAvailable, - onSelect, -}: { - method: RemoteAuthMethod; - isSelected: boolean; - isDirty: boolean; - filesAvailable: boolean; - onSelect: () => void; -}) { - const { t } = useTranslation(); - const filesLabel = method.source_files?.join(", ") ?? ""; - return ( - -
- {method.label ?? t("executors:copyAuthFiles")} - - {filesAvailable - ? filesLabel - : t("executors:authFilesNotFoundOnThisMachine", { files: filesLabel })} - -
-
- ); -} - -function EnvOption({ - method, - isSelected, - isDirty, - secretId, - baselineSecretId, - onSecretIdChange, - secrets, - onSelect, -}: { - method: RemoteAuthMethod; - isSelected: boolean; - isDirty: boolean; - secretId: string | null; - baselineSecretId: string | null; - onSecretIdChange: (id: string | null) => void; - secrets: SecretListItem[]; - onSelect: () => void; -}) { - const { t } = useTranslation(); - return ( -
- -
- {t("executors:provideSecret")} - - - Set {method.env_var} via a - stored secret - - - {method.setup_hint && ( -
- {method.setup_hint} -
- )} -
-
- {isSelected && ( -
- -
- )} -
- ); -} - -function AuthChoiceRadio({ - choice, - baselineChoice, - onChoiceChange, - fileMethod, - envMethod, - secretId, - baselineSecretId, - onSecretIdChange, - secrets, -}: { - choice: AuthChoice; - baselineChoice: AuthChoice; - onChoiceChange: (v: AuthChoice) => void; - fileMethod?: RemoteAuthMethod; - envMethod?: RemoteAuthMethod; - secretId: string | null; - baselineSecretId: string | null; - onSecretIdChange: (id: string | null) => void; - secrets: SecretListItem[]; -}) { - const { t } = useTranslation(); - return ( -
- {fileMethod && ( - onChoiceChange("files")} - /> - )} - {envMethod?.env_var && ( - onChoiceChange("env")} - /> - )} -
- ); -} - -function AuthOptionButton({ - selected, - isDirty, - onSelect, - label, - children, -}: { - selected: boolean; - isDirty: boolean; - onSelect: () => void; - label: string; - children: ReactNode; -}) { - return ( - - ); -} - -function setMethodSelected(selectedIds: Set, methodId: string, selected: boolean) { - if (selected) { - selectedIds.add(methodId); - return; - } - selectedIds.delete(methodId); -} - -function AuthStatusBadge({ choice, hasSecret }: { choice: AuthChoice; hasSecret: boolean }) { - const { t } = useTranslation(); - if (choice === "env" && hasSecret) { - return ( - - {t("executors:configured")} - - ); - } - if (choice === "files") { - return ( - - {t("executors:filesSelected")} - - ); - } - return ( - - {t("executors:notConfigured")} - - ); -} diff --git a/apps/web/components/settings/profile-edit/serialize-executor-config.test.ts b/apps/web/components/settings/profile-edit/serialize-executor-config.test.ts new file mode 100644 index 0000000000..789c16b9f9 --- /dev/null +++ b/apps/web/components/settings/profile-edit/serialize-executor-config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { buildSaveConfig, type ExecutorProfileConfigForm } from "./serialize-executor-config"; + +function form(overrides: Partial = {}): ExecutorProfileConfigForm { + return { + isSprites: false, + networkPolicyRules: [], + isRemote: true, + remoteCredentials: [], + configBundleIds: [], + agentEnvVars: {}, + gitIdentityMode: "override", + localGitIdentity: { userName: "", userEmail: "" }, + gitUserName: "", + gitUserEmail: "", + isDocker: false, + dockerfile: "", + imageTag: "", + isSSH: false, + sshShell: "", + ...overrides, + }; +} + +describe("buildSaveConfig", () => { + it("persists selected configuration without requiring authentication", () => { + const config = buildSaveConfig(form({ configBundleIds: ["mock.settings"] }), { + remote_credentials: "stale", + keep: "yes", + }); + + expect(config).toEqual({ + agent_config_bundles: '["mock.settings"]', + keep: "yes", + }); + }); + + it("persists authentication without requiring configuration", () => { + const config = buildSaveConfig(form({ remoteCredentials: ["codex-auth"] })); + + expect(config).toEqual({ remote_credentials: '["codex-auth"]' }); + }); +}); diff --git a/apps/web/components/settings/profile-edit/serialize-executor-config.ts b/apps/web/components/settings/profile-edit/serialize-executor-config.ts new file mode 100644 index 0000000000..4acffd3017 --- /dev/null +++ b/apps/web/components/settings/profile-edit/serialize-executor-config.ts @@ -0,0 +1,62 @@ +import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; + +export type ExecutorProfileConfigForm = { + isSprites: boolean; + networkPolicyRules: NetworkPolicyRule[]; + isRemote: boolean; + remoteCredentials: string[]; + configBundleIds: string[]; + agentEnvVars: Record; + gitIdentityMode: "local" | "override"; + localGitIdentity: { userName: string; userEmail: string }; + gitUserName: string; + gitUserEmail: string; + isDocker: boolean; + dockerfile: string; + imageTag: string; + isSSH: boolean; + sshShell: string; +}; + +export function buildSaveConfig( + form: ExecutorProfileConfigForm, + baseConfig: Record = {}, +): Record { + const config = { ...baseConfig }; + setJsonConfig(config, "sprites_network_policy_rules", form.isSprites, form.networkPolicyRules); + setJsonConfig(config, "remote_credentials", form.isRemote, form.remoteCredentials); + setJsonConfig(config, "agent_config_bundles", form.isRemote, form.configBundleIds); + const envVars = Object.fromEntries( + Object.entries(form.agentEnvVars).filter(([, v]) => v != null), + ); + setJsonConfig(config, "remote_auth_secrets", form.isRemote, envVars); + + const gitName = + form.gitIdentityMode === "local" ? form.localGitIdentity.userName : form.gitUserName; + const gitEmail = + form.gitIdentityMode === "local" ? form.localGitIdentity.userEmail : form.gitUserEmail; + setTextConfig(config, "git_user_name", form.isRemote ? gitName.trim() : ""); + setTextConfig(config, "git_user_email", form.isRemote ? gitEmail.trim() : ""); + setTextConfig(config, "dockerfile", form.isDocker ? form.dockerfile : ""); + setTextConfig(config, "image_tag", form.isDocker ? form.imageTag.trim() : ""); + setTextConfig(config, "ssh_shell", form.isSSH ? form.sshShell.trim() : ""); + return config; +} + +function setJsonConfig( + config: Record, + key: string, + enabled: boolean, + value: unknown, +): void { + const hasValue = Array.isArray(value) + ? value.length > 0 + : value !== null && typeof value === "object" && Object.keys(value).length > 0; + if (enabled && hasValue) config[key] = JSON.stringify(value); + else delete config[key]; +} + +function setTextConfig(config: Record, key: string, value: string): void { + if (value && value.trim()) config[key] = value; + else delete config[key]; +} diff --git a/apps/web/components/task-create-dialog-options.test.tsx b/apps/web/components/task-create-dialog-options.test.tsx index 0bbbf170f0..6a0ca2a25e 100644 --- a/apps/web/components/task-create-dialog-options.test.tsx +++ b/apps/web/components/task-create-dialog-options.test.tsx @@ -147,11 +147,11 @@ describe("useAgentProfileOptions enabled filter", () => { }); }); -describe("useAgentProfileOptions no-silent-model-fallback gating", () => { - it("blocks a profile whose start model is gone (strict mode)", () => { +describe("useAgentProfileOptions executor-authoritative model hint", () => { + it("keeps a profile whose start model is absent from the host probe selectable", () => { const option = renderOptions([profileOption({ model: GONE_MODEL })]); - expect(option.getAttribute(DATA_DISABLED)).toBe("true"); - expect(option.getAttribute("data-reason")).toContain(GONE_MODEL); + expect(option.getAttribute(DATA_DISABLED)).toBeNull(); + expect(screen.getByTitle(/claude-gone/)).not.toBeNull(); }); it("keeps a profile with an available start model selectable", () => { @@ -164,27 +164,26 @@ describe("useAgentProfileOptions no-silent-model-fallback gating", () => { expect(option.getAttribute(DATA_DISABLED)).toBeNull(); }); - it("keeps a gone-model profile with a fallback selectable and warns", () => { + it("keeps a gone-model profile with a fallback selectable", () => { const option = renderOptions([profileOption({ model: GONE_MODEL, fallback_model: "gpt-5" })]); expect(option.getAttribute(DATA_DISABLED)).toBeNull(); expect(option.getAttribute("data-reason")).toBeNull(); - // The amber warning icon carries the fallback note. - expect(screen.getByTitle(/gpt-5/)).not.toBeNull(); + expect(screen.getByTitle(/claude-gone/)).not.toBeNull(); }); - it("blocks a profile whose fallback model is also gone (both-gone)", () => { + it("keeps a profile selectable when both saved models are absent from the host probe", () => { const option = renderOptions([ profileOption({ model: GONE_MODEL, fallback_model: "other-gone" }), ]); - expect(option.getAttribute(DATA_DISABLED)).toBe("true"); - expect(option.getAttribute("data-reason")).toContain(GONE_MODEL); + expect(option.getAttribute(DATA_DISABLED)).toBeNull(); + expect(screen.getByTitle(/claude-gone/)).not.toBeNull(); }); - it("keeps a gone-model profile with auto-fallback selectable without a warning", () => { + it("keeps auto-fallback profiles selectable and shows the host probe hint", () => { const option = renderOptions([profileOption({ model: GONE_MODEL, auto_fallback: true })]); expect(option.getAttribute(DATA_DISABLED)).toBeNull(); - expect(screen.queryByTitle(/claude-gone/)).toBeNull(); + expect(screen.getByTitle(/claude-gone/)).not.toBeNull(); }); it("does not gate when the agent model list is unknown (probe not landed)", () => { diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index 2ad97e09d1..af6797fd5d 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -142,45 +142,21 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt const profileLabel = parts[1] ?? ""; const isPassthrough = profile.cli_passthrough === true; const warning = getCapabilityWarning(profile.capability_status, profile.capability_error); - // No-silent-model-fallback: a profile whose start model is no longer - // advertised ("gone") is blocked from selection unless it opted into - // an explicit fallback model (shown as a warning) or the legacy - // auto-fallback toggle. - // - // The advertised list is the host-utility probe cache. While it has - // not loaded yet (empty list) gone-ness is unknown and deliberately - // NOT assumed: nothing is blocked and no warning is shown. The backend - // rejects the launch if the model is genuinely unavailable, so this - // startup window is cosmetic. A fallback model that is itself gone - // does not make the profile selectable — it would promise a switch - // SetModel cannot apply, so the profile is blocked like strict mode. + // The host-utility probe is an editing hint only. The selected + // executor owns the launch-time model catalog, so a host-only mismatch + // must never remove a profile from the task selector. const advertised = advertisedModelIDs(availableAgents, profile.agent_name); const startModelGone = Boolean( profile.model && advertised.length > 0 && !advertised.includes(profile.model), ); - const fallbackGone = Boolean( - profile.fallback_model && - advertised.length > 0 && - !advertised.includes(profile.fallback_model), - ); - const autoFallbackOn = profile.auto_fallback === true; - const fallbackUsable = Boolean(profile.fallback_model) && !fallbackGone; - const blocked = startModelGone && !autoFallbackOn && !fallbackUsable; - const fallbackWarning = startModelGone && !autoFallbackOn && fallbackUsable; - const disabledReason = blocked - ? t("settings:profileStartModelUnavailable", { model: profile.model }) - : undefined; - const fallbackNote = fallbackWarning - ? t("settings:profileFallbackWillBeUsed", { - model: profile.model, - fallback: profile.fallback_model, - }) + const modelProbeNote = startModelGone + ? t("settings:profileStartModelNotAdvertisedOnHost", { model: profile.model }) : undefined; return { value: profile.id, label: profile.label, - disabled: blocked || undefined, - disabledReason, + disabled: undefined, + disabledReason: undefined, renderLabel: () => ( @@ -190,10 +166,10 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt {warning && ( )} - {fallbackNote && ( + {modelProbeNote && ( )} @@ -211,10 +187,10 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt ) : null} - {fallbackNote && ( + {modelProbeNote && ( - {fallbackNote} + {modelProbeNote} )} diff --git a/apps/web/components/task/chat/messages/status-message.test.tsx b/apps/web/components/task/chat/messages/status-message.test.tsx new file mode 100644 index 0000000000..f3ba24c9ac --- /dev/null +++ b/apps/web/components/task/chat/messages/status-message.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { StatusMessage } from "./status-message"; +import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http"; + +afterEach(cleanup); + +function modelSelectionWarningMessage(): Message { + return { + id: "status-1", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + author_type: "agent", + content: "", + type: "status", + created_at: "2026-08-15T00:00:00Z", + metadata: { + variant: "warning", + kind: "model_selection_warning", + reason: "requested_not_advertised", + requested_model: "claude-sonnet-4", + effective_model: "claude-haiku-4", + agent_id: "claude-acp", + executor_type: "ssh", + executor_profile_id: "executor-1", + remediation: ["executor_credentials", "copied_agent_configuration", "agent_version"], + }, + }; +} + +describe("StatusMessage model selection warnings", () => { + it("renders the persisted decision context and remediation guidance", () => { + render(); + + expect(screen.getByText("The executor could not use the saved model selection.")).toBeTruthy(); + expect(screen.getByText("claude-sonnet-4")).toBeTruthy(); + expect(screen.getByText("claude-haiku-4")).toBeTruthy(); + expect(screen.getByText("claude-acp")).toBeTruthy(); + expect(screen.getByText("executor-1")).toBeTruthy(); + expect(screen.getByText("Check executor credentials.")).toBeTruthy(); + expect(screen.getByText("Check the copied agent configuration.")).toBeTruthy(); + expect(screen.getByText("Check the agent version in the executor.")).toBeTruthy(); + expect(screen.getByText("The saved model was not advertised by the executor.")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/chat/messages/status-message.tsx b/apps/web/components/task/chat/messages/status-message.tsx index 909a151ad7..437dfeca54 100644 --- a/apps/web/components/task/chat/messages/status-message.tsx +++ b/apps/web/components/task/chat/messages/status-message.tsx @@ -21,6 +21,16 @@ interface ErrorMetadata extends StatusMetadata { stderr?: string[]; provider?: string; provider_agent?: string; + kind?: string; + decision_id?: string; + reason?: string; + requested_model?: string; + effective_model?: string; + fallback_model?: string; + agent_id?: string; + executor_type?: string; + executor_profile_id?: string; + remediation?: string[]; } function getStatusStyle(isError: boolean, isWarning: boolean) { @@ -134,10 +144,103 @@ function parseStatusMetadata(comment: Message) { const progress = typeof metadata?.progress === "number" ? Math.min(Math.max(metadata.progress, 0), 100) : null; const statusLine = metadata?.stage || metadata?.status; - const message = metadata?.message || comment.content || statusLine || t("task:statusUpdate"); - const isError = comment.type === "error" || metadata?.variant === "error"; - const isWarning = metadata?.variant === "warning" || metadata?.cancelled === true; - return { metadata, progress, statusLine, message, isError, isWarning }; + return { + metadata, + progress, + statusLine, + message: getStatusMessage(comment, metadata, statusLine), + isError: isErrorStatus(comment, metadata), + isWarning: isWarningStatus(metadata), + }; +} + +function getStatusMessage( + comment: Message, + metadata: ErrorMetadata | undefined, + statusLine: string | undefined, +): string { + if (metadata?.kind === "model_selection_warning") return t("task:modelSelectionWarning"); + return metadata?.message || comment.content || statusLine || t("task:statusUpdate"); +} + +function isErrorStatus(comment: Message, metadata: ErrorMetadata | undefined): boolean { + return comment.type === "error" || metadata?.variant === "error"; +} + +function isWarningStatus(metadata: ErrorMetadata | undefined): boolean { + return metadata?.variant === "warning" || metadata?.cancelled === true; +} + +function modelSelectionReasonLabel(reason: string | undefined): string { + const keyByReason: Record = { + requested_not_advertised: "task:modelSelectionReasonRequestedNotAdvertised", + fallback_not_advertised: "task:modelSelectionReasonFallbackNotAdvertised", + catalog_empty: "task:modelSelectionReasonCatalogEmpty", + selection_unsupported: "task:modelSelectionReasonUnsupported", + selection_failed_auto_fallback: "task:modelSelectionReasonAutoFallback", + }; + return reason + ? t(keyByReason[reason] ?? "task:modelSelectionReasonUnknown", { reason }) + : t("task:unknown"); +} + +function ModelSelectionWarningDetails({ metadata }: { metadata: ErrorMetadata }) { + const requested = metadata.requested_model || t("task:unknown"); + const effective = metadata.effective_model || t("task:modelSelectionProviderDefault"); + const remediation = Array.isArray(metadata.remediation) ? metadata.remediation : []; + const remediationKey: Record = { + executor_credentials: "task:modelSelectionRemediationCredentials", + copied_agent_configuration: "task:modelSelectionRemediationConfiguration", + agent_version: "task:modelSelectionRemediationAgentVersion", + }; + return ( +
+
+

+ {t("task:modelSelectionRequested")}: {requested} +

+

+ {t("task:modelSelectionEffective")}: {effective} +

+ {metadata.fallback_model && ( +

+ {t("task:modelSelectionFallback")}: {metadata.fallback_model} +

+ )} + {metadata.agent_id && ( +

+ {t("task:modelSelectionAgent")}: {metadata.agent_id} +

+ )} + {metadata.executor_type && ( +

+ {t("task:modelSelectionExecutor")}: {metadata.executor_type} +

+ )} + {metadata.executor_profile_id && ( +

+ {t("task:modelSelectionExecutorProfile")}: {metadata.executor_profile_id} +

+ )} +

+ {t("task:modelSelectionReason")}:{" "} + {modelSelectionReasonLabel(metadata.reason)} +

+
+ {remediation.length > 0 && ( +
+

{t("task:modelSelectionRemediationTitle")}

+
    + {remediation.map((item) => ( +
  • + {t(remediationKey[item] ?? "task:modelSelectionRemediationUnknown")} +
  • + ))} +
+
+ )} +
+ ); } function computeExpandableContent(isError: boolean, metadata: ErrorMetadata | undefined) { @@ -203,6 +306,7 @@ type StatusMessageBodyProps = { hasExpandableContent: boolean; progress: number | null; statusLine: string | undefined; + modelSelectionWarning?: ErrorMetadata; }; function StatusMessageBody({ @@ -213,6 +317,7 @@ function StatusMessageBody({ hasExpandableContent, progress, statusLine, + modelSelectionWarning, }: StatusMessageBodyProps) { const { t } = useTranslation(); return ( @@ -221,6 +326,7 @@ function StatusMessageBody({ {message || t("task:anErrorOccurred")} {isExpanded && hasExpandableContent && } + {modelSelectionWarning && } {progress !== null && } ); @@ -262,6 +368,9 @@ export const StatusMessage = memo(function StatusMessage({ comment }: { comment: hasExpandableContent={hasExpandableContent} progress={progress} statusLine={statusLine} + modelSelectionWarning={ + metadata?.kind === "model_selection_warning" ? metadata : undefined + } /> diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index c26851bfbc..2a96c056dd 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -943,6 +943,23 @@ export class ApiClient { return this.request("GET", "/api/v1/executors"); } + async listAgentConfigBundles(): Promise<{ + bundles: Array<{ + id: string; + agent_id: string; + display_name: string; + label: string; + available: boolean; + files: Array<{ + source_path: string; + target_path: string; + available: boolean; + }>; + }>; + }> { + return this.request("GET", "/api/v1/agent-config-bundles"); + } + /** * Inference-capable agents as the utility/review paths see them. The `id` here * is the registered agent-type id (e.g. "claude-acp"), which is what diff --git a/apps/web/e2e/tests/chat/agent-message-comments-helpers.ts b/apps/web/e2e/tests/chat/agent-message-comments-helpers.ts index c76b491bce..ffd509e1b1 100644 --- a/apps/web/e2e/tests/chat/agent-message-comments-helpers.ts +++ b/apps/web/e2e/tests/chat/agent-message-comments-helpers.ts @@ -7,6 +7,26 @@ import type { ApiClient } from "../../helpers/api-client"; export const AGENT_REPLY = "The settled answer contains a useful detail."; export const SELECTED_REPLY_TEXT = "settled answer"; +export async function waitForAgentSessionInput( + apiClient: ApiClient, + taskId: string, + sessionId: string, +) { + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(taskId); + const state = sessions.find((session) => session.id === sessionId)?.state; + return state === "IDLE" || state === "WAITING_FOR_INPUT"; + }, + { + timeout: 45_000, + message: `agent session ${sessionId} never became ready for direct input`, + }, + ) + .toBe(true); +} + export async function expectAgentMessageHighlight(body: Locator, expectedCount: number) { const highlightName = await body.getAttribute("data-agent-message-highlight-name"); if (!highlightName) throw new Error("Expected an agent message highlight name"); diff --git a/apps/web/e2e/tests/chat/mobile-agent-message-comments.spec.ts b/apps/web/e2e/tests/chat/mobile-agent-message-comments.spec.ts index 9b2705ec6f..848e039c5d 100644 --- a/apps/web/e2e/tests/chat/mobile-agent-message-comments.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-agent-message-comments.spec.ts @@ -5,6 +5,7 @@ import { openSeededQuickChatReply, SELECTED_REPLY_TEXT, selectAgentReplyText, + waitForAgentSessionInput, } from "./agent-message-comments-helpers"; async function expectTouchTarget(locator: Locator) { @@ -46,9 +47,9 @@ test.describe("Agent message comments on mobile", () => { "Mobile Agent Message Comments", ); - // Commenting only needs the seeded agent message. Do not wait for the - // composer to become editable because the mock reply may be visible while - // the session is still reporting a running status on mobile. + // Run sends directly only when the session is ready for input. The seeded + // reply can be visible before the backend reports that state on mobile. + await waitForAgentSessionInput(apiClient, task.id, task.session_id!); await selectAgentReplyText(body, SELECTED_REPLY_TEXT); const commentTrigger = testPage.getByTestId("agent-message-comment-trigger"); await expect(commentTrigger).toBeVisible(); diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts index d65c1598c2..88caaad113 100644 --- a/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts @@ -123,18 +123,18 @@ async function touchDragTo( // announce a droppable for it. Dispatching DragMove before that resolves no // target and the drop silently becomes a no-op. await expect.poll(announcedTarget, { timeout: 5_000 }).toContain(MOVED_OVER); + const initialAnnouncement = await announcedTarget(); - // The second move is a 2px nudge that re-dispatches DragMove against the - // same droppable, so it produces no NEW announcement to wait on: a second - // `toContain(MOVED_OVER)` here would be satisfied by the first move's - // announcement and assert nothing, and a `.not.toBe(previous)` would never - // become true. The wait above already proves the drag is live and measured, - // which is the precondition pointerup actually needs. + // The first move can announce the dragged row itself because it also crosses + // the sensor's activation distance. The next move must resolve the intended + // row before pointerup; otherwise dnd-kit accepts a self-drop and the test + // passes through the gesture without exercising reorder at all. await page.dispatchEvent("body", "pointermove", { ...down, clientX: end.x, clientY: end.y + 2, }); + await expect.poll(announcedTarget, { timeout: 5_000 }).not.toBe(initialAnnouncement); await page.dispatchEvent("body", "pointerup", { ...down, diff --git a/apps/web/e2e/tests/docker/agent-config-copy.spec.ts b/apps/web/e2e/tests/docker/agent-config-copy.spec.ts new file mode 100644 index 0000000000..a21e09be24 --- /dev/null +++ b/apps/web/e2e/tests/docker/agent-config-copy.spec.ts @@ -0,0 +1,121 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "../../fixtures/docker-test-base"; +import { E2E_IMAGE_TAG } from "../../fixtures/docker-probe"; +import { dockerFileContent } from "../../helpers/docker"; +import { waitForLatestSessionDone, waitForSessionDone } from "../../helpers/session"; + +function dockerPathExists(containerId: string, filePath: string): boolean { + return ( + spawnSync("docker", ["exec", containerId, "test", "-e", filePath], { + stdio: "ignore", + }).status === 0 + ); +} + +test.describe("Docker executor — portable agent configuration", () => { + test("copies selected files on fresh provisioning and preserves them on warm resume", async ({ + apiClient, + backend, + seedData, + }) => { + test.setTimeout(240_000); + + const sourceDir = path.join(backend.tmpDir, ".mock-agent"); + const sourceFile = path.join(sourceDir, "settings.json"); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(sourceFile, '{"source":"first"}\n'); + fs.writeFileSync(path.join(sourceDir, "unselected.json"), "must not be copied\n"); + + const { executors } = await apiClient.listExecutors(); + const dockerExecutor = executors.find((executor) => executor.type === "local_docker"); + expect(dockerExecutor).toBeTruthy(); + const profile = await apiClient.createExecutorProfile(dockerExecutor!.id, { + name: "E2E portable config Docker profile", + config: { + image_tag: E2E_IMAGE_TAG, + agent_config_bundles: JSON.stringify(["mock.settings"]), + }, + prepare_script: "", + cleanup_script: "", + env_vars: [], + }); + + try { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Docker portable agent configuration", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: profile.id, + }, + ); + await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for fresh Docker task"); + + const fresh = await apiClient.getTaskEnvironment(task.id); + expect(fresh?.container_id).toBeTruthy(); + expect(dockerFileContent(fresh!.container_id!, "/root/.mock-agent/settings.json")).toBe( + '{"source":"first"}\n', + ); + expect(dockerPathExists(fresh!.container_id!, "/root/.mock-agent/unselected.json")).toBe( + false, + ); + + fs.writeFileSync(sourceFile, '{"source":"second"}\n'); + const warm = await apiClient.launchSession({ + task_id: task.id, + agent_profile_id: seedData.agentProfileId, + executor_profile_id: profile.id, + workflow_step_id: seedData.startStepId, + prompt: "/e2e:simple-message", + }); + await waitForSessionDone( + apiClient, + task.id, + warm.session_id, + "Waiting for warm Docker resume", + ); + + const resumed = await apiClient.getTaskEnvironment(task.id); + expect(resumed?.container_id).toBe(fresh?.container_id); + expect(dockerFileContent(resumed!.container_id!, "/root/.mock-agent/settings.json")).toBe( + '{"source":"first"}\n', + ); + + const reset = await apiClient.rawRequest( + "POST", + `/api/v1/tasks/${task.id}/environment/reset`, + {}, + ); + expect(reset.status).toBe(200); + const relaunched = await apiClient.launchSession({ + task_id: task.id, + agent_profile_id: seedData.agentProfileId, + executor_profile_id: profile.id, + workflow_step_id: seedData.startStepId, + prompt: "/e2e:simple-message", + }); + await waitForSessionDone( + apiClient, + task.id, + relaunched.session_id, + "Waiting for reset Docker launch", + ); + + const resetEnvironment = await apiClient.getTaskEnvironment(task.id); + expect(resetEnvironment?.container_id).toBeTruthy(); + expect(resetEnvironment?.container_id).not.toBe(fresh?.container_id); + expect( + dockerFileContent(resetEnvironment!.container_id!, "/root/.mock-agent/settings.json"), + ).toBe('{"source":"second"}\n'); + expect(relaunched.session_id).toBeTruthy(); + } finally { + await apiClient.deleteExecutorProfile(profile.id).catch(() => {}); + } + }); +}); diff --git a/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts b/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts index fc968ec4d0..722e64ca5c 100644 --- a/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts +++ b/apps/web/e2e/tests/integrations/hide-disabled-integrations-nav.spec.ts @@ -1,6 +1,25 @@ +import type { Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import { SETTINGS_TAKEOVER_TESTID, setSettingsMenuMode } from "../../helpers/settings-menu"; +function sidebarGitHubRow(testPage: Page) { + const sidebar = testPage.getByTestId("app-sidebar"); + return sidebar.locator('a[href="/github"]:not([data-testid="integration-header-shortcut"])'); +} + +async function expandIntegrationsSection(testPage: Page) { + const sidebar = testPage.getByTestId("app-sidebar"); + const integrationsToggle = sidebar.getByRole("button", { + name: "Integrations", + exact: true, + }); + await expect(integrationsToggle).toHaveCount(1, { timeout: 10_000 }); + if ((await integrationsToggle.getAttribute("aria-expanded")) !== "true") { + await integrationsToggle.click(); + } + await expect(integrationsToggle).toHaveAttribute("aria-expanded", "true"); +} + // Covers docs/specs/integrations/enable-disable-toggle.md's nav-visibility // scenarios: with "Hide disabled integrations from left panel navigation" // off (the default), a disabled-but-configured integration still shows in @@ -17,15 +36,20 @@ test.describe("hide disabled integrations from left panel navigation", () => { await apiClient.mockGitHubSetUser("test-user"); await testPage.goto("/settings/integrations"); + const hideDisabledSwitch = testPage.locator("#hide-disabled-integrations-in-nav"); + if ((await hideDisabledSwitch.getAttribute("aria-checked")) === "true") { + await hideDisabledSwitch.click(); + } + await expect(hideDisabledSwitch).toHaveAttribute("aria-checked", "false"); const githubSwitch = testPage.locator("#github-enabled"); + if ((await githubSwitch.getAttribute("aria-checked")) === "false") { + await githubSwitch.click(); + } await expect(githubSwitch).toHaveAttribute("aria-checked", "true"); await githubSwitch.click(); await expect(githubSwitch).toHaveAttribute("aria-checked", "false"); - const hideDisabledSwitch = testPage.locator("#hide-disabled-integrations-in-nav"); - await expect(hideDisabledSwitch).toHaveAttribute("aria-checked", "false"); - await testPage .getByTestId("settings-floating-save") .getByRole("button", { name: "Save changes" }) @@ -35,7 +59,11 @@ test.describe("hide disabled integrations from left panel navigation", () => { // Leave Settings — the sidebar's Integrations section only renders // outside the Settings takeover. await testPage.goto("/tasks"); - const githubNavLink = testPage.getByRole("link", { name: "GitHub", exact: true }); + // The header also exposes an always-visible GitHub shortcut. Scope this + // assertion to the actual sidebar row so it tests the setting's contract + // instead of a separate shortcut surface. + await expandIntegrationsSection(testPage); + const githubNavLink = sidebarGitHubRow(testPage); await expect(githubNavLink).toBeVisible({ timeout: 10_000 }); // The Settings left panel's per-workspace Integrations tree is also part @@ -71,7 +99,7 @@ test.describe("hide disabled integrations from left panel navigation", () => { await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible(); await testPage.goto("/tasks"); - await expect(testPage.getByRole("link", { name: "GitHub", exact: true })).not.toBeVisible(); + await expect(sidebarGitHubRow(testPage)).toHaveCount(0); // The Settings left-panel Integrations tree hides it as well. await testPage.goto(integrationsPath); @@ -89,8 +117,7 @@ test.describe("hide disabled integrations from left panel navigation", () => { await expect(testPage.getByTestId("settings-floating-save")).not.toBeVisible(); await testPage.goto("/tasks"); - await expect(testPage.getByRole("link", { name: "GitHub", exact: true })).toBeVisible({ - timeout: 10_000, - }); + await expandIntegrationsSection(testPage); + await expect(sidebarGitHubRow(testPage)).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts b/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts index e6a1e183c4..0ac8d31b3e 100644 --- a/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/mobile-github-workspace-settings.spec.ts @@ -85,18 +85,19 @@ test.describe("GitHub workspace settings on mobile", () => { expect(drawerBox).not.toBeNull(); await expect .poll( - async () => { - const [currentScrollBox, currentFadeBox] = await Promise.all([ - scrollBody.boundingBox(), - scrollFade.boundingBox(), - ]); - if (!currentScrollBox || !currentFadeBox) return Number.POSITIVE_INFINITY; - return Math.abs( - currentFadeBox.y + - currentFadeBox.height - - (currentScrollBox.y + currentScrollBox.height), - ); - }, + () => + testPage.evaluate(() => { + const scrollElement = document.querySelector( + '[data-testid="github-connection-scroll"]', + ); + const fadeElement = document.querySelector( + '[data-testid="github-connection-scroll-fade"]', + ); + if (!scrollElement || !fadeElement) return Number.POSITIVE_INFINITY; + const scrollBox = scrollElement.getBoundingClientRect(); + const fadeBox = fadeElement.getBoundingClientRect(); + return Math.abs(fadeBox.bottom - scrollBox.bottom); + }), { timeout: 10_000 }, ) .toBeLessThanOrEqual(2); @@ -110,9 +111,6 @@ test.describe("GitHub workspace settings on mobile", () => { expect(fadeBox).not.toBeNull(); expect(footerBox).not.toBeNull(); expect(initialSaveBox).not.toBeNull(); - expect( - Math.abs(fadeBox!.y + fadeBox!.height - (scrollBox!.y + scrollBox!.height)), - ).toBeLessThanOrEqual(2); expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(drawerBox!.y + drawerBox!.height); const methodGroup = drawer.getByRole("radiogroup", { name: "Connection method" }); await expect(methodGroup.getByRole("radio").first()).toHaveAttribute("id", "github-method-cli"); diff --git a/apps/web/e2e/tests/plugins/bitbucket-plugin-contract.spec.ts b/apps/web/e2e/tests/plugins/bitbucket-plugin-contract.spec.ts index 07e9cd2381..73eb477c04 100644 --- a/apps/web/e2e/tests/plugins/bitbucket-plugin-contract.spec.ts +++ b/apps/web/e2e/tests/plugins/bitbucket-plugin-contract.spec.ts @@ -146,7 +146,7 @@ test.describe("Bitbucket plugin contract", () => { // error must not disclose the fixture's credential value. Exercise this // first because entity-source menus dispose their selected source after a // successful submission. - await testPage.getByText("Mock Fast", { exact: true }).click(); + await session.clickSessionChatTab(); await session.waitForChatIdle({ timeout: 30_000 }); const editor = visibleEditor(session.activeChat()); await editor.fill(""); diff --git a/apps/web/e2e/tests/plugins/composer-actions.spec.ts b/apps/web/e2e/tests/plugins/composer-actions.spec.ts index dbbbfb6ffe..b4bd03ff8f 100644 --- a/apps/web/e2e/tests/plugins/composer-actions.spec.ts +++ b/apps/web/e2e/tests/plugins/composer-actions.spec.ts @@ -216,7 +216,6 @@ test.describe("Plugins — composer capability", () => { await expect(dialog.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 30_000 }); await composerAction.getByTestId("e2e-composer-submit").click(); - await expect(composerAction).toHaveAttribute("data-status", "submitted"); // The native creation handler ran, not a plugin-built request: the app // navigated to a real task and the dictated prompt reached the agent. diff --git a/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts b/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts index 774079799b..3ec4e11093 100644 --- a/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts +++ b/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts @@ -172,8 +172,12 @@ async function selectOnlyMissingPRFromAddPanel( await expect(testPage.getByTestId(openItemTestId)).toHaveCount(0); await expect(item).toBeVisible({ timeout: 2_000 }); await item.click({ force: true }); - await expect(session.prDetailTab()).toHaveCount(2, { timeout: 2_000 }); - }).toPass({ timeout: 15_000, intervals: [100, 250, 500] }); + // Dockview adds the keyed panel asynchronously after the menu closes. On + // a loaded CI shard that can take longer than the menu interaction itself. + // Keep the bounded menu retry, but give the panel its full readiness window + // before retrying the click. + await expect(session.prDetailTab()).toHaveCount(2, { timeout: 10_000 }); + }).toPass({ timeout: 30_000, intervals: [100, 250, 500] }); } test.describe("Multi-PR CI popover", () => { diff --git a/apps/web/e2e/tests/pr/pr-watcher-merged-cleanup.spec.ts b/apps/web/e2e/tests/pr/pr-watcher-merged-cleanup.spec.ts index 5667b0f887..e84720a8f2 100644 --- a/apps/web/e2e/tests/pr/pr-watcher-merged-cleanup.spec.ts +++ b/apps/web/e2e/tests/pr/pr-watcher-merged-cleanup.spec.ts @@ -2,6 +2,7 @@ import { execSync } from "node:child_process"; import path from "node:path"; import { test, expect } from "../../fixtures/test-base"; import { makeGitEnv } from "../../helpers/git-helper"; +import { waitForSessionDone } from "../../helpers/session"; import { KanbanPage } from "../../pages/kanban-page"; test.describe("PR watcher merged cleanup", () => { @@ -216,7 +217,15 @@ test.describe("PR watcher merged cleanup", () => { // on the auto-started session. const { sessions } = await apiClient.listTaskSessions(prTask!.id); expect(sessions.length).toBeGreaterThan(0); - await apiClient.addUserMessage(prTask!.id, sessions[0].id, "thanks, looks good"); + const session = sessions[0]; + await waitForSessionDone( + apiClient, + prTask!.id, + session.id, + "auto-started review session did not become ready for user input", + 45_000, + ); + await apiClient.addUserMessage(prTask!.id, session.id, "thanks, looks good"); // Simulate PR merged await apiClient.mockGitHubAddPRs([ diff --git a/apps/web/e2e/tests/search/shared.ts b/apps/web/e2e/tests/search/shared.ts index 0735ececae..a845d673b3 100644 --- a/apps/web/e2e/tests/search/shared.ts +++ b/apps/web/e2e/tests/search/shared.ts @@ -1,6 +1,8 @@ import { type Page } from "@playwright/test"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; +import { waitForLatestSessionDone } from "../../helpers/session"; +import { waitForSessionAgentctlReady } from "../../helpers/session-store"; import { SessionPage } from "../../pages/session-page"; import { multiMessageScript, planScript } from "../../helpers/seed-session-messages"; @@ -26,10 +28,19 @@ export async function seedTask( repository_ids: [seedData.repositoryId], }, ); + await waitForLatestSessionDone( + apiClient, + task.id, + 1, + `wait for seeded search session ${task.id} to finish its initial turn`, + 60_000, + ); await page.goto(`/t/${task.id}`); const session = new SessionPage(page); await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 60_000 }); + if (!task.session_id) throw new Error("seedTask did not return a session_id"); + await waitForSessionAgentctlReady(page, task.session_id); return { session, taskId: task.id, sessionId: task.session_id! }; } diff --git a/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts b/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts index 343e256bf3..8e7e6f5257 100644 --- a/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts +++ b/apps/web/e2e/tests/search/task-workspace-content-search.spec.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { execSync } from "node:child_process"; import { expect, test } from "../../fixtures/test-base"; import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; +import { waitForSessionAgentctlReady } from "../../helpers/session-store"; import { SessionPage } from "../../pages/session-page"; import { MODIFIER } from "./shared"; @@ -89,6 +90,7 @@ test("@search searches all task repositories and opens the selected match", asyn const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); + await waitForSessionAgentctlReady(testPage, task.session_id); // The global content-search shortcut must win even when an editable chat // surface owns focus. diff --git a/apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts b/apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts new file mode 100644 index 0000000000..5fb3599c4c --- /dev/null +++ b/apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { waitForSessionDone } from "../../helpers/session"; +import { SessionPage } from "../../pages/session-page"; +import { + createMismatchedProfile, + readModelSelectionWarnings, + UNADVERTISED_MODEL, +} from "./model-mismatch-warning-helpers"; + +test.describe("model mismatch warning on mobile", () => { + test("shows the persisted warning without horizontal overflow", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const profile = await createMismatchedProfile(apiClient, "Mobile executor warning profile"); + try { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile executor model mismatch warning", + profile.id, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + expect(task.session_id).toBeTruthy(); + await waitForSessionDone( + apiClient, + task.id, + task.session_id!, + "Waiting for mobile model mismatch task continuation", + ); + await expect + .poll(() => readModelSelectionWarnings(apiClient, task.session_id!), { + timeout: 15_000, + }) + .toHaveLength(1); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect( + session.activeChat().getByText("The executor could not use the saved model selection."), + ).toBeVisible({ timeout: 15_000 }); + await expect(session.activeChat()).toContainText(UNADVERTISED_MODEL); + await expect(session.activeChat()).toContainText("Mock Fast"); + await assertNoDocumentHorizontalOverflow(testPage, "mobile model mismatch warning"); + + await testPage.reload(); + await session.waitForLoad(); + await expect( + session.activeChat().getByText("The executor could not use the saved model selection."), + ).toBeVisible({ timeout: 15_000 }); + await assertNoDocumentHorizontalOverflow(testPage, "reloaded mobile model mismatch warning"); + } finally { + await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); + } + }); +}); diff --git a/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts b/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts new file mode 100644 index 0000000000..0fbe860ee9 --- /dev/null +++ b/apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts @@ -0,0 +1,27 @@ +import type { AgentProfile } from "../../../lib/types/http-agents"; +import type { ApiClient } from "../../helpers/api-client"; + +export const UNADVERTISED_MODEL = "host-only-model"; + +export async function createMismatchedProfile( + apiClient: ApiClient, + name: string, +): Promise { + const { agents } = await apiClient.listAgents(); + const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0]; + if (!agent) throw new Error("The E2E fixture must provide a mock agent"); + return apiClient.createAgentProfile(agent.id, name, { model: UNADVERTISED_MODEL }); +} + +export async function readModelSelectionWarnings( + apiClient: ApiClient, + sessionId: string, +): Promise< + Array<{ + content: string; + metadata?: Record; + }> +> { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.filter((message) => message.metadata?.kind === "model_selection_warning"); +} diff --git a/apps/web/e2e/tests/session/model-mismatch-warning.spec.ts b/apps/web/e2e/tests/session/model-mismatch-warning.spec.ts new file mode 100644 index 0000000000..0be82cf950 --- /dev/null +++ b/apps/web/e2e/tests/session/model-mismatch-warning.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from "../../fixtures/test-base"; +import { waitForSessionDone } from "../../helpers/session"; +import { SessionPage } from "../../pages/session-page"; +import { + createMismatchedProfile, + readModelSelectionWarnings, + UNADVERTISED_MODEL, +} from "./model-mismatch-warning-helpers"; + +test.describe("model mismatch warning", () => { + test("continues with the executor default and persists one warning after reload", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const profile = await createMismatchedProfile(apiClient, "Executor default warning profile"); + try { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Executor model mismatch warning", + profile.id, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + expect(task.session_id).toBeTruthy(); + await waitForSessionDone( + apiClient, + task.id, + task.session_id!, + "Waiting for model mismatch task continuation", + ); + + await expect + .poll(() => readModelSelectionWarnings(apiClient, task.session_id!), { + timeout: 15_000, + message: "Waiting for the persisted model-selection warning", + }) + .toHaveLength(1); + const warnings = await readModelSelectionWarnings(apiClient, task.session_id!); + expect(warnings[0]?.content).toContain("saved model selection"); + expect(warnings[0]?.metadata).toMatchObject({ + kind: "model_selection_warning", + reason: "requested_not_advertised", + requested_model: UNADVERTISED_MODEL, + agent_id: "mock-agent", + }); + expect(warnings[0]?.metadata?.effective_model).toBe("mock-fast"); + expect((await apiClient.getAgentProfile(profile.id)).model).toBe(UNADVERTISED_MODEL); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect( + session.activeChat().getByText("The executor could not use the saved model selection."), + ).toBeVisible({ timeout: 15_000 }); + await expect(session.activeChat()).toContainText(UNADVERTISED_MODEL); + + await testPage.reload(); + await session.waitForLoad(); + await expect( + session.activeChat().getByText("The executor could not use the saved model selection."), + ).toBeVisible({ timeout: 15_000 }); + await expect(session.activeChat()).toContainText("Mock Fast"); + await expect + .poll(() => readModelSelectionWarnings(apiClient, task.session_id!)) + .toHaveLength(1); + } finally { + await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); + } + }); +}); diff --git a/apps/web/e2e/tests/session/port-forward-dialog.spec.ts b/apps/web/e2e/tests/session/port-forward-dialog.spec.ts index f952dbee49..fac1b0ea1e 100644 --- a/apps/web/e2e/tests/session/port-forward-dialog.spec.ts +++ b/apps/web/e2e/tests/session/port-forward-dialog.spec.ts @@ -84,6 +84,7 @@ async function seedLocalSession( // See seedRemoteSession: waitForChatIdle handles the WS-subscribe race that // makes a raw idleInput wait flake when the auto-started agent finishes early. await session.waitForChatIdle({ timeout: 30_000 }); + await waitForSessionAgentctlReady(testPage, task.session_id); return { session, sessionId: task.session_id }; } diff --git a/apps/web/e2e/tests/settings/executor-agent-config-helpers.ts b/apps/web/e2e/tests/settings/executor-agent-config-helpers.ts new file mode 100644 index 0000000000..d7814d1393 --- /dev/null +++ b/apps/web/e2e/tests/settings/executor-agent-config-helpers.ts @@ -0,0 +1,45 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +export const PORTABLE_CONFIG_BUNDLE_ID = "mock.settings"; +export const MOCK_CODEX_CONFIG_BUNDLE_ID = "codex-acp.settings"; +export const DEFAULT_CONFIG_AGENT_ID = "mock-agent"; + +export function portableConfigSection(page: Page, agentId = DEFAULT_CONFIG_AGENT_ID): Locator { + return page.getByTestId(`agent-config-options-${agentId}`); +} + +export function portableConfigInfo(page: Page, agentId = DEFAULT_CONFIG_AGENT_ID): Locator { + return page.getByTestId(`agent-config-info-${agentId}`); +} + +export async function selectPortableConfigBundle( + page: Page, + bundleId = PORTABLE_CONFIG_BUNDLE_ID, + agentId = DEFAULT_CONFIG_AGENT_ID, +): Promise { + const row = portableConfigSection(page, agentId).getByTestId( + `portable-config-bundle-${bundleId}`, + ); + await expect(row).toBeVisible(); + await row.getByRole("checkbox").check(); +} + +export function selectedBundleIds(config: Record | undefined): string[] { + const raw = config?.agent_config_bundles; + if (!raw) return []; + return JSON.parse(raw) as string[]; +} + +export async function expectTouchTarget(locator: Locator): Promise { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeGreaterThanOrEqual(44); + expect(box!.height).toBeGreaterThanOrEqual(44); +} + +export async function expectNoHorizontalOverflow(page: Page): Promise { + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + ); + expect(overflow).toBe(false); +} diff --git a/apps/web/e2e/tests/settings/executor-agent-config.spec.ts b/apps/web/e2e/tests/settings/executor-agent-config.spec.ts new file mode 100644 index 0000000000..fd79ead7bc --- /dev/null +++ b/apps/web/e2e/tests/settings/executor-agent-config.spec.ts @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "../../fixtures/test-base"; +import { + MOCK_CODEX_CONFIG_BUNDLE_ID, + portableConfigInfo, + portableConfigSection, + selectedBundleIds, + selectPortableConfigBundle, +} from "./executor-agent-config-helpers"; + +test.describe("portable agent configuration settings", () => { + test("saves configuration independently from authentication and reloads it", async ({ + apiClient, + backend, + testPage, + }) => { + // The baseline E2E agent has no credentials by design. Add the mock Codex + // alias for this settings-only scenario so the auth checkbox remains a real, + // independently selectable control without requiring provider secrets. + await backend.restart({ KANDEV_MOCK_PROVIDERS: "codex-acp" }); + const configDir = path.join(backend.tmpDir, ".mock-agent"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "settings.json"), '{"source":"settings"}\n'); + const catalog = await apiClient.listAgentConfigBundles(); + expect( + catalog.bundles.some( + (bundle) => bundle.id === MOCK_CODEX_CONFIG_BUNDLE_ID && bundle.agent_id === "codex-acp", + ), + ).toBe(true); + + const executor = await apiClient.createExecutor("E2E portable config executor", "local_docker"); + const profile = await apiClient.createExecutorProfile(executor.id, { + name: "E2E portable config profile", + config: {}, + prepare_script: "", + cleanup_script: "", + env_vars: [], + }); + + try { + await testPage.goto(`/settings/executors/${profile.id}`); + + const agentTrigger = testPage.getByRole("button", { name: /Mock Codex/ }); + await expect(agentTrigger).toBeVisible(); + await agentTrigger.click(); + + const section = portableConfigSection(testPage, "codex-acp"); + await expect(section).toBeVisible(); + const info = portableConfigInfo(testPage, "codex-acp"); + await expect(info).toBeVisible(); + await info.hover(); + await expect(testPage.getByRole("tooltip")).toContainText("without changes"); + + await selectPortableConfigBundle(testPage, MOCK_CODEX_CONFIG_BUNDLE_ID, "codex-acp"); + await expect( + section + .getByTestId(`portable-config-bundle-${MOCK_CODEX_CONFIG_BUNDLE_ID}`) + .getByRole("checkbox"), + ).toBeChecked(); + + await testPage.getByRole("button", { name: "Mock Codex Not Configured" }).click(); + const authChoice = testPage.getByRole("checkbox", { name: "Copy auth files" }).first(); + await expect(authChoice).toBeVisible(); + await authChoice.click(); + await expect(authChoice).toBeChecked(); + + const saveButton = testPage + .getByTestId("settings-floating-save") + .getByRole("button", { name: "Save changes" }); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(testPage.getByText("Profile saved")).toBeVisible(); + + const saved = await apiClient.getExecutorProfile(executor.id, profile.id); + expect(selectedBundleIds(saved.config)).toEqual([MOCK_CODEX_CONFIG_BUNDLE_ID]); + expect(JSON.parse(saved.config?.remote_credentials ?? "[]").length).toBeGreaterThan(0); + + await testPage.reload(); + await testPage.getByRole("button", { name: /Mock Codex/ }).click(); + await expect(section).toBeVisible(); + await expect( + section + .getByTestId(`portable-config-bundle-${MOCK_CODEX_CONFIG_BUNDLE_ID}`) + .getByRole("checkbox"), + ).toBeChecked(); + } finally { + await apiClient.deleteExecutor(executor.id).catch(() => {}); + await backend.restart(); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/mobile-executor-agent-config.spec.ts b/apps/web/e2e/tests/settings/mobile-executor-agent-config.spec.ts new file mode 100644 index 0000000000..bd04923726 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-executor-agent-config.spec.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "../../fixtures/test-base"; +import { + expectNoHorizontalOverflow, + expectTouchTarget, + PORTABLE_CONFIG_BUNDLE_ID, + portableConfigInfo, + portableConfigSection, + selectPortableConfigBundle, +} from "./executor-agent-config-helpers"; + +test.describe("portable agent configuration settings on mobile", () => { + test("uses a bottom drawer and keeps bundle controls reachable", async ({ + apiClient, + backend, + testPage, + }) => { + const configDir = path.join(backend.tmpDir, ".mock-agent"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "settings.json"), '{"source":"settings"}\n'); + const executor = await apiClient.createExecutor("E2E mobile portable config", "local_docker"); + const profile = await apiClient.createExecutorProfile(executor.id, { + name: "E2E mobile portable config profile", + config: {}, + prepare_script: "", + cleanup_script: "", + env_vars: [], + }); + + try { + await testPage.goto(`/settings/executors/${profile.id}`); + const agentTrigger = testPage.getByRole("button", { name: /Mock Not Configured/ }); + await expect(agentTrigger).toBeVisible(); + await agentTrigger.tap(); + + const section = portableConfigSection(testPage); + await expect(section).toBeVisible(); + + const info = portableConfigInfo(testPage); + await expectTouchTarget(info); + await info.tap(); + const drawer = testPage.getByRole("dialog"); + await expect(drawer).toBeVisible(); + await expect(drawer).toContainText("Warm resumes keep the existing environment."); + await testPage.keyboard.press("Escape"); + await expect(drawer).toBeHidden(); + + const row = section.getByTestId(`portable-config-bundle-${PORTABLE_CONFIG_BUNDLE_ID}`); + await expectTouchTarget(row); + await selectPortableConfigBundle(testPage); + await expectNoHorizontalOverflow(testPage); + } finally { + await apiClient.deleteExecutor(executor.id).catch(() => {}); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts b/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts index 68de3da50f..1978bf32b0 100644 --- a/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts @@ -1,134 +1,35 @@ -import { test, expect } from "../../fixtures/test-base"; -import { useRegularMode } from "../../helpers/regular-mode"; +import { expect, test } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { + createMismatchedProfile, + UNADVERTISED_MODEL, +} from "../session/model-mismatch-warning-helpers"; -// Mobile companion to no-silent-model-fallback.spec.ts: the task-create -// picker's fallback explanation must be discoverable on touch — visible -// secondary text, not a hover-only tooltip (icon title). -const GONE_MODEL = "claude-gone"; - -useRegularMode(); - -test.describe("No silent model fallback on mobile", () => { - test("profile fallback settings stack and open help in a touch drawer", async ({ - testPage, - apiClient, - }) => { - test.setTimeout(60_000); - - const { agents } = await apiClient.listAgents(); - const agent = agents[0]; - if (!agent) throw new Error("no agents available"); - const profile = await apiClient.createAgentProfile(agent.id, "Mobile Fallback Settings", { - model: "mock-fast", - fallback_model: "mock-smart", - }); - +test.describe("executor-authoritative model selection on mobile", () => { + test("keeps the host-mismatched profile reachable by touch", async ({ testPage, apiClient }) => { + const profile = await createMismatchedProfile(apiClient, "Mobile host mismatch profile"); try { - await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); - - const fallbackTrigger = testPage.getByTestId("profile-fallback-settings-trigger"); - await expect(fallbackTrigger).toBeVisible({ timeout: 15_000 }); - await expect(fallbackTrigger).toHaveAttribute("aria-expanded", "false"); - await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText( - "Explicit fallback: mock-smart", - ); - - await fallbackTrigger.tap(); - const autoOption = testPage.getByTestId("profile-auto-fallback-option"); - const explicitOption = testPage.getByTestId("profile-explicit-fallback-option"); - await expect(autoOption).toBeVisible(); - await expect(explicitOption).toBeVisible(); - - const autoBox = await autoOption.boundingBox(); - const explicitBox = await explicitOption.boundingBox(); - expect(autoBox).not.toBeNull(); - expect(explicitBox).not.toBeNull(); - if (!autoBox || !explicitBox) throw new Error("fallback option cards are not laid out"); - expect(explicitBox.y).toBeGreaterThan(autoBox.y + 8); - - const helpButton = testPage.getByTestId("profile-automatic-fallback-help"); - const helpBox = await helpButton.boundingBox(); - expect(helpBox).not.toBeNull(); - if (!helpBox) throw new Error("automatic fallback help button is not laid out"); - expect(helpBox.width).toBeGreaterThanOrEqual(44); - expect(helpBox.height).toBeGreaterThanOrEqual(44); - - // The disclosure finishes a height transition when its children mount; - // keep the interaction touch-native while bypassing that transient - // stability window. - await helpButton.tap({ force: true }); - const helpDrawer = testPage.getByRole("dialog"); - await expect(helpDrawer).toContainText("Automatic fallback"); - await expect(helpDrawer).toContainText(/remaining advertised models/); - await testPage.keyboard.press("Escape"); - await expect(helpDrawer).toBeHidden(); - - const advancedOptions = testPage.getByTestId("profile-advanced-options"); - const advancedTrigger = testPage.getByTestId("profile-advanced-options-trigger"); - await expect(advancedTrigger).toBeVisible({ timeout: 10_000 }); - await expect(testPage.getByTestId("command-prefix-input")).toBeHidden(); - const fallbackBox = await testPage.getByTestId("profile-fallback-settings").boundingBox(); - const advancedBox = await advancedOptions.boundingBox(); - expect(fallbackBox).not.toBeNull(); - expect(advancedBox).not.toBeNull(); - if (!fallbackBox || !advancedBox) throw new Error("profile disclosures are not laid out"); - expect(advancedBox.y - (fallbackBox.y + fallbackBox.height)).toBeLessThanOrEqual(8); - - await advancedTrigger.tap(); - await expect(testPage.getByTestId("command-prefix-input")).toBeVisible(); - - const overflow = await testPage.evaluate(() => ({ - scrollWidth: document.documentElement.scrollWidth, - clientWidth: document.documentElement.clientWidth, - })); - expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth); - } finally { - await apiClient.deleteAgentProfile(profile.id, true); - } - }); - - test("task-create picker shows the fallback note as visible text on touch", async ({ - testPage, - apiClient, - }) => { - test.setTimeout(90_000); - - const { agents } = await apiClient.listAgents(); - const agent = agents[0]; - if (!agent) throw new Error("no agents available"); - const fallbackProfile = await apiClient.createAgentProfile(agent.id, "Gone Fallback Mobile", { - model: GONE_MODEL, - fallback_model: "mock-fast", - }); - - try { - // Mobile kanban opens the create-task dialog from the floating action - // button, not the desktop sidebar button. const kanban = new KanbanPage(testPage); await kanban.goto(); - const fab = testPage.getByTestId("mobile-fab"); - await expect(fab).toBeVisible({ timeout: 15_000 }); - await fab.tap(); + await testPage.reload({ waitUntil: "networkidle" }); + await testPage.getByRole("button", { name: "Add task" }).tap(); + const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); - - await testPage.getByTestId("task-title-input").fill("Gone model picker mobile test"); - await testPage.getByTestId("task-description-input").fill("verify visible fallback note"); - const selector = dialog.getByTestId("agent-profile-selector"); + await expect(selector).toBeVisible(); await selector.tap(); - const fallbackOption = testPage.getByRole("option", { name: /Gone Fallback Mobile/ }); - await expect(fallbackOption).toBeVisible({ timeout: 15_000 }); - await expect(fallbackOption).not.toHaveAttribute("aria-disabled", "true"); - - // The fallback explanation must be readable without hover: the - // picker renders it as visible secondary text inside the option. - await expect(fallbackOption).toContainText(/mock-fast/); - await expect(fallbackOption).toContainText(/no longer available/); + const option = testPage + .getByRole("listbox") + .getByRole("option", { name: profile.name, exact: false }); + await expect(option).toBeVisible(); + await expect(option).toBeEnabled(); + await expect(dialog).toContainText(UNADVERTISED_MODEL); + await assertNoDocumentHorizontalOverflow(testPage, "mobile model mismatch selector"); } finally { - await apiClient.deleteAgentProfile(fallbackProfile.id, true); + await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); } }); }); diff --git a/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts b/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts index 1ac0a8f0f8..e11b1e5e79 100644 --- a/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts +++ b/apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts @@ -1,210 +1,35 @@ -import { test, expect } from "../../fixtures/test-base"; -import { useRegularMode } from "../../helpers/regular-mode"; +import { expect, test } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; +import { + createMismatchedProfile, + UNADVERTISED_MODEL, +} from "../session/model-mismatch-warning-helpers"; -// The mock agent advertises `mock-fast` / `mock-smart`; any other model id -// is "gone" (configured but no longer advertised) — the scenario this -// feature makes explicit instead of silently falling back. -const GONE_MODEL = "claude-gone"; - -useRegularMode(); - -test.describe("No silent model fallback", () => { - test("profile editor explains and persists the fallback settings disclosure", async ({ - testPage, - apiClient, - }) => { - test.setTimeout(60_000); - - const { agents } = await apiClient.listAgents(); - const agent = agents[0]; - if (!agent) throw new Error("no agents available"); - const profile = await apiClient.createAgentProfile(agent.id, "Gone Start Model", { - model: GONE_MODEL, - }); - - try { - await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`); - - // The start-model trigger keeps the gone model visible and red. - const trigger = testPage.getByRole("button", { name: "Profile start model settings" }); - await expect(trigger).toBeVisible({ timeout: 15_000 }); - await expect(trigger).toContainText(GONE_MODEL); - await expect(trigger).toHaveClass(/text-destructive/); - - const capabilitiesRow = testPage.getByTestId("profile-capabilities-model-row"); - const modelOptionsError = testPage.getByTestId("model-config-resolution-error"); - const refreshCapabilities = testPage.getByTestId("profile-refresh-capabilities"); - await expect(modelOptionsError).toBeVisible({ timeout: 15_000 }); - const capabilitiesBox = await capabilitiesRow.boundingBox(); - const modelOptionsErrorBox = await modelOptionsError.boundingBox(); - const refreshBox = await refreshCapabilities.boundingBox(); - expect(capabilitiesBox).not.toBeNull(); - expect(modelOptionsErrorBox).not.toBeNull(); - expect(refreshBox).not.toBeNull(); - if (!capabilitiesBox || !modelOptionsErrorBox || !refreshBox) { - throw new Error("model capability controls are not laid out"); - } - expect(modelOptionsErrorBox.y).toBeGreaterThanOrEqual( - capabilitiesBox.y + capabilitiesBox.height - 1, - ); - expect(refreshBox.y).toBeLessThan(capabilitiesBox.y + capabilitiesBox.height); - - const fallbackSettings = testPage.getByTestId("profile-fallback-settings"); - const fallbackTrigger = testPage.getByTestId("profile-fallback-settings-trigger"); - await expect(fallbackTrigger).toBeVisible({ timeout: 10_000 }); - await expect(fallbackTrigger).toHaveAttribute("aria-expanded", "false"); - await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText( - "No fallback configured", - ); - - // The fallback options are intentionally hidden until the user opens - // the disclosure. - await expect(testPage.getByTestId("profile-fallback-model-field")).toBeHidden(); - await fallbackTrigger.click(); - await expect(fallbackSettings).toHaveAttribute("data-state", "open"); - - const fallbackField = testPage.getByTestId("profile-fallback-model-field"); - await expect(fallbackField).toBeVisible({ timeout: 10_000 }); - - // Desktop keeps the two options on one row. - const autoOption = testPage.getByTestId("profile-auto-fallback-option"); - const explicitOption = testPage.getByTestId("profile-explicit-fallback-option"); - const autoBox = await autoOption.boundingBox(); - const explicitBox = await explicitOption.boundingBox(); - expect(autoBox).not.toBeNull(); - expect(explicitBox).not.toBeNull(); - if (!autoBox || !explicitBox) throw new Error("fallback option cards are not laid out"); - expect(Math.abs(autoBox.y - explicitBox.y)).toBeLessThan(8); - expect(explicitBox.x).toBeGreaterThan(autoBox.x); - - // Fine-pointer users can focus or hover the info buttons for the - // detailed explanation. - const automaticHelp = testPage.getByTestId("profile-automatic-fallback-help"); - await automaticHelp.focus(); - await expect(testPage.getByRole("tooltip")).toContainText(/remaining advertised models/); - await testPage.keyboard.press("Escape"); - - // The fallback model is optional: without a value its selector stays - // hidden behind the attached switch, and opting in reveals it. - const fallbackSelector = testPage.getByRole("button", { - name: "Agent fallback model settings", - }); - await expect(fallbackSelector).toBeHidden({ timeout: 10_000 }); - await fallbackField.getByRole("switch", { name: "Agent fallback" }).click(); - await expect(fallbackSelector).toBeVisible({ timeout: 10_000 }); - await fallbackField.getByRole("switch", { name: "Agent fallback" }).click(); - await expect(fallbackSelector).toBeHidden({ timeout: 10_000 }); - - // Enabling automatic fallback keeps the explicit value visible but - // disables its controls so it cannot be edited accidentally. - await fallbackField.getByRole("switch", { name: "Agent fallback" }).click(); - await fallbackSelector.click(); - await testPage.getByRole("option", { name: /Mock Fast/ }).click(); - await expect(fallbackSelector).toContainText("Mock Fast"); - const toggle = testPage.getByTestId("profile-auto-fallback-field").getByRole("switch"); - await toggle.click(); - await expect(fallbackField).toBeVisible({ timeout: 10_000 }); - await expect(fallbackField.getByRole("switch", { name: "Agent fallback" })).toBeDisabled(); - await expect( - fallbackField.getByRole("button", { name: "Agent fallback model settings" }), - ).toBeDisabled(); - await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText( - "Automatic fallback enabled", - ); - - // Less frequently used launch settings follow the fallback disclosure. - // Keep the command prefix out of the default card height until requested. - const advancedOptions = testPage.getByTestId("profile-advanced-options"); - const advancedTrigger = testPage.getByTestId("profile-advanced-options-trigger"); - await expect(advancedTrigger).toBeVisible({ timeout: 10_000 }); - await expect(testPage.getByTestId("command-prefix-input")).toBeHidden(); - const fallbackBox = await fallbackSettings.boundingBox(); - const advancedBox = await advancedOptions.boundingBox(); - expect(fallbackBox).not.toBeNull(); - expect(advancedBox).not.toBeNull(); - if (!fallbackBox || !advancedBox) throw new Error("profile disclosures are not laid out"); - expect(advancedBox.y - (fallbackBox.y + fallbackBox.height)).toBeLessThanOrEqual(8); - - await advancedTrigger.click(); - await expect(testPage.getByTestId("command-prefix-input")).toBeVisible(); - - // The gone model survives a save (no auto-heal on save). - const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first(); - await expect(saveButton).toBeEnabled({ timeout: 10_000 }); - await saveButton.click(); - await expect(testPage.getByText(/unsaved changes/i)).toBeHidden({ timeout: 15_000 }); - - const savedProfile = await apiClient.getAgentProfile(profile.id); - expect(savedProfile.fallbackModel).toBe("mock-fast"); - expect(savedProfile.autoFallback).toBe(true); - - await testPage.reload(); - await expect(testPage.getByTestId("profile-fallback-settings-trigger")).toHaveAttribute( - "aria-expanded", - "false", - ); - await expect(testPage.getByTestId("profile-fallback-settings-summary")).toContainText( - "Automatic fallback enabled", - ); - await testPage.getByTestId("profile-fallback-settings-trigger").click(); - await expect( - testPage - .getByTestId("profile-fallback-model-field") - .getByRole("button", { name: "Agent fallback model settings" }), - ).toBeDisabled(); - } finally { - await apiClient.deleteAgentProfile(profile.id, true); - } - }); - - test("task-create picker blocks a strict-gone profile and warns for a fallback profile", async ({ - testPage, - apiClient, - }) => { - test.setTimeout(90_000); - - const { agents } = await apiClient.listAgents(); - const agent = agents[0]; - if (!agent) throw new Error("no agents available"); - const strictProfile = await apiClient.createAgentProfile(agent.id, "Gone Strict", { - model: GONE_MODEL, - }); - const fallbackProfile = await apiClient.createAgentProfile(agent.id, "Gone Fallback", { - model: GONE_MODEL, - fallback_model: "mock-fast", - }); - +test.describe("executor-authoritative model selection", () => { + test("keeps a host-mismatched profile selectable", async ({ testPage, apiClient }) => { + const profile = await createMismatchedProfile(apiClient, "Host mismatch selectable profile"); try { const kanban = new KanbanPage(testPage); await kanban.goto(); + await testPage.reload({ waitUntil: "networkidle" }); await kanban.createTaskButton.first().click(); + const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); - - await testPage.getByTestId("task-title-input").fill("Gone model picker test"); - await testPage.getByTestId("task-description-input").fill("verify gating"); - - // Open the agent profile dropdown. const selector = dialog.getByTestId("agent-profile-selector"); + await expect(selector).toBeVisible(); await selector.click(); - // Strict-gone profile: greyed out and unselectable. - const strictOption = testPage.getByRole("option", { name: /Gone Strict/ }); - await expect(strictOption).toBeVisible({ timeout: 15_000 }); - await expect(strictOption).toHaveAttribute("aria-disabled", "true"); - - // Fallback profile: selectable and carries the "fallback will be used" - // warning as visible secondary text (touch-discoverable, not just a - // hover tooltip) plus the alert icon. - const fallbackOption = testPage.getByRole("option", { name: /Gone Fallback/ }); - await expect(fallbackOption).toBeVisible({ timeout: 15_000 }); - await expect(fallbackOption).not.toHaveAttribute("aria-disabled", "true"); - await expect(fallbackOption.getByRole("img", { name: /mock-fast/ })).toBeVisible(); - await expect(fallbackOption).toContainText(/no longer available/); + const option = testPage + .getByRole("listbox") + .getByRole("option", { name: profile.name, exact: false }); + await expect(option).toBeVisible(); + await expect(option).toBeEnabled(); + await expect(dialog).toContainText( + `The host probe did not advertise ${UNADVERTISED_MODEL}. The selected executor will decide the model at launch.`, + ); } finally { - await apiClient.deleteAgentProfile(strictProfile.id, true); - await apiClient.deleteAgentProfile(fallbackProfile.id, true); + await apiClient.deleteAgentProfile(profile.id, true).catch(() => {}); } }); }); diff --git a/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts b/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts index 841500b292..c9293219c4 100644 --- a/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts +++ b/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts @@ -131,7 +131,10 @@ test.describe("create task selector scroll", () => { await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); await testPage.getByTestId("repo-chip-trigger").first().click(); - await testPage.getByRole("option", { name: /E2E Repo/ }).click(); + // Other tests may leave repositories whose names start with "E2E Repo". + // The seeded repository's accessible name continues with its path, so + // anchor the match at the repository name and path separator. + await testPage.getByRole("option", { name: /^E2E Repo \// }).click(); const branchSelector = testPage.getByTestId("branch-chip-trigger").first(); await expect(branchSelector).toBeEnabled({ timeout: 5_000 }); diff --git a/apps/web/e2e/tests/task/task-list.spec.ts b/apps/web/e2e/tests/task/task-list.spec.ts index f211145716..7f59bba5f2 100644 --- a/apps/web/e2e/tests/task/task-list.spec.ts +++ b/apps/web/e2e/tests/task/task-list.spec.ts @@ -69,10 +69,12 @@ test.describe("Task List", () => { hasText: "Hierarchy Child Task", }); + // Wait for the filtered list to settle before reading row attributes. SSR + // and the client list can briefly overlap while the search result mounts. + await expect(parentRow).toHaveCount(1, { timeout: 5000 }); + await expect(childRow).toHaveCount(1, { timeout: 5000 }); await expect(parentRow).toHaveAttribute("data-level", "0", { timeout: 5000 }); await expect(childRow).toHaveAttribute("data-level", "1", { timeout: 5000 }); - await expect(parentRow).toHaveCount(1); - await expect(childRow).toHaveCount(1); await expect(taskList.getByTestId("tasks-list-row-title")).toHaveText([ "Hierarchy Parent Task", "Hierarchy Child Task", diff --git a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts index c4a7c645fa..ec3c664fee 100644 --- a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts +++ b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { waitForSessionState } from "../../helpers/session"; +import { waitForSessionAgentctlReady } from "../../helpers/session-store"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; import { errors, type Page } from "@playwright/test"; @@ -280,6 +281,10 @@ test.describe("Terminal agent (TUI passthrough)", () => { message: "the initial passthrough turn did not settle before the cascade", timeout: 30_000, }); + // The API state can settle before the page has subscribed to the session's + // agentctl lifecycle. Establish that subscription before moving the task, + // so reset/relaunch events cannot race the terminal's initial handshake. + await waitForSessionAgentctlReady(testPage, task.session_id as string); // Trigger cascade: Analyze → (turn complete) → Implement (reset + auto_start) → Review await apiClient.moveTask(task.id, workflow.id, analyzeStep.id); diff --git a/apps/web/e2e/tests/terminal/terminal-keyboard.spec.ts b/apps/web/e2e/tests/terminal/terminal-keyboard.spec.ts index 8c2ba7ec10..6204d35a51 100644 --- a/apps/web/e2e/tests/terminal/terminal-keyboard.spec.ts +++ b/apps/web/e2e/tests/terminal/terminal-keyboard.spec.ts @@ -3,6 +3,8 @@ import { type Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; +import { waitForLatestSessionDone } from "../../helpers/session"; +import { waitForSessionAgentctlReady } from "../../helpers/session-store"; import { SessionPage } from "../../pages/session-page"; // --------------------------------------------------------------------------- @@ -27,6 +29,13 @@ async function seedTaskWithSession( repository_ids: [seedData.repositoryId], }, ); + await waitForLatestSessionDone( + apiClient, + task.id, + 1, + `wait for terminal keyboard session ${task.id} to finish its initial turn`, + 60_000, + ); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); @@ -34,20 +43,23 @@ async function seedTaskWithSession( // waitForChatIdle (not a raw idleInput wait) rides out the WS-subscribe race: // the mock agent can settle RUNNING->WAITING_FOR_INPUT before the client's WS // subscription registers, so the idle placeholder never renders without a reload. - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForChatIdle({ timeout: 60_000 }); + if (!task.session_id) throw new Error("seedTaskWithSession did not return a session_id"); + await waitForSessionAgentctlReady(testPage, task.session_id); return session; } /** Focus the terminal and wait for the shell to be ready (prompt visible). */ async function focusTerminal(page: Page, session: SessionPage): Promise { await expect(session.terminal).toBeVisible({ timeout: 15_000 }); + await session.expectTerminalConnected(60_000); await expect .poll( async () => { const buf = await readTerminalBuffer(page); return buf.length > 0; }, - { timeout: 15_000, message: "Waiting for terminal shell to connect" }, + { timeout: 60_000, message: "Waiting for terminal shell to connect" }, ) .toBe(true); diff --git a/apps/web/hooks/domains/session/use-workspace-content-search.test.ts b/apps/web/hooks/domains/session/use-workspace-content-search.test.ts index 0e74452af3..ecea8f2896 100644 --- a/apps/web/hooks/domains/session/use-workspace-content-search.test.ts +++ b/apps/web/hooks/domains/session/use-workspace-content-search.test.ts @@ -57,6 +57,36 @@ describe("useWorkspaceContentSearch request lifecycle", () => { expect(result.current.isSearching).toBe(false); }); + it("publishes matches before retry polling completes", async () => { + const results = [ + { + repository_name: "web", + path: "src/cached-content-target.ts", + line: 180, + column: 1, + preview: "cached marker", + match_ranges: [{ start: 0, end: 13 }], + }, + ]; + mockSearchWorkspaceContent.mockResolvedValue({ results }); + const { result } = renderHook(() => + useWorkspaceContentSearch({ + enabled: true, + query: "cached marker", + sessionId: "session-1", + }), + ); + + await act(async () => vi.advanceTimersByTimeAsync(250)); + + expect(mockSearchWorkspaceContent).toHaveBeenCalledTimes(1); + expect(result.current.results).toEqual(results); + expect(result.current.isSearching).toBe(true); + + await act(async () => vi.runAllTimersAsync()); + expect(result.current.isSearching).toBe(false); + }); + it("clears the previous query results while the next search is pending", async () => { const previousResults = [ { @@ -81,7 +111,9 @@ describe("useWorkspaceContentSearch request lifecycle", () => { expect(result.current.results).toEqual([]); expect(result.current.isSearching).toBe(true); }); +}); +describe("useWorkspaceContentSearch retry publishing", () => { it("publishes the first available results while later repositories are still retrying", async () => { const primaryResult = { repository_name: "primary", diff --git a/apps/web/lib/api/domains/agent-config-api.test.ts b/apps/web/lib/api/domains/agent-config-api.test.ts new file mode 100644 index 0000000000..21cc649671 --- /dev/null +++ b/apps/web/lib/api/domains/agent-config-api.test.ts @@ -0,0 +1,26 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../client", () => ({ + fetchJson: vi.fn(), +})); + +import { fetchJson } from "../client"; +import { listAgentConfigBundles } from "./agent-config-api"; + +beforeEach(() => { + vi.mocked(fetchJson).mockResolvedValue({ bundles: [] }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("agent configuration API", () => { + it("does not reuse a stale availability response", async () => { + await listAgentConfigBundles(); + + expect(fetchJson).toHaveBeenCalledWith("/api/v1/agent-config-bundles", { + cache: "no-store", + }); + }); +}); diff --git a/apps/web/lib/api/domains/agent-config-api.ts b/apps/web/lib/api/domains/agent-config-api.ts new file mode 100644 index 0000000000..c7caf9e012 --- /dev/null +++ b/apps/web/lib/api/domains/agent-config-api.ts @@ -0,0 +1,29 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; + +export type AgentConfigBundleFile = { + source_path: string; + target_path: string; + available: boolean; +}; + +export type AgentConfigBundle = { + id: string; + agent_id: string; + display_name: string; + label: string; + files: AgentConfigBundleFile[]; + available: boolean; +}; + +export type ListAgentConfigBundlesResponse = { + bundles: AgentConfigBundle[]; +}; + +export async function listAgentConfigBundles( + options?: ApiRequestOptions, +): Promise { + return fetchJson("/api/v1/agent-config-bundles", { + ...options, + cache: "no-store", + }); +} diff --git a/apps/web/lib/types/session-events.ts b/apps/web/lib/types/session-events.ts index 1f5bcf35cb..24646f19a3 100644 --- a/apps/web/lib/types/session-events.ts +++ b/apps/web/lib/types/session-events.ts @@ -6,6 +6,7 @@ import type { AgentCapabilitiesPayload, SessionInfoPayload, SessionModelsPayload, + SessionModelSelectionWarningPayload, SessionMCPStatusPayload, SessionPromptUsagePayload, SessionTodosPayload, @@ -237,6 +238,10 @@ export type SessionBackendMessageMap = { timestamp: string; } >; + "session.model_selection_warning": BackendMessage< + "session.model_selection_warning", + SessionModelSelectionWarningPayload + >; "session.mcp_status_updated": BackendMessage< "session.mcp_status_updated", SessionMCPStatusPayload diff --git a/apps/web/lib/types/session-runtime-payloads.ts b/apps/web/lib/types/session-runtime-payloads.ts index bbe72efc1b..67e2447cbe 100644 --- a/apps/web/lib/types/session-runtime-payloads.ts +++ b/apps/web/lib/types/session-runtime-payloads.ts @@ -50,6 +50,24 @@ export type SessionModelsPayload = { timestamp: string; }; +export type SessionModelSelectionWarningPayload = { + task_id: string; + session_id: string; + agent_id: string; + warning: { + kind: string; + decision_id: string; + reason: string; + requested_model?: string; + effective_model?: string; + fallback_model?: string; + agent_id: string; + executor_type: string; + executor_profile_id: string; + }; + timestamp: string; +}; + export type MCPAttachmentServerPayload = { name: string; source?: "kandev" | "profile"; diff --git a/apps/web/lib/ws/handlers/messages.test.ts b/apps/web/lib/ws/handlers/messages.test.ts index 1bd3a3f7b8..197b3ee559 100644 --- a/apps/web/lib/ws/handlers/messages.test.ts +++ b/apps/web/lib/ws/handlers/messages.test.ts @@ -7,7 +7,12 @@ import { createMessagesHandlerRegistration, createMessageUpdateScheduler } from type UpdatedMessage = BackendMessageMap["session.message.updated"]; const TEST_TIMESTAMP = "2026-08-02T00:00:00.000Z"; -function makePayload(sessionId: string, messageId: string, content: string) { +function makePayload( + sessionId: string, + messageId: string, + content: string, + overrides: Record = {}, +) { return { task_id: "task-1", session_id: sessionId, @@ -19,6 +24,7 @@ function makePayload(sessionId: string, messageId: string, content: string) { type: "message", created_at: TEST_TIMESTAMP, updated_at: "2026-08-02T00:00:01.000Z", + ...overrides, }; } @@ -32,7 +38,7 @@ function makeUpdated(sessionId: string, messageId: string, content: string): Upd }; } -function makeStore(currentMessages: Record = {}) { +function makeStore(currentMessages: Record = {}, completedTurn = false) { const updateMessage = vi.fn(); const addMessage = vi.fn(); const removeMessage = vi.fn(); @@ -41,6 +47,13 @@ function makeStore(currentMessages: Record = {}) { addMessage, removeMessage, messages: { bySession: currentMessages }, + turns: { + bySession: completedTurn + ? { + "session-1": [{ id: "turn-1", completed_at: TEST_TIMESTAMP }], + } + : {}, + }, }; return { store: { getState: () => state } as unknown as StoreApi, @@ -196,3 +209,55 @@ describe("session message snapshot ordering", () => { registration.dispose(); }); }); + +describe("late tool messages", () => { + it("settles a tool message added after its turn completed", () => { + const { store, addMessage } = makeStore({}, true); + const registration = createMessagesHandlerRegistration(store); + + registration.handlers["session.message.added"]!({ + id: "add-tool", + type: "notification", + action: "session.message.added", + payload: makePayload("session-1", "tool-1", "Terminal", { + type: "tool_call", + metadata: { tool_call_id: "call-1", status: "running" }, + }), + timestamp: TEST_TIMESTAMP, + } as never); + + expect(addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { tool_call_id: "call-1", status: "complete" }, + }), + ); + registration.dispose(); + }); + + it("does not let a late running update reintroduce a spinner", () => { + const { store, updateMessage } = makeStore({}, true); + const registration = createMessagesHandlerRegistration(store); + + registration.handlers["session.message.updated"]!( + makeUpdated("session-1", "tool-1", "Terminal") as never, + ); + registration.handlers["session.message.updated"]!({ + id: "updated-tool", + type: "notification", + action: "session.message.updated", + payload: makePayload("session-1", "tool-1", "Terminal", { + type: "tool_call", + metadata: { tool_call_id: "call-1", status: "running" }, + }), + timestamp: TEST_TIMESTAMP, + } as never); + registration.scheduler.flush(); + + expect(updateMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + metadata: { tool_call_id: "call-1", status: "complete" }, + }), + ); + registration.dispose(); + }); +}); diff --git a/apps/web/lib/ws/handlers/messages.ts b/apps/web/lib/ws/handlers/messages.ts index d775a2bc39..c4d2fcab7b 100644 --- a/apps/web/lib/ws/handlers/messages.ts +++ b/apps/web/lib/ws/handlers/messages.ts @@ -45,6 +45,36 @@ function toMessage(payload: MessagePayload): MessageUpdate { }; } +function isTerminalToolStatus(status: unknown): boolean { + return ( + status === "complete" || + status === "error" || + status === "failed" || + status === "cancelled" || + status === "success" + ); +} + +function settleMessageForCompletedTurn( + store: StoreApi, + message: MessageUpdate, +): MessageUpdate { + if (message.type === "permission_request" || !message.turn_id) return message; + + const turn = store + .getState() + .turns?.bySession[message.session_id]?.find((candidate) => candidate.id === message.turn_id); + if (!turn?.completed_at) return message; + + const metadata = message.metadata as Record | undefined; + if (!metadata?.tool_call_id || isTerminalToolStatus(metadata.status)) return message; + + return { + ...message, + metadata: { ...metadata, status: "complete" }, + }; +} + function messageKey(payload: MessagePayload): string { return `${payload.session_id}:${payload.message_id}`; } @@ -98,7 +128,7 @@ export function createMessageUpdateScheduler( for (const payload of updates) { if (!payload.session_id || !payload.message_id) continue; if (isOlderThanCurrentSnapshot(store, payload)) continue; - store.getState().updateMessage(toMessage(payload)); + store.getState().updateMessage(settleMessageForCompletedTurn(store, toMessage(payload))); } }; @@ -135,7 +165,7 @@ export function createMessagesHandlerRegistration( const payload = message.payload; if (!payload.session_id) return; scheduler.flush(); - store.getState().addMessage(toMessage(payload)); + store.getState().addMessage(settleMessageForCompletedTurn(store, toMessage(payload))); }, "session.message.updated": (message) => { scheduler.enqueue(message.payload); diff --git a/apps/web/src/locales/en/executors.json b/apps/web/src/locales/en/executors.json index e14e67b521..655596debb 100644 --- a/apps/web/src/locales/en/executors.json +++ b/apps/web/src/locales/en/executors.json @@ -155,6 +155,23 @@ "profileNotFound": "Profile not found", "profileSaved": "Profile saved", "profiles": "Profiles", + "portableConfigAvailable": "Available", + "portableConfigBundleClaudeSettings": "Copy Claude settings", + "portableConfigBundleCodexConfig": "Copy Codex configuration", + "portableConfigBundleGeneric": "Agent configuration ({{id}})", + "portableConfigBundleMockSettings": "Copy mock-agent settings", + "portableConfigBundleOpenCodeConfig": "Copy OpenCode configuration", + "portableConfigDescription": "Copy selected agent settings, which may contain secrets or commands, into newly provisioned or reset environments.", + "portableConfigFileMapping": "{{source}} to {{target}}", + "portableConfigInfoCopyTiming": "Kandev copies selected files without changes only during fresh provisioning or environment reset. A fresh copy can replace an existing target file. Warm resumes keep the existing environment.", + "portableConfigInfoErrors": "Missing or unreadable optional files are skipped with a warning and do not block launch. Files can contain secrets or environment values.", + "portableConfigInfoLabel": "Portable agent configuration details", + "portableConfigInfoScope": "Only allow-listed files are copied. A file can add hooks or commands, change models, permissions, MCP servers, or endpoints, and contain host paths that do not work in the executor. Authentication files and configuration bundles are separate choices.", + "portableConfigInfoSsh": "On SSH, the target is the configured remote user's shared home and can replace configuration used by other processes on that host.", + "portableConfigInfoTitle": "Portable agent configuration", + "portableConfigNotFound": "Not found", + "portableConfigNotFoundHint": "This is an editing hint. You can save the profile and add the file later.", + "portableConfigTitle": "Agent configuration", "provideSecret": "Provide secret", "quickPresets": "Quick presets", "refresh": "Refresh", diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index ae11ef2cb9..88aea56662 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -488,6 +488,7 @@ "profileForTasksCreatedByAgents": "Profile for Tasks Created by Agents", "programming": "Programming", "profileFallbackWillBeUsed": "Start model {{model}} is no longer available; fallback {{fallback}} will be used.", + "profileStartModelNotAdvertisedOnHost": "The host probe did not advertise {{model}}. The selected executor will decide the model at launch.", "profileStartModelUnavailable": "Start model {{model}} is no longer available; change it in the agent profile.", "promptAdd": "Add prompt", "promptContentPlaceholder": "Prompt content", diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 4f1f16ff87..6db4a71aee 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -732,6 +732,26 @@ "messageTime": "Message time", "modKeyEnterToAdd": "{{modKey}}+Enter to add", "model": "model", + "modelSelectionAgent": "Agent", + "modelSelectionEffective": "Effective model", + "modelSelectionExecutor": "Executor", + "modelSelectionExecutorProfile": "Executor profile", + "modelSelectionFallback": "Advertised fallback", + "modelSelectionProviderDefault": "Agent default", + "modelSelectionReason": "Reason", + "modelSelectionReasonAutoFallback": "The advertised model could not be applied, so automatic fallback continued with the agent default.", + "modelSelectionReasonCatalogEmpty": "The executor did not advertise a model catalog.", + "modelSelectionReasonFallbackNotAdvertised": "The saved fallback model was not advertised by the executor.", + "modelSelectionReasonRequestedNotAdvertised": "The saved model was not advertised by the executor.", + "modelSelectionReasonUnknown": "{{reason}}", + "modelSelectionReasonUnsupported": "The executor does not support model selection.", + "modelSelectionRemediationAgentVersion": "Check the agent version in the executor.", + "modelSelectionRemediationConfiguration": "Check the copied agent configuration.", + "modelSelectionRemediationCredentials": "Check executor credentials.", + "modelSelectionRemediationTitle": "Check these items", + "modelSelectionRemediationUnknown": "Check the executor configuration.", + "modelSelectionRequested": "Requested model", + "modelSelectionWarning": "The executor could not use the saved model selection.", "monitor": "Monitor", "monitorCommand": "Monitor: {{command}}", "moreActions": "More actions", diff --git a/apps/web/src/locales/pseudo/executors.json b/apps/web/src/locales/pseudo/executors.json index 28fed06ac9..8e9ca7e4d0 100644 --- a/apps/web/src/locales/pseudo/executors.json +++ b/apps/web/src/locales/pseudo/executors.json @@ -155,6 +155,23 @@ "profileNotFound": "Ƥŕōƒĩĺē ńōţ ƒōũńď", "profileSaved": "Ƥŕōƒĩĺē śàvēď", "profiles": "Ƥŕōƒĩĺēś", + "portableConfigAvailable": "Àvàĩĺàƀĺē", + "portableConfigBundleClaudeSettings": "Ćōƥŷ Ćĺàũďē śēţţĩńĝś", + "portableConfigBundleCodexConfig": "Ćōƥŷ Ćōďēx ćōńƒĩĝũŕàţĩōń", + "portableConfigBundleGeneric": "Àĝēńţ ćōńƒĩĝũŕàţĩōń ({{id}})", + "portableConfigBundleMockSettings": "Ćōƥŷ ḿōćķ-àĝēńţ śēţţĩńĝś", + "portableConfigBundleOpenCodeConfig": "Ćōƥŷ ŌƥēńĆōďē ćōńƒĩĝũŕàţĩōń", + "portableConfigDescription": "Ćōƥŷ śēĺēćţēď àĝēńţ śēţţĩńĝś, ŵĥĩćĥ ḿàŷ ćōńţàĩń śēćŕēţś ōŕ ćōḿḿàńďś, ĩńţō ńēŵĺŷ ƥŕōvĩśĩōńēď ōŕ ŕēśēţ ēńvĩŕōńḿēńţś.", + "portableConfigFileMapping": "{{source}} ţō {{target}}", + "portableConfigInfoCopyTiming": "Ķàńďēv ćōƥĩēś śēĺēćţēď ƒĩĺēś ŵĩţĥōũţ ćĥàńĝēś ōńĺŷ ďũŕĩńĝ ƒŕēśĥ ƥŕōvĩśĩōńĩńĝ ōŕ ēńvĩŕōńḿēńţ ŕēśēţ. À ƒŕēśĥ ćōƥŷ ćàń ŕēƥĺàćē àń ēxĩśţĩńĝ ţàŕĝēţ ƒĩĺē. Ŵàŕḿ ŕēśũḿēś ķēēƥ ţĥē ēxĩśţĩńĝ ēńvĩŕōńḿēńţ.", + "portableConfigInfoErrors": "Ḿĩśśĩńĝ ōŕ ũńŕēàďàƀĺē ōƥţĩōńàĺ ƒĩĺēś àŕē śķĩƥƥēď ŵĩţĥ à ŵàŕńĩńĝ àńď ďō ńōţ ƀĺōćķ ĺàũńćĥ. Ƒĩĺēś ćàń ćōńţàĩń śēćŕēţś ōŕ ēńvĩŕōńḿēńţ vàĺũēś.", + "portableConfigInfoLabel": "Ƥōŕţàƀĺē àĝēńţ ćōńƒĩĝũŕàţĩōń ďēţàĩĺś", + "portableConfigInfoScope": "Ōńĺŷ àĺĺōŵ-ĺĩśţēď ƒĩĺēś àŕē ćōƥĩēď. À ƒĩĺē ćàń àďď ĥōōķś ōŕ ćōḿḿàńďś, ćĥàńĝē ḿōďēĺś, ƥēŕḿĩśśĩōńś, ḾĆƤ śēŕvēŕś, ōŕ ēńďƥōĩńţś, àńď ćōńţàĩń ĥōśţ ƥàţĥś ţĥàţ ďō ńōţ ŵōŕķ ĩń ţĥē ēxēćũţōŕ. Àũţĥēńţĩćàţĩōń ƒĩĺēś àńď ćōńƒĩĝũŕàţĩōń ƀũńďĺēś àŕē śēƥàŕàţē ćĥōĩćēś.", + "portableConfigInfoSsh": "Ōń ŚŚĤ, ţĥē ţàŕĝēţ ĩś ţĥē ćōńƒĩĝũŕēď ŕēḿōţē ũśēŕ'ś śĥàŕēď ĥōḿē àńď ćàń ŕēƥĺàćē ćōńƒĩĝũŕàţĩōń ũśēď ƀŷ ōţĥēŕ ƥŕōćēśśēś ōń ţĥàţ ĥōśţ.", + "portableConfigInfoTitle": "Ƥōŕţàƀĺē àĝēńţ ćōńƒĩĝũŕàţĩōń", + "portableConfigNotFound": "Ńōţ ƒōũńď", + "portableConfigNotFoundHint": "Ţĥĩś ĩś àń ēďĩţĩńĝ ĥĩńţ. Ŷōũ ćàń śàvē ţĥē ƥŕōƒĩĺē àńď àďď ţĥē ƒĩĺē ĺàţēŕ.", + "portableConfigTitle": "Àĝēńţ ćōńƒĩĝũŕàţĩōń", "provideSecret": "Ƥŕōvĩďē śēćŕēţ", "quickPresets": "Qũĩćķ ƥŕēśēţś", "refresh": "Ŕēƒŕēśĥ", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index c156d2d2c8..2ae02fb440 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -488,6 +488,7 @@ "profileForTasksCreatedByAgents": "Ƥŕōƒĩĺē ƒōŕ Ţàśķś Ćŕēàţēď ƀŷ Àĝēńţś", "programming": "Ƥŕōĝŕàḿḿĩńĝ", "profileFallbackWillBeUsed": "Śţàŕţ ḿōďēĺ {{model}} ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē; ƒàĺĺƀàćķ {{fallback}} ŵĩĺĺ ƀē ũśēď.", + "profileStartModelNotAdvertisedOnHost": "Ţĥē ĥōśţ ƥŕōƀē ďĩď ńōţ àďvēŕţĩśē {{model}}. Ţĥē śēĺēćţēď ēxēćũţōŕ ŵĩĺĺ ďēćĩďē ţĥē ḿōďēĺ àţ ĺàũńćĥ.", "profileStartModelUnavailable": "Śţàŕţ ḿōďēĺ {{model}} ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē; ćĥàńĝē ĩţ ĩń ţĥē àĝēńţ ƥŕōƒĩĺē.", "promptAdd": "Àďď ƥŕōḿƥţ", "promptContentPlaceholder": "Ƥŕōḿƥţ ćōńţēńţ", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 76d55dd433..211ee5984f 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -732,6 +732,26 @@ "messageTime": "Ḿēśśàĝē ţĩḿē", "modKeyEnterToAdd": "{{modKey}}+Ēńţēŕ ţō àďď", "model": "ḿōďēĺ", + "modelSelectionAgent": "Àĝēńţ", + "modelSelectionEffective": "Ēƒƒēćţĩvē ḿōďēĺ", + "modelSelectionExecutor": "Ēxēćũţōŕ", + "modelSelectionExecutorProfile": "Ēxēćũţōŕ ƥŕōƒĩĺē", + "modelSelectionFallback": "Àďvēŕţĩśēď ƒàĺĺƀàćķ", + "modelSelectionProviderDefault": "Àĝēńţ ďēƒàũĺţ", + "modelSelectionReason": "Ŕēàśōń", + "modelSelectionReasonAutoFallback": "Ţĥē àďvēŕţĩśēď ḿōďēĺ ćōũĺď ńōţ ƀē àƥƥĺĩēď, śō àũţōḿàţĩć ƒàĺĺƀàćķ ćōńţĩńũēď ŵĩţĥ ţĥē àĝēńţ ďēƒàũĺţ.", + "modelSelectionReasonCatalogEmpty": "Ţĥē ēxēćũţōŕ ďĩď ńōţ àďvēŕţĩśē à ḿōďēĺ ćàţàĺōĝ.", + "modelSelectionReasonFallbackNotAdvertised": "Ţĥē śàvēď ƒàĺĺƀàćķ ḿōďēĺ ŵàś ńōţ àďvēŕţĩśēď ƀŷ ţĥē ēxēćũţōŕ.", + "modelSelectionReasonRequestedNotAdvertised": "Ţĥē śàvēď ḿōďēĺ ŵàś ńōţ àďvēŕţĩśēď ƀŷ ţĥē ēxēćũţōŕ.", + "modelSelectionReasonUnknown": "{{reason}}", + "modelSelectionReasonUnsupported": "Ţĥē ēxēćũţōŕ ďōēś ńōţ śũƥƥōŕţ ḿōďēĺ śēĺēćţĩōń.", + "modelSelectionRemediationAgentVersion": "Ćĥēćķ ţĥē àĝēńţ vēŕśĩōń ĩń ţĥē ēxēćũţōŕ.", + "modelSelectionRemediationConfiguration": "Ćĥēćķ ţĥē ćōƥĩēď àĝēńţ ćōńƒĩĝũŕàţĩōń.", + "modelSelectionRemediationCredentials": "Ćĥēćķ ēxēćũţōŕ ćŕēďēńţĩàĺś.", + "modelSelectionRemediationTitle": "Ćĥēćķ ţĥēśē ĩţēḿś", + "modelSelectionRemediationUnknown": "Ćĥēćķ ţĥē ēxēćũţōŕ ćōńƒĩĝũŕàţĩōń.", + "modelSelectionRequested": "Ŕēqũēśţēď ḿōďēĺ", + "modelSelectionWarning": "Ţĥē ēxēćũţōŕ ćōũĺď ńōţ ũśē ţĥē śàvēď ḿōďēĺ śēĺēćţĩōń.", "monitor": "Ḿōńĩţōŕ", "monitorCommand": "Ḿōńĩţōŕ: {{command}}", "moreActions": "Ḿōŕē àćţĩōńś", diff --git a/docs/audits/settings-typography/README.md b/docs/audits/settings-typography/README.md index 3513142b33..1680ea3f77 100644 --- a/docs/audits/settings-typography/README.md +++ b/docs/audits/settings-typography/README.md @@ -1,6 +1,6 @@ # Settings typography audit -Status: Active implementation audit +Status: Active implementation audit Last reviewed: 2026-08-16 This audit covers the first-party settings routes, shared settings components, the settings navigation, workspace and integration settings, system and account settings, and the existing settings E2E coverage. diff --git a/docs/decisions/2026-08-15-executor-authoritative-model-selection.md b/docs/decisions/2026-08-15-executor-authoritative-model-selection.md new file mode 100644 index 0000000000..3857ef5cc6 --- /dev/null +++ b/docs/decisions/2026-08-15-executor-authoritative-model-selection.md @@ -0,0 +1,118 @@ +# ADR-2026-08-15: Let the Executor Own Model Selection + +**Status:** accepted +**Date:** 2026-08-15 +**Area:** backend, frontend, protocol, persistence + +## Context + +The Kandev host probes agent capabilities before a task starts. +The selected executor starts another agent process and another ACP session. + +These two sessions can advertise different model catalogs. +Credentials, configuration, agent versions, account permissions, and provider state can cause the difference. + +Optional configuration copying can reduce the difference. +It cannot guarantee equal catalogs for all providers or accounts. + +The current strict policy fails a launch when the executor omits the profile model. +The frontend can also block the profile from host-probe data. + +This policy makes a host observation authoritative for a different runtime. +It also prevents the agent from using its valid executor default. + +## Decision + +### The executor catalog is authoritative + +The ACP session in the selected executor owns model availability at launch. +The host probe remains an editing hint. + +The host and executor catalogs do not need to match. +Kandev does not block task creation because the host probe omits a saved model. + +Kandev keeps the saved profile model unchanged. +The runtime decision does not rewrite the agent profile. + +### Kandev does not set an unadvertised model + +After ACP initialization, Kandev reads the executor model catalog and current model. +Then Kandev applies this order: + +1. If the requested model is advertised, Kandev applies it. +2. If an explicit fallback is advertised, Kandev applies that fallback. +3. Otherwise, Kandev does not call the model-selection method. +4. The agent continues with its current or default model. + +An empty model catalog means that the requested model is not advertised. +Kandev does not use a speculative model-selection call in this case. + +If the agent does not support model selection, Kandev keeps the agent default. +Kandev also creates the same warning when the profile requested a model. + +If an advertised model fails to apply, Kandev reports an explicit launch error. +This error can show a transport, provider, or protocol problem. + +The existing `auto_fallback` option keeps its best-effort behavior for an apply error. +This mode continues with the agent default and creates a warning. + +### Every model change is visible and durable + +Kandev creates one persisted status message when it does not apply the requested model. +The message uses the warning variant. + +The message metadata contains these fields: + +- A stable semantic kind, `model_selection_warning`. +- The reason, such as `requested_not_advertised` or `selection_unsupported`. +- The requested model. +- The effective model when the agent reports it. +- The explicit fallback model when Kandev applies it. +- The agent provider. +- The executor type and executor profile ID. + +If the effective model is unknown, the UI shows `provider default, model not reported`. + +The localized message explains that Kandev did not set the requested model. +It also tells the user to inspect credentials, copied configuration, and the agent version. + +The message survives browser reload and backend restart. +Kandev creates at most one message for one session-start model decision. + +An event can update live model-selector state. +The persisted message remains the user audit record. + +### Configuration copying remains optional + +Portable configuration bundles are a parity aid. +They are not a precondition for a successful task launch. + +If the user does not copy configuration, this model-selection policy still applies. +The executor default keeps the task operational and the warning explains the difference. + +### Office routing remains separate + +This decision controls the initial model selection inside one executor session. +It does not change Office provider routing after a provider error. + +Office uses the workspace route policy from `2026-08-08-provider-neutral-agent-error-recovery.md`. +That policy can select another configured execution profile after a runtime error. + +## Consequences + +- A model-catalog difference no longer stops a task before the agent can start. +- Kandev never sends an unadvertised model to the executor agent. +- The user receives a durable explanation for every default or explicit fallback. +- Host capability data no longer acts as an executor launch gate. +- A profile can remain useful across executors with different provider accounts. +- An advertised model apply error remains visible and can stop the launch. +- Configuration copying can improve parity without becoming hidden authority. + +## Alternatives Considered + +1. **Require equal host and executor catalogs.** Rejected because Kandev cannot control provider accounts, caches, or agent versions. +2. **Fail when the requested model is absent.** Rejected because the agent default can still operate and the user requested continuation. +3. **Call the model method and ignore its error.** Rejected because this hides protocol errors and creates an unnecessary failed request. +4. **Rewrite the saved profile model.** Rejected because one executor observation must not change the user choice for all executors. +5. **Rely on configuration copying only.** Rejected because copied files cannot guarantee provider authorization or equal runtime state. +6. **Show only an ephemeral toast.** Rejected because the explanation must survive reload and remain in the task history. diff --git a/docs/decisions/2026-08-15-portable-agent-configuration-bundles.md b/docs/decisions/2026-08-15-portable-agent-configuration-bundles.md new file mode 100644 index 0000000000..2828826660 --- /dev/null +++ b/docs/decisions/2026-08-15-portable-agent-configuration-bundles.md @@ -0,0 +1,167 @@ +# ADR-2026-08-15: Use Explicit Portable Agent Configuration Bundles + +**Status:** accepted +**Date:** 2026-08-15 +**Area:** backend, frontend, protocol, security + +## Context + +Agent tools read user configuration from files in the user home directory. +An isolated executor does not share these files with the Kandev host. + +This difference can change models, hooks, MCP servers, permissions, and provider behavior. +The difference applies to Claude, Codex, OpenCode, and other agent providers. + +Kandev already transfers selected authentication files to isolated executors. +Authentication and configuration have different risks and different user choices. + +Some configuration files can contain secrets, environment values, or commands. +Some files also contain host paths that do not work in another environment. + +Kandev needs an optional transfer function for simple configuration files. +This function must not copy a complete agent home or accept arbitrary host paths. + +## Decision + +### Agent integrations declare portable bundles + +Each agent integration can declare zero or more portable configuration bundles. +A bundle has a stable ID, a label, and one or more file mappings. + +Each file mapping contains these values: + +- An operating-system-specific source path under the Kandev host home. +- A target path under the agent home in the executor. +- A file size limit. + +Kandev controls this allowlist in source code. +The profile API does not accept user-defined source paths or target paths. + +The first release contains these bundles: + +| Agent | Host source | Executor target | +|---|---|---| +| Claude | `.claude/settings.json` | `.claude/settings.json` | +| Codex | `.codex/config.toml` | `.codex/config.toml` | +| OpenCode | `.config/opencode/opencode.json` | `.config/opencode/opencode.json` | +| OpenCode | `.config/opencode/opencode.jsonc` | `.config/opencode/opencode.jsonc` | + +The OpenCode bundle copies each listed file that exists. +At least one listed OpenCode file must exist before a new selection is available. + +An agent without a safe bundle does not show a configuration option. +The agent integration can add a bundle after its file contract is known. + +### Configuration selection is independent from authentication + +An executor profile stores selected bundle IDs in `config.agent_config_bundles`. +The value is a JSON array of stable bundle IDs. + +The profile can select authentication and configuration independently. +For example, a Codex profile can use `OPENAI_API_KEY` and copy `config.toml`. + +The Codex authentication bundle will contain `auth.json` only. +Kandev will stop treating `config.toml` as an authentication file. + +Existing profiles do not get a configuration selection automatically. +This rule removes the current implicit Docker copy of Codex `config.toml`. + +### The backend owns discovery and transfer + +The backend builds the bundle catalog from enabled agent integrations. +It also checks which declared host files exist. + +The frontend reads this catalog from `GET /api/v1/agent-config-bundles`. +The response contains metadata and availability only. +The response never contains file data. + +The executor profile save API stores bundle IDs in the existing profile configuration map. +No new database table or migration is necessary. + +During fresh provisioning, the lifecycle manager resolves the selected IDs again. +Then it reads the current host files and copies them to the executor. + +The file data exists in memory during transfer. +Kandev does not store the file data in SQLite or in the executor profile. + +### Transfer rules + +The transfer applies to Local Docker, SSH, and Sprites executors. +Local and Worktree executors already use the host agent configuration. +Remote Docker remains unavailable. + +Kandev copies configuration only during fresh environment provisioning. +An environment reset also causes a new copy. +A warm resume uses the files that already exist in the executor. + +Kandev overwrites a selected target file during fresh provisioning. +For SSH, this target is under the configured remote user home. +This overwrite can affect other processes that use the same remote account. + +Kandev copies only regular files. +It does not follow symbolic links or copy directories. + +Each source file has a limit of 1 MiB. +All selected files for one launch have a combined limit of 4 MiB. + +The transfer rejects absolute targets and path traversal. +The transfer writes target files with mode `0600`. + +Kandev does not parse or remove parts of a selected file. +The agent provider remains the authority for the file format and behavior. + +If a selected source disappears, Kandev skips that source and writes a preparation warning. +If a target write fails, Kandev writes a preparation warning and continues the launch. +The agent then uses the configuration that remains in the executor. + +### The user interface shows the risk + +The executor profile editor shows each supported agent as an expandable row. +Each expanded row contains the agent's authentication controls and independent +configuration checkboxes. There is no separate global configuration section. + +The configuration controls show a warning icon beside their title. +The visible description states that Kandev copies selected files without changes. + +On a fine pointer, hover or keyboard focus opens the warning tooltip. +On a coarse pointer, the same control opens a bottom drawer. + +The warning explains these risks: + +- A configuration file can contain secrets or environment values. +- A configuration file can add hooks or commands in the executor. +- A configuration file can change permissions, MCP servers, models, and network endpoints. +- A host path can be invalid in the executor. +- Fresh provisioning overwrites the selected target file. + +### Configuration copying does not guarantee model parity + +A copied file can improve parity between the host and an isolated executor. +It cannot guarantee an equal model catalog. + +Credentials, agent versions, provider accounts, and network access can still differ. +The executor ACP catalog remains authoritative when Kandev starts an agent. + +If the executor omits the profile model, Kandev follows the +[executor-authoritative model-selection decision](2026-08-15-executor-authoritative-model-selection.md). +Kandev does not send the missing model, starts the agent default, and persists a chat warning. + +## Consequences + +- Agent providers use one shared extension contract for portable configuration. +- Users can reproduce simple host configuration without copying opaque runtime state. +- Authentication does not imply consent to copy configuration. +- Executor profiles persist intent, but they do not persist file data. +- File changes on the host apply after fresh provisioning or an environment reset. +- SSH users must understand that the copy can replace files in a shared remote home. +- Provider configuration can run extra hooks with the authority of the agent process. +- Task launch remains safe when configuration copying is off or does not create model parity. + +## Alternatives Considered + +1. **Copy the complete agent home.** Rejected because it includes credentials, sessions, databases, caches, and host-specific state. +2. **Accept arbitrary host paths.** Rejected because it creates a broad host-file export function. +3. **Keep configuration inside authentication bundles.** Rejected because users need independent consent and independent secret choices. +4. **Store file data in the profile.** Rejected because profiles are database records and file data can contain secrets. +5. **Copy configuration on every resume.** Rejected because a resume must preserve the existing executor state. +6. **Parse and sanitize provider files.** Rejected because Kandev does not own each provider format or its full semantics. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 7ffae8dd30..a299c1ea81 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -92,6 +92,8 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-28-visible-wip-overflow-queues | [Separate Visible Queueing From WIP Admission](2026-07-28-visible-wip-overflow-queues.md) | proposed | backend, frontend, protocol, workflow | 2026-07-28 | | 2026-08-12-queue-task-moves-at-wip-capacity | [Queue Task Moves at WIP Capacity](2026-08-12-queue-task-moves-at-wip-capacity.md) | accepted | backend, frontend, protocol, workflow | 2026-08-12 | | 2026-08-15-subagent-context-schema-boundary | [Start with the execution-aware subagent schema](2026-08-15-subagent-context-schema-boundary.md) | accepted | backend | 2026-08-15 | +| 2026-08-15-portable-agent-configuration-bundles | [Use Explicit Portable Agent Configuration Bundles](2026-08-15-portable-agent-configuration-bundles.md) | accepted | backend, frontend, protocol, security | 2026-08-15 | +| 2026-08-15-executor-authoritative-model-selection | [Let the Executor Own Model Selection](2026-08-15-executor-authoritative-model-selection.md) | accepted | backend, frontend, protocol, persistence | 2026-08-15 | | 2026-07-28-coarse-running-busy-signal | [Restore Coarse Running Prompt Admission](2026-07-28-coarse-running-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-28 | | 2026-07-29-agent-stall-user-controlled-recovery | [Keep Agent Stall Recovery User Controlled](2026-07-29-agent-stall-user-controlled-recovery.md) | accepted | backend, frontend, protocol | 2026-07-29 | | 2026-07-29-quarantine-retention-override | [Make Quarantine Retention Overridable but Visible](2026-07-29-quarantine-retention-override.md) | accepted | backend, frontend | 2026-07-29 | diff --git a/docs/plans/executor-model-mismatch-fallback/plan.md b/docs/plans/executor-model-mismatch-fallback/plan.md new file mode 100644 index 0000000000..cf139ba170 --- /dev/null +++ b/docs/plans/executor-model-mismatch-fallback/plan.md @@ -0,0 +1,200 @@ +--- +spec: docs/specs/no-silent-model-fallback/spec.md +decision: docs/decisions/2026-08-15-executor-authoritative-model-selection.md +created: 2026-08-15 +status: implemented +--- + +# Implementation Plan: Executor Model Mismatch Fallback + +## Overview + +Replace strict launch failure with an executor-authoritative model decision. +If the executor omits the profile model, Kandev does not send a model-selection request. + +The agent continues with its current or default model. +Kandev persists a warning in task chat. + +This behavior does not depend on portable configuration copying. +Configuration copying remains an optional method to improve parity. + +## Runtime decision + +Refactor `applyStartModelPolicy` to return a typed decision. +The decision contains requested, fallback, effective, outcome, and warning values. + +Apply this order after ACP initialization: + +1. Apply the requested model only when the executor advertises it. +2. Apply an explicit fallback only when the executor advertises it. +3. Otherwise, send no model-selection request. +4. Continue with the agent current or default model. + +An empty model catalog follows step 3. +A method-not-supported response also uses the agent default. + +An advertised model apply error remains an explicit error. +If `auto_fallback` is active, the error remains best-effort and creates a warning. + +Use the same decision helper for initial launch, context reset, and workspace rebind. +No later layer can repeat a handled model decision. + +## Durable warning + +Generalize the current fallback event into a provider-neutral model-selection warning. +Keep the old fallback event as a compatibility projection during migration. + +The warning metadata contains these values: + +- `kind = model_selection_warning` +- `reason` +- `requested_model` +- `effective_model`, when reported +- `fallback_model`, when applied +- `agent_id` +- `executor_type` +- `executor_profile_id` + +The orchestrator persists one `status` message with `variant = warning`. +Use a stable idempotency key for one session-start decision. + +The message is best-effort. +A message-persistence error cannot stop the task launch. + +The frontend renders localized text from structured metadata. +If the effective model is unknown, it shows `provider default, model not reported`. + +The message tells the user to inspect these items: + +- Executor credentials. +- Copied agent configuration. +- The agent version in the executor. + +## Frontend behavior + +The host model catalog remains useful in profile editors. +It does not block task creation or Office setup. + +A host-catalog difference shows an advisory warning. +Every affected profile remains selectable. + +The chat status renderer shows the persisted warning on desktop and mobile. +The existing chat list remains the only scroll owner. + +No new mobile overlay is necessary. +The warning uses the existing mobile chat composition and status-message component. + +The live model selector can show the effective executor model. +The persisted chat message remains the durable audit record. + +## Test strategy + +### Backend + +- Cover advertised requested models and advertised explicit fallbacks. +- Cover absent requested models, absent fallbacks, and empty catalogs. +- Make sure that absent models cause zero model-selection calls. +- Cover method-not-supported and advertised apply errors. +- Cover `auto_fallback` warning behavior. +- Cover initial launch, context reset, and workspace rebind. +- Cover event payloads, message metadata, idempotency, and persistence errors. +- Cover message hydration after service restart. + +### Frontend + +- Cover advisory profile warnings without disabled profile options. +- Cover localized status-message output for known and unknown effective models. +- Cover live model state after explicit fallback and provider-default decisions. +- Cover metadata with missing optional fields. + +### E2E + +- Create a profile model that the task agent does not advertise. +- Start the task and make sure that the task continues. +- Make sure that chat shows one warning with requested and effective values. +- Reload the task and make sure that the warning remains. +- Repeat the user outcome in the mobile project. +- Make sure that the task-create profile remains selectable. + +## Desktop and mobile behavior + +The desktop entry point is the task-create profile selector and task chat. +The mobile entry point is the same flow through the mobile task-create dialog. + +The nearest shipped exemplar is `status-message.tsx` in the task chat. +The warning stays inside the message list on both viewport classes. + +The settings or task page owns vertical scrolling. +The warning does not add a nested scroll area. + +The profile option remains a standard selectable row. +Mobile E2E uses `tap()` and checks horizontal overflow. + +## Public documentation + +Update `docs/public/agents-and-profiles.md` with the host and executor authority boundary. +Update `docs/public/executors.md` with default-on-mismatch behavior and warning guidance. + +The documentation must state that configuration copying cannot guarantee equal catalogs. +It must also state that Kandev does not rewrite the saved profile model. + +## Implementation waves + +Wave 1: + +- [x] [Task 01: Define executor model decisions](task-01-executor-model-decision.md) + +Wave 2: + +- [x] [Task 02: Persist model-selection warnings](task-02-persist-model-warning.md) + +Wave 3: + +- [x] [Task 03: Remove host model launch gates](task-03-remove-host-model-gates.md) + +Wave 4: + +- [x] [Task 04: Prove model-mismatch recovery](task-04-model-mismatch-evidence.md) + +## Verification + +```bash +cd apps/backend && go test -tags fts5 -run 'TestApplyStartModelPolicy|TestInitializeAndPrompt.*Model|TestReapplySessionModel|TestWorkspaceRebind.*Model' ./internal/agent/runtime/lifecycle +cd apps/backend && go test -tags fts5 -run 'Test.*ModelSelectionWarning|Test.*ModelFallback' ./internal/orchestrator ./internal/gateway/websocket +cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-options components/task/chat/messages/status-message lib/ws/handlers/session-models +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check +cd apps/web && pnpm run i18n:ratchet +cd apps/web && pnpm e2e:run tests/settings/no-silent-model-fallback.spec.ts tests/session/model-mismatch-warning.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome --no-build tests/settings/mobile-no-silent-model-fallback.spec.ts tests/session/mobile-model-mismatch-warning.spec.ts +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Risks + +- A provider can report an incorrect current model after Kandev skips selection. +- Message creation happens during session initialization and needs a valid task-session identity. +- A replayed lifecycle event can create a duplicate warning without an idempotency key. +- Existing strict-mode tests and copy must change without weakening advertised-model error handling. +- The old fallback event has consumers that need a compatibility period. + +## Results + +- Implemented executor-authoritative model decisions for launch, reset, and + workspace rebind, with default continuation and explicit advertised fallback + handling. +- Persisted one provider-neutral warning per decision with durable metadata and + reload hydration; host model probes remain advisory. +- Backend targeted tests: 7,299 passed across 45 packages. Desktop and mobile + mismatch E2E scenarios passed after reload. +- Public agent-profile and executor documentation passed the public-doc audit. + +## Out of scope + +- Equal host and executor model catalogs. +- Automatic configuration copying. +- Mid-turn model switching. +- Office provider-route policy after a runtime error. +- Rewriting the saved profile model from executor state. diff --git a/docs/plans/executor-model-mismatch-fallback/task-01-executor-model-decision.md b/docs/plans/executor-model-mismatch-fallback/task-01-executor-model-decision.md new file mode 100644 index 0000000000..9103c6f1a0 --- /dev/null +++ b/docs/plans/executor-model-mismatch-fallback/task-01-executor-model-decision.md @@ -0,0 +1,72 @@ +--- +id: "01-executor-model-decision" +title: "Define executor model decisions" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/no-silent-model-fallback/spec.md" +--- + +# Task 01: Define executor model decisions + +## Acceptance + +- An unadvertised requested model causes no model-selection call, and the agent default continues. +- An explicit fallback applies only when advertised, while advertised apply errors remain explicit. +- Initial launch, context reset, and workspace rebind use one typed decision helper without duplicate calls. + +## TDD scenarios + +1. RED: Replace strict-gone tests with no-call and provider-default expectations. +2. RED: Add absent fallback, empty catalog, method-not-supported, and auto-fallback cases. +3. RED: Add context-reset and workspace-rebind decision cases. +4. GREEN: Add the typed decision and apply the executor-authoritative order. +5. REFACTOR: Remove duplicated model-selection branches and keep one policy owner. + +## Verification + +- `cd apps/backend && go test -tags fts5 -run 'TestApplyStartModelPolicy|TestInitializeAndPrompt.*Model|TestReapplySessionModel|TestWorkspaceRebind.*Model' ./internal/agent/runtime/lifecycle` +- `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/lifecycle` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/agent/runtime/lifecycle/start_model.go` +- `apps/backend/internal/agent/runtime/lifecycle/start_model_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/session.go` +- `apps/backend/internal/agent/runtime/lifecycle/session_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_interaction_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential. Tasks 02 and 03 consume the typed decision contract. + +## Inputs + +- The amended no-silent-model-fallback specification. +- The executor-authoritative model-selection ADR. +- The current strict `applyStartModelPolicy` implementation. + +## Output contract + +Report the decision outcomes, model-selection call lists, RED evidence, GREEN evidence, and test results. + +## Risks + +- An empty catalog can mean unsupported selection or incomplete provider data. +- Runtime model overrides must follow the same executor catalog rules. +- A handled default decision must prevent a later profile-layer retry. + +## Results + +Implemented the typed executor-authoritative decision and reused it for initial +launch, context reset, and workspace rebind. Lifecycle coverage passed with +1,857 tests. diff --git a/docs/plans/executor-model-mismatch-fallback/task-02-persist-model-warning.md b/docs/plans/executor-model-mismatch-fallback/task-02-persist-model-warning.md new file mode 100644 index 0000000000..adf35fed03 --- /dev/null +++ b/docs/plans/executor-model-mismatch-fallback/task-02-persist-model-warning.md @@ -0,0 +1,78 @@ +--- +id: "02-persist-model-warning" +title: "Persist model-selection warnings" +status: done +wave: 2 +depends_on: ["01-executor-model-decision"] +plan: "plan.md" +spec: "../../specs/no-silent-model-fallback/spec.md" +--- + +# Task 02: Persist model-selection warnings + +## Acceptance + +- Every default or explicit fallback emits structured requested, effective, agent, executor, and reason data. +- The orchestrator persists one warning status message for each session-start decision, and reload hydration returns it. +- Event replay or message failure does not create duplicates or stop the task launch. + +## TDD scenarios + +1. RED: Add lifecycle event tests for explicit fallback, provider default, unknown effective model, and unsupported selection. +2. RED: Add orchestrator tests for status metadata, idempotency, replay, and persistence errors. +3. RED: Add gateway tests for the provider-neutral live event and old-event compatibility. +4. GREEN: Generalize the lifecycle event and persist the semantic warning message. +5. REFACTOR: Keep the event payload, stored metadata, and WebSocket payload on one shared shape. + +## Verification + +- `cd apps/backend && go test -tags fts5 -run 'Test.*ModelSelectionWarning|Test.*ModelFallback' ./internal/agent/runtime/lifecycle ./internal/orchestrator ./internal/gateway/websocket` +- `cd apps/backend && go test -tags fts5 ./internal/orchestrator ./internal/gateway/websocket` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/agentctl/types/streams/agent.go` +- `apps/backend/internal/agent/runtime/lifecycle/event_types.go` +- `apps/backend/internal/agent/runtime/lifecycle/events.go` +- `apps/backend/internal/agent/runtime/lifecycle/session.go` +- `apps/backend/internal/events/types.go` +- `apps/backend/internal/orchestrator/event_handlers_streaming.go` +- `apps/backend/internal/orchestrator/event_handlers_streaming_test.go` +- `apps/backend/internal/orchestrator/service.go` +- `apps/backend/internal/gateway/websocket/session_notifications.go` +- `apps/backend/internal/gateway/websocket/session_notifications_test.go` +- `apps/backend/pkg/websocket/actions.go` + +## Dependencies + +- Task 01 supplies the typed model decision. + +## Parallelism + +Sequential after Task 01. +This task defines the persisted and live warning contracts. + +## Inputs + +- The typed decision from Task 01. +- The current `session_model_fallback` event. +- The existing `CreateSessionMessage` warning path. +- The existing task message repository and hydration flow. + +## Output contract + +Report the event shape, message metadata, idempotency method, RED evidence, GREEN evidence, and test results. + +## Risks + +- The lifecycle event can arrive before the session identity is linked. +- A blank turn ID must use the existing session-status message convention. +- Compatibility consumers can still depend on `session.model_fallback`. + +## Results + +Implemented structured warning events, idempotent best-effort persistence, +provider-neutral WebSocket notification, and durable metadata hydration. The +orchestrator and gateway suites passed with 2,270 tests; the combined backend +targeted suite passed with 7,299 tests. diff --git a/docs/plans/executor-model-mismatch-fallback/task-03-remove-host-model-gates.md b/docs/plans/executor-model-mismatch-fallback/task-03-remove-host-model-gates.md new file mode 100644 index 0000000000..bd89f54725 --- /dev/null +++ b/docs/plans/executor-model-mismatch-fallback/task-03-remove-host-model-gates.md @@ -0,0 +1,84 @@ +--- +id: "03-remove-host-model-gates" +title: "Remove host model launch gates" +status: done +wave: 3 +depends_on: ["02-persist-model-warning"] +plan: "plan.md" +spec: "../../specs/no-silent-model-fallback/spec.md" +--- + +# Task 03: Remove host model launch gates + +## Acceptance + +- Host-catalog differences show advisory text but never disable task-create or Office profile options. +- Persisted model-selection metadata renders one localized warning with requested, effective, agent, executor, and remediation details. +- Desktop and mobile use the existing chat message flow, with no hover-only information or new scroll owner. + +## TDD scenarios + +1. RED: Change profile-option tests from disabled behavior to selectable warnings. +2. RED: Add status-message tests for known effective, unknown effective, and explicit fallback outcomes. +3. RED: Add WebSocket state tests for provider-default and explicit-fallback live decisions. +4. GREEN: Remove host-only gates and add the structured warning renderer. +5. REFACTOR: Share warning derivation between task-create and Office selectors. + +## Verification + +- `cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-options app/office/setup/agent-profile-setup-controls components/task/chat/messages/status-message lib/ws/handlers/session-models` +- `cd apps/web && pnpm run typecheck` +- `cd apps/web && pnpm run i18n:check` +- `cd apps/web && pnpm run i18n:ratchet` +- `cd apps && pnpm --filter @kandev/web lint` +- `git diff --check` + +## Files likely touched + +- `apps/web/components/task-create-dialog-options.tsx` +- `apps/web/components/task-create-dialog-options.test.tsx` +- `apps/web/app/office/setup/agent-profile-setup-controls.tsx` +- `apps/web/app/office/setup/agent-profile-setup-controls.test.tsx` +- `apps/web/components/task/chat/messages/status-message.tsx` +- `apps/web/components/task/chat/messages/status-message.test.tsx` +- `apps/web/lib/ws/handlers/session-models.ts` +- `apps/web/lib/ws/handlers/session-models.test.ts` +- `apps/web/lib/types/session-events.ts` +- `apps/web/src/locales/en/chat.json` +- `apps/web/src/locales/pseudo/chat.json` +- `apps/web/src/locales/pt-pt/chat.json` +- `apps/web/src/locales/zh-cn/chat.json` +- `apps/web/src/locales/zh-hk/chat.json` +- `apps/web/src/locales/zh-tw/chat.json` + +## Dependencies + +- Task 02 supplies the stored message and live-event contracts. + +## Parallelism + +Sequential after Task 02. +This task owns frontend selector, message, state, and locale files. + +## Inputs + +- The warning metadata contract from Task 02. +- Existing disabled profile logic. +- Existing status-message and session-model handlers. +- Existing mobile chat composition. + +## Output contract + +Report selector behavior, warning copy, accessibility, translations, RED evidence, GREEN evidence, and test results. + +## Risks + +- Executor compatibility gates must remain active. +- A warning must not imply that the host catalog is authoritative. +- Missing optional metadata must not produce broken sentences. + +## Results + +Removed host-only launch gates, added advisory selector copy, and rendered the +localized persisted warning with optional metadata handling. Focused frontend +tests, typecheck, lint, i18n check, and i18n ratchet passed. diff --git a/docs/plans/executor-model-mismatch-fallback/task-04-model-mismatch-evidence.md b/docs/plans/executor-model-mismatch-fallback/task-04-model-mismatch-evidence.md new file mode 100644 index 0000000000..1cbcbfb75c --- /dev/null +++ b/docs/plans/executor-model-mismatch-fallback/task-04-model-mismatch-evidence.md @@ -0,0 +1,77 @@ +--- +id: "04-model-mismatch-evidence" +title: "Prove model-mismatch recovery" +status: done +wave: 4 +depends_on: ["03-remove-host-model-gates"] +plan: "plan.md" +spec: "../../specs/no-silent-model-fallback/spec.md" +--- + +# Task 04: Prove model-mismatch recovery + +## Acceptance + +- Desktop E2E proves profile selection, task continuation, one warning, and warning persistence after reload. +- Mobile E2E proves the same outcome through touch input without horizontal overflow. +- Public documentation explains executor authority, optional configuration copying, and the unchanged saved profile model. + +## TDD scenarios + +1. RED: Update the existing profile-picker E2E to require a selectable host mismatch. +2. RED: Add a task-launch scenario with an executor catalog that omits the requested model. +3. RED: Add reload and mobile scenarios for the persisted warning. +4. GREEN: Complete missing fixtures and selectors without weakening the runtime assertions. +5. GREEN: Update the public agent-profile and executor documentation. +6. REFACTOR: Share desktop and mobile model-mismatch setup helpers. + +## Verification + +- `cd apps/web && pnpm e2e:run tests/settings/no-silent-model-fallback.spec.ts tests/session/model-mismatch-warning.spec.ts` +- `cd apps/web && pnpm e2e:run --project mobile-chrome --no-build tests/settings/mobile-no-silent-model-fallback.spec.ts tests/session/mobile-model-mismatch-warning.spec.ts` +- `node --test scripts/validate-public-docs.test.mjs` +- `node scripts/validate-public-docs.mjs` +- `git diff --check` + +## Files likely touched + +- `apps/web/e2e/tests/settings/no-silent-model-fallback.spec.ts` +- `apps/web/e2e/tests/settings/mobile-no-silent-model-fallback.spec.ts` +- `apps/web/e2e/tests/session/model-mismatch-warning.spec.ts` +- `apps/web/e2e/tests/session/mobile-model-mismatch-warning.spec.ts` +- `apps/web/e2e/tests/session/model-mismatch-warning-helpers.ts` +- `apps/web/e2e/helpers/api-client.ts` +- `docs/public/agents-and-profiles.md` +- `docs/public/executors.md` + +## Dependencies + +- Task 03 completes runtime, persistence, and frontend behavior. + +## Parallelism + +Sequential after Task 03. +This task validates the complete user flow. + +## Inputs + +- The completed model-decision and warning flow. +- Existing no-silent-model-fallback E2E files. +- The mock agent model catalog. +- The public executor documentation. + +## Output contract + +Report exact E2E commands, reload evidence, mobile geometry, public pages, failures, and final package status. + +## Risks + +- The host and executor catalogs need a deterministic mismatch fixture. +- Worker-scoped profiles and tasks require cleanup after each scenario. +- A warning visible before reload does not prove persistence. + +## Results + +Desktop and mobile E2E proved task continuation, one warning, saved-profile +preservation, and warning visibility after reload without horizontal overflow. +Public documentation validation passed with 61 tests and 41 published pages. diff --git a/docs/plans/portable-agent-configuration/plan.md b/docs/plans/portable-agent-configuration/plan.md new file mode 100644 index 0000000000..5220c3e3b0 --- /dev/null +++ b/docs/plans/portable-agent-configuration/plan.md @@ -0,0 +1,199 @@ +--- +spec: docs/specs/portable-agent-configuration/spec.md +decision: docs/decisions/2026-08-15-portable-agent-configuration-bundles.md +created: 2026-08-15 +status: implemented +--- + +# Implementation Plan: Portable Agent Configuration + +## Overview + +Add an optional executor-profile control for small agent configuration files. +Keep authentication and configuration as separate choices. + +The backend will own the file allowlist and file transfer. +The frontend will store selected bundle IDs in the existing profile configuration map. + +## Architecture + +### Provider declarations + +- Add an optional portable-configuration capability beside `RemoteAuth`. +- Add one catalog package for stable bundle IDs and host-file availability. +- Declare initial bundles for Claude, Codex, and OpenCode. +- Remove `config.toml` from the Codex authentication file list. +- Keep agents without a safe declaration out of the catalog. + +### API and state + +- Add `GET /api/v1/agent-config-bundles` to the executor-profile handler group. +- Return bundle metadata, source paths, target paths, and host availability. +- Do not return file data through the API. +- Store selected IDs in `ExecutorProfile.Config.agent_config_bundles` as JSON. +- Pass this profile key into lifecycle launch metadata. +- Keep the raw file data out of the database and frontend state. + +### Transfer engine + +- Resolve selected IDs against the backend catalog during fresh provisioning. +- Read only regular files below the Kandev host home. +- Reject symbolic links, absolute targets, path traversal, and size-limit violations. +- Limit one file to 1 MiB and one launch to 4 MiB. +- Write target files with mode `0600`. +- Preserve the declared relative target path. +- Report a preparation warning and continue after an optional copy error. + +### Executor integration + +- Local Docker writes selected files into its Kandev-managed agent session directory. +- SSH writes selected files under the configured remote user home. +- Sprites writes selected files under the sandbox home. +- Fresh provisioning and environment reset copy current host data. +- Warm resume keeps the executor copy and does not read the host again. +- Local and Worktree do not use this transfer path. + +### Frontend + +- Add the configuration controls to each matching agent row in the existing remote-credentials settings card. +- Keep each configuration checkbox independent from that agent's authentication radio group. +- Do not render a separate global agent-configuration section. +- Use the existing route-level save coordinator and dirty-state markers. +- Show unavailable host files without removing a saved selection. +- Add translated copy for all labels, descriptions, warnings, and states. + +## Desktop and mobile behavior + +The desktop and mobile entry point is **Settings > Executors > profile**. +The nearest shipped exemplar is the warning help in `sleep-inhibition-settings.tsx`. + +The existing settings page remains the only vertical scroll owner. +The configuration controls stay inside the existing remote-credentials card. + +The warning icon uses a tooltip for a fine pointer. +It uses a bottom drawer for a coarse pointer. + +The warning control has a 44 by 44 CSS pixel touch target. +Each checkbox row also has a minimum 44 CSS pixel height. + +The visible description gives the main risk without the tooltip. +The tooltip or drawer gives the full list of effects. + +## Warning content + +The warning must tell the user these facts: + +- Kandev copies the selected file without changes. +- The file can contain secrets or environment values. +- The file can add hooks or commands in the executor. +- The file can change models, permissions, MCP servers, and endpoints. +- The file can contain host paths that do not work in the executor. +- Fresh provisioning can replace an existing target file. +- An SSH copy can affect other processes that use the same remote account. + +## Test strategy + +### Backend unit and integration tests + +- Build catalogs for Linux, macOS, missing files, and agents without bundles. +- Make sure that stable IDs do not depend on declaration order. +- Make sure that Codex authentication contains `auth.json` only. +- Cover regular files, symbolic links, traversal, missing files, modes, and size limits. +- Cover fresh provisioning, warm resume, and environment reset. +- Cover Local Docker, SSH, and Sprites transfer adapters. +- Cover unknown saved IDs and best-effort warnings. + +### Frontend tests + +- Keep configuration and authentication selections independent. +- Keep dirty state and save serialization correct. +- Keep a missing saved selection visible and removable. +- Cover tooltip focus and hover behavior. +- Cover coarse-pointer drawer behavior. +- Cover translated labels and accessible names. + +### E2E tests + +- Add a desktop settings test for selection, save, reload, and independent authentication. +- Add a mobile test for tap behavior, drawer content, touch targets, and horizontal overflow. +- Add a container test that proves the selected file appears in a fresh Docker agent home. +- Prove that an unselected file does not appear. +- Prove that warm resume does not overwrite the executor file. + +## Public documentation + +Update `docs/public/executors.md` as a how-to and security explanation. +Document selection, fresh-copy timing, resume behavior, file limits, and SSH overwrite risk. + +Update `docs/public/security.md` with the new host-file authority grant. +State that configuration can contain secrets and executable hooks. + +## Required companion plan + +Configuration copying does not guarantee equal host and executor model catalogs. +The [executor model mismatch plan](../executor-model-mismatch-fallback/plan.md) handles the remaining difference. + +That plan keeps task launch operational and persists a warning in task chat. +It works when configuration copying is off or cannot create parity. + +## Implementation waves + +Wave 1: + +- [x] [Task 01: Define portable configuration catalog](task-01-portable-config-catalog.md) + +Wave 2: + +- [x] [Task 02: Transfer selected configuration bundles](task-02-configuration-transfer.md) +- [x] [Task 03: Add executor configuration controls](task-03-executor-config-controls.md) + +Tasks 02 and 03 are parallel-safe after Task 01. +They have separate backend-runtime and frontend file ownership. + +Wave 3: + +- [x] [Task 04: Prove executor configuration behavior](task-04-executor-config-evidence.md) + +## Verification + +```bash +cd apps/backend && go test -tags fts5 ./internal/agent/agents ./internal/agent/remoteconfig ./internal/task/handlers +cd apps/backend && go test -tags fts5 ./internal/agent/runtime/lifecycle ./internal/orchestrator/executor +cd apps && pnpm --filter @kandev/web test -- --run components/settings/profile-edit lib/api/domains +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check +cd apps/web && pnpm run i18n:ratchet +cd apps/web && pnpm e2e:run tests/settings/executor-agent-config.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome --no-build tests/settings/mobile-executor-agent-config.spec.ts +cd apps/web && pnpm e2e:run --project containers tests/docker/agent-config-copy.spec.ts +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Risks + +- A copied file can run provider hooks with the authority of the agent process. +- An SSH copy can replace shared remote-user configuration. +- Provider file formats can change outside Kandev releases. +- A host path inside the file can be invalid in the executor. +- Removing implicit Codex configuration copy can change existing Docker behavior. +- Best-effort transfer can leave older configuration in a reused remote home. + +## Results + +- Implemented the catalog, safe transfer engine, Local Docker/SSH/Sprites wiring, + independent settings controls, and translated desktop/mobile guidance. +- Backend targeted tests: 7,299 passed across 45 packages. +- Frontend typecheck, lint, i18n checks, focused component tests, and E2E sleep + ratchet passed. +- Desktop, mobile, and gated Docker E2E scenarios passed. Public-doc validation + passed with 61 tests and 41 published pages. + +## Out of scope + +- The provider-neutral model behavior in the required companion plan. +- Arbitrary file selection. +- File content preview or edit functions. +- Project configuration that already travels with the repository. +- Runtime-state databases, sessions, caches, and lock files. diff --git a/docs/plans/portable-agent-configuration/task-01-portable-config-catalog.md b/docs/plans/portable-agent-configuration/task-01-portable-config-catalog.md new file mode 100644 index 0000000000..5701ae1568 --- /dev/null +++ b/docs/plans/portable-agent-configuration/task-01-portable-config-catalog.md @@ -0,0 +1,77 @@ +--- +id: "01-portable-config-catalog" +title: "Define portable configuration catalog" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/portable-agent-configuration/spec.md" +--- + +# Task 01: Define portable configuration catalog + +## Acceptance + +- Agent integrations declare stable, allowlisted configuration bundles that remain independent from `RemoteAuth`. +- The catalog API returns metadata and local availability, but it never returns file data. +- Claude, Codex, and OpenCode expose the required bundles, and Codex authentication contains `auth.json` only. + +## TDD scenarios + +1. RED: Add catalog tests for stable IDs, operating-system paths, availability, and empty declarations. +2. RED: Add handler tests for the response contract and the no-file-data rule. +3. RED: Update provider tests to require the initial bundle declarations. +4. GREEN: Add the optional agent capability, catalog, provider declarations, and handler route. +5. REFACTOR: Keep provider declarations separate from host discovery and API types. + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/agent/agents ./internal/agent/remoteconfig ./internal/task/handlers` +- `cd apps/backend && go test -tags fts5 -run 'Test.*PortableConfig|TestHTTPListAgentConfigBundles' ./internal/agent/agents ./internal/agent/remoteconfig ./internal/task/handlers` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/agent/agents/agent.go` +- `apps/backend/internal/agent/agents/claude_acp.go` +- `apps/backend/internal/agent/agents/claude_acp_test.go` +- `apps/backend/internal/agent/agents/codex_acp.go` +- `apps/backend/internal/agent/agents/codex_acp_test.go` +- `apps/backend/internal/agent/agents/opencode_acp.go` +- `apps/backend/internal/agent/agents/opencode_acp_test.go` +- `apps/backend/internal/agent/remoteconfig/catalog.go` +- `apps/backend/internal/agent/remoteconfig/catalog_test.go` +- `apps/backend/internal/task/handlers/executor_profile_handlers.go` +- `apps/backend/internal/task/handlers/executor_profile_handlers_test.go` +- `apps/backend/internal/task/handlers/route_registration_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential. This task defines the contract that Tasks 02 and 03 consume. + +## Inputs + +- The feature specification. +- The portable-bundle ADR. +- Existing `RemoteAuth` declarations and catalog behavior. +- Provider documentation for Claude, Codex, and OpenCode configuration files. + +## Output contract + +Report the API shape, bundle IDs, provider paths, RED evidence, GREEN evidence, and test results. + +## Risks + +- A provider path can change after a provider release. +- A bundle declaration can accidentally include credentials or runtime state. + +## Results + +Implemented the optional portable-configuration capability, stable catalog IDs, +Claude/Codex/OpenCode declarations, metadata-only HTTP endpoint, and Codex +auth/config separation. Backend catalog and handler tests passed: 827 passed in +3 packages. diff --git a/docs/plans/portable-agent-configuration/task-02-configuration-transfer.md b/docs/plans/portable-agent-configuration/task-02-configuration-transfer.md new file mode 100644 index 0000000000..9ff02008b9 --- /dev/null +++ b/docs/plans/portable-agent-configuration/task-02-configuration-transfer.md @@ -0,0 +1,81 @@ +--- +id: "02-configuration-transfer" +title: "Transfer selected configuration bundles" +status: done +wave: 2 +depends_on: ["01-portable-config-catalog"] +plan: "plan.md" +spec: "../../specs/portable-agent-configuration/spec.md" +--- + +# Task 02: Transfer selected configuration bundles + +## Acceptance + +- Fresh Local Docker, SSH, and Sprites provisioning copies selected bundles with safe paths, limits, and mode `0600`. +- Warm resume does not copy host configuration, while an environment reset copies current host data. +- A missing, invalid, or failed optional copy writes a preparation warning and does not stop the launch. + +## TDD scenarios + +1. RED: Add transfer tests for regular files, symbolic links, traversal, size limits, modes, missing sources, and partial bundles. +2. RED: Add lifecycle tests for fresh provisioning, warm resume, reset, unknown IDs, and unselected bundles. +3. RED: Add adapter tests for Local Docker, SSH, and Sprites target paths. +4. GREEN: Pass `agent_config_bundles` through profile metadata and add the shared transfer engine. +5. GREEN: Connect the transfer engine to each isolated executor. +6. REFACTOR: Keep authentication upload and configuration upload independent while sharing safe file-write primitives. + +## Verification + +- `cd apps/backend && go test -tags fts5 -run 'Test.*PortableConfig|Test.*AgentConfigBundle' ./internal/agent/runtime/lifecycle ./internal/orchestrator/executor` +- `cd apps/backend && go test -tags fts5 ./internal/agent/runtime/lifecycle ./internal/orchestrator/executor` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/orchestrator/executor/executor_state.go` +- `apps/backend/internal/orchestrator/executor/executor_state_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_backend.go` +- `apps/backend/internal/agent/runtime/lifecycle/credential_uploader.go` +- `apps/backend/internal/agent/runtime/lifecycle/credential_uploader_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go` +- `apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_docker.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_remote_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_sprites_credentials.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_sprites_e2e_test.go` + +## Dependencies + +- Task 01 supplies the bundle types, IDs, and catalog lookup. + +## Parallelism + +Parallel-safe with Task 03 after Task 01. +This task owns backend runtime and orchestrator files. + +## Inputs + +- The catalog from Task 01. +- Existing credential upload adapters. +- Existing Docker session-directory seeding. +- Existing SSH and Sprites remote-home resolution. + +## Output contract + +Report lifecycle timing, warning behavior, security checks, RED evidence, GREEN evidence, and test results. + +## Risks + +- SSH writes into a persistent remote user home. +- A reused remote home can retain an older file after a skipped copy. +- A transfer error must not expose configuration contents in logs. + +## Results + +Implemented allowlisted regular-file transfer with traversal/symlink/size +checks, private file modes, best-effort preparation warnings, and fresh/reset +versus warm-resume behavior for Local Docker, SSH, and Sprites. Lifecycle tests +passed with 1,857 passed. The gated Docker E2E passed the selected-only, +warm-resume, and reset-copy scenario. diff --git a/docs/plans/portable-agent-configuration/task-03-executor-config-controls.md b/docs/plans/portable-agent-configuration/task-03-executor-config-controls.md new file mode 100644 index 0000000000..1cc0743813 --- /dev/null +++ b/docs/plans/portable-agent-configuration/task-03-executor-config-controls.md @@ -0,0 +1,85 @@ +--- +id: "03-executor-config-controls" +title: "Add executor configuration controls" +status: done +wave: 2 +depends_on: ["01-portable-config-catalog"] +plan: "plan.md" +spec: "../../specs/portable-agent-configuration/spec.md" +--- + +# Task 03: Add executor configuration controls + +## Acceptance + +- Local Docker, SSH, and Sprites profile forms show each agent's independent bundle checkboxes inside that agent's expanded row and persist `agent_config_bundles` through the shared save action. +- Visible copy and a warning icon explain secrets, hooks, commands, paths, target replacement, and the SSH shared-home risk. +- Fine pointers use a tooltip, coarse pointers use a drawer, and every touch control meets the 44 CSS pixel target. + +## TDD scenarios + +1. RED: Add API-client and parser tests for the bundle response and saved JSON list. +2. RED: Add component tests for independence, dirty state, unavailable files, saved missing files, and serialization. +3. RED: Add pointer-mode tests for tooltip focus and coarse-pointer drawer behavior. +4. GREEN: Add the API types, profile state, save integration, controls, and translated copy. +5. REFACTOR: Extract the warning control and bundle list from the existing authentication card. + +## Verification + +- `cd apps && pnpm --filter @kandev/web test -- --run components/settings/profile-edit lib/api/domains` +- `cd apps/web && pnpm run typecheck` +- `cd apps/web && pnpm run i18n:check` +- `cd apps/web && pnpm run i18n:ratchet` +- `cd apps && pnpm --filter @kandev/web lint` +- `git diff --check` + +## Files likely touched + +- `apps/web/lib/api/domains/settings-api.ts` +- `apps/web/components/settings/profile-edit/executor-profile-baselines.ts` +- `apps/web/components/settings/profile-edit/remote-credentials-card.tsx` +- `apps/web/components/settings/profile-edit/remote-credentials-card.test.tsx` +- `apps/web/components/settings/profile-edit/profile-runtime-sections.tsx` +- `apps/web/app/settings/executors/[profileId]/page.tsx` +- `apps/web/app/settings/executors/new/[type]/page.tsx` +- `apps/web/src/locales/en/executors.json` +- `apps/web/src/locales/pseudo/executors.json` +- `apps/web/src/locales/pt-pt/executors.json` +- `apps/web/src/locales/zh-cn/executors.json` +- `apps/web/src/locales/zh-hk/executors.json` +- `apps/web/src/locales/zh-tw/executors.json` + +## Dependencies + +- Task 01 supplies the HTTP response contract and stable bundle IDs. + +## Parallelism + +Parallel-safe with Task 02 after Task 01. +This task owns frontend settings files and locale catalogs. + +## Inputs + +- The catalog response from Task 01. +- The existing remote-credentials card. +- The settings save coordinator. +- `SleepInhibitionInfoTooltip` and `useTouchDrawer` as interaction examples. + +## Output contract + +Report desktop behavior, mobile behavior, accessibility, translations, RED evidence, GREEN evidence, and test results. + +## Risks + +- The existing remote-credentials card is close to the frontend file-size limit. +- A tooltip-only warning does not meet the visible settings guidance. +- A saved missing source must remain removable. + +## Results + +Implemented independent authentication and configuration controls, persistent +bundle IDs, unavailable-source hints, fine-pointer tooltip/coarse-pointer +drawer behavior, and translated copy. Configuration choices are now rendered +inside the matching expanded agent row instead of a separate global section. +Focused frontend tests passed with 25 tests. Web typecheck, lint, i18n check, +and i18n ratchet passed. diff --git a/docs/plans/portable-agent-configuration/task-04-executor-config-evidence.md b/docs/plans/portable-agent-configuration/task-04-executor-config-evidence.md new file mode 100644 index 0000000000..fa1c19a7d9 --- /dev/null +++ b/docs/plans/portable-agent-configuration/task-04-executor-config-evidence.md @@ -0,0 +1,79 @@ +--- +id: "04-executor-config-evidence" +title: "Prove executor configuration behavior" +status: done +wave: 3 +depends_on: ["02-configuration-transfer", "03-executor-config-controls"] +plan: "plan.md" +spec: "../../specs/portable-agent-configuration/spec.md" +--- + +# Task 04: Prove executor configuration behavior + +## Acceptance + +- Desktop and mobile E2E prove selection, warning access, save, reload, touch geometry, and independent authentication. +- Container E2E proves selected-only fresh copy and no overwrite during warm resume. +- Public executor and security documentation explains the copy timing, limits, hook risk, and SSH overwrite risk. + +## TDD scenarios + +1. RED: Add desktop and mobile settings scenarios before the controls exist. +2. RED: Add a container scenario that fails because no selected bundle reaches the agent home. +3. GREEN: Complete missing selectors, fixtures, and causal waits without weakening assertions. +4. GREEN: Update the executor and security documentation. +5. REFACTOR: Share settings page helpers and restore all changed fixture state. + +## Verification + +- `cd apps/web && pnpm e2e:run tests/settings/executor-agent-config.spec.ts` +- `cd apps/web && pnpm e2e:run --project mobile-chrome --no-build tests/settings/mobile-executor-agent-config.spec.ts` +- `cd apps/web && pnpm e2e:run --project containers tests/docker/agent-config-copy.spec.ts` +- `node --test scripts/validate-public-docs.test.mjs` +- `node scripts/validate-public-docs.mjs` +- `git diff --check` + +## Files likely touched + +- `apps/web/e2e/tests/settings/executor-agent-config.spec.ts` +- `apps/web/e2e/tests/settings/mobile-executor-agent-config.spec.ts` +- `apps/web/e2e/tests/settings/executor-agent-config-helpers.ts` +- `apps/web/e2e/tests/docker/agent-config-copy.spec.ts` +- `apps/web/e2e/helpers/api-client.ts` +- `docs/public/executors.md` +- `docs/public/security.md` + +## Dependencies + +- Task 02 supplies runtime copy behavior. +- Task 03 supplies the user interface. + +## Parallelism + +Sequential after Tasks 02 and 03. +The task validates their combined behavior. + +## Inputs + +- Completed runtime and frontend tasks. +- Existing executor profile E2E fixtures. +- The Playwright container project. +- The public executor and security guides. + +## Output contract + +Report RED evidence, GREEN evidence, exact E2E commands, public pages, failures, and final package status. + +## Risks + +- The container suite needs a real Docker daemon. +- Worker-scoped executor profiles require explicit cleanup after each scenario. +- A mobile click does not prove coarse-pointer behavior. The scenario must use `tap()`. + +## Results + +Desktop settings E2E passed selection, authentication independence, save, and +reload. Mobile E2E passed the bottom drawer, touch targets, and overflow checks. +The real Docker E2E passed fresh provisioning, warm resume preservation, and +environment-reset replacement. Executor and security documentation passed the +public-doc audit. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 69c77269e5..eb975db974 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -127,6 +127,24 @@ change makes a saved option value unsupported, Kandev removes that value after a successful resolution; a failed resolution keeps the draft unchanged so you can retry it. +### Host probes and executor model catalogs + +The model list shown while editing a profile comes from a host probe. It is an +editing hint, not a launch gate. A profile remains selectable when its saved +model is missing from that host list. + +At task launch, the selected executor's ACP catalog is authoritative. Kandev +sends the requested model only when the executor advertises it. If it does +not, Kandev uses an advertised fallback when available, or sends no model +request and continues with the agent's current or default model. Kandev stores +one warning in task chat with the requested model and the effective model when +known. The warning also identifies the agent and executor and asks you to +check credentials, copied configuration, and the agent version. + +The saved profile model is not changed. Optional portable configuration can +copy selected allowlisted files into a remote executor, but it cannot guarantee +that the host and executor expose the same model catalog. + ### Monitor capability and subscription status Use the profile refresh control after installing, authenticating, or upgrading an agent. A manual refresh updates both the advertised models, modes, and commands and the visible capability status, so an old failure banner does not remain authoritative after the local CLI recovers. diff --git a/docs/public/executors.md b/docs/public/executors.md index 9a22691b15..d1c369808d 100644 --- a/docs/public/executors.md +++ b/docs/public/executors.md @@ -72,6 +72,44 @@ Kandev fails closed before provisioning when a repository binding is missing, de SSH has an additional forwarding boundary. Remote agent and terminal instances receive the managed credential allowlist plus the repository keys explicitly approved by these bindings. Arbitrary host, request, or unrelated executor-profile variables are not forwarded to the remote process. +### Portable agent configuration + +Local Docker, SSH, and Sprites profiles can copy selected agent configuration +bundles. Open an agent row in the remote credentials settings to choose that +agent's authentication files and configuration bundles independently. +Kandev owns the allowlist. You cannot enter an arbitrary host path or copy a +complete agent home. + +Kandev copies each selected file without changes. A file can contain secrets, +environment values, hooks, commands, model settings, permissions, MCP servers, +endpoints, or host paths that do not work in the executor. A fresh provision or +**Reset Environment** can replace the target file. A warm resume keeps the +existing executor file and does not read the host again. + +Each file is limited to 1 MiB and each launch is limited to 4 MiB. Kandev +writes copied files with owner-only mode `0600`. Missing, unreadable, invalid, +or oversized optional files produce a preparation warning and do not stop the +launch. File contents are not returned by the API or stored in the profile. + +SSH writes below the configured remote user's home. If that account is shared, +the copied configuration can affect other processes that use the same account. +Review the selected bundles before saving the profile. + +### Model selection in remote executors + +The host model probe helps edit a profile, but it is not the launch authority. +At launch, the selected executor's advertised ACP catalog decides whether +Kandev sends the saved model. If the executor does not advertise that model, +Kandev sends no request for it. It uses an advertised fallback only when one +exists; otherwise the agent uses its current or default model. + +Kandev writes one warning to task chat when this happens. The warning can list +the requested model, effective model, agent, executor, and executor profile. +It also tells you to check executor credentials, copied agent configuration, +and the agent version. Kandev does not rewrite the saved profile model. +Portable configuration can improve parity, but it does not guarantee equal +host and executor model catalogs. + ### Script behavior is runtime-specific Do not treat the two script fields as universal hooks: diff --git a/docs/public/security.md b/docs/public/security.md index a20bc4df77..7e8a68e21c 100644 --- a/docs/public/security.md +++ b/docs/public/security.md @@ -81,6 +81,27 @@ Agent CLIs can also discover authentication from their normal home-directory fil See [Agents and profiles](agents-and-profiles.md) for exact profile fields and [Automation and MCP](automation-and-mcp.md) for unattended and external-client boundaries. +### Treat copied agent configuration as an authority grant + +Portable agent configuration lets a profile copy a small allowlisted file from +the Kandev host into a Docker, SSH, or Sprites executor. The copy is verbatim. +It can therefore contain secrets, environment values, hooks, commands, model +and permission settings, MCP servers, endpoints, or host paths. An agent and +its child processes can use everything that the copied file grants. + +Kandev does not accept arbitrary paths or complete agent homes. It rejects +symlinks, path traversal, non-regular files, and files above the per-file or +per-launch size limit. It writes successful copies with owner-only mode +`0600`, keeps raw contents out of the browser, API, and database, and reports +optional copy failures as warnings. Fresh provisioning and **Reset +Environment** read the current host file. Warm resume keeps the executor copy. + +An SSH copy is written below the configured remote user's home. A shared remote +account can expose the file and its effects to other processes. Use a +dedicated account when the configuration contains credentials or executable +hooks. Review the selected bundle and the executor trust boundary before +launching an agent. + ## Treat workspace sources as access grants Adding a repository or folder gives the task access to that source. A local folder is a live host-path grant, not an upload: Kandev does not copy, move, delete, or add marker files to it. Folder sources are therefore limited to Local/Local PC and Worktree tasks and are never sent to Docker, SSH, Sprites, or Remote Docker. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index a1a4301191..a6fdead761 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -268,8 +268,6 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance, | [mcp-session-observability](mcp-session-observability/spec.md) | approved | | [subagent-context-persistence](subagent-context-persistence/spec.md) | draft | | [auth](auth/spec.md) | building | -| [auth — self-actions guard](auth/self-actions-guard.md) | shipped | -| [auth — trusted proxies](auth/trusted-proxies.md) | building | | [create-local-repository](create-local-repository/spec.md) | shipped | | [workflow-cycle-guardrails](workflow-cycle-guardrails/spec.md) | building | | [improve-kandev](improve-kandev/spec.md) | building | @@ -290,6 +288,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance, | [automations-pr-merged-trigger](automations-pr-merged-trigger/spec.md) | draft | | [automation-runs-delete-all-by-status](automation-runs-delete-all-by-status/spec.md) | draft | | [no-silent-model-fallback](no-silent-model-fallback/spec.md) | approved | +| [portable-agent-configuration](portable-agent-configuration/spec.md) | draft | | [e2e-duration-aware-sharding](e2e-duration-aware-sharding/spec.md) | implemented | | [board-step-visibility-filter](board-step-visibility-filter/spec.md) | draft | | [shutdown-turn-failure-suppression](shutdown-turn-failure-suppression/spec.md) | draft | diff --git a/docs/specs/no-silent-model-fallback/spec.md b/docs/specs/no-silent-model-fallback/spec.md index bbcdc4abec..b7d3ca64ab 100644 --- a/docs/specs/no-silent-model-fallback/spec.md +++ b/docs/specs/no-silent-model-fallback/spec.md @@ -1,6 +1,6 @@ # No Silent Model Fallback -**Status**: approved (design package; amended 2026-08-09) +**Status**: implemented (executor-authority amendment 2026-08-15) **Date**: 2026-08-04 **Slug**: `no-silent-model-fallback` @@ -29,33 +29,36 @@ become unavailable. Today Kandev silently compensates in several places: active model that disappeared from the ACP list, so the session continues on whatever the agent picks. -**Bottom line: the user never asked for a different model, yet the system -switches models for them, invisibly.** +**Bottom line: the host and executor can advertise different model catalogs. +Kandev must keep the task operational and explain the effective model.** ## Goals -- A configured/selected model that is unavailable ("gone") is **never** - silently replaced. Either the run/session fails explicitly with an - actionable "change the model" message, or — only when the user opted in — - it switches to an explicitly configured alternative. +- A configured model that the executor does not advertise is never applied. + The agent continues with its current or default model. +- Every default or explicit fallback creates a persisted warning in task chat. + The warning survives reload and shows the effective model when known. +- A Claude model that is valid for the user's account can start in a cold, + isolated executor even when the initial Claude ACP model list omits it. - "Gone" models render **greyed out and unselectable** in every model picker, while remaining visible so the user can see what was configured. - Agent profiles **keep** their previously configured start model even when gone (shown red + unselectable in the editor) instead of having it auto-healed. -- Profiles whose start model is gone are **blocked** in the new-task / - new-agent profile picker, unless the profile opts into an explicit - fallback. +- Profiles remain selectable when the host probe does not advertise the saved + model. The host probe is an editing hint, not an executor launch gate. - A new optional per-profile **agent fallback model** allows an explicit, single-model automatic switch when the start model becomes unavailable at - session start. Office post-start routing is unchanged and remains governed + session start. Kandev applies it only when the executor advertises it. + Office post-start routing is unchanged and remains governed by the workspace routing configuration (see ADR `2026-08-08-provider-neutral-agent-error-recovery.md`): the profile's model policy is the owner of the session-start decision, not of office post-start provider fallback. - A new explicit per-profile toggle **"Fallback automatically to next model"** restores the legacy automatic-fallback behavior (session start - best-effort + office routing re-dispatch). Enabling the toggle disables the + best-effort + office routing re-dispatch). A failed apply continues with the + agent default and creates a persisted warning. Enabling the toggle disables the optional fallback-model controls without clearing their saved value (the two are mutually exclusive opt-ins). - Agent-profile editors group both fallback choices in a collapsed **Fallback @@ -70,6 +73,8 @@ switches models for them, invisibly.** provider order, execution profiles) itself. - Provider-level auth flows (login/refresh UI) — only the *consequence* of auth expiry (unavailable models) is addressed. +- Requiring equal model catalogs on the Kandev host and executor. +- Requiring portable configuration copying before a task can start. - Mid-turn model switching on the live ACP session (retry the current turn on a different model) — fallback applies to the next attempt/launch, not to an in-flight turn. @@ -83,34 +88,42 @@ switches models for them, invisibly.** - **Gone model**: a model ID that is configured (profile start model, active session model, fallback model) but absent from the currently advertised list. Deterministic on the frontend: `configured ∉ advertised`. -- **Strict mode**: profile has `auto_fallback = false` and no - `fallback_model`. No automatic switching anywhere; unavailable start - model ⇒ explicit failure. +- **Default-on-mismatch mode**: profile has `auto_fallback = false` and no + `fallback_model`. If the executor omits the start model, Kandev skips model + selection and the agent uses its default. Kandev persists a warning. - **Fallback-model mode**: profile has `auto_fallback = false` and a non-empty `fallback_model`. The only permitted automatic switch is to - that single model. + that single model when the executor advertises it. Otherwise, the agent uses + its default and Kandev persists a warning. - **Auto-fallback mode**: profile has `auto_fallback = true`. Legacy behavior (session-start best-effort; office routing re-dispatch to next - candidate). `fallback_model` is ignored. + candidate). Every session-start deviation creates a warning. + `fallback_model` is ignored. +- **Cold isolated executor**: a non-standalone executor that does not share the + host probe's Claude settings or model cache. Docker, remote Docker, Sprites, + and SSH executors can have this property. ## Behavior Matrix Per agent profile, one of three modes (precedence: `auto_fallback` wins over `fallback_model`): -| Scenario | Strict | Fallback-model | Auto-fallback | +| Scenario | Default-on-mismatch | Fallback-model | Auto-fallback | |---|---|---|---| -| Session start, start model gone | **Fail launch explicitly** with "start model X is no longer available — change the model in the profile or configure a fallback". Run/session enters failed state; no session starts. | Apply `fallback_model` instead; surface an explicit "using fallback model Y because X is unavailable" note (log + UI). | Legacy: `SetModel` best-effort (warn + continue on provider default). | -| Session start, `SetModel` fails for another reason | Fail explicitly (strict + fallback-model modes). | Same | Legacy warn + continue. | -| Mid-session model/auth failure (office run, post-start) | Unchanged ADR behavior: office re-dispatches via the workspace routing chain (`routingerr.Decide(ContextOffice)`; availability codes → `DecisionFallback`). The profile's model policy does **not** gate office fallback — the workspace routing configuration is the office authorization owner. | Same as strict: `fallback_model` is a session-start policy, not an office routing input. | Legacy: re-dispatch to next candidate in the provider order (unchanged). | +| Session start, start model not advertised | Do not call `SetModel`. Continue on the agent default and persist a warning. | If the fallback is advertised, apply it and persist a warning. Otherwise, use the agent default and persist a warning. | Do not call `SetModel`. Continue on the agent default and persist a warning. | +| Session start, advertised start model fails to apply | Fail explicitly. | Fail explicitly. | Continue on the agent default and persist a warning. | +| Session start, model selection unsupported | Continue on the agent default and persist a warning. | Same | Same | +| Mid-session model/auth failure (office run, post-start) | Unchanged ADR behavior: office re-dispatches via the workspace routing chain (`routingerr.Decide(ContextOffice)`; availability codes → `DecisionFallback`). The profile's model policy does **not** gate office fallback — the workspace routing configuration is the office authorization owner. | Same as default-on-mismatch: `fallback_model` is a session-start policy, not an office routing input. | Legacy: re-dispatch to next candidate in the provider order (unchanged). | | Boot reconciliation | Never overwrite a gone start model (keep it; UI shows it red). Same for a gone `fallback_model`. | Same | Same (reconciler is mode-independent). | -| New-task / new-agent profile picker | Profile **blocked** (greyed, unselectable, reason tooltip). | Profile **selectable with a warning** ("start model X is gone — fallback Y will be used"). If the fallback model is also gone ("both-gone"), the profile is **blocked** — a fallback the session cannot apply is not a valid opt-in. | Selectable normally. | +| New-task / new-agent profile picker | Profile selectable with a host-catalog warning. | Profile selectable with a host-catalog warning. | Profile selectable with a host-catalog warning. | | Model picker (profile editor, session toolbar) | Gone models greyed out, unselectable, visible. | Same. | Same. | `SetModel` failures that mean "this agent does not support model selection" -(JSON-RPC `-32601`, `sessionmodel.MethodNone` / `IsMethodNotFound`) are never -treated as availability failures — a profile without model selection simply -continues as today. +(JSON-RPC `-32601`, `sessionmodel.MethodNone` / `IsMethodNotFound`) do not stop +the launch. The agent uses its default and Kandev persists a warning. + +The executor ACP catalog is authoritative for launch. +The host probe remains an editing hint and does not block profile selection. ## Backend Changes @@ -149,42 +162,110 @@ The `p.Model == ""` seed-default branch is unchanged. Apply the same keep-when-gone rule to `fallback_model` (no auto-heal; UI surfaces it). Mode healing is unchanged (modes are not part of this feature). -### 3. Session start: strict model application (agentctl runtime) +### 3. Session start: executor-authoritative model application -In `internal/agent/runtime/lifecycle/session.go`, the profile-model -application block (`if profileModel != "" && execution.agentctl != nil`) -changes from best-effort to policy-driven, using the session's advertised -model list (`execution.GetModelState()` after `InitializeSession`) and the -profile's mode: +In `internal/agent/runtime/lifecycle/session.go`, the start-model policy uses +the executor session state after `InitializeSession`. -1. `auto_fallback` ON → keep today's warn-and-continue behavior. -2. Start model gone (advertised list non-empty and model absent): - - `fallback_model` set → `SetModel(fallback_model)`; on success log + - surface an explicit note (extend the model state / emit an event the UI - can render, e.g. a `session.model_fallback` event or a field on the - model state); on failure → fail session init explicitly. - - otherwise → fail session init explicitly with an actionable message. -3. Start model present in list → `SetModel(model)`; on error: - - `sessionmodel.IsMethodNotFound(err)` → continue silently (no model - selection support). - - otherwise → strict: fail explicitly (both strict and fallback-model - modes); auto-fallback mode keeps warn-and-continue. +The policy returns a typed model decision with these values: -The explicit failure must propagate as the session/run error message so the -chat and run detail render "start model unavailable — change the model". +- Requested model. +- Advertised fallback model, when one is applied. +- Effective model, when the agent reports it. +- Outcome and warning reason. +- Whether Kandev called `SetModel`. -The start-model policy owns every model application attempted during this -decision. Its outcome distinguishes **handled** from **applied**: best-effort -auto-fallback failure and method-not-supported are handled even though no model -was applied. Later profile/configuration layers must not retry a handled model -attempt. An explicit fallback may still make its intentional ordered attempts -(`start`, then `fallback`) when the advertised model list is unknown, but no -layer repeats either attempt. +The policy applies this order: -The same policy applies to the context-reset re-application path -(`reapplySessionModelAfterReset` / `effectiveSessionModelForReset` in -`manager_interaction.go`) via a shared helper, so a context reset cannot -silently drop a gone model either. +1. If the start model is advertised, call `SetModel(start_model)`. +2. If the start model is absent and the explicit fallback is advertised, call `SetModel(fallback_model)`. +3. Otherwise, do not call `SetModel` and continue with the agent default. + +An empty advertised list follows step 3. +It does not authorize a speculative `SetModel` request. + +If an advertised start or fallback model fails to apply, fail the launch. +Transport and protocol errors remain explicit. + +If `auto_fallback` is enabled, an apply error remains best-effort. +The launch continues with the agent default and persists a warning. + +If `sessionmodel.IsMethodNotFound(err)` is true, continue with the agent default. +Persist a warning because the profile requested a model that Kandev could not apply. + +The start-model policy owns every model-selection attempt. +Later profile or configuration layers must not repeat a handled attempt. + +The same policy applies after a context reset. +A reset cannot send a model that the new executor session does not advertise. + +### 3a. Persisted model-selection warning + +If Kandev does not apply the requested model, it emits a provider-neutral +`session_model_selection_warning` event. + +The orchestrator persists one `status` message for the session-start decision. +The message uses `metadata.variant = "warning"` and +`metadata.kind = "model_selection_warning"`. + +The metadata contains the reason, requested model, effective model, fallback +model, agent ID, executor type, and executor profile ID. + +The message content is structured and localized by the frontend status renderer. +If the agent reports no effective model, the UI shows +`provider default, model not reported`. + +The warning includes this remediation guidance: + +- Inspect the executor credentials. +- Inspect copied agent configuration. +- Inspect the agent version in the executor. + +Message persistence is best-effort and does not stop the agent launch. +Persistence errors are logged without model data from configuration files. + +The persistence path uses an idempotency key for the session-start decision. +Reconnect, replay, and browser reload do not create duplicate warning messages. + +The existing ephemeral `session.model_fallback` signal becomes a compatibility +projection of the provider-neutral decision event. + +### 3b. Claude ACP model exposure in cold executors + +The host probe can advertise a Claude model from its warm settings or model +cache. A cold isolated executor can start with only the bridge's baseline model +list. This difference does not prove that the configured model is unavailable +to the user's account. + +Before the initial Claude ACP process starts in a non-standalone executor, the +lifecycle exposes the effective start model through Claude's documented launch +environment. The effective model is the request model override when present. +Otherwise, it is the profile start model. The lifecycle exposes an explicit +profile fallback as a selectable custom model when `auto_fallback` is false. +During workspace recovery, a persisted session runtime model is treated as the +selected model for launch-environment derivation and takes precedence over the +profile start model. + +Provider launch environment values use fill-missing precedence. Request, +executor, and profile environment values remain authoritative. The lifecycle +does not replace an existing `ANTHROPIC_MODEL` or +`ANTHROPIC_CUSTOM_MODEL_OPTION` value. + +This behavior is an optional agent capability. Other agents receive no Claude +environment values. A standalone Claude process keeps the current host behavior +because it shares the settings and cache used by the probe. + +SSH keeps its credential allowlist unchanged, but projects these non-secret +Claude model values into the remote agent controller so its child process sees +the same launch environment. Recovery fails before environment construction if +the execution profile cannot be resolved, rather than silently dropping its +model and environment values. + +Kandev does not relax model validation after the ACP session starts. +If Claude still omits the model, the executor-authoritative policy uses the default and warns. + +Kandev does not copy the bridge private cache. +The separate portable-configuration feature can copy `settings.json` after explicit user selection. ### 4. Office post-start failure gating (unchanged, ADR-governed) @@ -198,9 +279,9 @@ feature's model policy is owned by the session-start decision (`execution.AgentProfileID`); the office post-start path sees the stable office identity (`run.AgentProfileID`), and the two can disagree. Reading `agent.FallbackModel` / `agent.AutoFallback` there would create a second, -conflicting policy owner. Office runs still get the session-start guarantee -(launch fails explicitly on a gone start model), and mid-session office -failures keep the workspace-configured routing behavior. +conflicting policy owner. Office runs still get the session-start guarantee. +Kandev does not send an unadvertised model, and it persists a warning. +Mid-session Office failures keep the workspace-configured routing behavior. Terminal office failures (for example after max attempts) surface an actionable "provider/model unavailable — change the model" hint: map @@ -258,7 +339,7 @@ Session toolbar picker (`apps/web/components/task/model-selector.tsx`): disabled. Helper text explains the semantics. - **Fallback settings disclosure**: both choices sit inside a semantic collapsible section that is closed on initial render. Its closed header - summarizes the effective mode (strict, automatic, or the configured explicit + summarizes the effective mode (executor default, automatic, or the configured explicit fallback) so saved state and hidden dirty state remain discoverable. The trigger is keyboard-operable, reports expanded state, and has a touch target of at least 44px. @@ -276,35 +357,39 @@ All new copy is externalized via `t()` into the `settings` i18n namespace (`apps/web/src/locales/{en,pseudo,pt-pt,zh-cn}/settings.json`) — the i18n ratchet judges added lines even in unmigrated files. -### 8. Profile picker gating (new-task / new-agent) +### 8. Profile picker warnings (new-task / new-agent) `apps/web/lib/state/slices/settings/types.ts` — `AgentProfileOption` gains `model`, `fallbackModel`, `autoFallback` (populated in `toAgentProfileOption`). `apps/web/components/task-create-dialog-options.tsx` -(`useAgentProfileOptions`): compute gone-ness (`profile.model` set and not -in the agent's `model_config.available_models`): +(`useAgentProfileOptions`) can compute a host-catalog difference. +This difference is advisory only. -- strict → `disabled: true` with `disabledReason` ("start model X is no - longer available — change it in the agent profile"). -- fallback-model mode → selectable with an amber warning icon/tooltip - ("start model X is gone — fallback Y will be used"). If the fallback - model is itself absent from the advertised list, the profile is blocked - like strict mode (both-gone): a fallback the runtime cannot apply must - not be presented as a valid opt-in. -- auto-fallback → normal. +- Every profile remains selectable. +- A missing host model shows an amber warning and visible secondary text. +- The warning says that the executor decides availability at launch. +- The warning does not promise that an explicit fallback will be available. +- The warning does not change the saved profile model. `apps/web/app/office/setup/agent-profile-setup-controls.tsx` -(`useSelectableProfileOptions`): same gating for office agent setup. +(`useSelectableProfileOptions`) uses the same advisory behavior. + +The persisted `model_selection_warning` chat message renders through +`apps/web/components/task/chat/messages/status-message.tsx`. +The renderer uses localized copy and structured metadata. + +The message appears in the normal chat flow on desktop and mobile. +It does not require a new drawer or a hover-only control. ### 9. i18n New keys in `apps/web/src/locales/{en,pseudo}/settings.json` (camelCase, -`settings:` namespace), covering: gone-model reason, fallback-model row -label + placeholder, auto-fallback toggle label + helper, profile-picker -blocked reason, profile-picker fallback warning. No hardcoded literals on -added/edited lines (ratchet). +`settings:` namespace), cover the gone-model hint and fallback controls. + +New keys in the chat namespace cover the persisted model-selection warning. +No added or edited line contains hardcoded user copy. ## Tests @@ -313,40 +398,51 @@ Backend (Go, `*_test.go` beside source): - Store: `fallback_model` / `auto_fallback` round-trip + migration. - Reconciler: gone start model is kept, not overwritten; empty model still seeded; gone fallback_model kept. -- Runtime session start: strict mode fails init with actionable message when - start model gone; fallback-model mode applies the fallback and notes it; - auto-fallback keeps legacy warn-continue; `-32601` continues silently. - (`session_test.go` or the manager test harness.) -- Runtime session start: a successful start-model application, an auto-fallback - best-effort failure, and method-not-supported each produce one policy attempt - with no profile-layer retry. When an unknown advertised list requires trying - an explicit fallback after the start model fails, the ordered call list is - exactly `[start, fallback]`. +- Runtime session start: an unadvertised start model causes no `SetModel` call. + The session continues with the reported current or default model. +- Runtime session start: an advertised explicit fallback is applied. + An unadvertised fallback causes no call and the agent default remains active. +- Runtime session start: an empty catalog and method-not-supported both continue + with the agent default and create a model-selection warning. +- Runtime session start: an advertised model apply error fails explicitly. + Auto-fallback keeps best-effort behavior and creates a warning. +- Runtime session start: each decision produces at most one model-selection + attempt with no profile-layer retry. +- Runtime launch environment: a non-standalone Claude launch exposes the + effective start model before ACP initialization. It exposes an explicit + fallback only when auto fallback is off. Request, executor, and profile + environment values take precedence. Standalone Claude launches and other + agents receive no new environment values. - Office post-start: unchanged — availability failures requeue via the workspace routing chain regardless of the profile's fallback settings (regression test pins that the profile policy does not gate office). -- Error mapping helper unit tests. +- Model-decision event and warning metadata tests. +- Orchestrator persistence tests for message type, warning metadata, + idempotency, reload hydration, and persistence errors. Frontend (Vitest, `*.test.ts(x)`): - `model-config-selector`: disabled option not selectable; greyed class. - `session-models` WS handler: stale active model is kept (not cleared). -- `useAgentProfileOptions`: strict profile disabled with reason; fallback - profile selectable with warning; auto-fallback selectable. +- `useAgentProfileOptions`: every host-mismatch profile remains selectable and + shows an advisory warning. - Profile editor: gone start model renders red + disabled; auto-fallback keeps the explicit fallback choice visible but disables its controls. - Profile editors: fallback settings start collapsed; expanding exposes both choices, auto-fallback disables rather than removes the explicit fallback, and desktop tooltip help is available through pointer and keyboard focus. - `agent-profile-normalize`: new fields round-trip. +- Status message: structured model-selection metadata renders localized + requested, effective, agent, executor, and remediation text. E2E (Playwright, `apps/web/e2e`): - Mock backend (`KANDEV_E2E_MOCK=true`): create a profile whose start model - is not in the mock agent's advertised list; assert the profile editor - shows it red/disabled, the task-create profile picker blocks it (greyed, - unselectable) or shows the fallback warning when a fallback is set, and - the fallback settings disclosure controls both fallback modes. + is not in the host catalog. Make sure that the task-create picker keeps the + profile selectable and shows the advisory warning. +- Launch with an executor catalog that omits the profile model. Make sure that + no model-selection call occurs, the task continues, and chat shows one warning. +- Reload the task page. Make sure that the warning remains in chat. - Desktop profile settings: the disclosure starts closed, expands to two horizontally aligned option columns, summarizes the current fallback mode, and exposes each info explanation on hover/focus. @@ -359,33 +455,36 @@ E2E (Playwright, `apps/web/e2e`): - `agent_profiles.fallback_model TEXT NOT NULL DEFAULT ''` - `agent_profiles.auto_fallback INTEGER NOT NULL DEFAULT 0` -Existing rows default to strict mode (`auto_fallback = 0`, no fallback). -This IS a behavior change for existing profiles: a session whose start -model is gone now fails explicitly at launch instead of silently continuing -on the provider default. Users who relied on legacy automatic fallback must -explicitly enable the toggle — that opt-in is the point of the feature. +Existing rows use `auto_fallback = 0` and no fallback model. +This state means default-on-mismatch behavior. + +If the executor omits the saved model, the agent uses its default and Kandev +persists a warning. The saved profile model does not change. ## Risks & Open Questions -- **Behavior change for existing strict-mode profiles**: after this change, - a session whose start model is gone will fail at launch instead of - starting on the provider default. That is the requested behavior; the - failure message must be actionable (covered by the error-mapping helper). +- **Behavior change for existing profiles**: an unadvertised start model no + longer stops the launch. Kandev uses the executor agent default and persists + an actionable warning. - **Office post-start fallback remains workspace-routing-governed**: a - strict profile's office run can still be re-dispatched to another + default-mode profile's Office run can still be re-dispatched to another provider mid-session by the ADR office policy (availability codes → `DecisionFallback`). This is intentional — office authorization is the workspace routing configuration, not the execution profile — and is documented in the behavior matrix above. - **Probe staleness**: the advertised list can be stale (probe cached). - Membership checks use the freshest available signal (session - `models_updated` at start; probe cache for pickers). A stale cache - showing a gone model as available is acceptable — the agent's own - `validateAvailableModel` fails fast at `SetModel` time, and the runtime - treats that as unavailable. -- **Office vs. kanban surfaces**: both share the same agent-profiles rows; - the picker gating in §8 covers the kanban task-create dialog and the - office setup flow. Office run-detail routing surfaces are unchanged. + The profile picker uses it only as a hint. The executor session catalog owns + the launch decision. +- **Cold Claude model lists**: a valid restricted model can be absent from a + cold bridge's initial list. Pre-session exposure lets the bridge include and + select the configured model. If the bridge still omits it, Kandev uses the + agent default and persists a warning. +- **Context reset model changes**: this amendment covers the model selected + before the initial process starts. It does not restart a live ACP bridge to + expose a newly selected hidden model during context reset. +- **Office vs. kanban surfaces**: both share the same agent-profile rows. + The advisory picker behavior covers kanban task creation and Office setup. + Office run-detail routing surfaces are unchanged. - **Collapsed controls remain legible**: the disclosure header summarizes the effective mode, and dirty-state decoration is applied to the disclosure container so a collapsed section cannot conceal that it has unsaved changes. diff --git a/docs/specs/portable-agent-configuration/spec.md b/docs/specs/portable-agent-configuration/spec.md new file mode 100644 index 0000000000..91c1b5cf01 --- /dev/null +++ b/docs/specs/portable-agent-configuration/spec.md @@ -0,0 +1,231 @@ +--- +status: implemented +created: 2026-08-15 +owner: kandev +decision: docs/decisions/2026-08-15-portable-agent-configuration-bundles.md +--- + +# Copy agent configuration to isolated executors + +## Why + +An agent can use different configuration on the host and in an isolated executor. +This difference can change the models, hooks, permissions, MCP servers, and provider behavior. + +Users need an optional copy function for small and known configuration files. +Kandev must show the risk before the user enables this function. + +## Outcome + +An executor profile can select portable configuration bundles for supported agents. +Kandev copies the selected files during fresh provisioning. + +The user sees a warning icon and clear risk information. +Authentication choices remain separate from configuration choices. + +## Scope + +This feature applies to these executor types: + +- Local Docker +- SSH +- Sprites + +Local and Worktree use the host agent configuration directly. +Remote Docker is not available. + +The first release supports these agent files: + +| Agent | Source file | Target file | +|---|---|---| +| Claude | `~/.claude/settings.json` | `~/.claude/settings.json` | +| Codex | `~/.codex/config.toml` | `~/.codex/config.toml` | +| OpenCode | `~/.config/opencode/opencode.json` | `~/.config/opencode/opencode.json` | +| OpenCode | `~/.config/opencode/opencode.jsonc` | `~/.config/opencode/opencode.jsonc` | + +The tilde in the target column means the agent home in the executor. +Kandev copies each OpenCode file that exists. + +## User interface + +The executor profile editor shows each supported agent as an expandable row. +The row appears for Local Docker, SSH, and Sprites profiles and contains both +the agent's authentication choices and its configuration bundle choices. +Authentication and configuration remain independent selections. There is no +separate global agent-configuration section. + +The configuration choices give this information: + +- Kandev copies the selected files without changes. +- A copied file can contain secrets, environment values, hooks, or commands. +- A copied file can change models, permissions, MCP servers, and network endpoints. +- A copied host path can be invalid in the executor. + +A warning icon appears beside the configuration choices. +The icon has a translated accessible name. + +On desktop, hover or keyboard focus opens a tooltip. +On a coarse pointer, a tap opens a bottom drawer with the same information. + +The warning control has a minimum touch target of 44 by 44 CSS pixels. +The control does not depend on hover. + +Each supported agent has one checkbox for each declared bundle. +The checkbox is independent from the agent authentication controls. + +If no source file exists, the checkbox is disabled for a new selection. +The row shows that Kandev did not find the file on this machine. + +If a saved source later disappears, the saved checkbox stays selected. +The user can remove the selection and save the profile. + +The existing route-level **Save changes** action stores the selection. +The page shows dirty state before the save. + +Desktop and mobile show the same choices and outcomes. +The existing settings page remains the only vertical scroll owner. + +## Architecture and communication + +The feature uses this data flow: + +```text +Executor profile editor + -> GET /api/v1/agent-config-bundles + <- bundle metadata and local file availability + -> save ExecutorProfile.Config.agent_config_bundles + +Task launch + -> orchestrator copies selected bundle IDs into launch metadata + -> lifecycle resolves IDs against the backend bundle catalog + -> lifecycle reads allowlisted files from the Kandev host home + -> Docker, SSH, or Sprites writes files into the executor agent home + -> agent process reads its normal configuration path +``` + +The frontend never reads the configuration file data. +The browser receives file paths, labels, bundle IDs, and availability only. + +The backend is the authority for the bundle definitions. +Agent integrations declare the supported files and target paths. + +The task handler exposes the catalog through HTTP. +The executor profile APIs already store the profile configuration map. + +The orchestrator passes selected bundle IDs to the lifecycle manager. +The lifecycle manager owns file checks and transfer rules. + +## State and data storage + +The executor profile stores selected bundle IDs in `config.agent_config_bundles`. +This value is a JSON array of strings in the existing profile record. + +The backend catalog exists in process memory. +Kandev builds it from enabled agent integrations and the current host operating system. + +Local file availability is transient. +The backend calculates it for each catalog request. + +The copied file data is transient. +Kandev reads it during fresh provisioning and sends it to the selected executor. + +Kandev does not store copied file data in SQLite. +Kandev does not send copied file data to the browser. + +Docker stores the copy in the Kandev-managed session directory for that environment. +Sprites stores the copy in the sandbox home. +SSH stores the copy in the configured remote user home. + +## Lifecycle + +The profile selection has these states: + +1. **Off:** The profile has no bundle ID. Kandev does not copy the bundle. +2. **Selected:** The profile contains the bundle ID. Kandev copies it during fresh provisioning. +3. **Unavailable:** A selected source does not exist or cannot be read. Kandev warns and continues. +4. **Copied:** The executor contains the file with mode `0600`. + +An environment reset starts fresh provisioning and copies current host data. +A warm resume does not copy the files again. + +## Transfer and security rules + +- Kandev accepts bundle IDs only. +- Kandev does not accept arbitrary source or target paths from the browser. +- Kandev copies regular files only. +- Kandev does not follow symbolic links. +- Kandev does not copy directories, sessions, databases, caches, or lock files. +- One source file has a maximum size of 1 MiB. +- One launch has a maximum combined size of 4 MiB. +- Every target must remain under the executor agent home. +- Kandev writes each target with mode `0600`. +- Fresh provisioning replaces an existing selected target file. + +The SSH copy can replace configuration for other processes on the same remote account. +The warning text must include this effect for SSH profiles. + +## Failure behavior + +If a bundle ID is unknown, Kandev skips the bundle and writes a preparation warning. +The launch continues with the configuration that is already in the executor. + +If a source is missing, unreadable, a symbolic link, or too large, Kandev skips it. +Kandev writes a preparation warning without file data. + +If a target path is unsafe, Kandev skips it and records an internal configuration error. +The launch continues because the bundle is optional. + +If a target write fails, Kandev writes a preparation warning. +The launch continues and the agent uses its remaining configuration. + +Warnings can contain the agent name, bundle label, and relative path. +Warnings must not contain file data or secret values. + +## Scenarios + +- **GIVEN** a Codex agent row, **WHEN** the user selects an authentication method and the Codex configuration bundle, **THEN** both choices remain selected independently. +- **GIVEN** a selected Claude bundle, **WHEN** Kandev creates a fresh Docker environment, **THEN** `.claude/settings.json` exists in the agent home. +- **GIVEN** a selected OpenCode bundle with JSON configuration, **WHEN** Kandev creates an SSH environment, **THEN** Kandev copies it to the remote user home. +- **GIVEN** a selected bundle, **WHEN** the task uses a warm resume, **THEN** Kandev does not copy the host file again. +- **GIVEN** a selected bundle, **WHEN** the user resets the environment, **THEN** Kandev copies the current host file. +- **GIVEN** a missing selected source, **WHEN** Kandev prepares the environment, **THEN** Kandev warns and continues the launch. +- **GIVEN** a phone with a coarse pointer, **WHEN** the user taps the warning icon, **THEN** a drawer shows the full warning. +- **GIVEN** a desktop with a fine pointer, **WHEN** the user focuses the warning icon, **THEN** a tooltip shows the full warning. +- **GIVEN** a saved selection, **WHEN** the user reloads the profile page, **THEN** the same checkbox stays selected. + +## Compatibility + +Codex `auth.json` remains available as an authentication file. +Codex `config.toml` moves to the independent configuration bundle. + +Kandev does not migrate old profiles to the new configuration selection. +Users must enable the copy when they want `config.toml` in a new environment. + +This change removes the implicit Local Docker copy of provider configuration. +It keeps explicit authentication choices and secret references unchanged. + +## Permissions + +The existing executor profile permissions control catalog access and profile changes. +This feature does not add a new user role or permission. + +## Required companion behavior + +Configuration copying can improve model parity, but it cannot guarantee parity. +The executor can still advertise fewer models because credentials, versions, accounts, or network access differ. + +The [no-silent-model-fallback specification](../no-silent-model-fallback/spec.md) handles that case. +If the executor omits the profile model, Kandev starts the agent default and persists a chat warning. + +The companion behavior works when copying is off, unavailable, incomplete, or stale. +Portable configuration is not a launch requirement. + +## Out of scope + +- Arbitrary user-defined file paths. +- Complete agent-home copies. +- Project-level configuration inside a repository. +- File merge, template, edit, or content preview functions. +- Secret detection or content sanitization. +- The model-selection implementation in the companion specification. +- Configuration synchronization after a warm resume.