diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index d5d7c1891c..b1579e1b1a 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -9,7 +9,6 @@ import ( "net/http" "net/url" "os" - "runtime" "strings" "time" @@ -708,7 +707,6 @@ func webRuntimeConfig(debug bool) webapp.RuntimeConfig { return webapp.RuntimeConfig{ APIPrefix: "/api/v1", WebSocketPath: "/ws", - HostOS: runtime.GOOS, Debug: debug, } } diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index fa91d068a3..d324829be9 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "path/filepath" - "runtime" "strings" "testing" "time" @@ -1153,27 +1152,6 @@ func TestBootPayloadIncludesDebugRuntimeWhenDevMode(t *testing.T) { } } -func TestBootPayloadIncludesBackendHostOS(t *testing.T) { - t.Parallel() - - payload := bootPayload(context.Background(), nil, routeParams{}, webapp.ClassifyRoute("/")) - raw, err := json.Marshal(payload) - if err != nil { - t.Fatalf("Marshal payload: %v", err) - } - var decoded struct { - Runtime struct { - HostOS string `json:"hostOS"` - } `json:"runtime"` - } - if err := json.Unmarshal(raw, &decoded); err != nil { - t.Fatalf("Unmarshal payload: %v", err) - } - if decoded.Runtime.HostOS != runtime.GOOS { - t.Fatalf("runtime.hostOS = %q, want %q", decoded.Runtime.HostOS, runtime.GOOS) - } -} - func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() diff --git a/apps/backend/internal/editors/capabilities/capabilities.go b/apps/backend/internal/editors/capabilities/capabilities.go new file mode 100644 index 0000000000..4defbf8f74 --- /dev/null +++ b/apps/backend/internal/editors/capabilities/capabilities.go @@ -0,0 +1,24 @@ +// Package capabilities resolves editor support from a task session's executor. +package capabilities + +import "github.com/kandev/kandev/internal/task/models" + +const ( + hostOSLinux = "linux" + hostOSDarwin = "darwin" +) + +// SupportsEmbeddedVscode reports whether the executor can run Kandev's +// code-server-based embedded editor. Unknown and test-only executors fail +// closed so callers never advertise an unsupported launch path. +func SupportsEmbeddedVscode(executorType models.ExecutorType, hostOS string) bool { + switch executorType { + case models.ExecutorTypeLocal, models.ExecutorTypeWorktree: + return hostOS == hostOSLinux || hostOS == hostOSDarwin + case models.ExecutorTypeLocalDocker, models.ExecutorTypeRemoteDocker, + models.ExecutorTypeSprites, models.ExecutorTypeSSH: + return true + default: + return false + } +} diff --git a/apps/backend/internal/editors/capabilities/capabilities_test.go b/apps/backend/internal/editors/capabilities/capabilities_test.go new file mode 100644 index 0000000000..d6928bf168 --- /dev/null +++ b/apps/backend/internal/editors/capabilities/capabilities_test.go @@ -0,0 +1,39 @@ +package capabilities + +import ( + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestSupportsEmbeddedVscode(t *testing.T) { + tests := []struct { + name string + executorType models.ExecutorType + hostOS string + want bool + }{ + {name: "local linux", executorType: models.ExecutorTypeLocal, hostOS: "linux", want: true}, + {name: "local macOS", executorType: models.ExecutorTypeLocal, hostOS: "darwin", want: true}, + {name: "local windows", executorType: models.ExecutorTypeLocal, hostOS: "windows", want: false}, + {name: "local unknown host", executorType: models.ExecutorTypeLocal, hostOS: "freebsd", want: false}, + {name: "worktree linux", executorType: models.ExecutorTypeWorktree, hostOS: "linux", want: true}, + {name: "worktree macOS", executorType: models.ExecutorTypeWorktree, hostOS: "darwin", want: true}, + {name: "worktree windows", executorType: models.ExecutorTypeWorktree, hostOS: "windows", want: false}, + {name: "worktree unknown host", executorType: models.ExecutorTypeWorktree, hostOS: "freebsd", want: false}, + {name: "local Docker on Windows host", executorType: models.ExecutorTypeLocalDocker, hostOS: "windows", want: true}, + {name: "remote Docker", executorType: models.ExecutorTypeRemoteDocker, hostOS: "windows", want: true}, + {name: "Sprites", executorType: models.ExecutorTypeSprites, hostOS: "windows", want: true}, + {name: "SSH", executorType: models.ExecutorTypeSSH, hostOS: "windows", want: true}, + {name: "mock remote fails closed", executorType: models.ExecutorTypeMockRemote, hostOS: "linux", want: false}, + {name: "unknown executor fails closed", executorType: models.ExecutorType("future"), hostOS: "linux", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SupportsEmbeddedVscode(tt.executorType, tt.hostOS); got != tt.want { + t.Errorf("SupportsEmbeddedVscode(%q, %q) = %v, want %v", tt.executorType, tt.hostOS, got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/editors/service/service.go b/apps/backend/internal/editors/service/service.go index b6d2ffba79..55bf6d43d9 100644 --- a/apps/backend/internal/editors/service/service.go +++ b/apps/backend/internal/editors/service/service.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/kandev/kandev/internal/common/securityutil" + "github.com/kandev/kandev/internal/editors/capabilities" "github.com/kandev/kandev/internal/editors/models" "github.com/kandev/kandev/internal/editors/store" taskmodels "github.com/kandev/kandev/internal/task/models" @@ -25,6 +26,7 @@ import ( type taskSessionReader interface { GetTaskSession(ctx context.Context, id string) (*taskmodels.TaskSession, error) GetRepository(ctx context.Context, id string) (*taskmodels.Repository, error) + GetExecutor(ctx context.Context, id string) (*taskmodels.Executor, error) } var ( @@ -99,6 +101,9 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string if err != nil { return "", err } + if editor.Kind == editorKindInternalVscode && !s.supportsEmbeddedVscode(ctx, session) { + return "", ErrEditorUnavailable + } // Opening the embedded editor at folder level only needs a valid session. // Repository-less sessions still have an executor workspace where code-server // can run, but they intentionally have no task worktree to resolve here. @@ -119,6 +124,17 @@ func (s *Service) OpenEditor(ctx context.Context, input OpenEditorInput) (string return s.dispatchEditorKind(editor, worktreePath, absPath, input.Line, input.Column) } +func (s *Service) supportsEmbeddedVscode(ctx context.Context, session *taskmodels.TaskSession) bool { + if session == nil || session.ExecutorID == "" { + return false + } + executor, err := s.taskRepo.GetExecutor(ctx, session.ExecutorID) + if err != nil || executor == nil { + return false + } + return capabilities.SupportsEmbeddedVscode(executor.Type, runtime.GOOS) +} + func (s *Service) dispatchEditorKind(editor *models.Editor, worktreePath, absPath string, line, column int) (string, error) { switch editor.Kind { case editorKindInternalVscode: diff --git a/apps/backend/internal/editors/service/service_test.go b/apps/backend/internal/editors/service/service_test.go index aaa25e8da6..57296abcf7 100644 --- a/apps/backend/internal/editors/service/service_test.go +++ b/apps/backend/internal/editors/service/service_test.go @@ -23,7 +23,8 @@ func (r *openEditorRepository) GetEditorByID(_ context.Context, editorID string) } type openEditorTaskRepository struct { - session *taskmodels.TaskSession + session *taskmodels.TaskSession + executor *taskmodels.Executor } func (r *openEditorTaskRepository) GetTaskSession(_ context.Context, _ string) (*taskmodels.TaskSession, error) { @@ -34,6 +35,10 @@ func (r *openEditorTaskRepository) GetRepository(_ context.Context, _ string) (* return nil, nil } +func (r *openEditorTaskRepository) GetExecutor(_ context.Context, _ string) (*taskmodels.Executor, error) { + return r.executor, nil +} + type openEditorUserSettings struct { defaultEditorID string } @@ -54,7 +59,10 @@ func TestOpenEditor_InternalVscodeWithoutWorkspace(t *testing.T) { Kind: editorKindInternalVscode, Enabled: true, }}, - &openEditorTaskRepository{session: &taskmodels.TaskSession{ID: "session-1"}}, + &openEditorTaskRepository{ + session: &taskmodels.TaskSession{ID: "session-1", ExecutorID: "executor-1"}, + executor: &taskmodels.Executor{ID: "executor-1", Type: taskmodels.ExecutorTypeLocalDocker}, + }, &openEditorUserSettings{defaultEditorID: editorID}, ) @@ -91,6 +99,32 @@ func TestOpenEditor_InternalVscodeRejectsMissingSession(t *testing.T) { } } +func TestOpenEditor_InternalVscodeRejectsUnsupportedExecutor(t *testing.T) { + const editorID = "embedded-vscode" + svc := NewService( + &openEditorRepository{editor: &editormodels.Editor{ + ID: editorID, + Kind: editorKindInternalVscode, + Enabled: true, + }}, + &openEditorTaskRepository{ + session: &taskmodels.TaskSession{ID: "session-1", ExecutorID: "executor-1"}, + executor: &taskmodels.Executor{ID: "executor-1", Type: taskmodels.ExecutorTypeMockRemote}, + }, + &openEditorUserSettings{defaultEditorID: editorID}, + ) + + for _, input := range []OpenEditorInput{ + {SessionID: "session-1"}, + {SessionID: "session-1", EditorID: editorID, FilePath: "main.go"}, + } { + _, err := svc.OpenEditor(context.Background(), input) + if err != ErrEditorUnavailable { + t.Fatalf("OpenEditor(%+v) error = %v, want ErrEditorUnavailable", input, err) + } + } +} + func TestOpenFolder_EmptySessionID(t *testing.T) { svc := &Service{} err := svc.OpenFolder(context.Background(), "", "") diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index d93f5465d9..a68a9a2983 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -29,6 +29,13 @@ type QueueResponse struct { Total int `json:"total"` } +// TaskSessionCapabilities describes the launch features supported by the +// current session executor. It is backend-owned so clients never infer runtime +// support from an executor name or browser platform. +type TaskSessionCapabilities struct { + EmbeddedVscode bool `json:"embedded_vscode"` +} + // TaskSessionStatusResponse is the response for task.session.status type TaskSessionStatusResponse struct { // Session metadata @@ -49,16 +56,17 @@ type TaskSessionStatusResponse struct { ACPSessionID string `json:"acp_session_id,omitempty"` // Executor/runtime info - ExecutorID string `json:"executor_id,omitempty"` - ExecutorType string `json:"executor_type,omitempty"` - ExecutorName string `json:"executor_name,omitempty"` - Runtime agentruntime.Runtime `json:"runtime,omitempty"` - IsRemoteExecutor bool `json:"is_remote_executor"` - RemoteState string `json:"remote_state,omitempty"` - RemoteName string `json:"remote_name,omitempty"` - RemoteCreatedAt string `json:"remote_created_at,omitempty"` - RemoteCheckedAt string `json:"remote_checked_at,omitempty"` - RemoteStatusErr string `json:"remote_status_error,omitempty"` + ExecutorID string `json:"executor_id,omitempty"` + ExecutorType string `json:"executor_type,omitempty"` + ExecutorName string `json:"executor_name,omitempty"` + Runtime agentruntime.Runtime `json:"runtime,omitempty"` + IsRemoteExecutor bool `json:"is_remote_executor"` + Capabilities TaskSessionCapabilities `json:"capabilities"` + RemoteState string `json:"remote_state,omitempty"` + RemoteName string `json:"remote_name,omitempty"` + RemoteCreatedAt string `json:"remote_created_at,omitempty"` + RemoteCheckedAt string `json:"remote_checked_at,omitempty"` + RemoteStatusErr string `json:"remote_status_error,omitempty"` // Worktree info WorktreePath *string `json:"worktree_path,omitempty"` diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 7b58e7a5b6..bc6026b056 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -870,6 +870,9 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses if err != nil { return nil, err } + if execCfg.ExecutorID != "" { + session.ExecutorID = execCfg.ExecutorID + } req.OfficeAgentProfileID = opts.OfficeAgentProfileID if req.OfficeAgentProfileID == "" && session.AgentProfileID != "" { req.OfficeAgentProfileID = session.AgentProfileID diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 8affe3b526..b9faf80c71 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -415,6 +415,50 @@ func TestLaunchPreparedSession_Success(t *testing.T) { } } +func TestLaunchPreparedSession_PersistsResolvedExecutorID(t *testing.T) { + repo := newMockRepository() + now := time.Now().UTC() + repo.sessions["session-123"] = &models.TaskSession{ + ID: "session-123", + TaskID: "task-123", + AgentProfileID: "profile-123", + ExecutorID: models.ExecutorIDLocal, + State: models.TaskSessionStateCreated, + StartedAt: now, + UpdatedAt: now, + } + repo.executors[models.ExecutorIDLocalDocker] = &models.Executor{ + ID: models.ExecutorIDLocalDocker, + Type: models.ExecutorTypeLocalDocker, + Status: models.ExecutorStatusActive, + } + + executor := newTestExecutor(t, &mockAgentManager{ + launchAgentFunc: func(context.Context, *LaunchAgentRequest) (*LaunchAgentResponse, error) { + return &LaunchAgentResponse{AgentExecutionID: "exec-123", Status: v1.AgentStatusStarting}, nil + }, + }, repo) + + _, err := executor.LaunchPreparedSession(context.Background(), &v1.Task{ + ID: "task-123", + WorkspaceID: "workspace-123", + }, "session-123", LaunchOptions{ + AgentProfileID: "profile-123", + ExecutorID: models.ExecutorIDLocalDocker, + }) + if err != nil { + t.Fatalf("LaunchPreparedSession failed: %v", err) + } + + stored, err := repo.GetTaskSession(context.Background(), "session-123") + if err != nil { + t.Fatalf("GetTaskSession failed: %v", err) + } + if stored.ExecutorID != models.ExecutorIDLocalDocker { + t.Fatalf("ExecutorID = %q, want %q", stored.ExecutorID, models.ExecutorIDLocalDocker) + } +} + func TestLaunchPreparedSession_AbortsWhenStartingPersistenceFails(t *testing.T) { repo := newMockRepository() session := &models.TaskSession{ diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 0743ef4559..4ed77a9fe6 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -7,6 +7,7 @@ import ( "fmt" "maps" "os" + "runtime" "sort" "strings" "sync" @@ -19,6 +20,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/common/constants" + "github.com/kandev/kandev/internal/editors/capabilities" "github.com/kandev/kandev/internal/orchestrator/dto" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" @@ -2161,6 +2163,7 @@ func (s *Service) populateExecutorStatusInfo(ctx context.Context, session *model resp.ExecutorType = string(execModel.Type) resp.ExecutorName = execModel.Name resp.IsRemoteExecutor = models.IsRemoteExecutorType(execModel.Type) + resp.Capabilities.EmbeddedVscode = capabilities.SupportsEmbeddedVscode(execModel.Type, runtime.GOOS) } func (s *Service) applyRemoteRuntimeStatus(ctx context.Context, sessionID string, resp *dto.TaskSessionStatusResponse) { diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index c81199e79d..04931c949f 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -2,6 +2,7 @@ package orchestrator import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -4578,6 +4579,64 @@ func TestErrorClassificationFunctions(t *testing.T) { // --- GetTaskSessionStatus --- +func TestGetTaskSessionStatus_ReportsEmbeddedVscodeCapability(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + + if err := repo.CreateExecutor(ctx, &models.Executor{ + ID: "executor-docker", + Name: "Docker", + Type: models.ExecutorTypeLocalDocker, + Status: models.ExecutorStatusActive, + }); err != nil { + t.Fatalf("create executor: %v", err) + } + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("get session: %v", err) + } + session.ExecutorID = "executor-docker" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("update session: %v", err) + } + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{}) + svc.executor = executor.NewExecutor(svc.agentManager, repo, testLogger(), executor.ExecutorConfig{}) + + resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1") + if err != nil { + t.Fatalf("GetTaskSessionStatus: %v", err) + } + if !resp.Capabilities.EmbeddedVscode { + t.Fatal("embedded_vscode = false, want true for a Docker session") + } + encoded, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal status response: %v", err) + } + if !strings.Contains(string(encoded), `"capabilities":{"embedded_vscode":true}`) { + t.Fatalf("serialized status = %s, want embedded_vscode capability", encoded) + } +} + +func TestGetTaskSessionStatus_FailsClosedWithoutExecutorCapability(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{}) + svc.executor = executor.NewExecutor(svc.agentManager, repo, testLogger(), executor.ExecutorConfig{}) + + resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1") + if err != nil { + t.Fatalf("GetTaskSessionStatus: %v", err) + } + if resp.Capabilities.EmbeddedVscode { + t.Fatal("embedded_vscode = true, want false without an executor") + } +} + func TestGetTaskSessionStatus_HealsStuckStartingSession(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/webapp/payload.go b/apps/backend/internal/webapp/payload.go index 337ec8c477..99e6df405a 100644 --- a/apps/backend/internal/webapp/payload.go +++ b/apps/backend/internal/webapp/payload.go @@ -36,7 +36,6 @@ type ActivePluginPayload struct { type RuntimeConfig struct { APIPrefix string `json:"apiPrefix"` WebSocketPath string `json:"webSocketPath"` - HostOS string `json:"hostOS"` Debug bool `json:"debug,omitempty"` } diff --git a/apps/web/components/task/editors-menu-availability.test.ts b/apps/web/components/task/editors-menu-availability.test.ts index 09750b9365..8e0f5d111e 100644 --- a/apps/web/components/task/editors-menu-availability.test.ts +++ b/apps/web/components/task/editors-menu-availability.test.ts @@ -24,18 +24,20 @@ describe("getAvailableTaskTopbarEditors", () => { editor({ id: "missing", kind: "built_in", installed: false }), ]; - it("hides embedded VS Code when the backend host is Windows", () => { - expect(getAvailableTaskTopbarEditors(editors, "windows").map(({ id }) => id)).toEqual(["zed"]); - }); - - it("keeps embedded VS Code for non-Windows or unknown hosts", () => { - for (const hostOS of ["linux", "darwin", undefined]) { - expect(getAvailableTaskTopbarEditors(editors, hostOS).map(({ id }) => id)).toEqual([ - "internal-vscode", + it("hides embedded VS Code when the active session does not support it", () => { + for (const supported of [false, undefined]) { + expect(getAvailableTaskTopbarEditors(editors, supported).map(({ id }) => id)).toEqual([ "zed", ]); } }); + + it("keeps embedded VS Code when the active session supports it", () => { + expect(getAvailableTaskTopbarEditors(editors, true).map(({ id }) => id)).toEqual([ + "internal-vscode", + "zed", + ]); + }); }); describe("resolveTaskTopbarEditorId", () => { diff --git a/apps/web/components/task/editors-menu-availability.ts b/apps/web/components/task/editors-menu-availability.ts index 42ab241a46..b8ad919c8e 100644 --- a/apps/web/components/task/editors-menu-availability.ts +++ b/apps/web/components/task/editors-menu-availability.ts @@ -1,16 +1,15 @@ import type { EditorOption } from "@/lib/types/http"; -const WINDOWS_HOST_OS = "windows"; const EMBEDDED_VSCODE_KIND = "internal_vscode"; export function getAvailableTaskTopbarEditors( editors: EditorOption[], - hostOS?: string, + embeddedVscodeSupported = false, ): EditorOption[] { return editors.filter((editor) => { if (!editor.enabled) return false; if (editor.kind === "built_in" && !editor.installed) return false; - return !(hostOS === WINDOWS_HOST_OS && editor.kind === EMBEDDED_VSCODE_KIND); + return editor.kind !== EMBEDDED_VSCODE_KIND || embeddedVscodeSupported; }); } diff --git a/apps/web/components/task/editors-menu.tsx b/apps/web/components/task/editors-menu.tsx index 07476b6755..5ef800755d 100644 --- a/apps/web/components/task/editors-menu.tsx +++ b/apps/web/components/task/editors-menu.tsx @@ -18,7 +18,6 @@ import { useOpenSessionInEditor } from "@/hooks/use-open-session-in-editor"; import { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees"; import { useAppStore } from "@/components/state-provider"; import type { EditorOption } from "@/lib/types/http"; -import { readBackendHostOS } from "@/src/boot-payload"; import { buildWorktreeOptions, type WorktreeOption, @@ -32,6 +31,7 @@ const menuItemClass = "cursor-pointer"; type EditorsMenuProps = { activeSessionId: string | null; + embeddedVscodeSupported: boolean; }; function useWorktreeOptions(sessionId: string | null): WorktreeOption[] { @@ -172,16 +172,14 @@ function EditorMenuEntry({ ); } -export function EditorsMenu({ activeSessionId }: EditorsMenuProps) { +export function EditorsMenu({ activeSessionId, embeddedVscodeSupported }: EditorsMenuProps) { const openEditor = useOpenSessionInEditor(activeSessionId ?? null); const { editors } = useEditors(); const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId); const worktreeOptions = useWorktreeOptions(activeSessionId ?? null); - const backendHostOS = readBackendHostOS(); - const enabledEditors = useMemo( - () => getAvailableTaskTopbarEditors(editors, backendHostOS), - [editors, backendHostOS], + () => getAvailableTaskTopbarEditors(editors, embeddedVscodeSupported), + [editors, embeddedVscodeSupported], ); const resolvedEditorId = useMemo(() => { diff --git a/apps/web/components/task/task-page-editor-capability.test.ts b/apps/web/components/task/task-page-editor-capability.test.ts new file mode 100644 index 0000000000..d55dfa9550 --- /dev/null +++ b/apps/web/components/task/task-page-editor-capability.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getEmbeddedVscodeSupported } from "./task-page-editor-capability"; + +describe("getEmbeddedVscodeSupported", () => { + it("fails closed while session capability data is missing", () => { + expect(getEmbeddedVscodeSupported(null)).toBe(false); + expect(getEmbeddedVscodeSupported({})).toBe(false); + }); + + it("uses the active session capability only when it is explicitly true", () => { + expect(getEmbeddedVscodeSupported({ capabilities: { embedded_vscode: true } })).toBe(true); + expect(getEmbeddedVscodeSupported({ capabilities: { embedded_vscode: false } })).toBe(false); + }); +}); diff --git a/apps/web/components/task/task-page-editor-capability.ts b/apps/web/components/task/task-page-editor-capability.ts new file mode 100644 index 0000000000..d2c691d411 --- /dev/null +++ b/apps/web/components/task/task-page-editor-capability.ts @@ -0,0 +1,11 @@ +type SessionEditorCapabilityStatus = { + capabilities?: { + embedded_vscode?: boolean; + }; +}; + +export function getEmbeddedVscodeSupported( + status: SessionEditorCapabilityStatus | null | undefined, +): boolean { + return status?.capabilities?.embedded_vscode === true; +} diff --git a/apps/web/components/task/task-page-inner.tsx b/apps/web/components/task/task-page-inner.tsx index eea4d5c11a..56a3d7539b 100644 --- a/apps/web/components/task/task-page-inner.tsx +++ b/apps/web/components/task/task-page-inner.tsx @@ -14,6 +14,7 @@ import type { Layout } from "react-resizable-panels"; import { TaskArchivedProvider } from "./task-archived-context"; import { SessionCommands } from "@/components/session-commands"; import { TaskPRShortcut } from "@/components/task/task-pr-shortcut"; +import { getEmbeddedVscodeSupported } from "@/components/task/task-page-editor-capability"; import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs"; import { buildDebugEntries, @@ -62,6 +63,9 @@ type RemoteExecutorStatus = { remote_created_at?: string | null; remote_checked_at?: string | null; remote_status_error?: string | null; + capabilities?: { + embedded_vscode?: boolean; + }; }; function toNullable(value: string | null | undefined): string | null { @@ -100,6 +104,7 @@ function buildTaskTopBarProps(params: { remote: ReturnType; sessionWorkflowStepId: string | null; agentctlReady: boolean; + embeddedVscodeSupported: boolean; officeTaskHref?: string | null; onTaskUnarchived: (taskId: string) => void; }) { @@ -124,6 +129,7 @@ function buildTaskTopBarProps(params: { isArchived: taskProps.isArchived, isRemoteExecutor: params.remote.isRemoteExecutor, isAgentctlReady: params.agentctlReady, + embeddedVscodeSupported: params.embeddedVscodeSupported, remoteExecutorType: params.remote.remoteExecutorType, officeTaskHref: params.officeTaskHref, onTaskUnarchived: params.onTaskUnarchived, @@ -220,6 +226,7 @@ export function TaskPageInner({ }: TaskPageInnerProps) { const taskProps = resolveTaskProps(task, repository); const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null); + const embeddedVscodeSupported = getEmbeddedVscodeSupported(resumption.sessionStatus); const activeSessionMetadata = useAppStore((state) => effectiveSessionId ? (state.taskSessions.items[effectiveSessionId]?.metadata ?? null) : null, ); @@ -245,6 +252,7 @@ export function TaskPageInner({ remote, sessionWorkflowStepId: sessionPanel.sessionWorkflowStepId, agentctlReady: agentctlStatus.isReady, + embeddedVscodeSupported, officeTaskHref, onTaskUnarchived, }); diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index dbeaf7eb67..e93d23ad8d 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -42,6 +42,7 @@ type TaskTopBarProps = { isArchived?: boolean; isRemoteExecutor?: boolean; isAgentctlReady?: boolean; + embeddedVscodeSupported?: boolean; remoteExecutorType?: string | null; officeTaskHref?: string | null; onTaskUnarchived?: (taskId: string) => void; @@ -68,6 +69,7 @@ const TaskTopBar = memo(function TaskTopBar({ isArchived, isRemoteExecutor, isAgentctlReady, + embeddedVscodeSupported, issueUrl, issueNumber, remoteExecutorType, @@ -106,6 +108,7 @@ const TaskTopBar = memo(function TaskTopBar({ workspaceId={workspaceId} isRemoteExecutor={isRemoteExecutor} isAgentctlReady={isAgentctlReady} + embeddedVscodeSupported={embeddedVscodeSupported} taskTitle={taskTitle} issueUrl={issueUrl} issueNumber={issueNumber} @@ -298,11 +301,13 @@ function TopbarToolsGroup({ showDebugOverlay, onToggleDebugOverlay, isArchived, + embeddedVscodeSupported, }: { activeSessionId?: string | null; showDebugOverlay?: boolean; onToggleDebugOverlay?: () => void; isArchived?: boolean; + embeddedVscodeSupported?: boolean; }) { const showDebugToggle = isDebugUI() && onToggleDebugOverlay; @@ -311,7 +316,10 @@ function TopbarToolsGroup({ {!isArchived && ( <> - + )} {showDebugToggle && ( @@ -336,6 +344,7 @@ function TopBarRight({ workspaceId, isRemoteExecutor, isAgentctlReady, + embeddedVscodeSupported, taskTitle, issueUrl, issueNumber, @@ -350,6 +359,7 @@ function TopBarRight({ workspaceId?: string | null; isRemoteExecutor?: boolean; isAgentctlReady?: boolean; + embeddedVscodeSupported?: boolean; taskTitle?: string; issueUrl?: string; issueNumber?: number; @@ -396,6 +406,7 @@ function TopBarRight({ showDebugOverlay={showDebugOverlay} onToggleDebugOverlay={onToggleDebugOverlay} isArchived={isArchived} + embeddedVscodeSupported={embeddedVscodeSupported} /> ); diff --git a/apps/web/e2e/helpers/boot-payload.ts b/apps/web/e2e/helpers/boot-payload.ts deleted file mode 100644 index 5a46b5baf1..0000000000 --- a/apps/web/e2e/helpers/boot-payload.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Page } from "@playwright/test"; - -export async function rewriteBackendHostOS(page: Page, hostOS: string): Promise { - await page.route("**/*", async (route) => { - if (route.request().resourceType() !== "document") { - await route.continue(); - return; - } - const response = await route.fetch(); - const body = await response.text(); - const rewritten = body.replace(/"hostOS":"[^"]*"/, `"hostOS":"${hostOS}"`); - if (rewritten === body) { - throw new Error("The boot document did not contain runtime.hostOS"); - } - await route.fulfill({ response, body: rewritten }); - }); -} diff --git a/apps/web/e2e/helpers/session-capabilities.ts b/apps/web/e2e/helpers/session-capabilities.ts new file mode 100644 index 0000000000..6b9187c2e7 --- /dev/null +++ b/apps/web/e2e/helpers/session-capabilities.ts @@ -0,0 +1,96 @@ +import type { Page } from "@playwright/test"; + +type MessageEnvelope = { + action?: unknown; + id?: unknown; + payload?: unknown; + type?: unknown; +}; + +function asEnvelope(value: unknown): MessageEnvelope | null { + return typeof value === "object" && value !== null ? (value as MessageEnvelope) : null; +} + +function parseFrame(message: string | Buffer): MessageEnvelope[] | null { + if (typeof message !== "string") return null; + const frames: MessageEnvelope[] = []; + for (const part of message.split("\n")) { + if (!part.trim()) continue; + try { + const parsed = asEnvelope(JSON.parse(part)); + if (parsed) frames.push(parsed); + } catch { + return null; + } + } + return frames; +} + +function rewriteStatusResponses( + message: string | Buffer, + requestIDs: Set, + embeddedVscodeSupported: boolean, +): string | Buffer { + if (typeof message !== "string") return message; + + let changed = false; + const rewritten = message + .split("\n") + .map((part) => { + if (!part.trim()) return part; + try { + const envelope = asEnvelope(JSON.parse(part)); + if ( + envelope?.type !== "response" || + typeof envelope.id !== "string" || + !requestIDs.delete(envelope.id) || + !asEnvelope(envelope.payload) + ) { + return part; + } + changed = true; + return JSON.stringify({ + ...envelope, + payload: { + ...envelope.payload, + capabilities: { embedded_vscode: embeddedVscodeSupported }, + }, + }); + } catch { + return part; + } + }) + .join("\n"); + + return changed ? rewritten : message; +} + +/** + * Rewrites only task.session.status responses, preserving the real WebSocket + * connection and every unrelated frame. This lets browser tests exercise the + * session capability contract without duplicating executor policy in E2E code. + */ +export async function routeSessionEmbeddedVscodeCapability( + page: Page, + embeddedVscodeSupported: boolean, +): Promise { + const statusRequestIDs = new Set(); + await page.routeWebSocket(/\/ws$/, (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + for (const envelope of parseFrame(message) ?? []) { + if ( + envelope.type === "request" && + envelope.action === "task.session.status" && + typeof envelope.id === "string" + ) { + statusRequestIDs.add(envelope.id); + } + } + server.send(message); + }); + server.onMessage((message) => { + ws.send(rewriteStatusResponses(message, statusRequestIDs, embeddedVscodeSupported)); + }); + }); +} diff --git a/apps/web/e2e/tests/task/windows-host-embedded-vscode-availability.spec.ts b/apps/web/e2e/tests/task/executor-embedded-vscode-availability.spec.ts similarity index 74% rename from apps/web/e2e/tests/task/windows-host-embedded-vscode-availability.spec.ts rename to apps/web/e2e/tests/task/executor-embedded-vscode-availability.spec.ts index 502888b4dd..24975983e1 100644 --- a/apps/web/e2e/tests/task/windows-host-embedded-vscode-availability.spec.ts +++ b/apps/web/e2e/tests/task/executor-embedded-vscode-availability.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; +import { routeSessionEmbeddedVscodeCapability } from "../../helpers/session-capabilities"; import type { Page } from "@playwright/test"; -import { rewriteBackendHostOS } from "../../helpers/boot-payload"; import { SessionPage } from "../../pages/session-page"; async function createHostedEditor(apiClient: ApiClient): Promise { @@ -26,7 +26,7 @@ async function seedTaskAndOpenEditorMenu( ) { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, - "Host platform editor availability", + "Executor editor availability", seedData.agentProfileId, { description: "/e2e:simple-message", @@ -42,32 +42,32 @@ async function seedTaskAndOpenEditorMenu( await page.getByTestId("editors-menu-list").click(); } -test.describe("host platform controls embedded VS Code availability", () => { - test("hides embedded VS Code for a Windows backend and keeps custom editors", async ({ +test.describe("executor capability controls embedded VS Code availability", () => { + test("hides embedded VS Code for an unsupported session and keeps custom editors", async ({ testPage, apiClient, seedData, prCapture, }) => { + await routeSessionEmbeddedVscodeCapability(testPage, false); await createHostedEditor(apiClient); - await rewriteBackendHostOS(testPage, "windows"); await seedTaskAndOpenEditorMenu(testPage, apiClient, seedData); const menu = testPage.getByRole("menu"); await expect(menu.getByRole("menuitem", { name: "Hosted Preview" })).toBeVisible(); await expect(menu.getByRole("menuitem", { name: "VS Code (Embedded)" })).toHaveCount(0); - await prCapture.screenshot("windows-host-editor-menu-desktop", { - caption: "Windows-hosted Kandev editor menu with embedded VS Code unavailable", + await prCapture.screenshot("unsupported-executor-editor-menu-desktop", { + caption: "Unsupported session hides embedded VS Code while custom editors remain available", }); }); - test("ignores a Windows browser platform when the backend host is Linux", async ({ + test("shows embedded VS Code for a supported session despite a Windows browser", async ({ testPage, apiClient, seedData, }) => { - await createHostedEditor(apiClient); + await routeSessionEmbeddedVscodeCapability(testPage, true); await testPage.addInitScript(() => { Object.defineProperty(Navigator.prototype, "platform", { configurable: true, diff --git a/apps/web/e2e/tests/task/mobile-windows-host-embedded-vscode-availability.spec.ts b/apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts similarity index 75% rename from apps/web/e2e/tests/task/mobile-windows-host-embedded-vscode-availability.spec.ts rename to apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts index 8e79ca077e..d34a071262 100644 --- a/apps/web/e2e/tests/task/mobile-windows-host-embedded-vscode-availability.spec.ts +++ b/apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts @@ -1,7 +1,6 @@ import { test, expect } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import type { Page } from "@playwright/test"; -import { rewriteBackendHostOS } from "../../helpers/boot-payload"; import { SessionPage } from "../../pages/session-page"; async function seedTask( @@ -16,7 +15,7 @@ async function seedTask( ) { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, - "Mobile host platform editor availability", + "Mobile executor editor availability", seedData.agentProfileId, { description: "/e2e:simple-message", @@ -30,13 +29,12 @@ async function seedTask( await session.waitForLoad(); } -test("Windows host keeps the intentional mobile topbar without desktop editor controls", async ({ +test("executor capability keeps the intentional mobile topbar without desktop editor controls", async ({ testPage, apiClient, seedData, prCapture, }) => { - await rewriteBackendHostOS(testPage, "windows"); await seedTask(testPage, apiClient, seedData); await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible(); @@ -46,7 +44,7 @@ test("Windows host keeps the intentional mobile topbar without desktop editor co scroll: document.documentElement.scrollWidth, })); expect(width.scroll).toBeLessThanOrEqual(width.client + 1); - await prCapture.screenshot("windows-host-editor-menu-mobile", { - caption: "Mobile task topbar remains compact on a Windows Kandev host", + await prCapture.screenshot("executor-editor-menu-mobile", { + caption: "Mobile task topbar remains compact for every executor capability", }); }); diff --git a/apps/web/hooks/domains/session/use-session-resumption.test.ts b/apps/web/hooks/domains/session/use-session-resumption.test.ts index b73acea7cc..2e33c9cd2d 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.test.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.test.ts @@ -237,4 +237,42 @@ describe("useSessionResumption", () => { await waitFor(() => expect(mockSetTaskSession).toHaveBeenCalled()); expect(mockSetTaskSession).toHaveBeenCalledWith(expect.objectContaining({ updated_at: "" })); }); + + it("refreshes the status and embedded editor capability after a successful resume", async () => { + mockRequest + .mockResolvedValueOnce({ + session_id: "s1", + task_id: "t1", + state: "WAITING_FOR_INPUT", + is_agent_running: false, + is_resumable: true, + needs_resume: true, + capabilities: { embedded_vscode: false }, + }) + .mockResolvedValueOnce({ + success: true, + task_id: "t1", + session_id: "s1", + state: "STARTING", + }) + .mockResolvedValueOnce({ + session_id: "s1", + task_id: "t1", + state: "STARTING", + is_agent_running: true, + is_resumable: false, + needs_resume: false, + capabilities: { embedded_vscode: true }, + }); + + const { result } = renderHook(() => useSessionResumption("t1", "s1")); + + await waitFor(() => { + expect(result.current.sessionStatus?.capabilities?.embedded_vscode).toBe(true); + }); + expect(mockRequest).toHaveBeenLastCalledWith("task.session.status", { + task_id: "t1", + session_id: "s1", + }); + }); }); diff --git a/apps/web/hooks/domains/session/use-session-resumption.ts b/apps/web/hooks/domains/session/use-session-resumption.ts index 360380c0fe..a81e054e9c 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.ts @@ -38,6 +38,9 @@ export type SessionStatus = { remote_created_at?: string; remote_checked_at?: string; remote_status_error?: string; + capabilities?: { + embedded_vscode: boolean; + }; error?: string; }; @@ -106,7 +109,7 @@ async function resumeViaLaunch( taskId: string, sessionId: string, ) => { request: import("@/lib/services/session-launch-service").LaunchSessionRequest }, -): Promise { +): Promise { setters.setResumptionState("resuming"); const { request } = buildRequest(taskId, sessionId); const launchResp = await launchSession(request); @@ -132,6 +135,7 @@ async function resumeViaLaunch( if (ok && request.intent === "restore_workspace" && setters.setAgentctlReady) { setters.setAgentctlReady(sessionId); } + return ok; } /** Attempt resume, silently falling back to restore_workspace on any failure. @@ -145,7 +149,7 @@ export async function resumeWithSilentFallback( sessionId: string, session: SessionLike, setters: ResumeStateSetter, -): Promise { +): Promise { setters.setResumptionState("resuming"); if ( await tryLaunch( @@ -156,7 +160,7 @@ export async function resumeWithSilentFallback( setters, ) ) { - return; + return true; } // Resume failed (returned success=false OR threw). Fall back to read-only // workspace restore so the user keeps file/terminal/git access. @@ -169,10 +173,11 @@ export async function resumeWithSilentFallback( setters, ) ) { - return; + return true; } setters.setResumptionState("error"); setters.setError("Failed to resume session — workspace restore also unavailable"); + return false; } /** Run a single launch attempt; returns true on success, false on any failure. @@ -246,6 +251,38 @@ function applyStatusToState( } } +type RefreshSessionStatusParams = { + client: NonNullable>; + taskId: string; + sessionId: string; + session: SessionLike; + setSessionStatus: (status: SessionStatus) => void; + setters: ResumeStateSetter; +}; + +async function refreshSessionStatus({ + client, + taskId, + sessionId, + session, + setSessionStatus, + setters, +}: RefreshSessionStatusParams): Promise { + try { + const status = await client.request("task.session.status", { + task_id: taskId, + session_id: sessionId, + }); + setSessionStatus(status); + applyStatusToState(status, taskId, sessionId, session, setters); + if (status.is_agent_running && setters.setAgentctlReady) { + setters.setAgentctlReady(sessionId); + } + } catch (err) { + console.error("[refreshSessionStatus] failed to refresh session status", { sessionId, err }); + } +} + async function checkAndResume({ taskId, sessionId, @@ -274,15 +311,25 @@ async function checkAndResume({ if (status.is_agent_running && setters.setAgentctlReady) { setters.setAgentctlReady(sessionId); } + let resumed = false; if (status.is_agent_running) { setters.setResumptionState("running"); } else if (status.needs_resume && status.is_resumable) { - await resumeWithSilentFallback(taskId, sessionId, session, setters); + resumed = await resumeWithSilentFallback(taskId, sessionId, session, setters); } else if (status.needs_workspace_restore) { - await resumeViaLaunch(taskId, sessionId, session, setters, buildRestoreWorkspaceRequest); + resumed = await resumeViaLaunch( + taskId, + sessionId, + session, + setters, + buildRestoreWorkspaceRequest, + ); } else { setters.setResumptionState("idle"); } + if (resumed) { + await refreshSessionStatus({ client, taskId, sessionId, session, setSessionStatus, setters }); + } } catch (err) { setters.setResumptionState("error"); setters.setError(err instanceof Error ? err.message : "Unknown error"); diff --git a/apps/web/src/boot-payload.test.ts b/apps/web/src/boot-payload.test.ts index b231046b46..013f318edd 100644 --- a/apps/web/src/boot-payload.test.ts +++ b/apps/web/src/boot-payload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { loadBootPayload, readBackendHostOS, readBootPayload } from "./boot-payload"; +import { loadBootPayload, readBootPayload } from "./boot-payload"; const JIRA_BUNDLE_URL = "/api/plugins/jira/bundle"; @@ -23,7 +23,6 @@ describe("readBootPayload", () => { runtime: { apiPrefix: "/api/v1", webSocketPath: "/ws", - hostOS: "linux", }, initialState: { tasks: { activeTaskId: "task-1" }, @@ -43,7 +42,6 @@ describe("readBootPayload", () => { runtime: { apiPrefix: "/api/v1", webSocketPath: "/ws", - hostOS: "linux", }, initialState: { tasks: { activeTaskId: "task-1" }, @@ -76,24 +74,6 @@ describe("readBootPayload", () => { expect(readBootPayload(win).runtime?.debug).toBe(true); expect(win.__KANDEV_DEBUG).toBe(true); }); - - it("ignores an invalid runtime host OS value", () => { - const win = { - __KANDEV_BOOT_PAYLOAD__: { - runtime: { hostOS: 42 }, - }, - } as unknown as Window; - - expect(readBootPayload(win).runtime?.hostOS).toBeUndefined(); - }); - - it("reads the backend host OS from the injected runtime payload", () => { - const win = { - __KANDEV_BOOT_PAYLOAD__: { runtime: { hostOS: "windows" } }, - } as unknown as Window; - - expect(readBackendHostOS(win)).toBe("windows"); - }); }); describe("readBootPayload plugins", () => { diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts index 142933ab0c..d7327da7ec 100644 --- a/apps/web/src/boot-payload.ts +++ b/apps/web/src/boot-payload.ts @@ -16,7 +16,6 @@ export type BootRoute = { export type BootRuntime = { apiPrefix?: string; webSocketPath?: string; - hostOS?: string; debug?: boolean; }; @@ -80,10 +79,6 @@ export function readInterimSettingsInterlockToken(): string | undefined { return readBootPayload(window).interimSettingsInterlockToken; } -export function readBackendHostOS(win: Window = window): string | undefined { - return readBootPayload(win).runtime?.hostOS; -} - function readPlugins(value: unknown[]): ActivePlugin[] { return value.filter(isRecord).flatMap((entry) => { const plugin = readPlugin(entry); @@ -138,7 +133,6 @@ function readRuntime(value: Record): BootRuntime { return { apiPrefix: readString(value.apiPrefix), webSocketPath: readString(value.webSocketPath), - hostOS: readString(value.hostOS), debug: value.debug === true ? true : undefined, }; } diff --git a/docs/decisions/2026-07-30-embedded-editor-executor-capabilities.md b/docs/decisions/2026-07-30-embedded-editor-executor-capabilities.md new file mode 100644 index 0000000000..5adab4a9c4 --- /dev/null +++ b/docs/decisions/2026-07-30-embedded-editor-executor-capabilities.md @@ -0,0 +1,77 @@ +# ADR-2026-07-30-embedded-editor-executor-capabilities: Derive Embedded Editor Availability from the Active Executor + +**Status:** accepted +**Date:** 2026-07-30 +**Area:** backend, frontend, protocol + +## Context + +Kandev's embedded VS Code integration starts code-server through agentctl inside a task session's +execution environment. The original Windows guard instead used `runtime.hostOS` from the SPA boot +payload, which describes the Kandev control-plane process. That is correct only for host-backed +Local and Worktree executors. It incorrectly disables Linux Docker, Remote Docker, Sprites, and +supported SSH environments whenever the Kandev backend itself runs on Windows. + +The task-session status contract already identifies the active session's executor and runtime. +Availability is needed before the user launches an editor, and the launch API must enforce the +same answer so a stale or handcrafted client cannot bypass the UI. + +## Decision + +Embedded-editor availability is a capability of the active session execution environment, not of +the browser or the Kandev installation as a whole. + +The backend is the single authority for this capability. `task.session.status` returns a typed +`capabilities.embedded_vscode` boolean. The frontend consumes that boolean and does not maintain an +executor-type allowlist. + +The backend resolver maps executor runtime ownership as follows: + +- Standalone Local and Worktree execution uses the Kandev host platform and supports code-server + on Linux and macOS, but not Windows. +- Docker, Remote Docker, and Sprites execute in supported Linux environments. +- SSH supports code-server while Kandev's SSH runtime accepts only Linux and macOS targets. If SSH + later accepts Windows, its capability must use persisted or live remote-platform metadata rather + than remaining unconditionally true by executor type. +- Missing executors, unknown executor types, and unknown runtimes fail closed. + +The same resolver is used by task-session status projection and by the editor service. Requests to +open `internal_vscode` are rejected with the existing editor-unavailable error when the resolved +capability is false. Other editors retain their existing enabled, installed, and configuration +rules. + +The frontend treats a missing capability field as false, filters the topbar editor candidates +before resolving the saved default, and keys the value to the currently active session. A saved +preference is never rewritten merely because it is incompatible with one session. + +The editor-specific `runtime.hostOS` boot field is removed because it has no remaining consumer. +General host diagnostics, if needed later, should use a diagnostic contract rather than influence +task-runtime capabilities. + +## Consequences + +- Windows users regain embedded VS Code for Linux-backed Docker and other supported remote task + runtimes while native Windows Local and Worktree sessions remain protected. +- Availability changes when the active session changes, even within one task. +- Capability policy lives beside backend runtime knowledge and can evolve without shipping a + matching frontend executor list. +- UI filtering and API enforcement cannot drift if both use the shared resolver. +- Until session status arrives, the embedded editor is hidden. Other configured editors remain + available. +- Adding a new executor or expanding an existing executor to another platform requires an explicit + capability decision and tests. + +## Alternatives Considered + +1. **Keep using the Kandev backend host OS.** Rejected because it conflates the control plane with + Docker, sandbox, and remote execution environments. +2. **Hardcode supported executor names in React.** Rejected because it duplicates backend runtime + knowledge, cannot safely represent future platform-dependent SSH support, and offers no API + enforcement. +3. **Expose only executor type and let every client infer support.** Rejected because every client + would need to reproduce the capability matrix and update in lockstep. +4. **Probe code-server only after the user clicks.** Rejected because unsupported actions remain + visible, failures arrive late, and saved-default fallback cannot be resolved beforehand. +5. **Replace code-server with Microsoft VS Code Server.** Rejected for this change because it is a + separate product and licensing/integration decision and is unnecessary for Windows-hosted Linux + executors. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 180f770c5e..42c376b029 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -88,4 +88,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 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 | +| 2026-07-30-embedded-editor-executor-capabilities | [Derive Embedded Editor Availability from the Active Executor](2026-07-30-embedded-editor-executor-capabilities.md) | accepted | backend, frontend, protocol | 2026-07-30 | | 2026-07-30-session-owned-mcp-observability | [Keep MCP Attachment Evidence Session Owned](2026-07-30-session-owned-mcp-observability.md) | accepted | backend, frontend, protocol, security | 2026-07-30 | diff --git a/docs/plans/embedded-vscode-executor-availability/plan.md b/docs/plans/embedded-vscode-executor-availability/plan.md new file mode 100644 index 0000000000..0cd9e03c4c --- /dev/null +++ b/docs/plans/embedded-vscode-executor-availability/plan.md @@ -0,0 +1,258 @@ +--- +spec: docs/specs/ui/embedded-vscode-executor-availability.md +created: 2026-07-30 +status: complete +--- + +# Implementation Plan: Embedded VS Code Executor Availability + +## Overview + +Replace the installation-wide backend-host check with a backend-owned capability for the active +task session. Compute the capability from the executor runtime and host platform, expose it through +the existing task-session status response, enforce it in the open-editor service, and make the +desktop topbar filter and fallback follow the active session. Remove the obsolete boot +`runtime.hostOS` field, prove the backend matrix and frontend session behavior with focused tests +and Playwright, and update public executor/editor guidance. + +## Backend + +### Shared executor capability + +- Add a small backend-owned resolver under + `apps/backend/internal/editors/capabilities/`. +- The resolver accepts `models.ExecutorType` and the control-plane OS rather than reading browser + or request data. It implements the spec's explicit executor matrix: + - Local and Worktree are supported on Linux or macOS and unsupported on Windows or an unknown + host OS; + - Local Docker, Remote Docker, Sprites, and SSH are supported by their currently accepted task + platforms; + - unknown, empty, and test-only executor types fail closed. +- Cover every executor type/OS combination in + `apps/backend/internal/editors/capabilities/capabilities_test.go`. +- Keep the policy in this shared backend package. Neither the orchestrator, editor service, nor + frontend may reproduce the matrix. + +### Session capability contract + +- Add a typed capabilities DTO to + `apps/backend/internal/orchestrator/dto/dto.go`: + + ```text + capabilities.embedded_vscode boolean + ``` + +- Populate it in `apps/backend/internal/orchestrator/task_operations.go` while resolving the + session executor. Authorized existing sessions always return the nested capability object; + missing or unknown executor data yields `false`. +- Extend focused task-session status tests in + `apps/backend/internal/orchestrator/task_operations_test.go` (or the closest existing + status-specific test file) to assert that a known executor uses the shared resolver with + `runtime.GOOS`, the nested boolean is serialized, and unresolved executor data is false. The + capability-package table owns simulated Windows/non-Windows matrix coverage without adding + production injection points. + +### Open-editor enforcement + +- Extend the editor service's task repository boundary in + `apps/backend/internal/editors/service/service.go` so it can resolve the selected session's + executor. +- Before returning an `internal://vscode` sentinel, evaluate the shared capability and return + `ErrEditorUnavailable` when false. +- Apply the check to folder-level and file-level embedded-editor opens. Do not change other editor + kinds. +- Extend `apps/backend/internal/editors/service/service_test.go` with supported and unsupported + session/executor fixtures, including the saved-default path. Preserve the handlers' existing + HTTP 409 mapping for `ErrEditorUnavailable`. + +## Frontend + +### Consume active-session capability + +- Add the nested capability shape to `SessionStatus` in + `apps/web/hooks/domains/session/use-session-resumption.ts`. +- Read `resumption.sessionStatus.capabilities.embedded_vscode` in + `apps/web/components/task/task-page-inner.tsx` and pass the boolean through + `TaskTopBar` → `TopBarRight` → `TopbarToolsGroup` → `EditorsMenu`. +- The value must be derived from the current `effectiveSessionId`; a missing status or field is + `false`, preventing a previous session's capability from leaking across a tab switch. +- Update `apps/web/components/task/task-top-bar.test.tsx` to capture the mocked `EditorsMenu` props + and prove the capability is forwarded. + +### Filter and fallback + +- Change `getAvailableTaskTopbarEditors` in + `apps/web/components/task/editors-menu-availability.ts` to accept + `embeddedVscodeSupported: boolean` instead of a host OS. +- Continue applying enabled and built-in installed checks first, then remove only + `internal_vscode` when the capability is false. +- Keep `resolveTaskTopbarEditorId` operating on the filtered set so an incompatible saved default + falls back without being persisted. +- Update `apps/web/components/task/editors-menu-availability.test.ts` with supported, + unsupported, missing-capability, fallback, and other-editor cases. + +### Retire host-wide boot metadata + +- Remove `HostOS` and its construction/assertions from: + - `apps/backend/internal/webapp/payload.go` + - `apps/backend/internal/backendapp/helpers.go` + - `apps/backend/internal/backendapp/helpers_test.go` +- Remove `BootRuntime.hostOS`, `readBackendHostOS`, and their tests from: + - `apps/web/src/boot-payload.ts` + - `apps/web/src/boot-payload.test.ts` +- Remove the now-unused Playwright boot rewrite helper + `apps/web/e2e/helpers/boot-payload.ts`. +- Confirm there are no remaining editor-availability reads of backend host metadata. + +## Mobile design contract + +- Desktop outcome: the existing split editor control remains in the desktop task topbar and + changes availability when the active session changes. +- Mobile entry point and hierarchy: `TaskPageInner` continues to render + `SessionMobileTopBar` instead of `TaskTopBar`; no editor action, drawer, route, or touch target is + added. +- Nearest shipped exemplar: + `apps/web/components/task/mobile/session-mobile-top-bar.tsx`. +- Presentation and state: the current mobile title, repository, git, plugin, executor, and task + switcher ordering remains unchanged. Capability loading introduces no mobile scroll owner, + safe-area, or persistence change. +- Coverage: retain a phone-viewport Playwright assertion that desktop editor controls are absent + and the document does not overflow horizontally. + +## Tests + +- **What:** executor and host-OS combinations produce the capability matrix. + **File:** `apps/backend/internal/editors/capabilities/capabilities_test.go`. + **How:** table-driven tests cover every executor type, Windows/Linux/macOS host-backed execution, + and unknown values. +- **What:** task-session status exposes a backend-owned capability for the session executor. + **File:** `apps/backend/internal/orchestrator/task_operations_test.go` or its nearest focused + sibling. + **How:** executor fixtures assert the known executor's resolver result for `runtime.GOOS`, the + serialized nested field, and missing executor data. +- **What:** direct embedded-editor opens cannot bypass capability filtering. + **File:** `apps/backend/internal/editors/service/service_test.go`. + **How:** service tests select `internal_vscode` explicitly and through the saved default for + supported and unsupported sessions, while a non-embedded editor remains unchanged. +- **What:** the topbar editor set and default fallback use capability state. + **File:** `apps/web/components/task/editors-menu-availability.test.ts`. + **How:** table-driven Vitest cases pass true/false and mixed `EditorOption` fixtures. +- **What:** the active-session capability reaches the menu and missing data fails closed. + **Files:** `apps/web/components/task/task-top-bar.test.tsx` and, if needed for the + `TaskPageInner` boundary, a focused helper test extracted beside + `apps/web/components/task/task-page-inner.tsx`. + **How:** assert the prop chain for supported, unsupported, and absent status data. +- **What:** boot host metadata is fully retired. + **Files:** `apps/backend/internal/backendapp/helpers_test.go` and + `apps/web/src/boot-payload.test.ts`. + **How:** remove obsolete assertions and retain normalization/serialization coverage for the + remaining runtime fields. + +## E2E tests + +- Replace + `apps/web/e2e/tests/task/windows-host-embedded-vscode-availability.spec.ts` with + `apps/web/e2e/tests/task/executor-embedded-vscode-availability.spec.ts`. +- Add a small WebSocket routing helper in + `apps/web/e2e/helpers/session-capabilities.ts` that proxies the real socket and rewrites only the + matching `task.session.status` response's `capabilities.embedded_vscode` value. Backend unit tests + own the executor matrix; this helper owns the browser's response to the contract. +- **Scenario:** an unsupported active session hides **VS Code (Embedded)**, retains a hosted custom + editor, and uses the custom editor when the saved embedded default is unavailable. +- **Scenario:** a supported active session shows **VS Code (Embedded)** even when + `navigator.platform` reports Windows, proving browser platform is irrelevant. +- **Scenario:** switching the active session from an unsupported capability to a supported + capability updates the menu without a page reload, if the existing session fixture can express + the switch without broad setup. Otherwise cover the stale-session guard in focused Vitest and + keep Playwright to the two contract cases above. +- Rename + `apps/web/e2e/tests/task/mobile-windows-host-embedded-vscode-availability.spec.ts` to + `apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts`; remove the boot + payload rewrite and retain the compact-header/no-overflow assertions at the phone viewport. +- Existing `apps/web/e2e/tests/settings/vscode-open-panel.spec.ts` remains the supported-runtime + launch smoke test. + +## Public documentation + +- Update `docs/public/developer-tools.md` to say that embedded VS Code runs in the active task + environment. Native Windows Local/Worktree sessions are unsupported, while Linux Docker and + supported remote executors remain available even when the Kandev app runs on Windows. +- Update the **Embedded VS Code** row in `docs/public/feature-status.md` with the executor-specific + boundary. +- Update `docs/public/executors.md` with a concise availability matrix or cross-link if that makes + the executor distinction easier to find. +- Keep the code-server release link and existing `--auth none` network-isolation warning. + +## Implementation waves and parallel candidates + +Wave 1: + +- [x] [Task 01: Add and enforce the session capability](task-01-add-session-capability.md) + (`done`) + +Wave 2: + +- [x] [Task 02: Wire active-session editor availability](task-02-wire-editor-availability.md) + (`done`, depends on Task 01) + +Wave 3: + +- [x] [Task 03: Prove executor-specific behavior](task-03-executor-availability-e2e.md) + (`done`, depends on Task 02) +- [x] [Task 04: Document executor-specific availability](task-04-document-executor-availability.md) + (`done`, depends on Tasks 01 and 02; parallel-safe with Task 03 only if the user explicitly + authorizes subagents) + +The primary conversation executes these tasks sequentially unless the user explicitly authorizes +subagents. + +## Risks + +- `task.session.status` is asynchronous. The frontend must fail closed for embedded VS Code without + disabling unrelated editors or retaining the previous session's capability. +- Executor type is not permanently equivalent to operating system. SSH is currently safe because + Kandev accepts only Linux/macOS remotes; future Windows SSH support must add remote-platform-aware + capability resolution. +- UI filtering alone is insufficient because file-level and other callers share the open-editor + API. The editor service guard must use the same backend resolver. +- Removing `runtime.hostOS` spans Go boot serialization, TypeScript normalization, and old + Playwright helpers. A repository-wide search is required before deletion is considered complete. +- A true capability means the runtime is supported, not that a particular download or startup will + succeed. Existing runtime errors must remain visible. +- E2E WebSocket rewriting must preserve every unrelated frame and correlate the exact status + request/response ID; otherwise the test could hide real connection behavior. + +## Verification + +From `apps/backend/`: + +```bash +go test ./internal/editors/capabilities ./internal/orchestrator ./internal/editors/service ./internal/editors/handlers +``` + +From `apps/`: + +```bash +pnpm --filter @kandev/web test -- src/boot-payload.test.ts components/task/editors-menu-availability.test.ts components/task/task-top-bar.test.tsx +pnpm --filter @kandev/web typecheck +pnpm --filter @kandev/web lint +pnpm --filter @kandev/web e2e:run -- tests/task/executor-embedded-vscode-availability.spec.ts -- --project=chromium +pnpm --filter @kandev/web e2e:run -- tests/task/mobile-executor-embedded-vscode-availability.spec.ts -- --project=mobile-chrome +``` + +From the repository root: + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +Implementation records the red/green results in each task file, updates task/plan statuses, and +moves the spec from `approved` to `building` at start and `shipped` only after every acceptance +scenario and verification command passes. + +## Completion record + +All four tasks completed on 2026-07-30. Backend capability and enforcement tests, focused web +tests, typecheck, lint, both targeted Playwright suites, and public-doc validation passed. See the +individual task records for exact commands and results. diff --git a/docs/plans/embedded-vscode-executor-availability/task-01-add-session-capability.md b/docs/plans/embedded-vscode-executor-availability/task-01-add-session-capability.md new file mode 100644 index 0000000000..73750183e6 --- /dev/null +++ b/docs/plans/embedded-vscode-executor-availability/task-01-add-session-capability.md @@ -0,0 +1,86 @@ +--- +id: "01-add-session-capability" +title: "Add and enforce the session capability" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/embedded-vscode-executor-availability.md" +--- + +# Task 01: Add and Enforce the Session Capability + +## Acceptance + +- A shared backend resolver returns the specified embedded-VS-Code capability for every known + runtime/host-OS combination and fails closed for unknown values. +- Every authorized task-session status response includes + `capabilities.embedded_vscode` for that session's executor. +- Opening `internal_vscode` directly or through the saved default returns + `ErrEditorUnavailable` for an unsupported session while supported sessions and other editor kinds + retain their existing behavior. + +## Verification + +Use TDD: add the executor matrix, status contract, and editor-service rejection tests; observe the +focused failures; implement the shared resolver and both consumers; then rerun from +`apps/backend/`: + +```bash +go test ./internal/editors/capabilities ./internal/orchestrator ./internal/editors/service ./internal/editors/handlers +``` + +## Files likely touched + +- `apps/backend/internal/editors/capabilities/capabilities.go` (new) +- `apps/backend/internal/editors/capabilities/capabilities_test.go` (new) +- `apps/backend/internal/orchestrator/dto/dto.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/task_operations_test.go` +- `apps/backend/internal/editors/service/service.go` +- `apps/backend/internal/editors/service/service_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential. This task owns the backend contract and shared enforcement consumed by Task 02. + +## Inputs + +- Spec sections: **What**, **API surface**, **Failure modes**, and scenarios 1–7. +- ADR: + `docs/decisions/2026-07-30-embedded-editor-executor-capabilities.md`. +- Existing patterns: + - executor constants in `apps/backend/internal/task/models/models.go` + - `TaskSessionStatusResponse` in `apps/backend/internal/orchestrator/dto/dto.go` + - `populateExecutorStatusInfo` in `apps/backend/internal/orchestrator/task_operations.go` + - `ErrEditorUnavailable` in `apps/backend/internal/editors/service/service.go` + +## Risks + +- Do not let unknown or test-only executor values inherit the default standalone runtime. +- Use actual session executor data for enforcement; do not trust a client-supplied executor type. +- Keep the editor service's repository interface minimal and update its test doubles without + broadening unrelated dependencies. + +## Output contract + +Report the API shape and capability matrix implemented, files changed, red and green command +results, and any remaining risk. Update this task to `done`, its plan checkbox/status, and the spec +to `building`. + +## Completion record + +- Added `editors/capabilities.SupportsEmbeddedVscode`, which accepts only Linux/Darwin for Local + and Worktree, accepts the supported container/remote executor types, and fails closed otherwise. +- `task.session.status` now always includes `capabilities.embedded_vscode`; missing executors + return `false`. Direct internal-editor requests resolve the session executor and return + `ErrEditorUnavailable` when unsupported, for saved-default, explicit, folder, and file paths. +- Red: the resolver/status/service tests initially failed to compile or returned a successful + internal-editor URL before the capability contract and guard were added. +- Green: `go test ./internal/editors/capabilities ./internal/orchestrator ./internal/editors/service ./internal/editors/handlers` passed. +- Remaining risk: a true capability identifies a supported runtime only; code-server download and + startup retain their existing runtime error handling. diff --git a/docs/plans/embedded-vscode-executor-availability/task-02-wire-editor-availability.md b/docs/plans/embedded-vscode-executor-availability/task-02-wire-editor-availability.md new file mode 100644 index 0000000000..e5f36b58ae --- /dev/null +++ b/docs/plans/embedded-vscode-executor-availability/task-02-wire-editor-availability.md @@ -0,0 +1,106 @@ +--- +id: "02-wire-editor-availability" +title: "Wire active-session editor availability" +status: done +wave: 2 +depends_on: ["01-add-session-capability"] +plan: "plan.md" +spec: "../../specs/ui/embedded-vscode-executor-availability.md" +--- + +# Task 02: Wire Active-Session Editor Availability + +## Acceptance + +- The desktop task topbar filters `internal_vscode` with the active session's backend capability; + missing capability data fails closed without hiding unrelated editors. +- Saved-default fallback and active-session changes use the filtered editor set without mutating + the stored preference or leaking the previous session's value. +- The obsolete boot `runtime.hostOS` field, parser/accessor, tests, and Playwright helper are + removed with no remaining consumer. + +## Verification + +Use TDD: update the availability and prop-flow tests to describe the capability contract, observe +the failures, wire the active-session value, and remove host metadata only after the new path is +green. From `apps/` run: + +```bash +pnpm --filter @kandev/web test -- src/boot-payload.test.ts components/task/editors-menu-availability.test.ts components/task/task-top-bar.test.tsx +pnpm --filter @kandev/web typecheck +pnpm --filter @kandev/web lint +``` + +From `apps/backend/` run the affected boot package tests: + +```bash +go test ./internal/backendapp ./internal/webapp +``` + +Finally search from the repository root: + +```bash +rg -n "readBackendHostOS|hostOS|rewriteBackendHostOS" apps +``` + +The search must return no obsolete editor-availability or boot-contract references. + +## Files likely touched + +- `apps/web/hooks/domains/session/use-session-resumption.ts` +- `apps/web/components/task/task-page-inner.tsx` +- `apps/web/components/task/task-top-bar.tsx` +- `apps/web/components/task/task-top-bar.test.tsx` +- `apps/web/components/task/editors-menu.tsx` +- `apps/web/components/task/editors-menu-availability.ts` +- `apps/web/components/task/editors-menu-availability.test.ts` +- `apps/web/src/boot-payload.ts` +- `apps/web/src/boot-payload.test.ts` +- `apps/backend/internal/webapp/payload.go` +- `apps/backend/internal/backendapp/helpers.go` +- `apps/backend/internal/backendapp/helpers_test.go` +- `apps/web/e2e/helpers/boot-payload.ts` (remove) + +## Dependencies + +- Task 01 provides the session capability and enforcement contract. + +## Parallelism + +Sequential. The frontend contract and metadata removal must land together before E2E coverage. + +## Inputs + +- Spec sections: **What**, **API surface**, **Failure modes**, and scenarios 1–6 and 8. +- Plan sections: **Consume active-session capability**, **Filter and fallback**, **Retire + host-wide boot metadata**, and **Mobile design contract**. +- Existing active-session source: + `resumption.sessionStatus` in `apps/web/components/task/task-page-inner.tsx`. + +## Risks + +- Reset capability to false when `effectiveSessionId` changes instead of retaining asynchronous + status from the previous session. +- Pass one capability value to both halves of the split editor control. +- Keep custom, hosted, Remote SSH, and installed built-in editors governed by their current rules. + +## Output contract + +Report the prop/data flow, removed host metadata, files changed, red and green results, and the +repository-wide search result. Update this task to `done` and its plan checkbox/status. + +## Completion record + +- The current session's `capabilities.embedded_vscode` flows from session resumption through + `TaskPageInner`, `TaskTopBar`, `TopBarRight`, `TopbarToolsGroup`, and `EditorsMenu`. Missing + data is false, while the hook's ID-keyed state prevents a previous session's status leaking into + the next session. +- Availability filtering now accepts the capability boolean and retains existing enabled/installed + checks. The filtered set continues to drive saved-default fallback without persisting a change. +- Removed boot `runtime.hostOS` from Go serialization and TypeScript parsing, plus the old E2E + HTML-rewrite helper. +- Red: the updated availability test still used the old host-OS argument and failed before the + filtering implementation changed. Green: focused Vitest tests passed (15 tests), + `pnpm run typecheck` and `pnpm run lint` passed, and + `go test ./internal/backendapp ./internal/webapp` passed. +- `rg -n "readBackendHostOS|rewriteBackendHostOS|hostOS" apps/web apps/backend/internal/webapp apps/backend/internal/backendapp -g '*.ts' -g '*.tsx' -g '*.go'` returned no matches. diff --git a/docs/plans/embedded-vscode-executor-availability/task-03-executor-availability-e2e.md b/docs/plans/embedded-vscode-executor-availability/task-03-executor-availability-e2e.md new file mode 100644 index 0000000000..1e38545125 --- /dev/null +++ b/docs/plans/embedded-vscode-executor-availability/task-03-executor-availability-e2e.md @@ -0,0 +1,80 @@ +--- +id: "03-executor-availability-e2e" +title: "Prove executor-specific behavior" +status: done +wave: 3 +depends_on: ["02-wire-editor-availability"] +plan: "plan.md" +spec: "../../specs/ui/embedded-vscode-executor-availability.md" +--- + +# Task 03: Prove Executor-Specific Behavior + +## Acceptance + +- Desktop Playwright coverage proves unsupported capability hides embedded VS Code while retaining + a compatible editor, and supported capability shows it independently of browser platform. +- Capability test routing rewrites only the correlated `task.session.status` response and proxies + every unrelated WebSocket frame unchanged. +- Phone-viewport coverage retains the intentional mobile topbar, absence of desktop editor + controls, and no horizontal overflow without relying on boot host metadata. + +## Verification + +From `apps/`: + +```bash +pnpm --filter @kandev/web e2e:run -- tests/task/executor-embedded-vscode-availability.spec.ts -- --project=chromium +pnpm --filter @kandev/web e2e:run -- tests/task/mobile-executor-embedded-vscode-availability.spec.ts -- --project=mobile-chrome +``` + +If the WebSocket helper has extractable pure frame-rewrite logic, add and run its focused Vitest +test before Playwright. Do not add a test-only production API or runtime flag. + +## Files likely touched + +- `apps/web/e2e/helpers/session-capabilities.ts` (new) +- `apps/web/e2e/tests/task/executor-embedded-vscode-availability.spec.ts` (replace old + host-platform spec) +- `apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts` (rename and update) +- `apps/web/e2e/tests/task/windows-host-embedded-vscode-availability.spec.ts` (remove) +- `apps/web/e2e/tests/task/mobile-windows-host-embedded-vscode-availability.spec.ts` (remove) + +## Dependencies + +- Task 02 supplies the active-session UI behavior and removes the boot-host helper. + +## Parallelism + +Parallel-safe with Task 04 only when the user explicitly authorizes subagents. Otherwise run +sequentially in the primary conversation. + +## Inputs + +- Spec scenarios 1–3, 8, and 9. +- Plan section: **E2E tests**. +- Existing WebSocket proxy pattern: `apps/web/e2e/helpers/ws-drop.ts`. +- Existing supported launch smoke: + `apps/web/e2e/tests/settings/vscode-open-panel.spec.ts`. + +## Risks + +- Correlate request IDs; do not mutate every response containing a `capabilities` key. +- Preserve newline-delimited frames and binary frames exactly when they are not the target. +- Avoid starting code-server in menu-availability tests; the existing launch smoke owns that cost. + +## Output contract + +Report scenarios covered, files replaced/removed, exact Playwright results, and any fixture +limitations. Update this task to `done` and its plan checkbox/status. + +## Completion record + +- Replaced both host-platform specs with executor-capability desktop and mobile specs. Added a + WebSocket proxy helper that records only `task.session.status` request IDs, rewrites only their + response capability, and forwards all other frames unchanged. +- `./e2e/scripts/run-e2e.sh --no-build --project chromium -- tests/task/executor-embedded-vscode-availability.spec.ts` passed: 2 tests. +- `pnpm e2e:run -- tests/task/mobile-executor-embedded-vscode-availability.spec.ts --project=mobile-chrome` passed: 1 test. +- The desktop suite covers unsupported-session filtering with a retained custom editor and a + supported capability under a Windows browser platform. The mobile suite confirms the compact + header, no desktop editor control, and no horizontal overflow. diff --git a/docs/plans/embedded-vscode-executor-availability/task-04-document-executor-availability.md b/docs/plans/embedded-vscode-executor-availability/task-04-document-executor-availability.md new file mode 100644 index 0000000000..0e95a5036b --- /dev/null +++ b/docs/plans/embedded-vscode-executor-availability/task-04-document-executor-availability.md @@ -0,0 +1,82 @@ +--- +id: "04-document-executor-availability" +title: "Document executor-specific availability" +status: done +wave: 3 +depends_on: ["01-add-session-capability", "02-wire-editor-availability"] +plan: "plan.md" +spec: "../../specs/ui/embedded-vscode-executor-availability.md" +--- + +# Task 04: Document Executor-Specific Availability + +## Acceptance + +- Public editor guidance distinguishes native Windows Local/Worktree sessions from supported Linux + Docker and remote task environments on a Windows Kandev host. +- The feature-status boundary and executor documentation agree with the approved spec and retain + the code-server network-isolation/startup caveats. +- Public-doc validation passes, and the spec/plan/task statuses accurately reflect the completed + implementation and recorded verification. + +## Verification + +From the repository root: + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +Cross-check terminology and stale host-wide claims: + +```bash +rg -n "Embedded VS Code|code-server|backend host is Windows|backend runs on Windows" docs/public docs/specs +``` + +## Files likely touched + +- `docs/public/developer-tools.md` +- `docs/public/feature-status.md` +- `docs/public/executors.md` +- `docs/specs/ui/embedded-vscode-executor-availability.md` +- `docs/specs/INDEX.md` +- `docs/plans/embedded-vscode-executor-availability/plan.md` +- `docs/plans/embedded-vscode-executor-availability/task-*.md` + +## Dependencies + +- Tasks 01 and 02 establish the final API and user-visible behavior. + +## Parallelism + +Parallel-safe with Task 03 only when the user explicitly authorizes subagents. Otherwise run +sequentially in the primary conversation. + +## Inputs + +- Spec sections: **What**, **Failure modes**, and **Out of scope**. +- Existing public guidance: + - `docs/public/developer-tools.md#files-and-editor-integrations` + - **Embedded VS Code** row in `docs/public/feature-status.md` + - executor boundaries in `docs/public/executors.md` + +## Risks + +- Do not claim that capability guarantees a successful download or startup. +- Do not imply native Windows code-server support. +- Keep external host-editor discovery separate from the embedded task-runtime editor. + +## Output contract + +Report public pages changed and validator results. After all implementation and E2E checks pass, +mark this task and every plan checkbox `done`, set the plan to `complete`, and set the spec to +`shipped`. + +## Completion record + +- Updated `developer-tools.md`, `feature-status.md`, and `executors.md` to distinguish native + Windows Local/Worktree from Linux-backed Docker, Sprites, and supported SSH execution, while + retaining the code-server isolation and startup caveats. +- `node --test scripts/validate-public-docs.test.mjs` passed (58 tests) and + `node scripts/validate-public-docs.mjs` validated 41 published pages. diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index c99c7cbb95..bf77561c20 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -132,7 +132,7 @@ For an idle, non-archived repository-backed task, **Files → Workspace actions The task **Files** panel browses, searches, opens, and edits task-worktree files. Kandev rejects file paths that escape the resolved worktree. A session with one worktree opens that worktree directly in a host editor. When a session has several worktrees, the editor button asks which repository or worktree to open, and each configured editor in the adjacent menu expands to the same repository-and-branch picker. Check that selection before launching an editor from a multi-repository task. Older API clients that omit `worktree_id` retain the first-worktree fallback. -**VS Code (Embedded)** runs code-server inside the task environment and displays it in a workbench panel. Opening it starts code-server independently of the agent process. It launches with `--auth none` and binds `0.0.0.0` on a random port inside that runtime; network/firewall isolation is therefore important, especially for Local and SSH environments. If the binary is absent, agentctl attempts to download it into `~/.kandev/tools/code-server`; first use needs a supported host platform and access to the [code-server release](https://github.com/coder/code-server/releases/tag/v4.130.0). Kandev does not have a supported standalone Windows code-server installation for this integration, so the task-detail topbar omits **VS Code (Embedded)** when the Kandev backend runs on Windows. This is based on the Kandev backend host, not the visitor's browser platform; other configured editors remain available. Use the panel error and task-environment logs when installation, startup, or proxying fails. +**VS Code (Embedded)** runs code-server inside the active task environment and displays it in a workbench panel. Opening it starts code-server independently of the agent process. It launches with `--auth none` and binds `0.0.0.0` on a random port inside that runtime; network/firewall isolation is therefore important, especially for Local and SSH environments. If the binary is absent, agentctl attempts to download it into `~/.kandev/tools/code-server`; first use needs a supported execution platform and access to the [code-server release](https://github.com/coder/code-server/releases/tag/v4.130.0). Native Windows Local and Worktree sessions do not support this integration. Linux-backed Local Docker, Sprites, and supported SSH sessions can offer it even when the Kandev app runs on Windows. The task-detail topbar follows the active session's executor capability, never the visitor's browser platform; other configured editors remain available. Use the panel error and task-environment logs when installation, startup, or proxying fails. Use the workbench top bar's split-editor action to open the selected session worktree in the default editor. Its menu lets you choose another configured editor. A file's **Open with** menu can also open a specific editor, copy the path, or ask the operating system to show the folder. diff --git a/docs/public/executors.md b/docs/public/executors.md index 4219206752..0f64603b6b 100644 --- a/docs/public/executors.md +++ b/docs/public/executors.md @@ -22,6 +22,15 @@ An executor determines where Kandev creates a task environment and runs `agentct Remote Docker deserves explicit treatment: the backend registers the runtime type, but its create and stop methods return `remote_docker runtime is not yet implemented`. The current **Settings > Executors** hub does not offer it. Older routes and stored fields such as `docker_host`, `docker_tls_verify`, and `docker_cert_path` do not make it operational. +## Embedded VS Code availability + +**VS Code (Embedded)** starts code-server inside the active task environment, so its availability +follows that session's executor rather than the operating system of the browser or desktop app. +It is available for Local and Worktree sessions on Linux or macOS, and for Linux-backed Local +Docker, Sprites, and supported SSH sessions. Native Windows Local and Worktree sessions do not +offer it. See [Developer tools](developer-tools.md#files-and-editor-integrations) for code-server network and +download requirements. + ## Create and select a profile Open **Settings > Executors**, then choose **Local**, **Worktree**, **Docker**, **Sprites.dev**, or **SSH** under **Create New Profile**. Local and Worktree profiles already exist in a new database. diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index cf2fdb1401..67afbb1a0d 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -54,7 +54,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | Utility agents and custom one-shot helpers | Dependency-bound | Require an ACP inference-capable agent and usable model. Passthrough-only agents do not satisfy this path. | | Configuration Chat | Limited | Each workspace currently presents one repository-less configuration conversation. Settings shows it in a tabless floating panel and can move the same session into Quick Chat. Confirmed tab closure deletes its backing task; config-mode tasks are excluded from Quick Chat expiry. | | Voice input | Dependency-bound | Browser Web Speech, a browser-downloaded Whisper model, and server Whisper paths exist. Selection does not retry another engine after a recognition error. Server Whisper requires `KANDEV_VOICE_OPENAI_API_KEY` (empty by default); without it the transcription route returns 503. | -| Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:`. It relies on task-runtime and network isolation rather than its own login. The task-detail topbar omits it when the Kandev backend host is Windows; this host boundary is independent of the visitor's browser platform, and other configured editors remain available. | +| Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:`. It relies on task-runtime and network isolation rather than its own login. Native Windows Local and Worktree sessions omit it; Linux-backed Local Docker, Sprites, and supported SSH sessions can offer it even when Kandev runs on Windows. The active session executor, not the visitor's browser platform, controls the topbar entry. | | Built-in editors and host editor launch | Dependency-bound | Built-in editing is shipped. External editors depend on host discovery/custom commands. A multi-worktree session presents a repository/worktree picker before host launch; clients that omit the worktree ID retain a first-worktree fallback. A remote executor path may not work without remote-editor configuration. | | Language servers | Limited | Current built-in mapping covers TypeScript/JavaScript, Go, Rust, and Python. Auto-start/installation default off, and subprocesses run on the backend host rather than inside Docker, SSH, or Sprites runtimes. | | Integrated shell terminals | Dependency-bound | Create, reopen, rename, and terminate are shipped. Shell settings affect new terminals, and executor/task-environment availability controls where the PTY runs. | diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 8b9f570131..63c7b45220 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -141,7 +141,8 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [external-vcs-file-links](ui/external-vcs-file-links.md) | shipped | | [task-listing-display-preferences](ui/task-listing-display-preferences.md) | shipped | | [task-workspace-content-search](ui/task-workspace-content-search.md) | shipped | -| [embedded-vscode-windows-availability](ui/embedded-vscode-windows-availability.md) | shipped | +| [embedded-vscode-executor-availability](ui/embedded-vscode-executor-availability.md) | approved | +| [embedded-vscode-windows-availability](ui/embedded-vscode-windows-availability.md) | archived; superseded by embedded-vscode-executor-availability | ## system-page/ — operational diagnostics & maintenance UI diff --git a/docs/specs/ui/embedded-vscode-executor-availability.md b/docs/specs/ui/embedded-vscode-executor-availability.md new file mode 100644 index 0000000000..ca896d1265 --- /dev/null +++ b/docs/specs/ui/embedded-vscode-executor-availability.md @@ -0,0 +1,127 @@ +--- +status: shipped +created: 2026-07-30 +owner: Kandev +--- + +# Embedded VS Code Executor Availability + +## Why + +Kandev starts **VS Code (Embedded)** as a code-server process inside the active task session's +execution environment. The Kandev backend host and that execution environment can use different +operating systems: a Windows desktop can run a Linux Docker task, while a Linux Kandev server can +control another supported remote runtime. + +The previous host-wide Windows rule therefore hid a working embedded editor from every task on a +Windows Kandev host, including Linux-backed Docker tasks. Availability must follow the environment +that will actually run code-server. + +## What + +- The active task session's executor runtime is authoritative for **VS Code (Embedded)** + availability. The Kandev backend host matters only when that executor runs directly on the host. +- The backend reports an explicit `embedded_vscode` capability for each task-session status + response. The frontend does not reconstruct support from executor names or browser platform + data. +- Kandev computes support as follows: + + | Executor type | Execution platform used for the decision | Embedded VS Code | + | --- | --- | --- | + | Local / Local PC (`local`) | Kandev backend host | Available on Linux and macOS; unavailable on Windows | + | Worktree (`worktree`) | Kandev backend host | Available on Linux and macOS; unavailable on Windows | + | Local Docker (`local_docker`) | Linux container | Available, including when Kandev runs on Windows | + | Remote Docker (`remote_docker`) | Linux container | Available | + | Sprites (`sprites`) | Linux sandbox | Available | + | SSH (`ssh`) | Supported remote platform | Available while SSH execution remains limited to Linux and macOS | + | Unknown or unresolved executor | Unknown | Unavailable | + +- When Kandev later supports an SSH or other executor on additional operating systems, the backend + capability resolver must be updated before advertising embedded-editor support there. +- The task-detail topbar's primary editor action and adjacent menu use the active session's + capability. Switching sessions updates the compatible editor set. +- The existing enabled and installed checks still apply to every editor. Only + `internal_vscode` is affected by the new capability. +- If a saved default is unavailable for the active session, the primary action uses the first + remaining compatible editor without changing the saved preference. +- If no compatible editor remains, both topbar editor controls are disabled and no editor is + launched. +- A direct request to open `internal_vscode` for an unsupported or unresolved session is rejected + as an unavailable editor. UI filtering is not the enforcement boundary. +- The visitor's browser or WebView operating system never controls this capability. +- The phone task-detail layout remains unchanged. It continues to use its compact mobile topbar + without the desktop editor action. + +## API surface + +The `task.session.status` WebSocket response includes: + +```json +{ + "capabilities": { + "embedded_vscode": true + } +} +``` + +`capabilities` is backend-owned session execution metadata. `embedded_vscode` is always a boolean +for an authorized, existing session. Missing capability data from an older backend, a missing +executor, or an unrecognized runtime is treated as `false` by the frontend. + +`POST /api/v1/task-sessions/:id/open-editor` returns the existing editor-unavailable conflict +response when `internal_vscode` is selected for a session whose capability is false. + +The boot runtime's `hostOS` field is removed because it is no longer an editor-availability +contract and has no other consumer. + +## Failure modes + +- If task-session status has not loaded yet, the embedded editor is withheld while other enabled + editors remain usable. +- If the executor lookup or runtime mapping fails, the backend reports + `embedded_vscode: false`. +- A stale or handcrafted client cannot bypass the rule: the open-editor service resolves the + session executor and rejects unsupported embedded-editor requests. +- A supported runtime can still fail during code-server download or startup. Existing panel errors + and task-environment logs remain the diagnostic surface for network, archive, process, and proxy + failures. +- Changing active sessions cannot reuse the previous session's capability. + +## Scenarios + +- **GIVEN** Kandev runs on Windows and the active session uses Local or Worktree, **WHEN** a user + opens the task-detail editor menu, **THEN** **VS Code (Embedded)** is absent and other compatible + editors remain available. +- **GIVEN** Kandev runs on Windows and the active session uses Local Docker, **WHEN** a user opens + the task-detail editor menu, **THEN** **VS Code (Embedded)** is available. +- **GIVEN** a task has one unsupported local session and one supported Docker session, **WHEN** the + user switches between them, **THEN** the editor menu follows the newly active session. +- **GIVEN** Kandev runs on Linux or macOS and the active session uses Local or Worktree, **WHEN** a + user opens the editor menu, **THEN** **VS Code (Embedded)** is available. +- **GIVEN** the saved default is **VS Code (Embedded)** but the active session does not support it, + **WHEN** the primary editor action is activated, **THEN** Kandev opens the first compatible + fallback without changing the saved default. +- **GIVEN** the active session executor is missing or unrecognized, **WHEN** task details render, + **THEN** the embedded editor is absent and cannot be opened through the API. +- **GIVEN** an unsupported session, **WHEN** a client directly requests `internal_vscode`, + **THEN** the backend returns the editor-unavailable conflict response. +- **GIVEN** a visitor's browser reports Windows but the active task runtime is supported, **WHEN** + the editor menu opens, **THEN** **VS Code (Embedded)** remains available. +- **GIVEN** a phone user opens task details for any executor, **WHEN** the mobile topbar renders, + **THEN** no desktop editor action is exposed and the header does not overflow horizontally. + +## Out of scope + +- Replacing code-server with Microsoft VS Code Server. +- Adding or packaging a native Windows code-server runtime. +- Changing Kandev's pinned code-server version or installation source. +- Adding Windows as a supported SSH execution platform. +- Proactively hiding the embedded editor from editor settings, file-level **Open with** menus, + layout presets, or the Dockview add-panel menu. The backend availability guard still applies to + open-editor API requests. +- Changing external editor discovery, custom editor commands, or hosted-editor behavior. +- Adding an editor action to the phone task topbar. + +## Implementation plan + +- [Embedded VS Code executor availability plan](../../plans/embedded-vscode-executor-availability/plan.md) diff --git a/docs/specs/ui/embedded-vscode-windows-availability.md b/docs/specs/ui/embedded-vscode-windows-availability.md index 2af69d3c87..0d777f8607 100644 --- a/docs/specs/ui/embedded-vscode-windows-availability.md +++ b/docs/specs/ui/embedded-vscode-windows-availability.md @@ -1,11 +1,16 @@ --- -status: shipped +status: archived created: 2026-07-29 owner: Kandev +superseded_by: embedded-vscode-executor-availability --- # Embedded VS Code Windows Availability +> Archived on 2026-07-30. This host-wide rule is superseded by +> [Embedded VS Code Executor Availability](embedded-vscode-executor-availability.md), which derives +> availability from the active session's executor runtime. + ## Why Kandev installs **VS Code (Embedded)** from code-server standalone release archives, while