From 8d89add12a427e4aa31946cbf93e8d2681747e6b Mon Sep 17 00:00:00 2001 From: zhouwei Date: Wed, 26 Aug 2026 14:07:00 +0800 Subject: [PATCH 1/4] feat: auto-attach tmux sessions on remote blocks via term:tmux:session When opening a remote SSH block that carries a term:tmux:session meta key, the shell startup automatically runs `tmux new -A -t `, attaching to the named tmux session (creating it if absent, resuming if present) without any manual typing. Changes: - add MetaKey_TermTmuxSession (term:tmux:session) meta key constant - makeSwapToken detects the key and injects the auto-attach script only for remote connections - session name is wrapped in shell single quotes to prevent injection - sync TypeScript type binding and add unit tests for the acceptance criteria --- frontend/types/gotypes.d.ts | 1 + pkg/blockcontroller/blockcontroller.go | 21 ++++++ pkg/blockcontroller/blockcontroller_test.go | 75 +++++++++++++++++++++ pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + 5 files changed, 99 insertions(+) create mode 100644 pkg/blockcontroller/blockcontroller_test.go diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..7f6612ba68 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1190,6 +1190,7 @@ declare global { "term:bellindicator"?: boolean; "term:osc52"?: string; "term:durable"?: boolean; + "term:tmux:session"?: string; "web:zoom"?: number; "web:hidenav"?: boolean; "web:partition"?: string; diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 75f1938e12..e8414953e2 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -490,5 +490,26 @@ func makeSwapToken(ctx context.Context, logCtx context.Context, blockId string, token.Env[k] = v } token.ScriptText = getCustomInitScript(logCtx, blockMeta, remoteName, shellType) + token.ScriptText += buildTmuxAttachScript(blockMeta, remoteName) return token } + +// buildTmuxAttachScript returns an inline script fragment that auto-attaches to a tmux +// session (or an empty string when it does not apply). It is injected only when the block +// carries a term:tmux:session meta key AND the connection is a remote SSH block, so opening +// a remote block attaches with `tmux new -A -t` without any manual typing. +func buildTmuxAttachScript(blockMeta waveobj.MetaMapType, remoteName string) string { + tmuxSession := blockMeta.GetString(waveobj.MetaKey_TermTmuxSession, "") + if tmuxSession == "" { + return "" + } + if conncontroller.IsLocalConnName(remoteName) { + return "" + } + // Wrap the session name in shell single quotes, escaping any inner single quote as '\'' + // so session names cannot break out of the command (injection-safe). + quoted := "'" + strings.ReplaceAll(tmuxSession, "'", "'\\''") + "'" + // Skip when already inside tmux to avoid nesting; fall back gracefully when tmux is not + // installed on the remote; exec replaces the shell so no empty shell lingers behind. + return fmt.Sprintf("\n[ -n \"$TMUX\" ] || ! command -v tmux >/dev/null 2>&1 || exec tmux new -A -t %s\n", quoted) +} diff --git a/pkg/blockcontroller/blockcontroller_test.go b/pkg/blockcontroller/blockcontroller_test.go new file mode 100644 index 0000000000..47eb8c9093 --- /dev/null +++ b/pkg/blockcontroller/blockcontroller_test.go @@ -0,0 +1,75 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package blockcontroller + +import ( + "strings" + "testing" + + "github.com/wavetermdev/waveterm/pkg/waveobj" +) + +// Requirement: auto-associate remote sessions with tmux (req/02-需求-远程会话自动关联tmux.md) +// Acceptance criteria coverage: +// 1. Remote block with term:tmux:session → tmux attach is injected +// 2. Local block / block without the meta key → no injection +// 3. Session names are quote-escaped to prevent injection + +func TestBuildTmuxAttachScript_RemoteConn(t *testing.T) { + meta := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: "omlx-11335", + } + script := buildTmuxAttachScript(meta, "aws:co-gpu") + if script == "" { + t.Fatal("expected tmux attach script for remote conn with tmux session meta") + } + if !strings.Contains(script, `exec tmux new -A -t 'omlx-11335'`) { + t.Fatalf("unexpected attach command: %q", script) + } + if !strings.Contains(script, `[ -n "$TMUX" ]`) { + t.Fatalf("missing nested tmux guard: %q", script) + } + if !strings.Contains(script, `command -v tmux`) { + t.Fatalf("missing tmux presence guard: %q", script) + } +} + +func TestBuildTmuxAttachScript_LocalConn(t *testing.T) { + meta := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: "omlx-11335", + } + for _, connName := range []string{"local", "local:whatever", ""} { + if script := buildTmuxAttachScript(meta, connName); script != "" { + t.Fatalf("expected no script for local conn %q, got %q", connName, script) + } + } +} + +func TestBuildTmuxAttachScript_NoMetaKey(t *testing.T) { + if script := buildTmuxAttachScript(waveobj.MetaMapType{}, "aws:co-gpu"); script != "" { + t.Fatalf("expected no script when meta key absent, got %q", script) + } + if script := buildTmuxAttachScript(waveobj.MetaMapType{waveobj.MetaKey_TermTmuxSession: ""}, "aws:co-gpu"); script != "" { + t.Fatalf("expected no script when session name empty, got %q", script) + } +} + +func TestBuildTmuxAttachScript_EscapesSessionName(t *testing.T) { + meta := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: `evil"; rm -rf /; echo "`, + } + script := buildTmuxAttachScript(meta, "aws:co-gpu") + // Session names must be single-quoted so inner double quotes / semicolons cannot break shell structure. + if !strings.Contains(script, `-t 'evil"; rm -rf /; echo "'`) { + t.Fatalf("expected single-quoted session name, got: %q", script) + } + // Session names containing single quotes are also safe: inner single quotes escape to '\''. + meta2 := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: `a'b`, + } + script2 := buildTmuxAttachScript(meta2, "aws:co-gpu") + if !strings.Contains(script2, `-t 'a'\''b'`) { + t.Fatalf("expected escaped inner single quote, got: %q", script2) + } +} diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 0ce08099d8..65e19b518f 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -129,6 +129,7 @@ const ( MetaKey_TermBellIndicator = "term:bellindicator" MetaKey_TermOsc52 = "term:osc52" MetaKey_TermDurable = "term:durable" + MetaKey_TermTmuxSession = "term:tmux:session" MetaKey_WebZoom = "web:zoom" MetaKey_WebHideNav = "web:hidenav" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index 2280b55d2d..3c57563212 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -133,6 +133,7 @@ type MetaTSType struct { TermBellIndicator *bool `json:"term:bellindicator,omitempty"` TermOsc52 string `json:"term:osc52,omitempty"` TermDurable *bool `json:"term:durable,omitempty"` + TermTmuxSession string `json:"term:tmux:session,omitempty"` WebZoom float64 `json:"web:zoom,omitempty"` WebHideNav *bool `json:"web:hidenav,omitempty"` From 4981ce7ea1504e007d24220485fac4bd2424ebce Mon Sep 17 00:00:00 2001 From: zhouwei Date: Wed, 26 Aug 2026 14:07:04 +0800 Subject: [PATCH 2/4] feat: list and switch tmux sessions from remote block context menu Add a Tmux Sessions submenu to the context/settings menu of remote SSH blocks: call the listtmuxsessions RPC to fetch session names from the remote host, show a checkbox single-select marking the currently associated session, and persist/clear term:tmux:session with an immediate controller restart. Changes: - add listtmuxsessions RPC (runs `tmux list-sessions` remotely) - make getContextMenuItems/getSettingsMenuItems async for dynamic submenu - extract tmux-session pure logic with unit tests on both ends --- frontend/app/block/blockframe-header.tsx | 4 +- frontend/app/store/wshclientapi.ts | 6 ++ frontend/app/view/term/term-model.ts | 68 ++++++++++++++++++++- frontend/app/view/term/term.tsx | 6 +- frontend/app/view/term/tmux-session.test.ts | 61 ++++++++++++++++++ frontend/app/view/term/tmux-session.ts | 20 ++++++ frontend/types/custom.d.ts | 2 +- pkg/blockcontroller/blockcontroller_test.go | 2 +- pkg/wshrpc/wshclient/wshclient.go | 6 ++ pkg/wshrpc/wshrpctypes.go | 1 + pkg/wshrpc/wshserver/wshserver.go | 43 +++++++++++++ pkg/wshrpc/wshserver/wshserver_test.go | 51 ++++++++++++++++ 12 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 frontend/app/view/term/tmux-session.test.ts create mode 100644 frontend/app/view/term/tmux-session.ts create mode 100644 pkg/wshrpc/wshserver/wshserver_test.go diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index a70f323e71..c44dd34059 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -31,7 +31,7 @@ import * as React from "react"; import { BlockEnv } from "./blockenv"; import { BlockFrameProps } from "./blocktypes"; -function handleHeaderContextMenu( +async function handleHeaderContextMenu( e: React.MouseEvent, blockId: string, viewModel: ViewModel, @@ -56,7 +56,7 @@ function handleHeaderContextMenu( }, }, ]; - const extraItems = viewModel?.getSettingsMenuItems?.(); + const extraItems = await viewModel?.getSettingsMenuItems?.(); if (extraItems && extraItems.length > 0) menu.push({ type: "separator" }, ...extraItems); menu.push( { type: "separator" }, diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8482be260d..7f264d3a3e 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -618,6 +618,12 @@ export class RpcApiType { return client.wshRpcCall("listalleditableapps", null, opts); } + // command "listtmuxsessions" [call] + ListTmuxSessionsCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "listtmuxsessions", data, opts); + return client.wshRpcCall("listtmuxsessions", data, opts); + } + // command "macosversion" [call] MacOSVersionCommand(client: WshClient, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "macosversion", null, opts); diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a256929e7d..ec2a7354bc 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -42,6 +42,7 @@ import * as React from "react"; import { getBlockingCommand } from "./shellblocking"; import { computeTheme, DefaultTermTheme, isLikelyOnSameHost, trimTerminalSelection } from "./termutil"; import { TermWrap, WebGLSupported } from "./termwrap"; +import { applyTmuxSessionChange, toggleTmuxSession } from "./tmux-session"; export class TermViewModel implements ViewModel { viewType: string; @@ -822,7 +823,7 @@ export class TermViewModel implements ViewModel { }); } - getContextMenuItems(): ContextMenuItem[] { + async getContextMenuItems(): Promise { const menu: ContextMenuItem[] = []; const hasSelection = this.termRef.current?.terminal?.hasSelection(); const selection = hasSelection ? this.termRef.current?.terminal.getSelection() : null; @@ -908,13 +909,68 @@ export class TermViewModel implements ViewModel { menu.push({ type: "separator" }); - const settingsItems = this.getSettingsMenuItems(); + const settingsItems = await this.getSettingsMenuItems(false); menu.push(...settingsItems); return menu; } - getSettingsMenuItems(): ContextMenuItem[] { + // Returns the Tmux Sessions submenu for remote blocks, or null when it does not apply. + // Fetches a fresh session snapshot when the settings menu opens and marks the active session as checked. + async getTmuxSessionMenuItems(): Promise { + const blockData = globalStore.get(this.blockAtom); + const connName = blockData?.meta?.connection ?? ""; + if (connName == "" || connName == "local" || connName.startsWith("local:")) { + return null; + } + const curSession = (globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:tmux:session")) ?? "") as string; + let sessions: string[] = []; + try { + sessions = await RpcApi.ListTmuxSessionsCommand(TabRpcClient, connName); + } catch (e) { + // Keep the settings menu usable when the connection is down, tmux is unavailable, or listing fails. + sessions = []; + } + const submenu: ContextMenuItem[] = []; + // Keep a missing active session visible so the user can still clear the association. + if (curSession != "" && !sessions.includes(curSession)) { + sessions = [curSession, ...sessions]; + } + if (sessions.length == 0) { + submenu.push({ label: "(No tmux sessions)", enabled: false }); + } else { + for (const name of sessions) { + submenu.push({ + label: name, + type: "checkbox", + checked: name == curSession, + click: () => { + fireAndForget(() => this.applyTmuxSession(toggleTmuxSession(curSession, name))); + }, + }); + } + } + return { label: "Tmux Sessions", type: "submenu", submenu }; + } + + // Persists or clears term:tmux:session, then restarts this block controller so the change takes effect immediately. + // An empty session clears the association; a non-empty session associates or switches without stopping remote tmux. + async applyTmuxSession(session: string): Promise { + const oldSession = (globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:tmux:session")) ?? "") as string; + await applyTmuxSessionChange( + oldSession, + session, + async (nextSession) => { + await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:tmux:session": nextSession || null }, + }); + }, + () => this.forceRestartController() + ); + } + + async getSettingsMenuItems(includeTmuxSessions = true): Promise { const fullConfig = globalStore.get(atoms.fullConfigAtom); const termThemes = fullConfig?.termthemes ?? {}; const termThemeKeys = Object.keys(termThemes); @@ -936,6 +992,12 @@ export class TermViewModel implements ViewModel { }; const fullMenu: ContextMenuItem[] = []; + if (includeTmuxSessions) { + const tmuxSubmenu = await this.getTmuxSessionMenuItems(); + if (tmuxSubmenu != null) { + fullMenu.push(tmuxSubmenu, { type: "separator" }); + } + } fullMenu.push({ label: "Split Horizontally", click: () => { diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 67eb5737c6..62091ef40d 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -378,8 +378,10 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - const menuItems = model.getContextMenuItems(); - ContextMenuModel.getInstance().showContextMenu(menuItems, e); + fireAndForget(async () => { + const menuItems = await model.getContextMenuItems(); + ContextMenuModel.getInstance().showContextMenu(menuItems, e); + }); }, [model] ); diff --git a/frontend/app/view/term/tmux-session.test.ts b/frontend/app/view/term/tmux-session.test.ts new file mode 100644 index 0000000000..81ff8a8074 --- /dev/null +++ b/frontend/app/view/term/tmux-session.test.ts @@ -0,0 +1,61 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { applyTmuxSessionChange, toggleTmuxSession } from "./tmux-session"; + +describe("toggleTmuxSession", () => { + it("associates an unchecked session", () => { + expect(toggleTmuxSession("", "mactop-3")).toBe("mactop-3"); + expect(toggleTmuxSession("workspace-8", "mactop-3")).toBe("mactop-3"); + }); + + it("cancels the association when the checked session is selected again", () => { + expect(toggleTmuxSession("mactop-3", "mactop-3")).toBe(""); + }); +}); + +describe("applyTmuxSessionChange", () => { + it("does nothing when the selected session is already active", async () => { + const persistSession = vi.fn(async () => {}); + const restartController = vi.fn(async () => {}); + + const changed = await applyTmuxSessionChange("mactop-3", "mactop-3", persistSession, restartController); + + expect(changed).toBe(false); + expect(persistSession).not.toHaveBeenCalled(); + expect(restartController).not.toHaveBeenCalled(); + }); + + it.each([ + ["cancel", "mactop-3", ""], + ["switch", "mactop-3", "workspace-8"], + ["associate", "", "mactop-3"], + ])("persists and restarts when attempting to %s", async (_operation, currentSession, nextSession) => { + const calls: string[] = []; + const persistSession = vi.fn(async (session: string) => { + calls.push(`persist:${session}`); + }); + const restartController = vi.fn(async () => { + calls.push("restart"); + }); + + const changed = await applyTmuxSessionChange(currentSession, nextSession, persistSession, restartController); + + expect(changed).toBe(true); + expect(calls).toEqual([`persist:${nextSession}`, "restart"]); + }); + + it("does not restart when persisting the session fails", async () => { + const persistSession = vi.fn(async () => { + throw new Error("set meta failed"); + }); + const restartController = vi.fn(async () => {}); + + await expect(applyTmuxSessionChange("mactop-3", "", persistSession, restartController)).rejects.toThrow( + "set meta failed" + ); + expect(restartController).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/app/view/term/tmux-session.ts b/frontend/app/view/term/tmux-session.ts new file mode 100644 index 0000000000..f14ca05607 --- /dev/null +++ b/frontend/app/view/term/tmux-session.ts @@ -0,0 +1,20 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export async function applyTmuxSessionChange( + currentSession: string, + nextSession: string, + persistSession: (session: string) => Promise, + restartController: () => Promise +): Promise { + if (currentSession === nextSession) { + return false; + } + await persistSession(nextSession); + await restartController(); + return true; +} + +export function toggleTmuxSession(currentSession: string, selectedSession: string): string { + return currentSession === selectedSession ? "" : selectedSession; +} diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 06157e2566..34ac32acf3 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -353,7 +353,7 @@ declare global { isBasicTerm?: (getFn: jotai.Getter) => boolean; // Returns menu items for the settings dropdown. - getSettingsMenuItems?: () => ContextMenuItem[]; + getSettingsMenuItems?: () => ContextMenuItem[] | Promise; // Attempts to give focus to the block, returning true if successful. giveFocus?: () => boolean; diff --git a/pkg/blockcontroller/blockcontroller_test.go b/pkg/blockcontroller/blockcontroller_test.go index 47eb8c9093..0a0b0fc2a6 100644 --- a/pkg/blockcontroller/blockcontroller_test.go +++ b/pkg/blockcontroller/blockcontroller_test.go @@ -10,7 +10,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/waveobj" ) -// Requirement: auto-associate remote sessions with tmux (req/02-需求-远程会话自动关联tmux.md) +// Requirement: auto-associate remote sessions with tmux. // Acceptance criteria coverage: // 1. Remote block with term:tmux:session → tmux attach is injected // 2. Local block / block without the meta key → no injection diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index d5333aec2b..41c93d42fa 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -616,6 +616,12 @@ func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshr return resp, err } +// command "listtmuxsessions", wshserver.ListTmuxSessionsCommand +func ListTmuxSessionsCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) ([]string, error) { + resp, err := sendRpcRequestCallHelper[[]string](w, "listtmuxsessions", data, opts) + return resp, err +} + // command "macosversion", wshserver.MacOSVersionCommand func MacOSVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { resp, err := sendRpcRequestCallHelper[string](w, "macosversion", nil, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 51e2338ba8..df6a87df87 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -54,6 +54,7 @@ type WshRpcInterface interface { ControllerDestroyCommand(ctx context.Context, blockId string) error ControllerResyncCommand(ctx context.Context, data CommandControllerResyncData) error ControllerAppendOutputCommand(ctx context.Context, data CommandControllerAppendOutputData) error + ListTmuxSessionsCommand(ctx context.Context, connName string) ([]string, error) ResolveIdsCommand(ctx context.Context, data CommandResolveIdsData) (CommandResolveIdsRtnData, error) CreateBlockCommand(ctx context.Context, data CommandCreateBlockData) (waveobj.ORef, error) CreateSubBlockCommand(ctx context.Context, data CommandCreateSubBlockData) (waveobj.ORef, error) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 38006fd9a8..f381c0f21f 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -333,6 +333,49 @@ func (ws *WshServer) ControllerInputCommand(ctx context.Context, data wshrpc.Com return blockcontroller.SendInput(data.BlockId, inputUnion) } +// ListTmuxSessionsCommand returns the names of running tmux sessions on the requested connection. +// Local or disconnected connections, a missing tmux executable, and an absent tmux server all return an empty list. +func (ws *WshServer) ListTmuxSessionsCommand(ctx context.Context, connName string) ([]string, error) { + if conncontroller.IsLocalConnName(connName) { + return []string{}, nil + } + opts, err := remote.ParseOpts(connName) + if err != nil { + return nil, fmt.Errorf("error parsing connection name: %w", err) + } + conn := conncontroller.MaybeGetConn(opts) + if conn == nil { + return []string{}, nil + } + client := conn.GetClient() + if client == nil { + return []string{}, nil + } + shellClient := genconn.MakeSSHShellClient(client) + // Use a login shell so the full PATH is available (including locations such as Homebrew's /opt/homebrew/bin). + // Single-quote the tmux command here; HardQuote adds the outer quoting for the remote shell command. + stdout, _, err := genconn.RunSimpleCommand(ctx, shellClient, genconn.CommandSpec{ + Cmd: `bash -lc 'tmux list-sessions -F "#{session_name}" 2>/dev/null' 2>/dev/null || true`, + }) + if err != nil { + return nil, fmt.Errorf("error listing tmux sessions: %w", err) + } + return parseTmuxSessionList(stdout), nil +} + +// parseTmuxSessionList parses `tmux list-sessions -F '#{session_name}'` output into session names, +// discarding blank lines and surrounding whitespace. +func parseTmuxSessionList(stdout string) []string { + sessions := []string{} + for _, line := range strings.Split(stdout, "\n") { + name := strings.TrimSpace(line) + if name != "" { + sessions = append(sessions, name) + } + } + return sessions +} + func (ws *WshServer) ControllerAppendOutputCommand(ctx context.Context, data wshrpc.CommandControllerAppendOutputData) error { outputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(data.Data64))) nw, err := base64.StdEncoding.Decode(outputBuf, []byte(data.Data64)) diff --git a/pkg/wshrpc/wshserver/wshserver_test.go b/pkg/wshrpc/wshserver/wshserver_test.go new file mode 100644 index 0000000000..b0368a64c5 --- /dev/null +++ b/pkg/wshrpc/wshserver/wshserver_test.go @@ -0,0 +1,51 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wshserver + +import ( + "reflect" + "testing" +) + +func TestParseTmuxSessionList(t *testing.T) { + tests := []struct { + name string + stdout string + want []string + }{ + { + name: "empty output", + stdout: "", + want: []string{}, + }, + { + name: "single session", + stdout: "mactop\n", + want: []string{"mactop"}, + }, + { + name: "multiple sessions", + stdout: "mactop\nomlx-11335\nomlx-11336\n", + want: []string{"mactop", "omlx-11335", "omlx-11336"}, + }, + { + name: "blank lines and surrounding whitespace", + stdout: "\n mactop \n\nomlx-11335\n\n", + want: []string{"mactop", "omlx-11335"}, + }, + { + name: "session name with spaces", + stdout: "my session with spaces\n", + want: []string{"my session with spaces"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTmuxSessionList(tt.stdout) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("parseTmuxSessionList(%q) = %#v, want %#v", tt.stdout, got, tt.want) + } + }) + } +} From 3d259ee4d3b922ba8d49bf4f1e999c082b5f21f1 Mon Sep 17 00:00:00 2001 From: zhouwei Date: Wed, 26 Aug 2026 14:23:13 +0800 Subject: [PATCH 3/4] fix: address tmux feature review feedback Address functional issues surfaced during review: - Surface the Tmux Sessions submenu in the terminal context menu, not just the header settings menu (drop the includeTmuxSessions flag). - Restrict auto-attach script injection to POSIX shells (bash/zsh); skip fish/pwsh, whose syntax cannot parse the fragment. - Prefix tmux list-sessions output with a sentinel and parse only sentinel-prefixed lines, so login-shell profile text on stdout can never leak into the session list. - Normalize a null listtmuxsessions result to an empty array so the consumer never throws on a downed connection. Adds regression tests for unsupported shells, sentinel parsing, and profile-output filtering. --- frontend/app/view/term/term-model.ts | 15 +++---- pkg/blockcontroller/blockcontroller.go | 12 ++++-- pkg/blockcontroller/blockcontroller_test.go | 47 ++++++++++++++------- pkg/wshrpc/wshserver/wshserver.go | 35 +++++++++++---- pkg/wshrpc/wshserver/wshserver_test.go | 32 ++++++++++++-- 5 files changed, 100 insertions(+), 41 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index ec2a7354bc..0fb0962d07 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -909,7 +909,7 @@ export class TermViewModel implements ViewModel { menu.push({ type: "separator" }); - const settingsItems = await this.getSettingsMenuItems(false); + const settingsItems = await this.getSettingsMenuItems(); menu.push(...settingsItems); return menu; @@ -926,7 +926,8 @@ export class TermViewModel implements ViewModel { const curSession = (globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:tmux:session")) ?? "") as string; let sessions: string[] = []; try { - sessions = await RpcApi.ListTmuxSessionsCommand(TabRpcClient, connName); + // Normalize a null result (RPC connection down) to an empty list so `.includes` below never throws. + sessions = (await RpcApi.ListTmuxSessionsCommand(TabRpcClient, connName)) ?? []; } catch (e) { // Keep the settings menu usable when the connection is down, tmux is unavailable, or listing fails. sessions = []; @@ -970,7 +971,7 @@ export class TermViewModel implements ViewModel { ); } - async getSettingsMenuItems(includeTmuxSessions = true): Promise { + async getSettingsMenuItems(): Promise { const fullConfig = globalStore.get(atoms.fullConfigAtom); const termThemes = fullConfig?.termthemes ?? {}; const termThemeKeys = Object.keys(termThemes); @@ -992,11 +993,9 @@ export class TermViewModel implements ViewModel { }; const fullMenu: ContextMenuItem[] = []; - if (includeTmuxSessions) { - const tmuxSubmenu = await this.getTmuxSessionMenuItems(); - if (tmuxSubmenu != null) { - fullMenu.push(tmuxSubmenu, { type: "separator" }); - } + const tmuxSubmenu = await this.getTmuxSessionMenuItems(); + if (tmuxSubmenu != null) { + fullMenu.push(tmuxSubmenu, { type: "separator" }); } fullMenu.push({ label: "Split Horizontally", diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index e8414953e2..aab52dab83 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -490,15 +490,16 @@ func makeSwapToken(ctx context.Context, logCtx context.Context, blockId string, token.Env[k] = v } token.ScriptText = getCustomInitScript(logCtx, blockMeta, remoteName, shellType) - token.ScriptText += buildTmuxAttachScript(blockMeta, remoteName) + token.ScriptText += buildTmuxAttachScript(blockMeta, remoteName, shellType) return token } // buildTmuxAttachScript returns an inline script fragment that auto-attaches to a tmux // session (or an empty string when it does not apply). It is injected only when the block -// carries a term:tmux:session meta key AND the connection is a remote SSH block, so opening -// a remote block attaches with `tmux new -A -t` without any manual typing. -func buildTmuxAttachScript(blockMeta waveobj.MetaMapType, remoteName string) string { +// carries a term:tmux:session meta key, the connection is a remote SSH block, and the shell +// is a POSIX-compatible shell (bash/zsh). fish and pwsh use a different syntax, so the +// fragment is skipped for them rather than emitting syntax they cannot parse. +func buildTmuxAttachScript(blockMeta waveobj.MetaMapType, remoteName string, shellType string) string { tmuxSession := blockMeta.GetString(waveobj.MetaKey_TermTmuxSession, "") if tmuxSession == "" { return "" @@ -506,6 +507,9 @@ func buildTmuxAttachScript(blockMeta waveobj.MetaMapType, remoteName string) str if conncontroller.IsLocalConnName(remoteName) { return "" } + if shellType != shellutil.ShellType_bash && shellType != shellutil.ShellType_zsh { + return "" + } // Wrap the session name in shell single quotes, escaping any inner single quote as '\'' // so session names cannot break out of the command (injection-safe). quoted := "'" + strings.ReplaceAll(tmuxSession, "'", "'\\''") + "'" diff --git a/pkg/blockcontroller/blockcontroller_test.go b/pkg/blockcontroller/blockcontroller_test.go index 0a0b0fc2a6..720f9baefc 100644 --- a/pkg/blockcontroller/blockcontroller_test.go +++ b/pkg/blockcontroller/blockcontroller_test.go @@ -7,31 +7,46 @@ import ( "strings" "testing" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/waveobj" ) // Requirement: auto-associate remote sessions with tmux. // Acceptance criteria coverage: -// 1. Remote block with term:tmux:session → tmux attach is injected +// 1. Remote block with term:tmux:session → tmux attach is injected (bash/zsh) // 2. Local block / block without the meta key → no injection // 3. Session names are quote-escaped to prevent injection +// 4. Unsupported shells (fish/pwsh) → no injection (syntax would not parse) func TestBuildTmuxAttachScript_RemoteConn(t *testing.T) { meta := waveobj.MetaMapType{ waveobj.MetaKey_TermTmuxSession: "omlx-11335", } - script := buildTmuxAttachScript(meta, "aws:co-gpu") - if script == "" { - t.Fatal("expected tmux attach script for remote conn with tmux session meta") - } - if !strings.Contains(script, `exec tmux new -A -t 'omlx-11335'`) { - t.Fatalf("unexpected attach command: %q", script) + for _, shellType := range []string{shellutil.ShellType_bash, shellutil.ShellType_zsh} { + script := buildTmuxAttachScript(meta, "aws:co-gpu", shellType) + if script == "" { + t.Fatalf("expected tmux attach script for remote conn with shell %q", shellType) + } + if !strings.Contains(script, `exec tmux new -A -t 'omlx-11335'`) { + t.Fatalf("unexpected attach command: %q", script) + } + if !strings.Contains(script, `[ -n "$TMUX" ]`) { + t.Fatalf("missing nested tmux guard: %q", script) + } + if !strings.Contains(script, `command -v tmux`) { + t.Fatalf("missing tmux presence guard: %q", script) + } } - if !strings.Contains(script, `[ -n "$TMUX" ]`) { - t.Fatalf("missing nested tmux guard: %q", script) +} + +func TestBuildTmuxAttachScript_UnsupportedShell(t *testing.T) { + meta := waveobj.MetaMapType{ + waveobj.MetaKey_TermTmuxSession: "omlx-11335", } - if !strings.Contains(script, `command -v tmux`) { - t.Fatalf("missing tmux presence guard: %q", script) + for _, shellType := range []string{shellutil.ShellType_fish, shellutil.ShellType_pwsh, shellutil.ShellType_unknown} { + if script := buildTmuxAttachScript(meta, "aws:co-gpu", shellType); script != "" { + t.Fatalf("expected no script for shell %q, got %q", shellType, script) + } } } @@ -40,17 +55,17 @@ func TestBuildTmuxAttachScript_LocalConn(t *testing.T) { waveobj.MetaKey_TermTmuxSession: "omlx-11335", } for _, connName := range []string{"local", "local:whatever", ""} { - if script := buildTmuxAttachScript(meta, connName); script != "" { + if script := buildTmuxAttachScript(meta, connName, shellutil.ShellType_bash); script != "" { t.Fatalf("expected no script for local conn %q, got %q", connName, script) } } } func TestBuildTmuxAttachScript_NoMetaKey(t *testing.T) { - if script := buildTmuxAttachScript(waveobj.MetaMapType{}, "aws:co-gpu"); script != "" { + if script := buildTmuxAttachScript(waveobj.MetaMapType{}, "aws:co-gpu", shellutil.ShellType_bash); script != "" { t.Fatalf("expected no script when meta key absent, got %q", script) } - if script := buildTmuxAttachScript(waveobj.MetaMapType{waveobj.MetaKey_TermTmuxSession: ""}, "aws:co-gpu"); script != "" { + if script := buildTmuxAttachScript(waveobj.MetaMapType{waveobj.MetaKey_TermTmuxSession: ""}, "aws:co-gpu", shellutil.ShellType_bash); script != "" { t.Fatalf("expected no script when session name empty, got %q", script) } } @@ -59,7 +74,7 @@ func TestBuildTmuxAttachScript_EscapesSessionName(t *testing.T) { meta := waveobj.MetaMapType{ waveobj.MetaKey_TermTmuxSession: `evil"; rm -rf /; echo "`, } - script := buildTmuxAttachScript(meta, "aws:co-gpu") + script := buildTmuxAttachScript(meta, "aws:co-gpu", shellutil.ShellType_bash) // Session names must be single-quoted so inner double quotes / semicolons cannot break shell structure. if !strings.Contains(script, `-t 'evil"; rm -rf /; echo "'`) { t.Fatalf("expected single-quoted session name, got: %q", script) @@ -68,7 +83,7 @@ func TestBuildTmuxAttachScript_EscapesSessionName(t *testing.T) { meta2 := waveobj.MetaMapType{ waveobj.MetaKey_TermTmuxSession: `a'b`, } - script2 := buildTmuxAttachScript(meta2, "aws:co-gpu") + script2 := buildTmuxAttachScript(meta2, "aws:co-gpu", shellutil.ShellType_bash) if !strings.Contains(script2, `-t 'a'\''b'`) { t.Fatalf("expected escaped inner single quote, got: %q", script2) } diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index f381c0f21f..05b30d5433 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -352,10 +352,11 @@ func (ws *WshServer) ListTmuxSessionsCommand(ctx context.Context, connName strin return []string{}, nil } shellClient := genconn.MakeSSHShellClient(client) - // Use a login shell so the full PATH is available (including locations such as Homebrew's /opt/homebrew/bin). - // Single-quote the tmux command here; HardQuote adds the outer quoting for the remote shell command. + // Use a login shell so the full PATH is available (including locations such as Homebrew's + // /opt/homebrew/bin). Login startup files may write to stdout, so each tmux record is prefixed + // with a sentinel and parsing only accepts sentinel-prefixed lines (see parseTmuxSessionList). stdout, _, err := genconn.RunSimpleCommand(ctx, shellClient, genconn.CommandSpec{ - Cmd: `bash -lc 'tmux list-sessions -F "#{session_name}" 2>/dev/null' 2>/dev/null || true`, + Cmd: `bash -lc 'tmux list-sessions -F "` + tmuxSessionSentinel + `#{session_name}" 2>/dev/null' 2>/dev/null || true`, }) if err != nil { return nil, fmt.Errorf("error listing tmux sessions: %w", err) @@ -363,15 +364,31 @@ func (ws *WshServer) ListTmuxSessionsCommand(ctx context.Context, connName strin return parseTmuxSessionList(stdout), nil } -// parseTmuxSessionList parses `tmux list-sessions -F '#{session_name}'` output into session names, -// discarding blank lines and surrounding whitespace. +// tmuxSessionSentinel prefixes each listed session so session names can be told apart from +// arbitrary stdout written by login-shell startup files. +const tmuxSessionSentinel = "WAVETERM_TMUX_SESSION:" + +// parseTmuxSessionList parses `tmux list-sessions` output into session names. It only accepts +// lines prefixed with tmuxSessionSentinel, so login-shell profile output that shares the stdout +// stream cannot leak into the session list; blank lines and surrounding whitespace are discarded. func parseTmuxSessionList(stdout string) []string { sessions := []string{} for _, line := range strings.Split(stdout, "\n") { - name := strings.TrimSpace(line) - if name != "" { - sessions = append(sessions, name) - } + sessions = appendTmuxSessionLine(sessions, line, tmuxSessionSentinel) + } + return sessions +} + +// appendTmuxSessionLine appends the session name extracted from a single output line, or leaves +// the slice unchanged when the line is not a valid sentinel-prefixed record. +func appendTmuxSessionLine(sessions []string, line, sentinel string) []string { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, sentinel) { + return sessions + } + name := strings.TrimSpace(strings.TrimPrefix(trimmed, sentinel)) + if name != "" { + sessions = append(sessions, name) } return sessions } diff --git a/pkg/wshrpc/wshserver/wshserver_test.go b/pkg/wshrpc/wshserver/wshserver_test.go index b0368a64c5..c891e649a1 100644 --- a/pkg/wshrpc/wshserver/wshserver_test.go +++ b/pkg/wshrpc/wshserver/wshserver_test.go @@ -9,6 +9,7 @@ import ( ) func TestParseTmuxSessionList(t *testing.T) { + const s = tmuxSessionSentinel tests := []struct { name string stdout string @@ -21,24 +22,34 @@ func TestParseTmuxSessionList(t *testing.T) { }, { name: "single session", - stdout: "mactop\n", + stdout: s + "mactop\n", want: []string{"mactop"}, }, { name: "multiple sessions", - stdout: "mactop\nomlx-11335\nomlx-11336\n", + stdout: s + "mactop\n" + s + "omlx-11335\n" + s + "omlx-11336\n", want: []string{"mactop", "omlx-11335", "omlx-11336"}, }, { name: "blank lines and surrounding whitespace", - stdout: "\n mactop \n\nomlx-11335\n\n", + stdout: "\n " + s + "mactop \n\n" + s + "omlx-11335\n\n", want: []string{"mactop", "omlx-11335"}, }, { name: "session name with spaces", - stdout: "my session with spaces\n", + stdout: s + "my session with spaces\n", want: []string{"my session with spaces"}, }, + { + name: "ignores unsentineled lines", + stdout: "some other output\n" + s + "mactop\n", + want: []string{"mactop"}, + }, + { + name: "login shell profile output before tmux records", + stdout: "Last login: Thu Aug 26 14:00:00 2026 on ttys000\nWelcome to bash\n" + s + "mactop\n" + s + "omlx-11335\n", + want: []string{"mactop", "omlx-11335"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -49,3 +60,16 @@ func TestParseTmuxSessionList(t *testing.T) { }) } } + +func TestAppendTmuxSessionLine(t *testing.T) { + const s = tmuxSessionSentinel + var sessions []string + sessions = appendTmuxSessionLine(sessions, "not a tmux record", s) + sessions = appendTmuxSessionLine(sessions, s+"mactop", s) + sessions = appendTmuxSessionLine(sessions, " "+s+" spaced name ", s) + sessions = appendTmuxSessionLine(sessions, s, s) // sentinel with no name + sessions = appendTmuxSessionLine(sessions, "", s) + if !reflect.DeepEqual(sessions, []string{"mactop", "spaced name"}) { + t.Fatalf("appendTmuxSessionLine produced %#v", sessions) + } +} From 8a3115cff4ff18fb65f651bbe63ea43e704f081a Mon Sep 17 00:00:00 2001 From: zhouwei Date: Wed, 26 Aug 2026 14:50:13 +0800 Subject: [PATCH 4/4] fix: preserve tmux session names with leading/trailing whitespace Strip only the sentinel and trailing CRLF when parsing tmux session names, keeping a name's own surrounding whitespace intact (tmux permits it). Adds regression tests for CRLF endings and padded session names. --- pkg/wshrpc/wshserver/wshserver.go | 9 +++++---- pkg/wshrpc/wshserver/wshserver_test.go | 22 +++++++++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 05b30d5433..9aaa12f0be 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -370,7 +370,8 @@ const tmuxSessionSentinel = "WAVETERM_TMUX_SESSION:" // parseTmuxSessionList parses `tmux list-sessions` output into session names. It only accepts // lines prefixed with tmuxSessionSentinel, so login-shell profile output that shares the stdout -// stream cannot leak into the session list; blank lines and surrounding whitespace are discarded. +// stream cannot leak into the session list; the sentinel and any trailing CRLF are stripped, but +// a session name's own leading/trailing whitespace is preserved (tmux allows it in names). func parseTmuxSessionList(stdout string) []string { sessions := []string{} for _, line := range strings.Split(stdout, "\n") { @@ -382,11 +383,11 @@ func parseTmuxSessionList(stdout string) []string { // appendTmuxSessionLine appends the session name extracted from a single output line, or leaves // the slice unchanged when the line is not a valid sentinel-prefixed record. func appendTmuxSessionLine(sessions []string, line, sentinel string) []string { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, sentinel) { + line = strings.TrimSuffix(line, "\r") + if !strings.HasPrefix(line, sentinel) { return sessions } - name := strings.TrimSpace(strings.TrimPrefix(trimmed, sentinel)) + name := strings.TrimPrefix(line, sentinel) if name != "" { sessions = append(sessions, name) } diff --git a/pkg/wshrpc/wshserver/wshserver_test.go b/pkg/wshrpc/wshserver/wshserver_test.go index c891e649a1..f949de126b 100644 --- a/pkg/wshrpc/wshserver/wshserver_test.go +++ b/pkg/wshrpc/wshserver/wshserver_test.go @@ -31,8 +31,13 @@ func TestParseTmuxSessionList(t *testing.T) { want: []string{"mactop", "omlx-11335", "omlx-11336"}, }, { - name: "blank lines and surrounding whitespace", - stdout: "\n " + s + "mactop \n\n" + s + "omlx-11335\n\n", + name: "blank lines are discarded", + stdout: "\n" + s + "mactop\n\n" + s + "omlx-11335\n\n", + want: []string{"mactop", "omlx-11335"}, + }, + { + name: "CRLF line endings", + stdout: s + "mactop\r\n" + s + "omlx-11335\r\n", want: []string{"mactop", "omlx-11335"}, }, { @@ -40,6 +45,11 @@ func TestParseTmuxSessionList(t *testing.T) { stdout: s + "my session with spaces\n", want: []string{"my session with spaces"}, }, + { + name: "session name with leading/trailing whitespace is preserved", + stdout: s + " padded-name \n", + want: []string{" padded-name "}, + }, { name: "ignores unsentineled lines", stdout: "some other output\n" + s + "mactop\n", @@ -66,10 +76,12 @@ func TestAppendTmuxSessionLine(t *testing.T) { var sessions []string sessions = appendTmuxSessionLine(sessions, "not a tmux record", s) sessions = appendTmuxSessionLine(sessions, s+"mactop", s) - sessions = appendTmuxSessionLine(sessions, " "+s+" spaced name ", s) - sessions = appendTmuxSessionLine(sessions, s, s) // sentinel with no name + sessions = appendTmuxSessionLine(sessions, s+" spaced name ", s) // name whitespace preserved + sessions = appendTmuxSessionLine(sessions, " "+s+"leading-space-sentinel", s) // sentinel not at line start → ignored + sessions = appendTmuxSessionLine(sessions, s, s) // sentinel with no name sessions = appendTmuxSessionLine(sessions, "", s) - if !reflect.DeepEqual(sessions, []string{"mactop", "spaced name"}) { + sessions = appendTmuxSessionLine(sessions, s+"crlf\r", s) // trailing CR stripped + if !reflect.DeepEqual(sessions, []string{"mactop", " spaced name ", "crlf"}) { t.Fatalf("appendTmuxSessionLine produced %#v", sessions) } }