Skip to content

Commit 1c53d3f

Browse files
committed
feat(command-runner): add custom command execution with session whitelist promotion
- Add "Custom Command..." entry at the top of the command menu for all target types (host, LXC container, QEMU VM) - Execute arbitrary non-interactive commands without whitelist validation, still applying the configured timeout and output size limit - Add ExecuteCustomHostCommand, ExecuteCustomContainerCommand, and ExecuteCustomVMCommand on Executor (skip validator, same SSH/agent paths) - Add AddToWhitelist on Executor for session-only in-memory promotion - After a successful custom run, pressing 'w' on the result screen saves the command to the session whitelist and inserts it at index 1 in the live list widget immediately — no close/reopen required - Fix backspace bubbling in parameter and custom command forms: only ESC closes the form, leaving backspace available for text editing - Register "customCommandForm" modal page name in the outer UI plugin
1 parent 0955433 commit 1c53d3f

4 files changed

Lines changed: 302 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Command Runner: Custom Commands**: The Command Runner plugin now exposes a "Custom Command..." entry at the top of the command menu for all target types (host, LXC container, QEMU VM). Users can type any non-interactive command and execute it directly without it needing to be on the whitelist. After a successful run, pressing `w` on the result screen promotes the command into the session whitelist and inserts it at the top of the command list immediately — no close/reopen required. Commands run without a PTY; `sudo` requiring a password will fail fast with a clear error, as will interactive programs.
13+
1214
- **CLI Subcommands**: `pvetui` can now be used as a non-interactive CLI tool in addition to launching the TUI. Running any subcommand bypasses the TUI entirely, making `pvetui` composable in scripts and AI agent workflows.
1315
- `pvetui nodes list` — list all cluster nodes with status and resource metrics.
1416
- `pvetui nodes show <node>` — detailed view of a single node.

internal/plugins/command-runner/executor.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,123 @@ func (e *Executor) GetAllowedVMCommands(vm VM) []string {
294294
return e.validator.GetAllowedVMCommands(vm)
295295
}
296296

297+
// AddToWhitelist adds a command to the in-memory whitelist for a target type.
298+
// This change is session-only and does not persist to the config file.
299+
func (e *Executor) AddToWhitelist(targetType TargetType, command string) {
300+
switch targetType {
301+
case TargetHost:
302+
e.config.AllowedCommands.Host = append(e.config.AllowedCommands.Host, command)
303+
case TargetContainer:
304+
e.config.AllowedCommands.Container = append(e.config.AllowedCommands.Container, command)
305+
case TargetVM:
306+
e.config.AllowedCommands.VM = append(e.config.AllowedCommands.VM, command)
307+
}
308+
// Refresh the validator so it picks up the new entry.
309+
e.validator = NewValidator(e.config)
310+
}
311+
312+
// ExecuteCustomHostCommand executes an arbitrary command on a host via SSH without
313+
// whitelist validation. The command must be non-interactive; sudo requiring a password
314+
// will fail immediately because no PTY is allocated.
315+
func (e *Executor) ExecuteCustomHostCommand(ctx context.Context, host, command string) ExecutionResult {
316+
start := time.Now()
317+
318+
result := ExecutionResult{Command: command}
319+
320+
ctx, cancel := context.WithTimeout(ctx, e.config.Timeout)
321+
defer cancel()
322+
323+
output, err := e.sshClient.ExecuteCommand(ctx, host, command)
324+
result.Duration = time.Since(start)
325+
326+
if err != nil {
327+
result.Error = fmt.Errorf("execution failed: %w", err)
328+
return result
329+
}
330+
331+
if len(output) > e.config.MaxOutputSize {
332+
result.Output = output[:e.config.MaxOutputSize]
333+
result.Truncated = true
334+
} else {
335+
result.Output = output
336+
}
337+
338+
return result
339+
}
340+
341+
// ExecuteCustomContainerCommand executes an arbitrary command in an LXC container via
342+
// SSH + pct exec without whitelist validation.
343+
func (e *Executor) ExecuteCustomContainerCommand(ctx context.Context, host string, containerID int, command string) ExecutionResult {
344+
start := time.Now()
345+
346+
result := ExecutionResult{Command: command}
347+
348+
ctx, cancel := context.WithTimeout(ctx, e.config.Timeout)
349+
defer cancel()
350+
351+
output, err := e.sshClient.ExecuteContainerCommand(ctx, host, containerID, command)
352+
result.Duration = time.Since(start)
353+
354+
if err != nil {
355+
result.Error = fmt.Errorf("execution failed: %w", err)
356+
return result
357+
}
358+
359+
if len(output) > e.config.MaxOutputSize {
360+
result.Output = output[:e.config.MaxOutputSize]
361+
result.Truncated = true
362+
} else {
363+
result.Output = output
364+
}
365+
366+
return result
367+
}
368+
369+
// ExecuteCustomVMCommand executes an arbitrary command in a QEMU VM via the guest
370+
// agent without whitelist validation.
371+
func (e *Executor) ExecuteCustomVMCommand(ctx context.Context, vm VM, command string) ExecutionResult {
372+
start := time.Now()
373+
374+
result := ExecutionResult{Command: command}
375+
376+
if e.apiClient == nil {
377+
result.Error = fmt.Errorf("API client not configured")
378+
result.Duration = time.Since(start)
379+
return result
380+
}
381+
382+
ctx, cancel := context.WithTimeout(ctx, e.config.Timeout)
383+
defer cancel()
384+
385+
cmdParts := e.buildGuestAgentCommand(vm, command)
386+
387+
stdout, stderr, exitCode, err := e.apiClient.ExecuteGuestAgentCommand(ctx, vm, cmdParts, e.config.Timeout)
388+
result.Duration = time.Since(start)
389+
result.ExitCode = exitCode
390+
391+
if err != nil {
392+
result.Error = fmt.Errorf("execution failed: %w", err)
393+
if stderr != "" {
394+
result.Output = stderr
395+
}
396+
return result
397+
}
398+
399+
output := stdout
400+
if stderr != "" {
401+
output = stdout + "\n--- stderr ---\n" + stderr
402+
}
403+
404+
if len(output) > e.config.MaxOutputSize {
405+
result.Output = output[:e.config.MaxOutputSize]
406+
result.Truncated = true
407+
} else {
408+
result.Output = output
409+
}
410+
411+
return result
412+
}
413+
297414
func (e *Executor) buildGuestAgentCommand(vm VM, command string) []string {
298415
switch detectOSFamily(vm.OSType) {
299416
case OSFamilyWindows:

internal/plugins/command-runner/ui.go

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ func (u *UIManager) showCommandMenu(targetType TargetType, target string, comman
7979
u.app.SetFocus(list)
8080
}
8181

82+
// addToList inserts a newly whitelisted command at index 1 (right after "Custom
83+
// Command...") so it appears at the top of the whitelist section immediately.
84+
addToList := func(cmd string) {
85+
desc := GetCommandDescription(cmd)
86+
cmdCopy := cmd
87+
list.InsertItem(1, cmdCopy, desc, 0, func() {
88+
u.handleCommandSelection(targetType, target, cmdCopy, returnToMenu)
89+
})
90+
}
91+
92+
// Custom command entry first so it is always visible at the top.
93+
list.AddItem("Custom Command...", "Type and run any non-interactive command", '!', func() {
94+
u.showCustomCommandForm(targetType, target, returnToMenu, addToList)
95+
})
96+
8297
for _, cmd := range commands {
8398
cmdCopy := cmd // Capture for closure
8499
description := GetCommandDescription(cmdCopy)
@@ -167,9 +182,9 @@ func (u *UIManager) showParameterForm(targetType TargetType, target, command str
167182

168183
form.AddButton("Cancel", closeForm)
169184

170-
// Set input handler for back keys
185+
// Only intercept ESC — backspace must remain available for text editing in input fields.
171186
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
172-
if isBackKey(event) {
187+
if event.Key() == tcell.KeyEsc {
173188
closeForm()
174189
return nil
175190
}
@@ -343,6 +358,171 @@ func (u *UIManager) ShowResultModal(result ExecutionResult, onClose func()) {
343358
pages.AddPage("commandResult", flex, true, true)
344359
}
345360

361+
// showCustomCommandForm displays an input form for the user to type an arbitrary command.
362+
// Commands run non-interactively: sudo requiring a password and interactive programs
363+
// (vim, top, etc.) will fail with an error from the remote end.
364+
func (u *UIManager) showCustomCommandForm(targetType TargetType, target string, onReturn func(), onAddToWhitelist func(string)) {
365+
pages := u.app.Pages()
366+
367+
closeForm := func() {
368+
pages.RemovePage("customCommandForm")
369+
if onReturn != nil {
370+
onReturn()
371+
}
372+
}
373+
374+
form := tview.NewForm()
375+
form.SetBorder(true)
376+
form.SetTitle(fmt.Sprintf(" Custom Command on %s (%s) — non-interactive only ", target, targetType))
377+
378+
var command string
379+
form.AddInputField("Command", "", 60, nil, func(text string) {
380+
command = text
381+
})
382+
383+
form.AddButton("Execute", func() {
384+
cmd := strings.TrimSpace(command)
385+
if cmd == "" {
386+
return
387+
}
388+
pages.RemovePage("customCommandForm")
389+
u.executeCustomAndShowResult(targetType, target, cmd, onReturn, onAddToWhitelist)
390+
})
391+
392+
form.AddButton("Cancel", closeForm)
393+
394+
// Only intercept ESC — backspace must remain available for text editing in input fields.
395+
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
396+
if event.Key() == tcell.KeyEsc {
397+
closeForm()
398+
return nil
399+
}
400+
return event
401+
})
402+
403+
pages.AddPage("customCommandForm", form, true, true)
404+
}
405+
406+
// executeCustomAndShowResult runs a custom command (no whitelist check) and shows the result.
407+
// It mirrors executeAndShowResult but calls the custom execution paths.
408+
func (u *UIManager) executeCustomAndShowResult(targetType TargetType, target, command string, onClose func(), onAddToWhitelist func(string)) {
409+
u.showExecutingModal(command)
410+
411+
go func() {
412+
var result ExecutionResult
413+
ctx := context.Background()
414+
415+
switch targetType {
416+
case TargetHost:
417+
result = u.executor.ExecuteCustomHostCommand(ctx, target, command)
418+
case TargetContainer:
419+
node, containerID, err := parseContainerTarget(target)
420+
if err != nil {
421+
result = ExecutionResult{
422+
Command: command,
423+
Error: fmt.Errorf("invalid target format: %w", err),
424+
}
425+
} else {
426+
result = u.executor.ExecuteCustomContainerCommand(ctx, node, containerID, command)
427+
}
428+
case TargetVM:
429+
vm, err := u.vmFromTarget(target)
430+
if err != nil {
431+
result = ExecutionResult{
432+
Command: command,
433+
Error: fmt.Errorf("invalid target format: %w", err),
434+
}
435+
} else {
436+
result = u.executor.ExecuteCustomVMCommand(ctx, vm, command)
437+
}
438+
default:
439+
result = ExecutionResult{
440+
Command: command,
441+
Error: fmt.Errorf("unsupported target type: %s", targetType),
442+
}
443+
}
444+
445+
u.app.QueueUpdateDraw(func() {
446+
u.showCustomResultModal(result, targetType, onClose, onAddToWhitelist)
447+
})
448+
}()
449+
}
450+
451+
// showCustomResultModal is like ShowResultModal but adds a "Save to Whitelist" button
452+
// so the user can promote a successful custom command into the session whitelist.
453+
func (u *UIManager) showCustomResultModal(result ExecutionResult, targetType TargetType, onClose func(), onAddToWhitelist func(string)) {
454+
pages := u.app.Pages()
455+
pages.RemovePage("executingCommand")
456+
457+
closeResult := func() {
458+
pages.RemovePage("commandResult")
459+
if onClose != nil {
460+
onClose()
461+
}
462+
}
463+
464+
var text strings.Builder
465+
fmt.Fprintf(&text, "Command: %s\n", result.Command)
466+
fmt.Fprintf(&text, "Duration: %v\n\n", result.Duration)
467+
468+
if result.Error != nil {
469+
fmt.Fprintf(&text, "Error: %v\n\n", result.Error)
470+
if result.Output != "" {
471+
text.WriteString("Output:\n")
472+
text.WriteString(result.Output)
473+
}
474+
} else {
475+
text.WriteString("Output:\n")
476+
text.WriteString(result.Output)
477+
if result.Truncated {
478+
text.WriteString("\n\n[Output truncated]")
479+
}
480+
}
481+
482+
textView := tview.NewTextView().
483+
SetText(text.String()).
484+
SetDynamicColors(true).
485+
SetScrollable(true)
486+
textView.SetBorder(true)
487+
textView.SetTitle(" Custom Command Result ")
488+
489+
// Build hint line; include whitelist shortcut only when the command succeeded.
490+
hintText := " [primary]ESC/Backspace[-] Close | [primary]↑/↓[-] Scroll"
491+
if result.Error == nil {
492+
hintText += " | [primary]w[-] Save to Whitelist"
493+
}
494+
hintText += " "
495+
496+
buttons := tview.NewTextView().
497+
SetText(hintText).
498+
SetTextAlign(tview.AlignCenter).
499+
SetDynamicColors(true)
500+
501+
flex := tview.NewFlex().
502+
SetDirection(tview.FlexRow).
503+
AddItem(textView, 0, 1, true).
504+
AddItem(buttons, 1, 0, false)
505+
506+
textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
507+
if isBackKey(event) {
508+
closeResult()
509+
return nil
510+
}
511+
if result.Error == nil && event.Key() == tcell.KeyRune && event.Rune() == 'w' {
512+
u.executor.AddToWhitelist(targetType, result.Command)
513+
if onAddToWhitelist != nil {
514+
onAddToWhitelist(result.Command)
515+
}
516+
// Update the hint to confirm the save (disable 'w' to prevent duplicates).
517+
buttons.SetText(" [green]Saved to whitelist (session only)[-] | [primary]ESC/Backspace[-] Close | [primary]↑/↓[-] Scroll ")
518+
return nil
519+
}
520+
return event
521+
})
522+
523+
pages.AddPage("commandResult", flex, true, true)
524+
}
525+
346526
// ShowErrorModal displays an error message in a modal
347527
func (u *UIManager) ShowErrorModal(title, message string, onClose func()) {
348528
pages := u.app.Pages()

internal/ui/plugins/commandrunner/plugin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func (p *Plugin) ModalPageNames() []string {
122122
"commandResult",
123123
"commandError",
124124
"executingCommand",
125+
"customCommandForm",
125126
}
126127
}
127128

0 commit comments

Comments
 (0)