feat(editors): scope embedded VS Code by executor (#2059)

This commit is contained in:
Carlos Florêncio
2026-07-30 22:24:16 +01:00
committed by GitHub
parent 5211a2d197
commit 3760f12939
40 changed files with 1340 additions and 124 deletions
@@ -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,
}
}
@@ -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()
@@ -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
}
}
@@ -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)
}
})
}
}
@@ -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:
@@ -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(), "", "")
+18 -10
View File
@@ -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"`
@@ -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
@@ -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{
@@ -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) {
@@ -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)
-1
View File
@@ -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"`
}
@@ -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", () => {
@@ -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;
});
}
+4 -6
View File
@@ -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(() => {
@@ -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);
});
});
@@ -0,0 +1,11 @@
type SessionEditorCapabilityStatus = {
capabilities?: {
embedded_vscode?: boolean;
};
};
export function getEmbeddedVscodeSupported(
status: SessionEditorCapabilityStatus | null | undefined,
): boolean {
return status?.capabilities?.embedded_vscode === true;
}
@@ -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<typeof resolveRemoteExecutor>;
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,
});
+12 -1
View File
@@ -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 && (
<>
<LayoutPresetSelector />
<EditorsMenu activeSessionId={activeSessionId ?? null} />
<EditorsMenu
activeSessionId={activeSessionId ?? null}
embeddedVscodeSupported={embeddedVscodeSupported ?? false}
/>
</>
)}
{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}
/>
</div>
);
-17
View File
@@ -1,17 +0,0 @@
import type { Page } from "@playwright/test";
export async function rewriteBackendHostOS(page: Page, hostOS: string): Promise<void> {
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 });
});
}
@@ -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<string>,
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<void> {
const statusRequestIDs = new Set<string>();
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));
});
});
}
@@ -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<void> {
@@ -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,
@@ -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",
});
});
@@ -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",
});
});
});
@@ -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<void> {
): Promise<boolean> {
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<void> {
): Promise<boolean> {
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<ReturnType<typeof getWebSocketClient>>;
taskId: string;
sessionId: string;
session: SessionLike;
setSessionStatus: (status: SessionStatus) => void;
setters: ResumeStateSetter;
};
async function refreshSessionStatus({
client,
taskId,
sessionId,
session,
setSessionStatus,
setters,
}: RefreshSessionStatusParams): Promise<void> {
try {
const status = await client.request<SessionStatus>("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");
+1 -21
View File
@@ -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", () => {
-6
View File
@@ -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<string, unknown>): BootRuntime {
return {
apiPrefix: readString(value.apiPrefix),
webSocketPath: readString(value.webSocketPath),
hostOS: readString(value.hostOS),
debug: value.debug === true ? true : undefined,
};
}
@@ -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.
+1
View File
@@ -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 |
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+1 -1
View File
@@ -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.
+9
View File
@@ -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.
+1 -1
View File
@@ -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:<random-port>`. 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:<random-port>`. 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. |
+2 -1
View File
@@ -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
@@ -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)
@@ -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