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
2 changes: 2 additions & 0 deletions internal/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,8 @@ func (d *dashboardServer) handleRoot(w http.ResponseWriter, r *http.Request) {
d.handleContainerWS(w, r, id)
case sub == "host/ws":
d.handleHostWS(w, r, id)
case sub == "claude/ws":
d.handleClaudeHostWS(w, r, id)
case sub == "start":
d.handleStartProject(w, r, id)
case sub == "stop":
Expand Down
84 changes: 84 additions & 0 deletions internal/dashboard/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,90 @@ func (d *dashboardServer) handleHostWS(w http.ResponseWriter, r *http.Request, i
})
}

// claudeShellSession returns the per-project tmux session name backing that
// project's INTERACTIVE CLAUDE terminal — the real `claude` TUI, as opposed to
// the plain host shell (hostShellSession) or the container's dev session
// (TmuxSessionNameForWorkspace). Distinct "-claude" suffix, same host socket, so
// its window sizing stays independent of the other sessions.
func claudeShellSession(workspace string) string {
return session.TmuxSessionNameForWorkspace(workspace) + "-claude"
}

// hostClaudeTerminalEnabled reports whether the interactive host-Claude terminal
// is turned on. It's behind a flag while the terminal is being brought up
// alongside the existing ChatDock; the flag is removed once the terminal replaces
// the bespoke chat UI (at which point this becomes the default host chat surface).
func hostClaudeTerminalEnabled() bool {
return os.Getenv("CORRAL_HOST_CLAUDE_TERMINAL") == "1"
}

// handleClaudeHostWS bridges a browser terminal to an interactive `claude` TUI
// running on the HOST, in the project's workspace directory. It mirrors
// handleHostWS (persistent per-project tmux session on the isolated host socket,
// created on first open and reattached thereafter) but the session's pane runs
// the real `claude` TUI instead of a shell — so slash commands, /login,
// permission prompts, plan mode, and the status line all work natively, with no
// bespoke chat rendering to keep at parity.
//
// SECURITY: like handleHostWS this is NOT sandboxed — `claude` runs with the
// operator's full host privileges and no firewall. That's acceptable on the same
// terms: the dashboard is loopback-only and token-gated, and the WebSocket is
// same-origin only (terminalUpgrader.CheckOrigin). The claude permission gate
// (--permission-mode default, and here the interactive prompts) remains the real
// guardrail. The trust banner is loudened where this surface is exposed.
func (d *dashboardServer) handleClaudeHostWS(w http.ResponseWriter, r *http.Request, id string) {
if !hostClaudeTerminalEnabled() {
http.Error(w, "the host Claude terminal is not enabled", http.StatusNotFound)
return
}
claudeBin, err := resolveClaudeBin()
if err != nil {
http.Error(w, "couldn't find the claude executable on the host: "+err.Error(), http.StatusBadGateway)
return
}
workspace, err := lookupWorkspaceByID(id)
if err != nil {
http.NotFound(w, r)
return
}
sessionName := claudeShellSession(workspace)

// Create the session (on the isolated host socket) if it doesn't exist yet,
// rooted in the project dir so `claude` sees the repo, and launch the TUI in
// its initial pane. Reattaches to the SAME live claude across reloads.
if hostTmux("has-session", "-t", sessionName).Run() != nil {
dir := workspace
if _, serr := os.Stat(workspace); serr != nil {
dir = "" // workspace gone; let tmux use the default cwd
}
// Start the session with `claude` as its command (not a shell that we then
// type into): the pane IS the TUI, so a stray shell can't linger behind it,
// and if claude exits the pane closes rather than dropping to a prompt.
args := []string{"new-session", "-d", "-s", sessionName}
if dir != "" {
args = append(args, "-c", dir)
}
args = append(args, claudeBin)
if hostTmux(args...).Run() == nil {
hostTmux("set-option", "-t", sessionName, "status", "off").Run()
hostTmux("set-option", "-t", sessionName, "mouse", "on").Run()
enableTmuxClipboard(hostTmux, sessionName)
}
}

// Attach on the host socket, forcing a PTY, with the same explicit window-resize
// hook the host shell uses (SIGWINCH alone doesn't reliably grow a tmux window's
// height). Detaching on tab close leaves the claude session alive for next time.
d.bridgePTY(w, r, hostTmux("attach-session", "-t", sessionName),
func(cols, rows uint16) {
if cols < 20 || rows < 6 {
return
}
hostTmux("resize-window", "-t", sessionName,
"-x", strconv.Itoa(int(cols)), "-y", strconv.Itoa(int(rows))).Run()
})
}

// handleUpdateWS bridges a browser terminal to `corral update` running on
// the HOST. This is the dashboard's "Update" button: rather than a silent
// privileged endpoint, it opens a real PTY so the update runs with the operator's
Expand Down
52 changes: 52 additions & 0 deletions internal/dashboard/terminal_claude_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package dashboard

import (
"os"
"testing"

"github.com/scoutapp/corral/internal/session"
)

// The interactive Claude terminal must live on its OWN tmux session, distinct
// from both the container dev session and the plain host shell — otherwise
// attaching one surface would show another's pane. Guard the "-claude" suffix
// and its separation from the "-host" shell session.
func TestClaudeShellSessionIsDistinct(t *testing.T) {
const ws = "/home/op/projects/acme"

claude := claudeShellSession(ws)
host := hostShellSession(ws)
dev := session.TmuxSessionNameForWorkspace(ws)

if claude == host {
t.Fatalf("claude session %q collides with host shell session", claude)
}
if claude == dev {
t.Fatalf("claude session %q collides with dev session", claude)
}
if want := dev + "-claude"; claude != want {
t.Fatalf("claude session = %q, want %q", claude, want)
}
// Stable across calls (used as a lookup key on every attach).
if again := claudeShellSession(ws); again != claude {
t.Fatalf("claude session not stable: %q then %q", claude, again)
}
}

// The terminal is behind a flag until it replaces the ChatDock. Confirm the gate
// reads the env var and defaults off.
func TestHostClaudeTerminalGate(t *testing.T) {
t.Setenv("CORRAL_HOST_CLAUDE_TERMINAL", "")
if hostClaudeTerminalEnabled() {
t.Fatal("expected disabled when env unset")
}
t.Setenv("CORRAL_HOST_CLAUDE_TERMINAL", "1")
if !hostClaudeTerminalEnabled() {
t.Fatal("expected enabled when env is 1")
}
t.Setenv("CORRAL_HOST_CLAUDE_TERMINAL", "true")
if hostClaudeTerminalEnabled() {
t.Fatal("only the literal \"1\" should enable the flag")
}
_ = os.Unsetenv("CORRAL_HOST_CLAUDE_TERMINAL")
}
Loading