diff --git a/internal/acp/agentkind_service_test.go b/internal/acp/agentkind_service_test.go index f12d6c3..3def549 100644 --- a/internal/acp/agentkind_service_test.go +++ b/internal/acp/agentkind_service_test.go @@ -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") +} diff --git a/internal/acp/session_service.go b/internal/acp/session_service.go index f30e9c7..83d2a81 100644 --- a/internal/acp/session_service.go +++ b/internal/acp/session_service.go @@ -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) } }() diff --git a/internal/acp/supervisor.go b/internal/acp/supervisor.go index 7a230c8..8a4c980 100644 --- a/internal/acp/supervisor.go +++ b/internal/acp/supervisor.go @@ -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) diff --git a/internal/acp/supervisor_test.go b/internal/acp/supervisor_test.go index 06d6244..d70a756 100644 --- a/internal/acp/supervisor_test.go +++ b/internal/acp/supervisor_test.go @@ -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) diff --git a/internal/app/app.go b/internal/app/app.go index f000419..0130d5e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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,