16495a6dcc
* 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>
478 lines
14 KiB
Go
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
|
|
}
|