Files
kandev/apps/backend/internal/agentctl/server/process/interactive_shells.go
T
João Salavisa 16495a6dcc fix(terminal): expose executor-profile env vars to terminal shells (#1992)
* fix(terminal): expose executor-profile env vars to terminal shells

PR #1971 got the profile env into the agent subprocess and the repository
setup script, but the terminal panel was still a gap: the local user shell
was started with `InteractiveStartRequest.Env` unset, so it inherited only
the backend process environment, and agentctl's per-terminal shells were
started from `shell.DefaultConfig` with no env overrides at all.

Local/worktree path: resolve the task environment's executor profile at
terminal start (revealing secret-backed entries) and pass it through
UserShellOptions.Env into the deferred PTY start. Resolution happens per
terminal rather than being cached at launch, so a terminal opened before
the agent ever runs still gets the vars.

Remote path (Docker/Sprites/SSH): StartTerminalShell now folds the agent
instance's env into the shell config, so a terminal inside the workspace
sees what the agent sees. SSH is unchanged in scope — its instance env is
still only the credential allowlist, not arbitrary profile variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(terminal): resolve terminal env from the session's executor profile

Address PR review. The resolver read executor_profile_id off the
task_environments row, but persistTaskEnvironment's reuse branch never
refreshes that column — it keeps whatever the first session stamped on it.
buildLaunchAgentRequest resolves the agent's env from
session.ExecutorProfileID instead, so a task environment reused by a later
session that picked a different profile of the same executor type would give
the terminal stale secrets and omit the ones the agent just received.

Prefer the session's profile and fall back to the environment row only when
the session recorded none. Also document why mergeAgentEnvIntoShellConfig
needs no defensive copy and why StartEnvForTesting is exported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(terminal): give env-row fallback subtests distinct session IDs

Address PR review nit: both subtests shared the hardcoded "session-3" at
the call site, so the error-path case read as a repeat of the first.
Move the session ID into the table as session-no-profile / session-error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:08:57 +01:00

478 lines
14 KiB
Go

package process
import (
"context"
"fmt"
"io"
"sort"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// UserShellOptions contains optional parameters for starting a user shell.
type UserShellOptions struct {
Label string // Display name (e.g., "Terminal" or script name)
InitialCommand string // Command to run after shell starts
Closable *bool // Whether the terminal can be closed (nil = auto-determine)
Env map[string]string // Extra env exported into the shell (e.g. executor-profile vars)
}
// CreateUserShellResult contains the result of creating a new user shell.
type CreateUserShellResult struct {
TerminalID string `json:"terminal_id"`
Label string `json:"label"`
Closable bool `json:"closable"`
}
// UserShellInfo contains information about a running user shell.
type UserShellInfo struct {
TerminalID string `json:"terminal_id"`
ProcessID string `json:"process_id"`
Running bool `json:"running"`
Label string `json:"label"` // Display name (e.g., "Terminal" or "Terminal 2")
Closable bool `json:"closable"` // Whether the terminal can be closed
InitialCommand string `json:"initial_command"` // Command that was run (empty for plain shells)
CreatedAt time.Time `json:"created_at"` // When the shell was created (for stable ordering)
}
// CreateUserShell creates a new user shell terminal with auto-assigned ID and label.
// The first shell for a scope is labeled "Terminal" and is not closable.
// Subsequent shells are labeled "Terminal 2", "Terminal 3", etc. and are closable.
// The entry is registered atomically to prevent races with ListUserShells.
//
// `scopeID` groups shells. Callers should pass `taskEnvironmentID` so sessions
// in the same task share one shell list.
func (r *InteractiveRunner) CreateUserShell(scopeID string) CreateUserShellResult {
r.userShellsMu.Lock()
defer r.userShellsMu.Unlock()
prefix := scopeID + ":"
shellCount := 0
for key, entry := range r.userShells {
if strings.HasPrefix(key, prefix) {
terminalID := key[len(prefix):]
if strings.HasPrefix(terminalID, "shell-") && entry.InitialCommand == "" {
shellCount++
}
}
}
terminalID := "shell-" + uuid.New().String()
var label string
var closable bool
if shellCount == 0 {
label = "Terminal"
closable = false
} else {
label = fmt.Sprintf("Terminal %d", shellCount+1)
closable = true
}
r.userShells[prefix+terminalID] = &userShellEntry{
ProcessID: "",
Label: label,
InitialCommand: "",
Closable: closable,
CreatedAt: time.Now().UTC(),
}
return CreateUserShellResult{
TerminalID: terminalID,
Label: label,
Closable: closable,
}
}
// RegisterScriptShell registers a script terminal entry so ListUserShells returns it.
// The actual process is not started until the WebSocket connects (StartUserShell handles that).
func (r *InteractiveRunner) RegisterScriptShell(scopeID, terminalID, label, initialCommand string) {
key := scopeID + ":" + terminalID
r.userShellsMu.Lock()
defer r.userShellsMu.Unlock()
r.userShells[key] = &userShellEntry{
ProcessID: "", // No process yet - will be started when WebSocket connects
Label: label,
InitialCommand: initialCommand,
Closable: true, // Script terminals are always closable
CreatedAt: time.Now().UTC(),
}
}
// LookupShellInitialCommand returns the InitialCommand stored for a pre-registered
// shell entry, or "" when no entry exists. Used by remote-shell handlers to recover
// the script command across the WS-handshake boundary, since the per-terminal WS URL
// only carries terminalId — not script_id or command.
func (r *InteractiveRunner) LookupShellInitialCommand(scopeID, terminalID string) string {
key := scopeID + ":" + terminalID
r.userShellsMu.RLock()
defer r.userShellsMu.RUnlock()
if entry, ok := r.userShells[key]; ok {
return entry.InitialCommand
}
return ""
}
// StartUserShell starts or returns an existing user shell for a terminal tab.
// Each terminal tab gets its own independent shell process. The shell is keyed
// in the userShells map by `scopeID` (taskEnvironmentID when known) so that
// sessions sharing an env share the same shell. The underlying process is
// still started under `processSessionID` for InteractiveStartRequest purposes
// (ring-buffer accounting, etc.) but `IsUserShell: true` excludes it from
// session-level routing.
func (r *InteractiveRunner) StartUserShell(ctx context.Context, scopeID, processSessionID, terminalID, workingDir, preferredShell string, opts *UserShellOptions) (*InteractiveProcessInfo, error) {
key := scopeID + ":" + terminalID
if opts == nil {
opts = &UserShellOptions{}
}
if opts.Label == "" {
opts.Label = "Terminal"
}
var existingEntry *userShellEntry
r.userShellsMu.RLock()
entry, exists := r.userShells[key]
if exists {
if entry.ProcessID != "" {
if info, ok := r.Get(entry.ProcessID, false); ok {
r.userShellsMu.RUnlock()
r.logger.Info("reusing user shell",
zap.String("scope_id", scopeID),
zap.String("session_id", processSessionID),
zap.String("terminal_id", terminalID),
zap.String("process_id", info.ID),
zap.Int("os_pid", info.OSPID),
zap.Strings("command", info.Command),
zap.String("working_dir", info.WorkingDir),
zap.String("label", entry.Label),
zap.String("initial_command", entry.InitialCommand))
return info, nil
}
}
existingEntry = entry
}
r.userShellsMu.RUnlock()
initialCommand := opts.InitialCommand
if initialCommand == "" && existingEntry != nil {
initialCommand = existingEntry.InitialCommand
}
label := opts.Label
if existingEntry != nil && existingEntry.Label != "" {
label = existingEntry.Label
}
req := InteractiveStartRequest{
SessionID: processSessionID,
Command: defaultShellCommand(preferredShell),
WorkingDir: workingDir,
ScopeID: scopeID,
TerminalID: terminalID,
Label: label,
InitialCommand: initialCommand,
Env: opts.Env,
DisableTurnDetection: true,
IsUserShell: true,
}
info, err := r.Start(ctx, req)
if err != nil {
return nil, err
}
r.userShellsMu.Lock()
if existingEntry != nil {
existingEntry.ProcessID = info.ID
r.userShells[key] = existingEntry
} else {
closable := true
if opts.Closable != nil {
closable = *opts.Closable
}
r.userShells[key] = &userShellEntry{
ProcessID: info.ID,
Label: opts.Label,
InitialCommand: opts.InitialCommand,
Closable: closable,
CreatedAt: time.Now().UTC(),
}
}
r.userShellsMu.Unlock()
r.logger.Info("started user shell",
zap.String("scope_id", scopeID),
zap.String("session_id", processSessionID),
zap.String("terminal_id", terminalID),
zap.String("process_id", info.ID),
zap.Int("os_pid", info.OSPID),
zap.String("shell", req.Command[0]),
zap.String("working_dir", workingDir),
zap.String("label", label),
zap.String("initial_command", initialCommand),
zap.Bool("deferred_start", info.OSPID == 0))
return info, nil
}
// StartEnvForTesting returns the environment recorded for a process's deferred
// PTY start. It is exported (rather than living in a _test.go file) only because
// its consumer is cross-package: TestStartUserShellProcessExportsExecutorProfileEnv
// in internal/gateway/websocket asserts the terminal handler wires profile env
// into the shell without spawning one. Same-package tests reach r.processes
// directly and do not need this.
func (r *InteractiveRunner) StartEnvForTesting(processID string) (map[string]string, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
proc, ok := r.processes[processID]
if !ok {
return nil, false
}
env := make(map[string]string, len(proc.startEnv))
for k, v := range proc.startEnv {
env[k] = v
}
return env, true
}
// ListUserShells returns every user shell registered for the scope, sorted
// by creation time. The list includes script terminals, the hardcoded
// bottom-panel entry (if it exists), and any orphan ordinary shells the
// agent has registered.
//
// Auto-creating the first "Terminal" entry is no longer this function's
// job — the terminal service (internal/terminal/service) owns that policy
// now and inserts a DB row alongside the agentctl-side entry. This keeps
// "what's in the panel" derivable from a single source of truth.
func (r *InteractiveRunner) ListUserShells(scopeID string) []UserShellInfo {
r.userShellsMu.RLock()
defer r.userShellsMu.RUnlock()
prefix := scopeID + ":"
var shells []UserShellInfo
for key, entry := range r.userShells {
if !strings.HasPrefix(key, prefix) {
continue
}
terminalID := key[len(prefix):]
_, running := r.Get(entry.ProcessID, false)
shells = append(shells, UserShellInfo{
TerminalID: terminalID,
ProcessID: entry.ProcessID,
Running: running,
Label: entry.Label,
Closable: entry.Closable,
InitialCommand: entry.InitialCommand,
CreatedAt: entry.CreatedAt,
})
}
sort.Slice(shells, func(i, j int) bool {
return shells[i].CreatedAt.Before(shells[j].CreatedAt)
})
return shells
}
// StopUserShell stops a user shell for a terminal tab.
func (r *InteractiveRunner) StopUserShell(ctx context.Context, scopeID, terminalID string) error {
key := scopeID + ":" + terminalID
r.userShellsMu.Lock()
entry, exists := r.userShells[key]
if exists {
delete(r.userShells, key)
}
r.userShellsMu.Unlock()
if !exists {
return nil
}
r.logger.Info("stopping user shell",
zap.String("scope_id", scopeID),
zap.String("terminal_id", terminalID),
zap.String("process_id", entry.ProcessID),
zap.Int("os_pid", r.osPIDForProcess(entry.ProcessID)),
zap.String("label", entry.Label),
zap.String("initial_command", entry.InitialCommand))
return r.Stop(ctx, entry.ProcessID)
}
// StopUserShellsForScope stops and unregisters every user shell in a scope.
// This is used by task archive/delete cleanup where bottom-panel/script shells
// may not have DB rows but still need to be torn down with the task environment.
func (r *InteractiveRunner) StopUserShellsForScope(ctx context.Context, scopeID string) (int, error) {
if scopeID == "" {
return 0, nil
}
prefix := scopeID + ":"
type stopTarget struct {
terminalID string
entry *userShellEntry
}
targets := []stopTarget{}
r.userShellsMu.Lock()
for key, entry := range r.userShells {
if !strings.HasPrefix(key, prefix) {
continue
}
delete(r.userShells, key)
targets = append(targets, stopTarget{
terminalID: key[len(prefix):],
entry: entry,
})
}
r.userShellsMu.Unlock()
var lastErr error
stoppedCount := 0
for _, target := range targets {
processID := ""
label := ""
initialCommand := ""
if target.entry != nil {
processID = target.entry.ProcessID
label = target.entry.Label
initialCommand = target.entry.InitialCommand
}
if processID == "" {
continue
}
r.logger.Info("stopping user shell for scope cleanup",
zap.String("scope_id", scopeID),
zap.String("terminal_id", target.terminalID),
zap.String("process_id", processID),
zap.Int("os_pid", r.osPIDForProcess(processID)),
zap.String("label", label),
zap.String("initial_command", initialCommand))
if err := r.Stop(ctx, processID); err != nil {
r.logger.Warn("failed to stop user shell for scope cleanup",
zap.String("scope_id", scopeID),
zap.String("terminal_id", target.terminalID),
zap.String("process_id", processID),
zap.Error(err))
lastErr = err
continue
}
stoppedCount++
}
return stoppedCount, lastErr
}
// IsUserShellAlive reports whether the PTY behind (scopeID, terminalID) is
// currently running. Returns false when the entry doesn't exist, the entry
// has no process id (lazy-start hasn't happened yet), or the process has
// since exited.
//
// ProcessID is read under the read lock so the subsequent r.Get call
// doesn't race with a concurrent shell-start path that mutates the entry
// pointer in place.
func (r *InteractiveRunner) IsUserShellAlive(scopeID, terminalID string) bool {
key := scopeID + ":" + terminalID
r.userShellsMu.RLock()
var processID string
if entry, exists := r.userShells[key]; exists {
processID = entry.ProcessID
}
r.userShellsMu.RUnlock()
if processID == "" {
return false
}
_, running := r.Get(processID, false)
return running
}
// ResizeUserShell resizes the PTY for a user shell.
func (r *InteractiveRunner) ResizeUserShell(scopeID, terminalID string, cols, rows uint16) error {
key := scopeID + ":" + terminalID
r.userShellsMu.RLock()
entry, exists := r.userShells[key]
r.userShellsMu.RUnlock()
if !exists {
return fmt.Errorf("no user shell found for scope %s terminal %s", scopeID, terminalID)
}
proc, ok := r.get(entry.ProcessID)
if !ok {
return fmt.Errorf("process not found: %s", entry.ProcessID)
}
return r.lazyStartAndResize(proc, cols, rows,
zap.String("scope_id", scopeID),
zap.String("terminal_id", terminalID),
)
}
// GetUserShellPtyWriter returns the PTY writer for a user shell.
func (r *InteractiveRunner) GetUserShellPtyWriter(scopeID, terminalID string) (io.Writer, string, error) {
key := scopeID + ":" + terminalID
r.userShellsMu.RLock()
entry, exists := r.userShells[key]
r.userShellsMu.RUnlock()
if !exists {
return nil, "", fmt.Errorf("no user shell found for scope %s terminal %s", scopeID, terminalID)
}
writer, err := r.GetPtyWriter(entry.ProcessID)
if err != nil {
return nil, entry.ProcessID, err
}
return writer, entry.ProcessID, nil
}
// ClearUserShellDirectOutput clears the direct output for a user shell.
func (r *InteractiveRunner) ClearUserShellDirectOutput(scopeID, terminalID string) {
key := scopeID + ":" + terminalID
r.userShellsMu.RLock()
entry, exists := r.userShells[key]
r.userShellsMu.RUnlock()
if !exists {
return
}
proc, ok := r.get(entry.ProcessID)
if !ok {
return
}
proc.directOutputMu.Lock()
proc.directOutput = nil
proc.hasActiveWebSocket = false
proc.directOutputMu.Unlock()
r.logger.Info("direct output cleared for user shell",
zap.String("scope_id", scopeID),
zap.String("terminal_id", terminalID),
zap.String("process_id", entry.ProcessID),
zap.Int("os_pid", proc.osPID()))
}
func (r *InteractiveRunner) osPIDForProcess(processID string) int {
if processID == "" {
return 0
}
pid, ok := r.GetOSPID(processID)
if !ok {
return 0
}
return pid
}