From fe94ada5348cc06602d3df7a2b8149f1c34a00c4 Mon Sep 17 00:00:00 2001 From: Jack Rothrock Date: Wed, 23 Sep 2026 12:33:17 -0600 Subject: [PATCH] feat(terminal): host Claude PTY terminal behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add handleClaudeHostWS: a browser terminal bridged to an interactive `claude` TUI running on the host in the project's workspace, mirroring handleHostWS's persistent per-project tmux session (isolated host socket, create-on-first-open, reattach thereafter) but with `claude` as the session's pane command instead of a shell. This gives slash commands, /login, permission prompts, plan mode and the status line natively — the groundwork for replacing the bespoke ChatDock host chat with a real terminal. Gated behind CORRAL_HOST_CLAUDE_TERMINAL=1 while it's brought up alongside the existing chat; the flag is removed once it replaces the chat UI. Route: /p/{id}/claude/ws. Session name is a distinct "-claude" suffix so its sizing/pane stay independent of the dev + host-shell sessions. Reuses the existing bridgePTY byte pump, resize hook, and resolveClaudeBin locator unchanged. NOT sandboxed (same trust terms as the host shell: loopback-only, token-gated, same-origin WS). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/dashboard/dashboard.go | 2 + internal/dashboard/terminal.go | 84 ++++++++++++++++++++++ internal/dashboard/terminal_claude_test.go | 52 ++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 internal/dashboard/terminal_claude_test.go diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index 8f7d3054..4960c5ed 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -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": diff --git a/internal/dashboard/terminal.go b/internal/dashboard/terminal.go index 0126770b..117af647 100644 --- a/internal/dashboard/terminal.go +++ b/internal/dashboard/terminal.go @@ -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 diff --git a/internal/dashboard/terminal_claude_test.go b/internal/dashboard/terminal_claude_test.go new file mode 100644 index 00000000..9561b7e7 --- /dev/null +++ b/internal/dashboard/terminal_claude_test.go @@ -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") +}