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:
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user