diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_profile_env.go b/apps/backend/internal/agent/runtime/lifecycle/executor_profile_env.go new file mode 100644 index 0000000000..a76a3aeeca --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_profile_env.go @@ -0,0 +1,93 @@ +package lifecycle + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/task/models" +) + +// ExecutorProfileReader loads the executor profile selected for a session. +// Implemented by the task repository; wired via SetExecutorProfileReader. +type ExecutorProfileReader interface { + GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) + GetTaskEnvironment(ctx context.Context, id string) (*models.TaskEnvironment, error) + GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) +} + +// SetExecutorProfileReader wires the reader used to expose executor-profile env +// vars to user shell terminals. Nil → terminals fall back to inheriting only the +// backend process environment. +func (m *Manager) SetExecutorProfileReader(reader ExecutorProfileReader) { + m.executorProfileReader = reader +} + +// ExecutorProfileEnvForSession resolves the executor profile's env vars for a +// terminal, revealing secret-backed entries. It mirrors what the agent +// subprocess receives (the orchestrator merges the same profile env into the +// launch request), so a user shell terminal opened on the workspace sees the +// same tokens the agent and the repository setup script do. +// +// Resolution is best-effort: a missing reader, session, environment, profile, or +// secret yields the entries that could be resolved rather than failing the +// terminal. +func (m *Manager) ExecutorProfileEnvForSession(ctx context.Context, sessionID, taskEnvironmentID string) map[string]string { + if m.executorProfileReader == nil { + return nil + } + profileID := m.terminalExecutorProfileID(ctx, sessionID, taskEnvironmentID) + if profileID == "" { + return nil + } + profile, err := m.executorProfileReader.GetExecutorProfile(ctx, profileID) + if err != nil { + m.logger.Warn("failed to load executor profile for terminal env", + zap.String("session_id", sessionID), + zap.String("task_environment_id", taskEnvironmentID), + zap.String("executor_profile_id", profileID), + zap.Error(err)) + return nil + } + if profile == nil || len(profile.EnvVars) == 0 { + return nil + } + // ExecutorProfile.EnvVars and agent-profile env vars are the same type, so + // the secret-revealing resolver is shared. + return m.resolveAgentProfileEnvVars(ctx, profile.EnvVars) +} + +// terminalExecutorProfileID picks the executor profile the terminal should +// inherit from. The session's profile wins: buildLaunchAgentRequest resolves the +// agent's env from session.ExecutorProfileID, while the task_environments row +// keeps whatever the *first* session stamped on it — the reuse branch in +// persistTaskEnvironment never refreshes executor_profile_id. Reading the +// environment row alone would hand a terminal stale secrets whenever a later +// session picked a different profile of the same executor type. The environment +// row is only a fallback for sessions that never recorded one. +func (m *Manager) terminalExecutorProfileID(ctx context.Context, sessionID, taskEnvironmentID string) string { + if sessionID != "" { + session, err := m.executorProfileReader.GetTaskSession(ctx, sessionID) + if err != nil { + m.logger.Warn("failed to load session for terminal env", + zap.String("session_id", sessionID), + zap.Error(err)) + } else if session != nil && session.ExecutorProfileID != "" { + return session.ExecutorProfileID + } + } + if taskEnvironmentID == "" { + return "" + } + env, err := m.executorProfileReader.GetTaskEnvironment(ctx, taskEnvironmentID) + if err != nil { + m.logger.Warn("failed to load task environment for terminal env", + zap.String("task_environment_id", taskEnvironmentID), + zap.Error(err)) + return "" + } + if env == nil { + return "" + } + return env.ExecutorProfileID +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_profile_env_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_profile_env_test.go new file mode 100644 index 0000000000..ebb49f006f --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_profile_env_test.go @@ -0,0 +1,196 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/task/models" +) + +type fakeExecutorProfileReader struct { + session *models.TaskSession + sessionErr error + env *models.TaskEnvironment + envErr error + profiles map[string]*models.ExecutorProfile + profileErr error + profileArgs []string +} + +func (f *fakeExecutorProfileReader) GetTaskSession(_ context.Context, _ string) (*models.TaskSession, error) { + return f.session, f.sessionErr +} + +func (f *fakeExecutorProfileReader) GetTaskEnvironment(_ context.Context, _ string) (*models.TaskEnvironment, error) { + return f.env, f.envErr +} + +func (f *fakeExecutorProfileReader) GetExecutorProfile(_ context.Context, id string) (*models.ExecutorProfile, error) { + f.profileArgs = append(f.profileArgs, id) + if f.profileErr != nil { + return nil, f.profileErr + } + return f.profiles[id], nil +} + +func newExecutorProfileEnvManager(t *testing.T, reader ExecutorProfileReader) *Manager { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("new logger: %v", err) + } + store := newInMemorySecretStore() + if createErr := store.Create(context.Background(), &secrets.SecretWithValue{ + Secret: secrets.Secret{ID: "sec-npm", Name: "npm-token"}, + Value: "fa-secret-value", + }); createErr != nil { + t.Fatalf("seed secret: %v", createErr) + } + m := &Manager{logger: log, secretStore: store} + m.SetExecutorProfileReader(reader) + return m +} + +// The terminal must see the same executor-profile env vars the agent subprocess +// and the repository setup script get (PR #1971 covered the setup script only). +func TestExecutorProfileEnvForSession_ResolvesValuesAndSecrets(t *testing.T) { + reader := &fakeExecutorProfileReader{ + session: &models.TaskSession{ID: "session-1", ExecutorProfileID: "prof-1"}, + env: &models.TaskEnvironment{ID: "env-1", ExecutorProfileID: "prof-1"}, + profiles: map[string]*models.ExecutorProfile{ + "prof-1": { + ID: "prof-1", + EnvVars: []models.ProfileEnvVar{ + {Key: "PLAIN", Value: "plain-value"}, + {Key: "FONTAWESOME_NPM_AUTH_TOKEN", SecretID: "sec-npm"}, + }, + }, + }, + } + m := newExecutorProfileEnvManager(t, reader) + + got := m.ExecutorProfileEnvForSession(context.Background(), "session-1", "env-1") + + if got["PLAIN"] != "plain-value" { + t.Fatalf("PLAIN = %q, want literal profile value", got["PLAIN"]) + } + if got["FONTAWESOME_NPM_AUTH_TOKEN"] != "fa-secret-value" { + t.Fatalf("FONTAWESOME_NPM_AUTH_TOKEN = %q, want revealed secret", got["FONTAWESOME_NPM_AUTH_TOKEN"]) + } +} + +// persistTaskEnvironment's reuse branch never refreshes executor_profile_id, so +// the environment row keeps the first session's profile. A later session that +// picked a different profile must still get *its* env in the terminal, matching +// what buildLaunchAgentRequest gave the agent. +func TestExecutorProfileEnvForSession_PrefersSessionProfileOverStaleEnvironmentRow(t *testing.T) { + reader := &fakeExecutorProfileReader{ + session: &models.TaskSession{ID: "session-2", ExecutorProfileID: "prof-current"}, + env: &models.TaskEnvironment{ID: "env-1", ExecutorProfileID: "prof-stale"}, + profiles: map[string]*models.ExecutorProfile{ + "prof-current": {ID: "prof-current", EnvVars: []models.ProfileEnvVar{{Key: "TOKEN", Value: "current"}}}, + "prof-stale": {ID: "prof-stale", EnvVars: []models.ProfileEnvVar{{Key: "TOKEN", Value: "stale"}}}, + }, + } + m := newExecutorProfileEnvManager(t, reader) + + got := m.ExecutorProfileEnvForSession(context.Background(), "session-2", "env-1") + + if got["TOKEN"] != "current" { + t.Fatalf("TOKEN = %q, want the session's profile value, not the stale environment row", got["TOKEN"]) + } + if len(reader.profileArgs) != 1 || reader.profileArgs[0] != "prof-current" { + t.Fatalf("profile lookups = %v, want [prof-current]", reader.profileArgs) + } +} + +// Sessions predating the executor_profile_id column (or launched without one) +// still resolve through the environment row. +func TestExecutorProfileEnvForSession_FallsBackToEnvironmentRow(t *testing.T) { + tests := []struct { + name string + sessionID string + reader *fakeExecutorProfileReader + }{ + { + name: "session has no executor profile", + sessionID: "session-no-profile", + reader: &fakeExecutorProfileReader{ + session: &models.TaskSession{ID: "session-no-profile"}, + env: &models.TaskEnvironment{ID: "env-1", ExecutorProfileID: "prof-env"}, + }, + }, + { + name: "session lookup fails", + sessionID: "session-error", + reader: &fakeExecutorProfileReader{ + sessionErr: errors.New("boom"), + env: &models.TaskEnvironment{ID: "env-1", ExecutorProfileID: "prof-env"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.reader.profiles = map[string]*models.ExecutorProfile{ + "prof-env": {ID: "prof-env", EnvVars: []models.ProfileEnvVar{{Key: "TOKEN", Value: "from-env-row"}}}, + } + m := newExecutorProfileEnvManager(t, tt.reader) + + got := m.ExecutorProfileEnvForSession(context.Background(), tt.sessionID, "env-1") + + if got["TOKEN"] != "from-env-row" { + t.Fatalf("TOKEN = %q, want environment-row fallback", got["TOKEN"]) + } + }) + } +} + +func TestExecutorProfileEnvForSession_EmptyCases(t *testing.T) { + tests := []struct { + name string + envID string + reader ExecutorProfileReader + }{ + {name: "no session or environment id", envID: "", reader: &fakeExecutorProfileReader{}}, + {name: "no reader wired", envID: "env-1", reader: nil}, + { + name: "environment lookup fails", + envID: "env-1", + reader: &fakeExecutorProfileReader{envErr: errors.New("boom")}, + }, + { + name: "neither session nor environment has a profile", + envID: "env-1", + reader: &fakeExecutorProfileReader{env: &models.TaskEnvironment{ID: "env-1"}}, + }, + { + name: "profile lookup fails", + envID: "env-1", + reader: &fakeExecutorProfileReader{ + env: &models.TaskEnvironment{ID: "env-1", ExecutorProfileID: "prof-1"}, + profileErr: errors.New("boom"), + }, + }, + { + name: "profile has no env vars", + envID: "env-1", + reader: &fakeExecutorProfileReader{ + env: &models.TaskEnvironment{ID: "env-1", ExecutorProfileID: "prof-1"}, + profiles: map[string]*models.ExecutorProfile{"prof-1": {ID: "prof-1"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newExecutorProfileEnvManager(t, tt.reader) + if got := m.ExecutorProfileEnvForSession(context.Background(), "", tt.envID); len(got) != 0 { + t.Fatalf("got %#v, want empty", got) + } + }) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager.go b/apps/backend/internal/agent/runtime/lifecycle/manager.go index 52291ac79c..c3c787bf60 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager.go @@ -119,6 +119,12 @@ type Manager struct { // only component allowed to write the lifecycle-owned columns of this table. runningWriter ExecutorRunningWriter + // executorProfileReader resolves the executor profile bound to a task + // environment so user shell terminals can be given the same profile env + // vars the agent subprocess gets. See executor_profile_env.go. Nil → the + // terminal inherits only the backend process environment. + executorProfileReader ExecutorProfileReader + // agentProfileReader resolves the full agent_profiles row (including the // office-enrichment fields added in ADR 0005 Wave A) for the launch-prep // SkillDeployer hook. Nil → skill deploy is skipped. diff --git a/apps/backend/internal/agentctl/server/process/interactive_shells.go b/apps/backend/internal/agentctl/server/process/interactive_shells.go index 61504d0d8f..c2cbfc2119 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_shells.go +++ b/apps/backend/internal/agentctl/server/process/interactive_shells.go @@ -14,9 +14,10 @@ import ( // 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) + 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. @@ -176,6 +177,7 @@ func (r *InteractiveRunner) StartUserShell(ctx context.Context, scopeID, process TerminalID: terminalID, Label: label, InitialCommand: initialCommand, + Env: opts.Env, DisableTurnDetection: true, IsUserShell: true, } @@ -219,6 +221,26 @@ func (r *InteractiveRunner) StartUserShell(ctx context.Context, scopeID, process 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 diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 509a763b4e..818ab6083e 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -2243,16 +2243,53 @@ func (m *Manager) StartShell() error { return nil } -// StartTerminalShell creates a managed per-terminal shell. +// StartTerminalShell creates a managed per-terminal shell. The shell inherits the +// instance's agent environment (executor-profile env vars, credentials, KANDEV_* +// metadata) so a terminal opened on a remote workspace sees the same variables +// the agent subprocess does. Caller-supplied cfg.Env still wins. func (m *Manager) StartTerminalShell(terminalID string, cfg shell.Config) (*shell.Session, error) { release, err := m.admitStart() if err != nil { return nil, err } defer release() + cfg.Env = mergeAgentEnvIntoShellConfig(m.agentEnvSnapshot(), cfg.Env) return m.shellMgr.Start(terminalID, cfg) } +// agentEnvSnapshot returns a copy of the instance agent environment under the +// start lock, so a concurrent Configure appending to AgentEnv can't race. +func (m *Manager) agentEnvSnapshot() []string { + m.startMu.Lock() + defer m.startMu.Unlock() + return append([]string(nil), m.cfg.AgentEnv...) +} + +// mergeAgentEnvIntoShellConfig folds the instance agent env into the shell's +// override map. Explicit overrides take precedence over the inherited value. +// +// With no agent env there is nothing to merge, so the caller's map is returned +// as-is rather than defensively copied: the sole caller holds cfg by value and +// immediately assigns the result back to its own cfg.Env, so no alias outlives +// this call. +func mergeAgentEnvIntoShellConfig(agentEnv []string, overrides map[string]string) map[string]string { + if len(agentEnv) == 0 { + return overrides + } + merged := make(map[string]string) + for _, entry := range agentEnv { + eq := strings.IndexByte(entry, '=') + if eq <= 0 { + continue + } + merged[entry[:eq]] = entry[eq+1:] + } + for k, v := range overrides { + merged[k] = v + } + return merged +} + // StartVscode starts the code-server process on a random OS-assigned port. // The VS Code server runs independently of the agent process. // Start is non-blocking — the caller should poll VscodeInfo() for status updates. diff --git a/apps/backend/internal/agentctl/server/process/runner_shells_test.go b/apps/backend/internal/agentctl/server/process/runner_shells_test.go index 1cc1023251..7fbc632288 100644 --- a/apps/backend/internal/agentctl/server/process/runner_shells_test.go +++ b/apps/backend/internal/agentctl/server/process/runner_shells_test.go @@ -455,3 +455,61 @@ func TestInteractiveRunner_CreateUserShell_AtomicRegistration(t *testing.T) { t.Error("CreateUserShell() result should be immediately visible in ListUserShells()") } } + +// User shell terminals must export the executor-profile env vars the caller +// resolved, so the terminal sees the same variables as the agent subprocess and +// the repository setup script. +func TestInteractiveRunner_StartUserShellForwardsOptionEnv(t *testing.T) { + log := newTestLogger(t) + runner := NewInteractiveRunner(nil, log, 2*1024*1024) + + info, err := runner.StartUserShell( + context.Background(), + "env-scope", "env-session", "term-1", t.TempDir(), "", + &UserShellOptions{Label: "Terminal", Env: map[string]string{"FONTAWESOME_NPM_AUTH_TOKEN": "fa-secret-value"}}, + ) + if err != nil { + t.Fatalf("StartUserShell() error = %v", err) + } + + runner.mu.RLock() + proc, ok := runner.processes[info.ID] + runner.mu.RUnlock() + if !ok { + t.Fatalf("process %q not registered", info.ID) + } + if got := proc.startEnv["FONTAWESOME_NPM_AUTH_TOKEN"]; got != "fa-secret-value" { + t.Fatalf("startEnv[FONTAWESOME_NPM_AUTH_TOKEN] = %q, want profile secret; env=%#v", got, proc.startEnv) + } +} + +func TestMergeAgentEnvIntoShellConfig(t *testing.T) { + if got := mergeAgentEnvIntoShellConfig(nil, nil); got != nil { + t.Fatalf("mergeAgentEnvIntoShellConfig(nil, nil) = %#v, want nil (inherit process env)", got) + } + + // Instance agent env is inherited; explicit overrides still win, and + // malformed entries are dropped rather than producing empty keys. + merged := mergeAgentEnvIntoShellConfig( + []string{"FONTAWESOME_NPM_AUTH_TOKEN=fa-secret-value", "TERM=dumb", "MALFORMED", "=novalue"}, + map[string]string{"TERM": "xterm-256color"}, + ) + if merged["FONTAWESOME_NPM_AUTH_TOKEN"] != "fa-secret-value" { + t.Fatalf("merged token = %q, want inherited agent env value", merged["FONTAWESOME_NPM_AUTH_TOKEN"]) + } + if merged["TERM"] != "xterm-256color" { + t.Fatalf("merged TERM = %q, want caller override to win", merged["TERM"]) + } + if _, exists := merged[""]; exists { + t.Fatalf("merged contains empty key: %#v", merged) + } + if _, exists := merged["MALFORMED"]; exists { + t.Fatalf("merged contains valueless entry: %#v", merged) + } + + // Overrides-only with no agent env passes the map through untouched. + overrides := map[string]string{"TOKEN": "t"} + if got := mergeAgentEnvIntoShellConfig(nil, overrides); got["TOKEN"] != "t" { + t.Fatalf("mergeAgentEnvIntoShellConfig(nil, overrides)[TOKEN] = %q, want t", got["TOKEN"]) + } +} diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index e3464b40b3..a026cd582e 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -460,6 +460,11 @@ func startAgentInfrastructure( // before any Launch / EnsureWorkspaceExecutionForSession can run. lifecycleMgr.SetExecutorRunningWriter(repos.Task) + // Lets user shell terminals export the executor profile's env vars, so the + // terminal sees the same variables the agent subprocess and the repository + // setup script get. + lifecycleMgr.SetExecutorProfileReader(repos.Task) + // Configure quick-chat workspace cleanup if homeDir := cfg.ResolvedHomeDir(); homeDir != "" { quickChatDir := filepath.Join(homeDir, "quick-chat") diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index bf1f7e80a9..34d35fa39c 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -416,7 +416,12 @@ func (h *TerminalHandler) startUserShellProcess( return "", http.StatusServiceUnavailable, err.Error() } - opts := &process.UserShellOptions{Label: label, InitialCommand: initialCommand} + // Export the executor profile's env vars (secrets revealed) so the terminal + // sees the same variables the agent subprocess and the repository setup + // script get. Best-effort: an unresolvable profile just yields no extras. + profileEnv := h.lifecycleMgr.ExecutorProfileEnvForSession(c.Request.Context(), sessionID, scopeID) + + opts := &process.UserShellOptions{Label: label, InitialCommand: initialCommand, Env: profileEnv} h.logger.Info("handleUserShellWS: calling StartUserShell", zap.String("session_id", sessionID), @@ -424,7 +429,8 @@ func (h *TerminalHandler) startUserShellProcess( zap.String("working_dir", workingDir), zap.String("preferred_shell", preferredShell), zap.String("label", opts.Label), - zap.String("initial_command", opts.InitialCommand)) + zap.String("initial_command", opts.InitialCommand), + zap.Int("profile_env_count", len(profileEnv))) info, err := interactiveRunner.StartUserShell( c.Request.Context(), scopeID, sessionID, terminalID, workingDir, preferredShell, opts, diff --git a/apps/backend/internal/gateway/websocket/terminal_handler_test.go b/apps/backend/internal/gateway/websocket/terminal_handler_test.go index 0ae1c92bdd..34050c42d6 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler_test.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler_test.go @@ -15,8 +15,10 @@ import ( "github.com/gin-gonic/gin" agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" + taskmodels "github.com/kandev/kandev/internal/task/models" ) func TestStripTerminalResponses(t *testing.T) { @@ -256,3 +258,73 @@ func testTerminalLogger(t *testing.T) *logger.Logger { } return log } + +type stubExecutorProfileReader struct { + session *taskmodels.TaskSession + env *taskmodels.TaskEnvironment + profile *taskmodels.ExecutorProfile +} + +func (s *stubExecutorProfileReader) GetTaskSession(_ context.Context, _ string) (*taskmodels.TaskSession, error) { + return s.session, nil +} + +func (s *stubExecutorProfileReader) GetTaskEnvironment(_ context.Context, _ string) (*taskmodels.TaskEnvironment, error) { + return s.env, nil +} + +func (s *stubExecutorProfileReader) GetExecutorProfile(_ context.Context, _ string) (*taskmodels.ExecutorProfile, error) { + return s.profile, nil +} + +// A shell terminal opened on a workspace must start with the executor profile's +// env vars exported — the agent subprocess and the repository setup script both +// get them, and the terminal was the remaining gap. +func TestStartUserShellProcessExportsExecutorProfileEnv(t *testing.T) { + log := testTerminalLogger(t) + manager := lifecycle.NewManager( + nil, + bus.NewMemoryEventBus(log), + nil, + nil, + nil, + nil, + lifecycle.ExecutorFallbackDeny, + t.TempDir(), + log, + ) + manager.SetExecutorProfileReader(&stubExecutorProfileReader{ + session: &taskmodels.TaskSession{ID: "session-1", ExecutorProfileID: "prof-1"}, + env: &taskmodels.TaskEnvironment{ID: "env-1", ExecutorProfileID: "prof-1"}, + profile: &taskmodels.ExecutorProfile{ + ID: "prof-1", + EnvVars: []taskmodels.ProfileEnvVar{{Key: "FONTAWESOME_NPM_AUTH_TOKEN", Value: "fa-secret-value"}}, + }, + }) + handler := NewTerminalHandler(manager, nil, nil, log) + runner := process.NewInteractiveRunner(nil, log, 2*1024*1024) + + execution := &lifecycle.AgentExecution{ + ID: "exec-1", + SessionID: "session-1", + TaskEnvironmentID: "env-1", + WorkspacePath: t.TempDir(), + } + + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/terminal/environment/env-1?terminalId=term-1", nil) + + processID, status, errMsg := handler.startUserShellProcess(c, execution, "env-1", "term-1", runner) + if errMsg != "" { + t.Fatalf("startUserShellProcess() status=%d error=%q", status, errMsg) + } + + env, ok := runner.StartEnvForTesting(processID) + if !ok { + t.Fatalf("process %q not registered", processID) + } + if env["FONTAWESOME_NPM_AUTH_TOKEN"] != "fa-secret-value" { + t.Fatalf("shell env = %#v, want executor-profile var exported", env) + } +} diff --git a/docs/public/executors.md b/docs/public/executors.md index 1f384763aa..9f9deaaa9d 100644 --- a/docs/public/executors.md +++ b/docs/public/executors.md @@ -42,7 +42,7 @@ A profile stores: - an MCP policy JSON object; - runtime-specific configuration. -Literal environment values are stored with the profile. Use secret references for credentials. Resolved values and copied credential files normally become accessible to the agent and commands in that environment. SSH is narrower: its remote agent process receives only the credential allowlist documented below, not arbitrary profile variables. +Literal environment values are stored with the profile. Use secret references for credentials. Resolved values and copied credential files normally become accessible to the agent and commands in that environment, including repository setup scripts and the terminal panel's shells. A terminal that is already open keeps the environment it started with; open a new terminal after changing the profile. SSH is narrower: its remote agent process and terminals receive only the credential allowlist documented below, not arbitrary profile variables. The MCP editor checks only that the value is a JSON object. Its presets cover stdio, HTTP, and SSE transport allowances, server allowlists, and URL rewrites. Test restrictive policies with the actual MCP servers the agent needs; see [Automation and MCP](automation-and-mcp.md).