feat(acp): Supervisor.Spawn extraEnv parameter

- extraEnv overrides agent_kinds.encrypted_env same-name keys (spec §6.1 #11)
- Prevents admin from hard-coding ACW_MCP_TOKEN in agent_kinds
- SessionService passes nil for now; system token wiring lands in I4
- BuildSpawnEnv exported for testing the override priority
- Fix app.go NewSupervisor call to match updated signature (from I5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 12:46:50 +08:00
parent 3a448cee5f
commit d44ffc851f
5 changed files with 70 additions and 9 deletions
+29
View File
@@ -2,6 +2,7 @@ package acp_test
import (
"context"
"encoding/json"
"testing"
"time"
@@ -227,3 +228,31 @@ func TestAgentKindService_Update_PatchSemantics(t *testing.T) {
last := rec.entries[len(rec.entries)-1]
assert.Equal(t, "acp.agent_kind.update", last.Action)
}
func TestSupervisor_BuildSpawnEnv_ExtraEnvOverrides(t *testing.T) {
t.Parallel()
enc := testEncryptor(t)
plain, err := json.Marshal(map[string]string{"ACW_MCP_TOKEN": "kind-value", "OTHER": "kept"})
require.NoError(t, err)
encrypted, err := enc.Encrypt(plain)
require.NoError(t, err)
kind := &acp.AgentKind{EncryptedEnv: encrypted}
sup := acp.NewSupervisor(nil, nil, nil, nil, nil, nil, enc, acp.SupervisorConfig{}, nil)
env, err := sup.BuildSpawnEnv(kind, map[string]string{
"ACW_MCP_TOKEN": "system-value",
"ACW_MCP_URL": "http://x",
})
require.NoError(t, err)
assert.Contains(t, env, "ACW_MCP_TOKEN=system-value")
var hasOld bool
for _, e := range env {
if e == "ACW_MCP_TOKEN=kind-value" {
hasOld = true
}
}
assert.False(t, hasOld, "kind value must be overridden")
assert.Contains(t, env, "OTHER=kept")
assert.Contains(t, env, "ACW_MCP_URL=http://x")
}
+1 -1
View File
@@ -219,7 +219,7 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
go func() {
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if _, err := s.sup.Spawn(spawnCtx, out, kind); err != nil {
if _, err := s.sup.Spawn(spawnCtx, out, kind, nil); err != nil {
s.releaseOnFailure(spawnCtx, out, sessCaller, worktreeID)
}
}()
+33 -1
View File
@@ -27,6 +27,7 @@ import (
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
"github.com/yan1h/agent-coding-workflow/internal/mcp"
"github.com/yan1h/agent-coding-workflow/internal/workspace"
)
@@ -51,6 +52,7 @@ type Supervisor struct {
notify *notify.Dispatcher
wtSvc workspace.WorktreeService
wsRepo workspace.Repository
mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token
crypto *crypto.Encryptor
cfg SupervisorConfig
log *slog.Logger
@@ -60,6 +62,7 @@ type Supervisor struct {
// 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。
func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher,
wtSvc workspace.WorktreeService, wsRepo workspace.Repository,
mcpTokens mcp.TokenService,
enc *crypto.Encryptor, cfg SupervisorConfig, log *slog.Logger) *Supervisor {
if log == nil {
log = slog.Default()
@@ -71,6 +74,7 @@ func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher,
notify: disp,
wtSvc: wtSvc,
wsRepo: wsRepo,
mcpTokens: mcpTokens,
crypto: enc,
cfg: cfg,
log: log,
@@ -143,12 +147,17 @@ func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay {
// Spawn 启动一个子进程并完成 ACP handshake。返回的 *Process 已注册到 procs map;
// handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的
// worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。
func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind) (*Process, error) {
func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, extraEnv map[string]string) (*Process, error) {
envMap, err := decryptEnv(s.crypto, kind.EncryptedEnv)
if err != nil {
return nil, err
}
// System override: extraEnv overrides agent_kinds same-name keys (spec §6.1 #11)
for k, v := range extraEnv {
envMap[k] = v
}
// strip env:只保留 PATH + 解密 overrides,避免泄漏 master key、git 凭据等。
osEnv := []string{"PATH=" + os.Getenv("PATH")}
for k, v := range envMap {
@@ -258,6 +267,22 @@ func (s *Supervisor) handshake(ctx context.Context, sess *Session, relay *Relay,
return nil
}
// BuildSpawnEnv exposes env merge logic for testing.
func (s *Supervisor) BuildSpawnEnv(kind *AgentKind, extraEnv map[string]string) ([]string, error) {
envMap, err := decryptEnv(s.crypto, kind.EncryptedEnv)
if err != nil {
return nil, err
}
for k, v := range extraEnv {
envMap[k] = v
}
out := []string{"PATH=" + os.Getenv("PATH")}
for k, v := range envMap {
out = append(out, fmt.Sprintf("%s=%s", k, v))
}
return out, nil
}
// Kill 终止指定 session 的子进程。在 Unix 平台上先发 SIGTERM 等待 grace 时间,
// 仍未退出再 Process.Kill;Windows 没有可靠 SIGTERM 实现,直接 Process.Kill。
// 始终阻塞直到 monitorProcess 关闭 done channel。Kill 是幂等的——目标 session
@@ -416,6 +441,13 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt
}
}
_ = waitErr // 已通过 KilledByUs / ExitError 编码到 status / exitCode
// Revoke session's system MCP tokens (spec §6.3)
if s.mcpTokens != nil {
if err := s.mcpTokens.RevokeBySession(ctx, proc.SessionID); err != nil {
s.log.Error("acp.on_exit.mcp_revoke",
"session_id", proc.SessionID, "err", err.Error())
}
}
// 最后关 relay:广播 session_terminated 给 WS subscribers。
proc.Relay.Close(string(status), &exitCode)
+3 -3
View File
@@ -86,7 +86,7 @@ func TestSupervisor_HappyPath_Spawn_Handshake(t *testing.T) {
require.True(t, ok)
sup := newSupervisorForTest(t, pool, repo, true)
proc, err := sup.Spawn(ctx, sess, k)
proc, err := sup.Spawn(ctx, sess, k, nil)
require.NoError(t, err)
require.NotNil(t, proc)
@@ -163,7 +163,7 @@ func TestSupervisor_HangAgent_HandshakeTimeout(t *testing.T) {
slogDiscard(),
)
_, err = sup.Spawn(ctx, sess, k)
_, err = sup.Spawn(ctx, sess, k, nil)
require.Error(t, err, "hang agent must trigger handshake timeout")
deadline := time.After(5 * time.Second)
@@ -215,7 +215,7 @@ func TestSupervisor_AgentCrashes(t *testing.T) {
require.True(t, ok)
sup := newSupervisorForTest(t, pool, repo, true)
proc, err := sup.Spawn(ctx, sess, k)
proc, err := sup.Spawn(ctx, sess, k, nil)
require.NoError(t, err)
require.NotNil(t, proc)
+1 -1
View File
@@ -333,7 +333,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
acpProjAccess := acpProjectAccessAdapter{wsRepo: wsRepo, projectRepo: projectRepo}
acpSup := acp.NewSupervisor(
acpRepo, auditRec, notifyDispatcher,
wtSvc, wsRepo, enc,
wtSvc, wsRepo, nil, enc,
acp.SupervisorConfig{
SpawnTimeout: cfg.Acp.SpawnTimeout,
KillGrace: cfg.Acp.KillGrace,