Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions Dockerfile.ubuntu-helix
Original file line number Diff line number Diff line change
Expand Up @@ -884,16 +884,35 @@ RUN mkdir -p /etc/skel/.config/google-chrome && \
echo '{"browser":{"has_seen_welcome_page":true},"distribution":{"skip_first_run_ui":true}}' \
> /etc/skel/.config/google-chrome/Default/Preferences

# Install chrome-devtools-mcp globally and pre-build fontconfig cache
RUN npm install -g chrome-devtools-mcp@latest && fc-cache -f -v
# Install MCP server packages globally so Zed and Claude Code can invoke them
# by direct binary path (e.g. /usr/bin/chrome-devtools-mcp) instead of
# `npx <pkg>@latest`. Going through npx hits the shared _npx cache, and when
# Zed and Claude Code spawn in parallel against the same cache the install
# dance ("reify mark retired" — rename node_modules/<pkg> → tmp, reinstall,
# rename back) races and the JSON-RPC `initialize` never returns. Spec-task
# logs then show `chrome-devtools context server failed to start: Context
# server request timeout` (180s). Going via the global binary avoids npx
# entirely and removes the contention.
RUN npm install -g \
chrome-devtools-mcp@0.25.0 \
@modelcontextprotocol/server-github@2025.4.8 \
&& fc-cache -f -v

# URL capture script for Claude Code OAuth login flow.
# When BROWSER=/usr/local/bin/helix-capture-browser, `claude auth login` writes
# the OAuth URL to a file instead of opening a browser. The Helix frontend polls
# for this file and opens the URL in the user's native browser.
COPY desktop/shared/helix-capture-browser.sh /usr/local/bin/helix-capture-browser
COPY desktop/shared/helix-claude-auth-wrapper.sh /usr/local/bin/helix-claude-auth-wrapper
RUN chmod +x /usr/local/bin/helix-capture-browser /usr/local/bin/helix-claude-auth-wrapper
# helix-npx — installed as /usr/local/bin/npx so it shadows the system
# /usr/bin/npx via PATH order. Gives each `npx <pkg>` invocation its own
# NPM_CONFIG_CACHE so parallel npx spawns (Zed + Claude both starting
# the same MCP at session start, or two parallel agent sessions hitting
# the same package) don't race in npm's `_npx/<hash>` rename dance.
# Zed's own ACP-wrapper bootstrapping calls npm via absolute path so it
# bypasses this shim.
COPY desktop/shared/helix-npx.sh /usr/local/bin/npx
RUN chmod +x /usr/local/bin/helix-capture-browser /usr/local/bin/helix-claude-auth-wrapper /usr/local/bin/npx

# Install drone-ci-mcp (Helix's Drone CI MCP server for build log navigation)
# Build and pack the package, then install globally from the tarball
Expand Down
12 changes: 10 additions & 2 deletions api/pkg/external-agent/zed_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,17 @@ func GenerateZedMCPConfig(
// console access, network analysis, and input automation.
// Uses Puppeteer internally to control Chrome via CDP (Chrome DevTools Protocol).
// See: https://developer.chrome.com/blog/chrome-devtools-mcp
//
// Invoke the globally-installed binary directly (Dockerfile.ubuntu-helix
// pins `chrome-devtools-mcp` via `npm install -g`). Going through
// `npx chrome-devtools-mcp@latest` instead causes npm's `_npx/<hash>`
// cache to do a "reify mark retired" rename dance every spawn; when Zed
// and Claude Code spawn in parallel the renames race and the JSON-RPC
// `initialize` never returns — Zed surfaces this as
// `chrome-devtools context server failed to start: Context server
// request timeout` (180s).
config.ContextServers["chrome-devtools"] = ContextServerConfig{
Command: "npx",
Command: "/usr/bin/chrome-devtools-mcp",
// --viewport sets the rendered page size (Chrome window ends up viewport + ~80px
// of decorations). 1280x800 sits at the canonical desktop-vs-mobile breakpoint
// so sites still render in desktop mode, and the resulting Chrome window leaves
Expand All @@ -298,7 +307,6 @@ func GenerateZedMCPConfig(
// Disables navigator.webdriver, suppresses "Chrome is being controlled" infobar,
// and prevents extension probing (e.g. LinkedIn bot detection).
Args: []string{
"chrome-devtools-mcp@latest",
"--viewport", "1280x800",
"--chrome-arg=--disable-blink-features=AutomationControlled",
"--chrome-arg=--no-first-run",
Expand Down
17 changes: 12 additions & 5 deletions api/pkg/server/simple_sample_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -678,11 +678,18 @@ This is IMPERATIVE - if you don't record and push the color, it cannot be cloned
},
},
{
Name: "GitHub",
Description: "Interact with GitHub repositories, issues, pull requests, and more",
Transport: "stdio",
Command: "npx",
Args: []string{"-y", "@modelcontextprotocol/server-github"},
Name: "GitHub",
Description: "Interact with GitHub repositories, issues, pull requests, and more",
Transport: "stdio",
// Globally installed in the desktop image (see Dockerfile.ubuntu-helix —
// `npm install -g @modelcontextprotocol/server-github`). Going through
// `npx -y @modelcontextprotocol/server-github` instead causes npm's
// _npx cache "reify mark retired" rename dance to race against the
// parallel chrome-devtools spawn from Zed/Claude Code, and the
// JSON-RPC `initialize` call hangs until the 180s context_server
// timeout fires.
Command: "mcp-server-github",
Args: []string{},
OAuthProvider: "github", // Reuse the GitHub OAuth connection from this sample project
},
},
Expand Down
44 changes: 44 additions & 0 deletions api/pkg/server/websocket_external_agent_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -3903,6 +3903,50 @@ func (apiServer *HelixAPIServer) handleUserCreatedThread(agentSessionID string,
return fmt.Errorf("failed to load existing session: %w", err)
}

// PHANTOM-DRAFT GUARD (belt-and-braces against helixml/zed Fix 1a not being
// in this Zed binary): on every container restart, Zed's agent panel
// speculatively calls new_session() to back its empty input editor — see
// crates/agent_ui/src/agent_panel.rs `activate_draft`. That fires
// UserCreatedThread to us even though the user never typed anything in the
// new "draft" thread. Without this guard, every restart leaks an empty
// "New Chat" row in spec_task_zed_threads and a duplicate Claude spawn that
// races against the existing one for npm `_npx/<hash>` cache, surfacing as
// 180s `chrome-devtools/github context server failed to start` errors.
//
// If this spec_task already has an active work_session whose helix_session
// has no interactions, the incoming UserCreatedThread is almost certainly
// such a phantom draft. Refuse and log loudly. The user creating a genuine
// new chat is unaffected: they only do that AFTER typing in the existing
// thread (which gives it ≥1 interaction), so the dedup wouldn't fire.
//
// Full diagnosis: design/2026-05-13-mcp-cache-contention-and-duplicate-claude-spawn.md
if specTaskID := existingSession.Metadata.SpecTaskID; specTaskID != "" {
existingThreads, listErr := apiServer.Controller.Options.Store.ListSpecTaskZedThreads(ctx, specTaskID)
if listErr == nil {
for _, et := range existingThreads {
if et.Status != types.SpecTaskZedStatusActive {
continue
}
ws, wErr := apiServer.Controller.Options.Store.GetSpecTaskWorkSession(ctx, et.WorkSessionID)
if wErr != nil || ws == nil {
continue
}
_, count, iErr := apiServer.Controller.Options.Store.ListInteractions(ctx, &types.ListInteractionsQuery{
SessionID: ws.HelixSessionID,
})
if iErr == nil && count == 0 {
log.Warn().
Str("acp_thread_id", acpThreadID).
Str("spec_task_id", specTaskID).
Str("phantom_zed_thread_id", et.ZedThreadID).
Str("phantom_helix_session", ws.HelixSessionID).
Msg("⚠️ [HELIX] Refusing to create new session — spec_task already has empty active work_session (probable phantom draft from Zed agent panel; see design/2026-05-13-mcp-cache-contention-and-duplicate-claude-spawn.md)")
return nil
}
}
}
}

// Create new Helix session for this user-created thread.
// Copy ALL metadata from existing session so the new session is properly
// associated with the spectask, project, and agent runtime.
Expand Down
156 changes: 156 additions & 0 deletions api/pkg/server/websocket_external_agent_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2942,6 +2942,11 @@ func (s *WebSocketSyncSuite) TestUserCreatedThread_CreatesWorkSessionForSpectask

s.store.EXPECT().GetSession(gomock.Any(), "ses_existing").Return(existingSession, nil)

// Phantom-draft guard: returns empty list (no existing zed_threads to dedup
// against), so the guard falls through to the normal create path.
s.store.EXPECT().ListSpecTaskZedThreads(gomock.Any(), "spt_test").
Return([]*types.SpecTaskZedThread{}, nil)

// Expect new session to be created with all metadata copied
var capturedSession types.Session
s.store.EXPECT().CreateSession(gomock.Any(), gomock.Any()).DoAndReturn(
Expand Down Expand Up @@ -3020,6 +3025,157 @@ func (s *WebSocketSyncSuite) TestUserCreatedThread_CreatesWorkSessionForSpectask
s.Equal(capturedSession.ID, mappedSession)
}

// TestUserCreatedThread_PhantomDraftGuard_RefusesWhenEmptyWorkSessionExists
// is the regression test for the bug documented in
// design/2026-05-13-mcp-cache-contention-and-duplicate-claude-spawn.md.
//
// Without the guard at handleUserCreatedThread, every container restart
// of a long-running spec_task leaks an empty "New Chat" helix_session +
// spec_task_zed_threads row. Cause: Zed's agent panel speculatively calls
// new_session() to back its empty input editor (the "draft" thread), then
// fires UserCreatedThread back to us — even though the user never typed
// anything in it.
//
// The guard refuses to create a new session if the spec_task already has
// an active work_session whose helix_session has zero interactions.
//
// To make this test fail when the guard is removed, comment out the
// "PHANTOM-DRAFT GUARD" block in handleUserCreatedThread and re-run.
func (s *WebSocketSyncSuite) TestUserCreatedThread_PhantomDraftGuard_RefusesWhenEmptyWorkSessionExists() {
// Existing helix_session that the dev container is bound to.
existingSession := &types.Session{
ID: "ses_existing",
Owner: "user-1",
OrganizationID: "org-1",
ProjectID: "prj-1",
ParentApp: "app-1",
Metadata: types.SessionMetadata{
AgentType: "zed_external",
SpecTaskID: "spt_phantom_test",
CodeAgentRuntime: "claude_code",
ZedThreadID: "thread-real",
},
}
s.store.EXPECT().GetSession(gomock.Any(), "ses_existing").Return(existingSession, nil)

// The spec_task already has one active zed_thread (thread-real) tied to
// a helix_session with no interactions. This is the scenario the bug
// produces on every container restart.
existingZedThread := &types.SpecTaskZedThread{
ID: "stzt_existing",
WorkSessionID: "stws_existing",
SpecTaskID: "spt_phantom_test",
ZedThreadID: "thread-real",
Status: types.SpecTaskZedStatusActive,
}
s.store.EXPECT().ListSpecTaskZedThreads(gomock.Any(), "spt_phantom_test").
Return([]*types.SpecTaskZedThread{existingZedThread}, nil)

existingWorkSession := &types.SpecTaskWorkSession{
ID: "stws_existing",
SpecTaskID: "spt_phantom_test",
HelixSessionID: "ses_existing",
Status: types.SpecTaskWorkSessionStatusActive,
}
s.store.EXPECT().GetSpecTaskWorkSession(gomock.Any(), "stws_existing").
Return(existingWorkSession, nil)

// helix_session has zero interactions — this is the signal that the
// existing work_session is itself a phantom draft (or just not yet
// touched by the user). The incoming UserCreatedThread is therefore a
// duplicate phantom from another panel-restore cycle. Refuse it.
s.store.EXPECT().ListInteractions(gomock.Any(), &types.ListInteractionsQuery{
SessionID: "ses_existing",
}).Return([]*types.Interaction{}, int64(0), nil)

// THE ASSERTION: the guard must short-circuit BEFORE any of these
// store mutations fire. If the guard is removed, gomock will fail
// with "missing call to CreateSession" / "missing call to
// CreateSpecTaskWorkSession" / "missing call to CreateSpecTaskZedThread"
// because the handler will fall through to the create path (which we
// have NOT mocked here). That test failure IS the regression signal.

syncMsg := &types.SyncMessage{
EventType: "user_created_thread",
Data: map[string]interface{}{
"acp_thread_id": "thread-phantom-from-zed-draft",
"title": "New Chat",
},
}

err := s.server.handleUserCreatedThread("ses_existing", syncMsg)
s.NoError(err, "guard should silently skip creation, not return an error")

// Belt-and-braces: also verify no context mapping was created for the
// phantom thread_id (it would only be set if we'd fallen through to
// the create path).
s.server.contextMappingsMutex.RLock()
_, mapped := s.server.contextMappings["thread-phantom-from-zed-draft"]
s.server.contextMappingsMutex.RUnlock()
s.False(mapped, "phantom thread should not be added to contextMappings")
}

// TestUserCreatedThread_PhantomDraftGuard_AllowsWhenExistingSessionHasInteractions
// verifies the guard does NOT block when the existing work_session has
// real activity in it. A user typing a follow-up that creates a genuinely
// new thread on top of an active conversation MUST still work.
func (s *WebSocketSyncSuite) TestUserCreatedThread_PhantomDraftGuard_AllowsWhenExistingSessionHasInteractions() {
existingSession := &types.Session{
ID: "ses_existing",
Owner: "user-1",
OrganizationID: "org-1",
Metadata: types.SessionMetadata{
AgentType: "zed_external",
SpecTaskID: "spt_active_test",
CodeAgentRuntime: "claude_code",
},
}
s.store.EXPECT().GetSession(gomock.Any(), "ses_existing").Return(existingSession, nil)

existingZedThread := &types.SpecTaskZedThread{
ID: "stzt_existing",
WorkSessionID: "stws_existing",
SpecTaskID: "spt_active_test",
ZedThreadID: "thread-active",
Status: types.SpecTaskZedStatusActive,
}
s.store.EXPECT().ListSpecTaskZedThreads(gomock.Any(), "spt_active_test").
Return([]*types.SpecTaskZedThread{existingZedThread}, nil)

existingWorkSession := &types.SpecTaskWorkSession{
ID: "stws_existing",
SpecTaskID: "spt_active_test",
HelixSessionID: "ses_existing",
Status: types.SpecTaskWorkSessionStatusActive,
}
s.store.EXPECT().GetSpecTaskWorkSession(gomock.Any(), "stws_existing").
Return(existingWorkSession, nil)

// Existing session HAS interactions → guard does not fire → fall through
// to the create path.
s.store.EXPECT().ListInteractions(gomock.Any(), &types.ListInteractionsQuery{
SessionID: "ses_existing",
}).Return([]*types.Interaction{{ID: "int_one"}}, int64(1), nil)

// Expect normal create path to execute.
s.store.EXPECT().CreateSession(gomock.Any(), gomock.Any()).Return(&types.Session{ID: "ses_new_active"}, nil)
s.store.EXPECT().GetSpecTaskWorkSessionByHelixSession(gomock.Any(), "ses_existing").
Return(existingWorkSession, nil)
s.store.EXPECT().CreateSpecTaskWorkSession(gomock.Any(), gomock.Any()).Return(nil)
s.store.EXPECT().CreateSpecTaskZedThread(gomock.Any(), gomock.Any()).Return(nil)

syncMsg := &types.SyncMessage{
EventType: "user_created_thread",
Data: map[string]interface{}{
"acp_thread_id": "thread-genuinely-new",
"title": "Continuation",
},
}

err := s.server.handleUserCreatedThread("ses_existing", syncMsg)
s.NoError(err)
}

func (s *WebSocketSyncSuite) TestUserCreatedThread_NonSpectaskSkipsWorkSession() {
// Session without SpecTaskID — should create session but skip work session
existingSession := &types.Session{
Expand Down
Loading