Skip to content

fix(tmux): validate exec split args and surface pane creation errors#164

Merged
alvinunreal merged 6 commits intoalvinunreal:mainfrom
alvinreal:fix/exec-split-args-validation
Mar 14, 2026
Merged

fix(tmux): validate exec split args and surface pane creation errors#164
alvinunreal merged 6 commits intoalvinunreal:mainfrom
alvinreal:fix/exec-split-args-validation

Conversation

@alvinreal
Copy link
Contributor

Summary

  • add tmux.exec_split_args support for exec pane creation
  • remove the duplicate fallback in buildSplitWindowArgs so DefaultConfig() remains the single source of truth for the default split args
  • reject reserved tmux flags (-t, -P, -F) in exec_split_args
  • surface pane creation errors from InitExecPane instead of discarding them silently
  • document the new config and add coverage for configured args, empty args, and reserved-flag validation

Why

PR #161 is pointed in the right direction, but two concerns were worth fixing before merge:

  1. the helper-level empty-slice fallback duplicated the default already defined in DefaultConfig()
  2. reserved flags in exec_split_args could consume internally appended arguments and break pane-ID retrieval without any visible error

This PR rebuilds that change with those guardrails in place.

Testing

  • go test ./...

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 14, 2026

Greptile Summary

This PR properly introduces exec_split_args support for customizing how TmuxAI creates its exec pane, consolidates the default args into DefaultConfig() to eliminate the previous duplication, adds reserved-flag validation in buildSplitWindowArgs, and surfaces pane-creation errors up the call stack instead of discarding them silently.

Key changes:

  • config/config.go: New TmuxConfig struct with ExecSplitArgs []string; default ["-d", "-h"] lives only in DefaultConfig().
  • system/tmux.go: TmuxCreateNewPane now accepts splitArgs; buildSplitWindowArgs validates and assembles the final arg slice, rejecting reserved flags (-t, -P, -F) while using a skipNext mechanism for flags that take a value argument.
  • internal/exec_pane.go: InitExecPane returns an error; timing-race fallback uses the pane ID returned directly by TmuxCreateNewPane if GetAvailablePane comes back empty.
  • internal/manager.go / internal/chat_command.go: Both call sites now handle the returned error.
  • system/tmux_test.go: New unit tests cover configured args, empty args, reserved-flag rejection, and value-skipping. However, TestBuildSplitWindowArgs_AllowsReservedFlagStringsAsValues and TestBuildSplitWindowArgs_AllowsReservedFlagAsValueForFlagTakingArg are byte-for-byte duplicates and one should be replaced.
  • system/tmux.go: -F appears in splitWindowFlagsWithValues but is unreachable dead code because the reserved-flag check fires first and returns an error before the skipNext path is ever evaluated.

Confidence Score: 4/5

  • PR is safe to merge with two minor style issues to clean up before or after landing.
  • The functional changes are correct and well-tested. The two issues found are both style-level: a duplicate test case that provides no additional coverage, and a dead entry in splitWindowFlagsWithValues that is unreachable at runtime. Neither causes incorrect behaviour.
  • system/tmux_test.go (duplicate test) and system/tmux.go (dead -F entry in splitWindowFlagsWithValues).

Important Files Changed

Filename Overview
system/tmux.go Adds buildSplitWindowArgs with reserved-flag validation and skipNext value-skipping logic. One dead entry (-F) in splitWindowFlagsWithValues is unreachable due to the reserved-flag early return. Otherwise the logic is sound.
system/tmux_test.go New test file with good coverage of buildSplitWindowArgs, but contains a fully duplicate test (TestBuildSplitWindowArgs_AllowsReservedFlagStringsAsValues and TestBuildSplitWindowArgs_AllowsReservedFlagAsValueForFlagTakingArg are byte-for-byte identical).
internal/exec_pane.go InitExecPane now returns an error and falls back to the raw pane ID when GetAvailablePane races; clean improvement over the previous silent discard.
config/config.go Adds TmuxConfig struct with ExecSplitArgs and correctly registers the default ["-d", "-h"] in DefaultConfig(), removing the previous duplication.
internal/manager.go Now propagates InitExecPane errors to the caller of NewManager, surfacing pane creation failures instead of swallowing them.
internal/chat_command.go Handles the new InitExecPane error return in the /prepare command path and prints a user-facing message.
internal/pane_details.go Refactors repeated WriteString(fmt.Sprintf(...)) calls to fmt.Fprintf, a minor but clean style improvement.
internal/squash.go Same fmt.Fprintf refactor as pane_details.go; no functional changes.

Sequence Diagram

sequenceDiagram
    participant Caller as NewManager / /prepare
    participant IE as InitExecPane
    participant GAP as GetAvailablePane
    participant TCNP as TmuxCreateNewPane
    participant BSW as buildSplitWindowArgs
    participant tmux as tmux split-window

    Caller->>IE: InitExecPane()
    IE->>GAP: GetAvailablePane()
    GAP-->>IE: TmuxPaneDetails{} (empty)
    IE->>TCNP: TmuxCreateNewPane(target, splitArgs)
    TCNP->>BSW: buildSplitWindowArgs(target, splitArgs)
    Note over BSW: Validate no reserved flags<br/>(-t, -P, -F)<br/>Assemble final arg slice
    BSW-->>TCNP: []string or error
    TCNP->>tmux: exec split-window ...args
    tmux-->>TCNP: paneID (stdout)
    TCNP-->>IE: paneID, nil
    IE->>GAP: GetAvailablePane() [re-query]
    alt pane found in list
        GAP-->>IE: TmuxPaneDetails{Id: paneID, ...}
    else timing race
        GAP-->>IE: TmuxPaneDetails{} (empty)
        Note over IE: Fallback: use paneID<br/>returned by TmuxCreateNewPane
    end
    IE-->>Caller: nil (success)
Loading

Last reviewed commit: cb46e48

Comment on lines 35 to 37
availablePane = m.GetAvailablePane()
}
m.ExecPane = &availablePane
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent failure when newly created pane is not discoverable

TmuxCreateNewPane is called and succeeds (no error returned), but the immediately-following GetAvailablePane() call can still return an empty TmuxPaneDetails{} — for example, due to a brief timing window before the pane is listed by list-panes. When that happens, m.ExecPane is set to a pointer to an empty struct where Id == "", and the function returns nil — signalling success to the caller even though the exec pane is essentially unusable. Any subsequent code that accesses m.ExecPane.Id without checking will produce silent failures.

The returned pane ID from TmuxCreateNewPane is also discarded (_), making this second lookup the only way to recover the pane, yet it is never validated.

Suggested change
availablePane = m.GetAvailablePane()
}
m.ExecPane = &availablePane
availablePane = m.GetAvailablePane()
if availablePane.Id == "" {
return fmt.Errorf("exec pane was created but could not be found afterwards")
}
}
m.ExecPane = &availablePane
return nil

Comment on lines +48 to +52
for _, arg := range splitArgs {
if _, reserved := reservedSplitWindowArgs[arg]; reserved {
return nil, fmt.Errorf("exec_split_args cannot include reserved tmux flag %q", arg)
}
args = append(args, arg)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reserved-flag check rejects legitimate argument values

The loop iterates over every element of splitArgs — both option names and their associated values — and compares each against the reserved set. This means a configuration like exec_split_args: ["-c", "-t"] (where -c sets the working directory and "-t" is an unusual but syntactically valid path) would be incorrectly rejected, since "-t" matches the reserved key even though it is a value, not a flag.

A safer approach is to only reject elements that look like flags (i.e. start with -) that appear in the reserved set, skipping elements that are clearly values (not starting with -):

for _, arg := range splitArgs {
    if strings.HasPrefix(arg, "-") {
        if _, reserved := reservedSplitWindowArgs[arg]; reserved {
            return nil, fmt.Errorf("exec_split_args cannot include reserved tmux flag %q", arg)
        }
    }
    args = append(args, arg)
}

This still catches -t, -P, -F when used as flags while allowing them to appear as literal values for other flags.

@alvinreal
Copy link
Contributor Author

Addressed.

Changes pushed to this PR:

  • fixed the pane_details.go lint issue by switching the builder writes to fmt.Fprintf
  • fixed the InitExecPane silent-success path by preserving the pane id returned from TmuxCreateNewPane and falling back to it if rediscovery races
  • tightened exec_split_args validation so reserved flags are rejected only when they are actually used as flags, not when they appear as values for flags like -c
  • added test coverage for the value-vs-flag case

go test ./... passes locally. I do not have golangci-lint installed on this machine, but the specific staticcheck complaint from CI was fixed directly.

@alvinunreal
Copy link
Owner

@greptile-app

Comment on lines +39 to +41
if availablePane.Id == "" {
return fmt.Errorf("exec pane was created but could not be found afterwards")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreachable error guard — dead code

The second if availablePane.Id == "" block (lines 39-41) can never be reached, so the error "exec pane was created but could not be found afterwards" is silently swallowed as dead code instead of providing the intended safety net.

Here is why it is unreachable:

  • TmuxCreateNewPane either returns (non-empty-string, nil) or ("", non-nil error). It explicitly validates paneId == "" before returning, so a nil-error return always carries a non-empty paneID.
  • The preceding if err != nil { return … } guard means we only reach line 37 when paneID != "".
  • Line 37 sets availablePane = system.TmuxPaneDetails{Id: paneID}, guaranteeing availablePane.Id != "".
  • Therefore the second if availablePane.Id == "" is always false.

The fix is to collapse the two checks into one, or drop the redundant guard entirely:

paneID, err := system.TmuxCreateNewPane(m.PaneId, m.Config.Tmux.ExecSplitArgs)
if err != nil {
    return fmt.Errorf("failed to create exec pane: %w", err)
}
availablePane = m.GetAvailablePane()
if availablePane.Id == "" {
    availablePane = system.TmuxPaneDetails{Id: paneID}
}
// availablePane.Id is guaranteed non-empty here — no second check needed

Comment on lines +46 to +51
var splitWindowFlagsWithValues = map[string]struct{}{
"-c": {},
"-e": {},
"-l": {},
"-p": {},
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

splitWindowFlagsWithValues may be incomplete for newer tmux versions

The skipNext mechanism correctly prevents a value that looks like a flag (e.g., -t as the argument to -c) from being rejected. However, splitWindowFlagsWithValues only enumerates four flags (-c, -e, -l, -p). Newer tmux 3.x versions added -x width and -y height to split-window. If a user on a newer tmux passes ["-x", "-5"] (malformed, but syntactically legal to tmux) or any future value-taking flag not listed here, the value would not have skipNext set, and if that value starts with - and happens to match a reserved flag, it would be incorrectly rejected.

Consider either documenting this limitation explicitly in the function comment, or adding -x and -y to the set for completeness:

var splitWindowFlagsWithValues = map[string]struct{}{
    "-c": {},
    "-e": {},
    "-l": {},
    "-p": {},
    "-x": {}, // tmux 3.x: width
    "-y": {}, // tmux 3.x: height
}

This is a low-severity edge case, but worth tracking as tmux evolves.

@alvinreal
Copy link
Contributor Author

Addressed the new review comments.

  1. Dead code in InitExecPane — removed the unreachable second if availablePane.Id == "" check. The fallback now directly assigns the known-valid paneID, and no redundant guard follows.

  2. splitWindowFlagsWithValues incomplete — added -x and -y for tmux 3.x width/height flags, and added test coverage showing a reserved flag can safely appear as a value for -x.

golangci-lint run → 0 issues, go test ./... → passes.

@alvinreal
Copy link
Contributor Author

Verified against tmux 3.6a on this machine:

split-window (splitw) [-bdefhIPvZ] [-c start-directory] [-e environment] [-F format] [-l size] [-t target-pane]

Changes:

  • added -F to splitWindowFlagsWithValues so its format-string value is skipped when -F is encountered (and rejected as reserved)
  • removed -x / -y — those flags do not exist in tmux 3.6a (nor do they appear in any tmux release notes for split-window; they may have been for a different command)
  • kept -c, -e, -l, -p as the value-taking flags for forward/backward compatibility

All tests pass, lint clean.

@alvinunreal
Copy link
Owner

@greptile-app

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
@alvinunreal alvinunreal merged commit 7770f15 into alvinunreal:main Mar 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants