Skip to content

Commit fb6bf3a

Browse files
kylecarbsCoder
andauthored
fix(agent): wire updateCommandEnv into process manager (#22451)
## Problem The `agentproc` process manager spawns processes with only `os.Environ()`, missing agent-level environment variables like `GIT_ASKPASS`, `CODER_*`, and `GIT_SSH_COMMAND` that are injected by the agent's `updateCommandEnv` function. This means processes started through the HTTP process API (used by chat tools) cannot authenticate git operations via the Coder gitaskpass helper. By contrast, SSH sessions get the full agent environment because the SSH server calls `updateCommandEnv` via its `UpdateEnv` config hook. ## Fix Wire the agent's `updateCommandEnv` hook into the process manager so all spawned processes receive the full agent environment. The hook is: - Passed as a parameter through `NewAPI` → `newManager` - Called in `manager.start()` with `os.Environ()` as the base, producing the same enriched env that SSH sessions get - Gracefully falls back to `os.Environ()` if the hook returns an error Request-level env vars (`req.Env`, set by chat tools) are still appended last and take precedence. ## Changes - `agent/agentproc/process.go`: Add `updateEnv` field to manager, call it when building process env - `agent/agentproc/api.go`: Accept `updateEnv` parameter in `NewAPI` - `agent/agent.go`: Pass `a.updateCommandEnv` when creating the process API - `agent/agentproc/api_test.go`: Add `UpdateEnvHook` and `UpdateEnvHookOverriddenByReqEnv` tests Co-authored-by: Coder <coder@coder.com>
1 parent 533b90a commit fb6bf3a

4 files changed

Lines changed: 94 additions & 19 deletions

File tree

agent/agent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ func (a *agent) init() {
377377
a.containerAPI = agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...)
378378

379379
a.filesAPI = agentfiles.NewAPI(a.logger.Named("files"), a.filesystem)
380-
a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer)
380+
a.processAPI = agentproc.NewAPI(a.logger.Named("processes"), a.execer, a.updateCommandEnv)
381381

382382
a.reconnectingPTYServer = reconnectingpty.NewServer(
383383
a.logger.Named("reconnecting-pty"),

agent/agentproc/api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ type API struct {
2222
}
2323

2424
// NewAPI creates a new process API handler.
25-
func NewAPI(logger slog.Logger, execer agentexec.Execer) *API {
25+
func NewAPI(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error)) *API {
2626
return &API{
2727
logger: logger,
28-
manager: newManager(logger, execer),
28+
manager: newManager(logger, execer, updateEnv),
2929
}
3030
}
3131

agent/agentproc/api_test.go

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,18 @@ func postSignal(t *testing.T, handler http.Handler, id string, req workspacesdk.
8888
// execer, returning the handler and API.
8989
func newTestAPI(t *testing.T) http.Handler {
9090
t.Helper()
91+
return newTestAPIWithUpdateEnv(t, nil)
92+
}
93+
94+
// newTestAPIWithUpdateEnv creates a new API with an optional
95+
// updateEnv hook for testing environment injection.
96+
func newTestAPIWithUpdateEnv(t *testing.T, updateEnv func([]string) ([]string, error)) http.Handler {
97+
t.Helper()
9198

9299
logger := slogtest.Make(t, &slogtest.Options{
93100
IgnoreErrors: true,
94101
}).Leveled(slog.LevelDebug)
95-
api := agentproc.NewAPI(logger, agentexec.DefaultExecer)
102+
api := agentproc.NewAPI(logger, agentexec.DefaultExecer, updateEnv)
96103
t.Cleanup(func() {
97104
_ = api.Close()
98105
})
@@ -257,6 +264,54 @@ func TestStartProcess(t *testing.T) {
257264
require.Equal(t, 0, *resp.ExitCode)
258265
require.Contains(t, strings.TrimSpace(resp.Output), envVal)
259266
})
267+
268+
t.Run("UpdateEnvHook", func(t *testing.T) {
269+
t.Parallel()
270+
271+
envKey := fmt.Sprintf("TEST_UPDATE_ENV_%d", time.Now().UnixNano())
272+
envVal := "injected_by_hook"
273+
274+
handler := newTestAPIWithUpdateEnv(t, func(current []string) ([]string, error) {
275+
return append(current, fmt.Sprintf("%s=%s", envKey, envVal)), nil
276+
})
277+
278+
// The process should see the variable even though it
279+
// was not passed in req.Env.
280+
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
281+
Command: fmt.Sprintf("printenv %s", envKey),
282+
})
283+
284+
resp := waitForExit(t, handler, id)
285+
require.NotNil(t, resp.ExitCode)
286+
require.Equal(t, 0, *resp.ExitCode)
287+
require.Contains(t, strings.TrimSpace(resp.Output), envVal)
288+
})
289+
290+
t.Run("UpdateEnvHookOverriddenByReqEnv", func(t *testing.T) {
291+
t.Parallel()
292+
293+
envKey := fmt.Sprintf("TEST_OVERRIDE_%d", time.Now().UnixNano())
294+
hookVal := "from_hook"
295+
reqVal := "from_request"
296+
297+
handler := newTestAPIWithUpdateEnv(t, func(current []string) ([]string, error) {
298+
return append(current, fmt.Sprintf("%s=%s", envKey, hookVal)), nil
299+
})
300+
301+
// req.Env should take precedence over the hook.
302+
id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{
303+
Command: fmt.Sprintf("printenv %s", envKey),
304+
Env: map[string]string{envKey: reqVal},
305+
})
306+
307+
resp := waitForExit(t, handler, id)
308+
require.NotNil(t, resp.ExitCode)
309+
require.Equal(t, 0, *resp.ExitCode)
310+
// When duplicate env vars exist, shells use the last
311+
// value. Since req.Env is appended after the hook,
312+
// the request value wins.
313+
require.Contains(t, strings.TrimSpace(resp.Output), reqVal)
314+
})
260315
}
261316

262317
func TestListProcesses(t *testing.T) {

agent/agentproc/process.go

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,23 @@ func (p *process) output() (string, *workspacesdk.ProcessTruncation) {
6565

6666
// manager tracks processes spawned by the agent.
6767
type manager struct {
68-
mu sync.Mutex
69-
logger slog.Logger
70-
execer agentexec.Execer
71-
clock quartz.Clock
72-
procs map[string]*process
73-
closed bool
68+
mu sync.Mutex
69+
logger slog.Logger
70+
execer agentexec.Execer
71+
clock quartz.Clock
72+
procs map[string]*process
73+
closed bool
74+
updateEnv func(current []string) (updated []string, err error)
7475
}
7576

7677
// newManager creates a new process manager.
77-
func newManager(logger slog.Logger, execer agentexec.Execer) *manager {
78+
func newManager(logger slog.Logger, execer agentexec.Execer, updateEnv func(current []string) (updated []string, err error)) *manager {
7879
return &manager{
79-
logger: logger,
80-
execer: execer,
81-
clock: quartz.NewReal(),
82-
procs: make(map[string]*process),
80+
logger: logger,
81+
execer: execer,
82+
clock: quartz.NewReal(),
83+
procs: make(map[string]*process),
84+
updateEnv: updateEnv,
8385
}
8486
}
8587

@@ -116,13 +118,31 @@ func (m *manager) start(req workspacesdk.StartProcessRequest) (*process, error)
116118
cmd.Stdout = buf
117119
cmd.Stderr = buf
118120

119-
if len(req.Env) > 0 {
120-
cmd.Env = os.Environ()
121-
for k, v := range req.Env {
122-
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
121+
// Build the process environment. If the manager has an
122+
// updateEnv hook (provided by the agent), use it to get the
123+
// full agent environment including GIT_ASKPASS, CODER_* vars,
124+
// etc. Otherwise fall back to the current process env.
125+
baseEnv := os.Environ()
126+
if m.updateEnv != nil {
127+
updated, err := m.updateEnv(baseEnv)
128+
if err != nil {
129+
m.logger.Warn(
130+
context.Background(),
131+
"failed to update command environment, falling back to os env",
132+
slog.Error(err),
133+
)
134+
} else {
135+
baseEnv = updated
123136
}
124137
}
125138

139+
// Always set cmd.Env explicitly so that req.Env overrides
140+
// are applied on top of the full agent environment.
141+
cmd.Env = baseEnv
142+
for k, v := range req.Env {
143+
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
144+
}
145+
126146
if err := cmd.Start(); err != nil {
127147
cancel()
128148
return nil, xerrors.Errorf("start process: %w", err)

0 commit comments

Comments
 (0)