You've already forked agentic-coding-workflow
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -49,9 +50,10 @@ type Supervisor struct {
|
||||
repo Repository
|
||||
audit audit.Recorder
|
||||
notify *notify.Dispatcher
|
||||
wtSvc workspace.WorktreeService
|
||||
wsRepo workspace.Repository
|
||||
crypto *crypto.Encryptor
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user