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>
This commit is contained in:
João Salavisa
2026-07-28 00:08:57 +01:00
committed by GitHub
parent d705b3c2f3
commit 16495a6dcc
10 changed files with 502 additions and 7 deletions
@@ -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
}
@@ -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)
}
})
}
}
@@ -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.
@@ -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
@@ -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.
@@ -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"])
}
}
+5
View File
@@ -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")
@@ -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,
@@ -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)
}
}
+1 -1
View File
@@ -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).