diff --git a/.gitignore b/.gitignore index 6c8726d..f740e06 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,7 @@ Thumbs.db /data/ # Internal docs (specs / plans should never be committed) -/docs/superpowers/ +/docs/ # Local worktrees (created by superpowers:using-git-worktrees skill) /.worktrees/ diff --git a/cmd/fake-acp-agent/main.go b/cmd/fake-acp-agent/main.go index 9699f9c..4735f78 100644 --- a/cmd/fake-acp-agent/main.go +++ b/cmd/fake-acp-agent/main.go @@ -7,6 +7,8 @@ // FAKE_ACP_FS_READ=path prompt 时发起 fs/read_text_file 请求该路径 // FAKE_ACP_FS_WRITE=path:content prompt 时发起 fs/write_text_file // FAKE_ACP_PROMPT_UPDATES=N 每 prompt 发 N 个 update(默认 3) +// FAKE_ACP_PERMISSION=toolName prompt 时发起 session/request_permission +// (options: allow_once/reject_once),阻塞等响应后再结束 package main import ( @@ -66,14 +68,14 @@ func main() { case "session/new": respond(out, msg.ID, map[string]any{"sessionId": "fake-session-id"}) case "session/prompt": - handlePrompt(out, msg.ID) + handlePrompt(in, out, msg.ID) case "session/cancel": // ignore } } } -func handlePrompt(out *bufio.Writer, id json.RawMessage) { +func handlePrompt(in *bufio.Reader, out *bufio.Writer, id json.RawMessage) { updates := 3 if v := os.Getenv("FAKE_ACP_PROMPT_UPDATES"); v != "" { if n, _ := strconv.Atoi(v); n > 0 { @@ -102,6 +104,10 @@ func handlePrompt(out *bufio.Writer, id json.RawMessage) { } } + if tool := os.Getenv("FAKE_ACP_PERMISSION"); tool != "" { + doRequestPermission(in, out, tool) + } + respond(out, id, map[string]any{"stopReason": "end_turn"}) if maxStr := os.Getenv("FAKE_ACP_CRASH_AFTER_PROMPTS"); maxStr != "" { @@ -139,6 +145,43 @@ func doFsWrite(out *bufio.Writer, path, content string) error { return nil } +// doRequestPermission 发起 session/request_permission 并阻塞读取 stdin 直到收到 +// 匹配 id 的响应(期间忽略其它消息)。用于端到端验证人工审批/自动放行链路。 +func doRequestPermission(in *bufio.Reader, out *bufio.Writer, tool string) { + id := nextServerID.Add(1) + idJSON, _ := json.Marshal(id) + body := mustJSON(Message{ + JSONRPC: "2.0", ID: idJSON, Method: "session/request_permission", + Params: mustJSON(map[string]any{ + "sessionId": "fake-session-id", + "toolCall": map[string]any{"name": tool}, + "options": []map[string]any{ + {"id": "allow_once", "name": "Allow"}, + {"id": "reject_once", "name": "Reject"}, + }, + }), + }) + _, _ = out.Write(body) + _, _ = out.Write([]byte{'\n'}) + _ = out.Flush() + + // 阻塞等待该 request 的响应(忽略中途其它帧)。 + want := string(idJSON) + for { + line, err := in.ReadBytes('\n') + if err != nil { + return + } + var m Message + if err := json.Unmarshal(line, &m); err != nil { + continue + } + if len(m.ID) > 0 && string(m.ID) == want && (m.Result != nil || m.Error != nil) { + return + } + } +} + func respond(out *bufio.Writer, id json.RawMessage, result any) { body := mustJSON(Message{JSONRPC: "2.0", ID: id, Result: mustJSON(result)}) _, _ = out.Write(body) diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/jobs.md b/docs/jobs.md deleted file mode 100644 index a39e5b2..0000000 --- a/docs/jobs.md +++ /dev/null @@ -1,141 +0,0 @@ -# 后台 Job Runner 操作指南 - -ACW 使用一个 DB-backed 的 job 队列 + 周期 runner 处理后台任务:workspace fetch、worktree -prune、附件清理、job reaper、job purge,以及 webhook 投递。本文档面向运维:如何启用、 -如何排障、如何手动验收。 - -## 总览 - -- **队列存储**:表 `jobs`(payload JSONB),`SELECT FOR UPDATE SKIP LOCKED` 并发租约。 -- **Worker 池**:`jobs.workers` 个 goroutine 拉取并执行;handler 失败按指数退避重试,超过 - `max_attempts` 或 handler 返回 `Permanent` 错误进入 `dead` 状态。 -- **周期 runner**:每个 runner 由 `time.Ticker` 触发;panic 由 scheduler 捕获不影响其他 - runner。 -- **Webhook**:`WebhookNotifier` 注册到通知 dispatcher 之上,按 topic 白名单 enqueue - `webhook.deliver` job;handler 用 HMAC-SHA256 签名后 POST。 -- **死信兜底**:webhook 进入 dead 状态时会写一条 audit、并向所有 admin 用户 fan-out 一条 - inApp 通知,避免外部投递失败被静默吞掉。 - -## 配置 - -配置在 `config.yaml` 的 `jobs:` 与 `notify.webhook:` 段,默认值见 `config.example.yaml`。 - -```yaml -jobs: - enabled: true # 整体开关;false 时不启动 scheduler/worker - workers: 2 # worker 池大小 - shutdown_grace: 30s # 关停时等待 worker 排空的最长时间 - - workspace_fetch: # A:每个 idle workspace 跑 git fetch - enabled: true - interval: 5m - - worktree_prune: # B:两阶段清理 idle worktree - enabled: true - interval: 24h - idle_threshold: 720h # 30d:超过即可被 prune - warning_lead: 72h # 3d:在 prune 前 72h 发警告通知 - - attachment_cleanup: # C:删除软删会话或孤儿态附件 - enabled: true - interval: 24h - retention: 720h # 30d - - job_reaper: # D:将超过 lease_timeout 仍未完成的 running job 回退为 pending - enabled: true - interval: 1m - lease_timeout: 5m - - job_purge: # 物理清理 completed/dead 旧记录 - enabled: true - interval: 24h - completed_retention: 168h # 7d - -notify: - webhook: - enabled: true - url: https://hooks.example.com/incoming - secret: ${WEBHOOK_SECRET} - timeout: 10s - topics: # 白名单:未列出的 topic 不会进 jobs 表 - - workspace.sync_failed - - worktree.prune_pending -``` - -`notify.webhook.enabled=false` 时 `WebhookNotifier.Send` 直接 short-circuit,不会写 -`jobs` 表。 - -## Webhook 协议 - -handler 对每个 webhook job POST 一次: - -| Header | 说明 | -| ------------------ | -------------------------------------------- | -| `Content-Type` | `application/json` | -| `X-ACW-Topic` | 通知 topic(同 body 中 `topic` 字段) | -| `X-ACW-Event-Id` | job UUID,用于接收方去重 | -| `X-ACW-Timestamp` | Unix 秒,签名 payload 的一部分 | -| `X-ACW-Signature` | `sha256=`,HMAC-SHA256(`.` + body) | - -**响应处理**: - -- 2xx → 标记完成 -- 4xx → 永久失败(`Permanent`),直接 dead-letter -- 5xx / 网络错误 → 退避重试,超过 `max_attempts` 后 dead-letter - -### 接收方验签示例(Python) - -```python -import hmac, hashlib, time - -def verify(secret: str, headers: dict, body: bytes) -> bool: - ts = headers["X-ACW-Timestamp"] - sig = headers["X-ACW-Signature"] - if abs(time.time() - int(ts)) > 300: - return False - mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256) - expected = f"sha256={mac.hexdigest()}" - return hmac.compare_digest(expected, sig) -``` - -## 手动验收清单 - -每次较大改动后按以下步骤过一遍。前置:本地起一个 `nc -l` 或 `httpbin.org/status/500` -作为 webhook 接收端。 - -- [ ] **关 webhook**(`notify.webhook.enabled=false`)→ 触发一次 fetch 失败 → 用户收到 - inApp 通知,但 `SELECT count(*) FROM jobs WHERE type='webhook.deliver'` 应为 0。 -- [ ] **开 webhook + 故意写错 URL**(指向 `localhost:1` 等不可达端口)→ 触发一条 - `workspace.sync_failed` → 看到 5 次重试日志(`job.fail_retry`)+ 1 条 - `status='dead'` 行 + 所有 admin 收到 `webhook.dead_letter` 兜底通知。 -- [ ] **prune 警告阶段**:手工 `UPDATE workspace_worktrees SET last_used_at = now() - - INTERVAL '28 days' WHERE id = ''` → 等下一轮 worktree_prune tick → 收到 - inApp 警告 + 该行 `prune_warning_at` 被写入。 -- [ ] **prune 复位**:接着对该 worktree acquire → release 一次 → `prune_warning_at` - 被清空、`last_used_at` 推到 now。 -- [ ] **prune 执行阶段**:`UPDATE workspace_worktrees SET last_used_at = now() - - INTERVAL '31 days' WHERE id = ''` → 等下一轮 tick → 真删 worktree(验证目录 - 消失 + DB 行删)。 -- [ ] **reaper 复位**:`kill -9` 进程模拟崩溃,重启后等 `lease_timeout + interval` → - `SELECT * FROM jobs WHERE status='running'` 应为空(被 D2 reaper 复位为 - `pending`)。 -- [ ] **purge**:手工把一行的 `completed_at` 改成 8 天前 → 等下一轮 job_purge tick → - 该行被物理删除。 - -## 常见排障 - -| 现象 | 排查思路 | -| ------------------------------------ | ----------------------------------------------------------------------------- | -| `jobs` 表只增不减 | 检查 `job_purge.enabled` 和 `completed_retention`;看 `job_purge` runner 日志 | -| webhook 永远收不到 | 检查 `notify.webhook.enabled`、URL 可达性、topic 是否在白名单 | -| 大量 `status='dead'` | 看 `last_error`;典型是接收端持续 5xx 或签名校验不过 | -| Worker 卡住不出 job | 看 `lease_timeout`,可能被 reaper 复位中;或 pool 已满(`workers` 配置过小) | -| 关停时 `jobs.module.shutdown_timeout` | 增大 `shutdown_grace`,或检查是否有 handler 不响应 ctx 取消 | - -## 相关代码索引 - -- `internal/jobs/`:核心包(domain / repository / scheduler / worker / module / notifier) -- `internal/jobs/handlers/webhook.go`:webhook 投递 handler -- `internal/jobs/runners/`:5 个周期 runner -- `internal/app/app.go`:runner 与 dispatcher 的 wiring + 关停顺序 -- `migrations/0005_jobs.up.sql`:表结构 diff --git a/internal/acp/agentkind_service.go b/internal/acp/agentkind_service.go index 0bd73c1..eb3a248 100644 --- a/internal/acp/agentkind_service.go +++ b/internal/acp/agentkind_service.go @@ -37,10 +37,15 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK if args == nil { args = []string{} } + allowlist := in.ToolAllowlist + if allowlist == nil { + allowlist = []string{} + } k := &AgentKind{ ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description, BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled, - CreatedBy: c.UserID, + ToolAllowlist: allowlist, + CreatedBy: c.UserID, } out, err := s.repo.CreateAgentKind(ctx, k) if err != nil { @@ -105,6 +110,10 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i cur.EncryptedEnv = encrypted changed["env"] = "" } + if in.ToolAllowlist != nil { + cur.ToolAllowlist = in.ToolAllowlist + changed["tool_allowlist"] = "" + } if in.Enabled != nil && *in.Enabled != cur.Enabled { cur.Enabled = *in.Enabled changed["enabled"] = *in.Enabled diff --git a/internal/acp/agentkind_service_test.go b/internal/acp/agentkind_service_test.go index 3def549..a9248e3 100644 --- a/internal/acp/agentkind_service_test.go +++ b/internal/acp/agentkind_service_test.go @@ -256,3 +256,26 @@ func TestSupervisor_BuildSpawnEnv_ExtraEnvOverrides(t *testing.T) { assert.Contains(t, env, "OTHER=kept") assert.Contains(t, env, "ACW_MCP_URL=http://x") } + +// ===== PermissionRequest(权限审批框架,测试桩) ===== +func (f *fakeAgentKindRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) { + return &acp.PermissionRequest{}, nil +} +func (f *fakeAgentKindRepo) GetPermissionRequestByID(context.Context, uuid.UUID) (*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeAgentKindRepo) ListPendingPermissionRequestsBySession(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeAgentKindRepo) ListPendingPermissionRequestsByUser(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeAgentKindRepo) DecidePermissionRequest(context.Context, uuid.UUID, acp.PermissionStatus, *string, *uuid.UUID) (*acp.PermissionRequest, bool, error) { + return nil, false, nil +} +func (f *fakeAgentKindRepo) ExpirePendingPermissionRequestsBySession(context.Context, uuid.UUID) (int64, error) { + return 0, nil +} +func (f *fakeAgentKindRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) { + return 0, nil +} diff --git a/internal/acp/domain.go b/internal/acp/domain.go index 6582d75..d294037 100644 --- a/internal/acp/domain.go +++ b/internal/acp/domain.go @@ -58,6 +58,7 @@ type AgentKind struct { Args []string EncryptedEnv []byte Enabled bool + ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示全部放行 CreatedBy uuid.UUID CreatedAt time.Time UpdatedAt time.Time @@ -66,23 +67,23 @@ type AgentKind struct { // Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自 // session/new response),与本表 ID 不同,仅用于 ACP 协议 method 内填充。 type Session struct { - ID uuid.UUID - WorkspaceID uuid.UUID - ProjectID uuid.UUID - AgentKindID uuid.UUID - UserID uuid.UUID - IssueID *uuid.UUID - RequirementID *uuid.UUID - AgentSessionID *string - Branch string - CwdPath string - IsMainWorktree bool - Status SessionStatus - PID *int32 - ExitCode *int32 - LastError *string - StartedAt time.Time - EndedAt *time.Time + ID uuid.UUID + WorkspaceID uuid.UUID + ProjectID uuid.UUID + AgentKindID uuid.UUID + UserID uuid.UUID + IssueID *uuid.UUID + RequirementID *uuid.UUID + AgentSessionID *string + Branch string + CwdPath string + IsMainWorktree bool + Status SessionStatus + PID *int32 + ExitCode *int32 + LastError *string + StartedAt time.Time + EndedAt *time.Time } // Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON @@ -136,22 +137,50 @@ type CreateSessionInput struct { // CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。 type CreateAgentKindInput struct { - Name string - DisplayName string - Description string - BinaryPath string - Args []string - Env map[string]string - Enabled bool + Name string + DisplayName string + Description string + BinaryPath string + Args []string + Env map[string]string + Enabled bool + ToolAllowlist []string } // UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。 // Env 三态:nil = 不修改;空 map = 清空全部 env;非空 = 整体替换。 type UpdateAgentKindInput struct { - DisplayName *string - Description *string - BinaryPath *string - Args []string // nil = 不改;非 nil = 替换 - Env map[string]string // nil = 不改;非 nil(含空)= 替换 - Enabled *bool + DisplayName *string + Description *string + BinaryPath *string + Args []string // nil = 不改;非 nil = 替换 + Env map[string]string // nil = 不改;非 nil(含空)= 替换 + Enabled *bool + ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换 +} + +// PermissionStatus 是 acp_permission_requests.status 枚举。 +type PermissionStatus string + +const ( + PermissionPending PermissionStatus = "pending" + PermissionApproved PermissionStatus = "approved" + PermissionDenied PermissionStatus = "denied" + PermissionAuto PermissionStatus = "auto" + PermissionExpired PermissionStatus = "expired" +) + +// PermissionRequest 是一条代理发起的工具调用授权请求记录。 +type PermissionRequest struct { + ID uuid.UUID + SessionID uuid.UUID + AgentRequestID string + ToolName string + ToolCall []byte // 原始 toolCall JSON + Options []byte // 原始 options JSON + Status PermissionStatus + ChosenOptionID *string + DecidedBy *uuid.UUID + DecidedAt *time.Time + CreatedAt time.Time } diff --git a/internal/acp/dto.go b/internal/acp/dto.go index eb5f720..3410afa 100644 --- a/internal/acp/dto.go +++ b/internal/acp/dto.go @@ -1,22 +1,26 @@ package acp import ( + "encoding/json" + "time" + "github.com/google/uuid" ) // AgentKindAdminDTO 是 admin 视图的完整字段。EnvKeys 仅返回 key 列表(不返回值)。 type AgentKindAdminDTO struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EnvKeys []string `json:"env_keys"` - Enabled bool `json:"enabled"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EnvKeys []string `json:"env_keys"` + Enabled bool `json:"enabled"` + ToolAllowlist []string `json:"tool_allowlist"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。 @@ -29,22 +33,47 @@ type AgentKindPublicDTO struct { // CreateAgentKindReq / UpdateAgentKindReq 是 HTTP 请求体。 type CreateAgentKindReq struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - Env map[string]string `json:"env"` - Enabled *bool `json:"enabled"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + Env map[string]string `json:"env"` + Enabled *bool `json:"enabled"` + ToolAllowlist []string `json:"tool_allowlist"` } type UpdateAgentKindReq struct { - DisplayName *string `json:"display_name,omitempty"` - Description *string `json:"description,omitempty"` - BinaryPath *string `json:"binary_path,omitempty"` - Args []string `json:"args,omitempty"` - Env map[string]string `json:"env,omitempty"` - Enabled *bool `json:"enabled,omitempty"` + DisplayName *string `json:"display_name,omitempty"` + Description *string `json:"description,omitempty"` + BinaryPath *string `json:"binary_path,omitempty"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + ToolAllowlist []string `json:"tool_allowlist,omitempty"` +} + +// PermissionRequestDTO 是权限请求的对外视图。 +type PermissionRequestDTO struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + ToolName string `json:"tool_name"` + ToolCall json.RawMessage `json:"tool_call"` + Options json.RawMessage `json:"options"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +func permissionRequestToDTO(p *PermissionRequest) PermissionRequestDTO { + return PermissionRequestDTO{ + ID: p.ID.String(), + SessionID: p.SessionID.String(), + ToolName: p.ToolName, + ToolCall: json.RawMessage(p.ToolCall), + Options: json.RawMessage(p.Options), + Status: string(p.Status), + CreatedAt: p.CreatedAt.Format(time.RFC3339), + } } // CreateSessionReq is the request body for POST /api/v1/acp/sessions. diff --git a/internal/acp/handler.go b/internal/acp/handler.go index 3866575..70a9b34 100644 --- a/internal/acp/handler.go +++ b/internal/acp/handler.go @@ -42,6 +42,7 @@ type Handler struct { sessSvc SessionService sup *Supervisor repo Repository + permSvc *PermissionService resolver middleware.SessionResolver adminLookup AdminLookup enc *crypto.Encryptor @@ -50,9 +51,10 @@ type Handler struct { // NewHandler constructs Handler with full dependencies. func NewHandler(ak AgentKindService, ss SessionService, sup *Supervisor, repo Repository, - resolver middleware.SessionResolver, al AdminLookup, enc *crypto.Encryptor, cfg WSConfig) *Handler { + permSvc *PermissionService, resolver middleware.SessionResolver, al AdminLookup, + enc *crypto.Encryptor, cfg WSConfig) *Handler { return &Handler{ - akSvc: ak, sessSvc: ss, sup: sup, repo: repo, + akSvc: ak, sessSvc: ss, sup: sup, repo: repo, permSvc: permSvc, resolver: resolver, adminLookup: al, enc: enc, cfg: cfg, } } @@ -75,7 +77,74 @@ func (h *Handler) Mount(r chi.Router) { r.Delete("/{id}", h.terminateSession) r.Get("/{id}/events", h.getEvents) r.Get("/{id}/ws", h.sessionWS) + r.Get("/{id}/permissions", h.listSessionPermissions) }) + r.Route("/api/v1/acp/permissions", func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + r.Post("/{reqID}/approve", h.approvePermission) + r.Post("/{reqID}/deny", h.denyPermission) + }) +} + +// listSessionPermissions 返回某会话的待审权限请求(owner 或 admin)。 +func (h *Handler) listSessionPermissions(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, perr := uuid.Parse(chi.URLParam(r, "id")) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) + return + } + reqs, err := h.permSvc.ListPendingBySession(r.Context(), c, id) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]PermissionRequestDTO, 0, len(reqs)) + for _, p := range reqs { + out = append(out, permissionRequestToDTO(p)) + } + httpx.WriteJSON(w, http.StatusOK, out) +} + +type approvePermissionReq struct { + OptionID string `json:"option_id"` +} + +func (h *Handler) approvePermission(w http.ResponseWriter, r *http.Request) { + h.resolvePermission(w, r, true) +} + +func (h *Handler) denyPermission(w http.ResponseWriter, r *http.Request) { + h.resolvePermission(w, r, false) +} + +func (h *Handler) resolvePermission(w http.ResponseWriter, r *http.Request, approve bool) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + reqID, perr := uuid.Parse(chi.URLParam(r, "reqID")) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad request id")) + return + } + var optionID string + if approve { + var body approvePermissionReq + // 批准时 option_id 可选(缺省自动挑选 allow 类);请求体可空。 + _ = json.NewDecoder(r.Body).Decode(&body) + optionID = body.OptionID + } + if err := h.permSvc.Resolve(r.Context(), c, reqID, approve, optionID); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) } func (h *Handler) caller(r *http.Request) (Caller, error) { @@ -132,13 +201,14 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) { enabled = *req.Enabled } in := CreateAgentKindInput{ - Name: strings.TrimSpace(req.Name), - DisplayName: req.DisplayName, - Description: req.Description, - BinaryPath: strings.TrimSpace(req.BinaryPath), - Args: req.Args, - Env: req.Env, - Enabled: enabled, + Name: strings.TrimSpace(req.Name), + DisplayName: req.DisplayName, + Description: req.Description, + BinaryPath: strings.TrimSpace(req.BinaryPath), + Args: req.Args, + Env: req.Env, + Enabled: enabled, + ToolAllowlist: req.ToolAllowlist, } out, err := h.akSvc.Create(r.Context(), c, in) if err != nil { @@ -188,12 +258,13 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) { return } in := UpdateAgentKindInput{ - DisplayName: req.DisplayName, - Description: req.Description, - BinaryPath: req.BinaryPath, - Args: req.Args, - Env: req.Env, - Enabled: req.Enabled, + DisplayName: req.DisplayName, + Description: req.Description, + BinaryPath: req.BinaryPath, + Args: req.Args, + Env: req.Env, + Enabled: req.Enabled, + ToolAllowlist: req.ToolAllowlist, } out, err := h.akSvc.Update(r.Context(), c, id, in) if err != nil { @@ -539,17 +610,18 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO { } } return AgentKindAdminDTO{ - ID: k.ID, - Name: k.Name, - DisplayName: k.DisplayName, - Description: k.Description, - BinaryPath: k.BinaryPath, - Args: append([]string(nil), k.Args...), - EnvKeys: envKeys, - Enabled: k.Enabled, - CreatedBy: k.CreatedBy, - CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), - UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + ID: k.ID, + Name: k.Name, + DisplayName: k.DisplayName, + Description: k.Description, + BinaryPath: k.BinaryPath, + Args: append([]string(nil), k.Args...), + EnvKeys: envKeys, + Enabled: k.Enabled, + ToolAllowlist: append([]string(nil), k.ToolAllowlist...), + CreatedBy: k.CreatedBy, + CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), } } diff --git a/internal/acp/handler_e2e_test.go b/internal/acp/handler_e2e_test.go index a6fea03..0c6db36 100644 --- a/internal/acp/handler_e2e_test.go +++ b/internal/acp/handler_e2e_test.go @@ -155,9 +155,12 @@ func TestHandler_Session_CreateAndWS_E2E(t *testing.T) { nil, // mcpTokens — not exercised by handler e2e ) + permSvc := acp.NewPermissionService(repo, nil, 0, nil) + sup.SetPermissionService(permSvc) + permSvc.SetRelayLocator(sup) h := acp.NewHandler( nil, // ak svc — not exercised by sessions endpoints - svc, sup, repo, + svc, sup, repo, permSvc, fakeResolver{uid: uid}, fakeAdminLookup{admin: map[uuid.UUID]bool{uid: false}}, enc, diff --git a/internal/acp/handler_test.go b/internal/acp/handler_test.go index df41d60..c80004e 100644 --- a/internal/acp/handler_test.go +++ b/internal/acp/handler_test.go @@ -49,6 +49,7 @@ func mountHandlerWithRepo(t *testing.T, callerUID uuid.UUID, isAdmin bool, repo nil, // sessSvc — not exercised by agent-kinds tests nil, // sup — not exercised by agent-kinds tests nil, // repo — not exercised by agent-kinds tests + nil, // permSvc — not exercised by agent-kinds tests fakeResolver{uid: callerUID}, fakeAdminLookup{admin: map[uuid.UUID]bool{callerUID: isAdmin}}, enc, diff --git a/internal/acp/handlers/handlers.go b/internal/acp/handlers/handlers.go index 6ee012f..f86fea8 100644 --- a/internal/acp/handlers/handlers.go +++ b/internal/acp/handlers/handlers.go @@ -22,7 +22,8 @@ type SessionContext struct { WorkspaceID uuid.UUID UserID uuid.UUID CwdPath string - AgentSessionID *string // 可能 nil(handshake 未完成时不应触发 fs/* 但兜底) + AgentSessionID *string // 可能 nil(handshake 未完成时不应触发 fs/* 但兜底) + ToolAllowlist []string // agent kind 配置的工具白名单,命中则自动放行 } // FsResult 是 fs/* method 处理后返回给 relay 的结果。要么 OK(带 result), @@ -48,4 +49,3 @@ type FsHandler struct { func NewFsHandler() *FsHandler { return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()} } - diff --git a/internal/acp/handlers/server_permission.go b/internal/acp/handlers/server_permission.go index 7fc100c..9af5202 100644 --- a/internal/acp/handlers/server_permission.go +++ b/internal/acp/handlers/server_permission.go @@ -8,23 +8,38 @@ import ( // PermissionHandler handles session/request_permission (spec §5.1). // -// MVP behaviour (decision §1 #8 + §10 misc #1): fs.* tool calls are answered -// directly by the server (so they never reach this handler). Other tool calls -// default to "reject_once" — fully automated, no human-in-the-loop. -type PermissionHandler struct{} +// 行为分两层: +// - fs.* 工具调用由服务端直接应答(不经过本 handler)。 +// - 其他工具调用:若注入了 PermissionDecider(生产路径),交由其裁决——命中 +// agent kind 白名单则自动放行,否则挂起等待人工审批;未注入 decider 时 +// (部分单测/向后兼容)回退到「默认拒绝」的纯本地逻辑。 +type PermissionHandler struct { + decider PermissionDecider +} -// NewPermissionHandler constructs a PermissionHandler. -func NewPermissionHandler() *PermissionHandler { return &PermissionHandler{} } +// PermOption 是 session/request_permission 提供的一个候选选项。 +type PermOption struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +// PermissionDecider 是权限裁决器契约。实现放在 internal/acp 主包(PermissionService), +// 以接口注入避免 handlers 子包反向 import 主包形成循环。返回选中的 option id; +// 空串表示拒绝。 +type PermissionDecider interface { + Decide(ctx context.Context, sess SessionContext, agentRequestID string, + toolCall json.RawMessage, options []PermOption) (chosenOptionID string) +} + +// NewPermissionHandler 构造 PermissionHandler。decider 可为 nil(回退到本地默认拒绝)。 +func NewPermissionHandler(decider PermissionDecider) *PermissionHandler { + return &PermissionHandler{decider: decider} +} type permissionParams struct { SessionID string `json:"sessionId"` ToolCall json.RawMessage `json:"toolCall"` - Options []permOption `json:"options"` -} - -type permOption struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` + Options []PermOption `json:"options"` } type permOutcomeSelected struct { @@ -39,25 +54,21 @@ type permResult struct { Outcome permOutcome `json:"outcome"` } -// Handle picks the first option whose ID looks like a "reject" choice; if none -// matches, falls back to the first option. Aligns with spec §10 misc #4 -// ("default reject"). -func (h *PermissionHandler) Handle(_ context.Context, _ SessionContext, paramsJSON json.RawMessage) FsResult { +// Handle 解析参数后委托 decider 裁决;无 decider 时回退到本地默认拒绝逻辑。 +// agentRequestID 是代理侧 JSON-RPC request id,用于持久化与排障。 +func (h *PermissionHandler) Handle(ctx context.Context, sess SessionContext, agentRequestID string, paramsJSON json.RawMessage) FsResult { var p permissionParams if err := json.Unmarshal(paramsJSON, &p); err != nil { return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}} } - chosen := "" - for _, opt := range p.Options { - lc := strings.ToLower(opt.ID) - if strings.Contains(lc, "reject") || strings.Contains(lc, "deny") || strings.Contains(lc, "no") { - chosen = opt.ID - break - } - } - if chosen == "" && len(p.Options) > 0 { - chosen = p.Options[0].ID + + var chosen string + if h.decider != nil { + chosen = h.decider.Decide(ctx, sess, agentRequestID, p.ToolCall, p.Options) + } else { + chosen = defaultRejectChoice(p.Options) } + if chosen == "" { out, _ := json.Marshal(permResult{Outcome: permOutcome{}}) return FsResult{OK: out} @@ -65,3 +76,14 @@ func (h *PermissionHandler) Handle(_ context.Context, _ SessionContext, paramsJS out, _ := json.Marshal(permResult{Outcome: permOutcome{Selected: &permOutcomeSelected{ID: chosen}}}) return FsResult{OK: out} } + +// defaultRejectChoice 是无 decider 时的回退:优先选 reject 类选项,否则返回空(拒绝)。 +func defaultRejectChoice(options []PermOption) string { + for _, opt := range options { + lc := strings.ToLower(opt.ID) + if strings.Contains(lc, "reject") || strings.Contains(lc, "deny") || strings.Contains(lc, "no") { + return opt.ID + } + } + return "" +} diff --git a/internal/acp/handlers/server_permission_test.go b/internal/acp/handlers/server_permission_test.go index 70289a8..64aba85 100644 --- a/internal/acp/handlers/server_permission_test.go +++ b/internal/acp/handlers/server_permission_test.go @@ -11,9 +11,10 @@ import ( "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" ) +// 无 decider(nil)时回退到「默认拒绝」逻辑:优先选 reject 类选项。 func TestPermission_AutoReject(t *testing.T) { t.Parallel() - h := handlers.NewPermissionHandler() + h := handlers.NewPermissionHandler(nil) params := mustJSON(t, map[string]any{ "sessionId": "x", "toolCall": map[string]any{"name": "shell.exec"}, @@ -22,7 +23,7 @@ func TestPermission_AutoReject(t *testing.T) { map[string]any{"id": "reject_once"}, }, }) - res := h.Handle(context.Background(), handlers.SessionContext{}, params) + res := h.Handle(context.Background(), handlers.SessionContext{}, "", params) require.Nil(t, res.Err) var out struct { @@ -36,9 +37,10 @@ func TestPermission_AutoReject(t *testing.T) { assert.Equal(t, "reject_once", out.Outcome.Selected.ID) } -func TestPermission_NoRejectOption_FallbackFirst(t *testing.T) { +// 无 reject 类选项且无 decider 时,默认拒绝(返回空 outcome,不再误选 allow)。 +func TestPermission_NoRejectOption_DefaultsToReject(t *testing.T) { t.Parallel() - h := handlers.NewPermissionHandler() + h := handlers.NewPermissionHandler(nil) params := mustJSON(t, map[string]any{ "sessionId": "x", "options": []any{ @@ -46,23 +48,16 @@ func TestPermission_NoRejectOption_FallbackFirst(t *testing.T) { map[string]any{"id": "allow_session"}, }, }) - res := h.Handle(context.Background(), handlers.SessionContext{}, params) - var out struct { - Outcome struct { - Selected struct { - ID string `json:"id"` - } `json:"selected"` - } `json:"outcome"` - } - require.NoError(t, json.Unmarshal(res.OK, &out)) - assert.Equal(t, "allow_once", out.Outcome.Selected.ID) + res := h.Handle(context.Background(), handlers.SessionContext{}, "", params) + require.Nil(t, res.Err) + assert.JSONEq(t, `{"outcome":{}}`, string(res.OK)) } func TestPermission_NoOptions_EmptyOutcome(t *testing.T) { t.Parallel() - h := handlers.NewPermissionHandler() + h := handlers.NewPermissionHandler(nil) params := mustJSON(t, map[string]any{"sessionId": "x"}) - res := h.Handle(context.Background(), handlers.SessionContext{}, params) + res := h.Handle(context.Background(), handlers.SessionContext{}, "", params) require.Nil(t, res.Err) assert.JSONEq(t, `{"outcome":{}}`, string(res.OK)) } diff --git a/internal/acp/permission.go b/internal/acp/permission.go index 2355912..6c91268 100644 --- a/internal/acp/permission.go +++ b/internal/acp/permission.go @@ -1,8 +1,8 @@ // permission.go 实现 ACP fs/* method 的路径安全校验。 // // 规则(spec §5.6): -// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd) -// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸) +// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd) +// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸) // // 三重校验:filepath.Abs + strings.HasPrefix + filepath.EvalSymlinks。 package acp diff --git a/internal/acp/permission_service.go b/internal/acp/permission_service.go new file mode 100644 index 0000000..f6cf3cf --- /dev/null +++ b/internal/acp/permission_service.go @@ -0,0 +1,397 @@ +// permission_service.go 实装 ACP 工具调用权限审批框架。 +// +// 代理通过 session/request_permission 发起工具调用授权请求,relay 为每个请求起 +// 独立 goroutine 调用 PermissionHandler.Handle → PermissionService.Decide。Decide +// 分两条路径: +// - 命中 agent kind 的 tool_allowlist:自动放行(status=auto),立即返回。 +// - 未命中:落库为 pending、通过 WS 推送 permission_request 事件、阻塞等待人工 +// 决策(Resolve)/ 超时(默认拒绝)/ 会话终止(CancelSession)。 +// +// 阻塞安全:relay.handleAgentRequest 对每个 agent 请求起独立 goroutine,因此在 +// Decide 内阻塞不会卡住 reader 主循环;ctx 来自该请求的 relay.Run,会话关闭时取消。 +package acp + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// DefaultPermissionTimeout 是未配置时的人工决策超时;到期默认拒绝。 +const DefaultPermissionTimeout = 5 * time.Minute + +// RelayLocator 让 PermissionService 按 session id 取到 *Relay 以推送 WS 事件。 +// Supervisor 满足该接口(GetRelay)。用窄接口避免 permSvc 依赖整个 Supervisor。 +type RelayLocator interface { + GetRelay(sid uuid.UUID) *Relay +} + +// decision 是投递给阻塞中 Decide 的人工/系统决策结果。 +type decision struct { + optionID string +} + +// waiter 关联一个挂起的权限请求与其所属会话,便于会话终止时定向清理。 +type waiter struct { + ch chan decision + sessionID uuid.UUID +} + +// PermissionService 是进程级单例,注入到所有 relay 的 PermissionHandler。 +type PermissionService struct { + repo Repository + audit audit.Recorder + log *slog.Logger + timeout time.Duration + + mu sync.Mutex + waiters map[uuid.UUID]*waiter // requestID -> waiter + + locMu sync.RWMutex + loc RelayLocator +} + +// NewPermissionService 构造 PermissionService。timeout<=0 时用 DefaultPermissionTimeout。 +func NewPermissionService(repo Repository, rec audit.Recorder, timeout time.Duration, log *slog.Logger) *PermissionService { + if log == nil { + log = slog.Default() + } + if timeout <= 0 { + timeout = DefaultPermissionTimeout + } + return &PermissionService{ + repo: repo, + audit: rec, + log: log, + timeout: timeout, + waiters: map[uuid.UUID]*waiter{}, + } +} + +// SetRelayLocator 注入 relay 定位器。因 Supervisor 与 PermissionService 互相依赖, +// 装配时先构造二者再回填(与 workspace CloneRunner.SetScheduler 同模式)。 +func (s *PermissionService) SetRelayLocator(loc RelayLocator) { + s.locMu.Lock() + s.loc = loc + s.locMu.Unlock() +} + +// Decide 实现 handlers.PermissionDecider。返回选中的 option id;空串表示拒绝。 +func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionContext, agentReqID string, + toolCall json.RawMessage, options []handlers.PermOption) string { + + toolName := extractToolName(toolCall) + optionsJSON, _ := json.Marshal(options) + + // 路径一:白名单命中 → 自动放行。 + if allowlistHit(sess.ToolAllowlist, toolName) { + chosen := chooseAllowOption(options) + _, err := s.repo.InsertPermissionRequest(ctx, &PermissionRequest{ + ID: uuid.New(), + SessionID: sess.SessionID, + AgentRequestID: agentReqID, + ToolName: toolName, + ToolCall: jsonOrNull(toolCall), + Options: optionsJSON, + Status: PermissionAuto, + ChosenOptionID: strPtrOrNil(chosen), + }) + if err != nil { + s.log.Error("acp.permission.insert_auto", "session_id", sess.SessionID, "err", err.Error()) + } + s.recordDecision(ctx, sess.UserID, sess.SessionID, toolName, "auto", nil) + return chosen + } + + // 路径二:落库 pending 并阻塞等待决策。 + reqID := uuid.New() + row, err := s.repo.InsertPermissionRequest(ctx, &PermissionRequest{ + ID: reqID, + SessionID: sess.SessionID, + AgentRequestID: agentReqID, + ToolName: toolName, + ToolCall: jsonOrNull(toolCall), + Options: optionsJSON, + Status: PermissionPending, + }) + if err != nil { + // 落库失败时无法人工审批,保守默认拒绝。 + s.log.Error("acp.permission.insert_pending", "session_id", sess.SessionID, "err", err.Error()) + return "" + } + + w := &waiter{ch: make(chan decision, 1), sessionID: sess.SessionID} + s.mu.Lock() + s.waiters[reqID] = w + s.mu.Unlock() + defer s.removeWaiter(reqID) + + s.fanout(sess.SessionID, &EventEnvelope{ + Kind: "permission_request", + Payload: mustJSON(permissionRequestPayload(row)), + }) + + select { + case d := <-w.ch: + return d.optionID + case <-time.After(s.timeout): + // 超时 → CAS 标记 expired(系统决策,decided_by=NULL)。 + if _, ok, _ := s.repo.DecidePermissionRequest(context.WithoutCancel(ctx), reqID, PermissionExpired, nil, nil); ok { + s.fanout(sess.SessionID, resolvedEnvelope(reqID, PermissionExpired, "")) + } + return "" + case <-ctx.Done(): + // relay/进程关闭:CancelSession 已处理 DB;这里仅退出,agent 收默认拒绝。 + return "" + } +} + +// Resolve 落地一次人工决策(approve/deny)。鉴权:session owner 或 admin。 +func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UUID, approve bool, optionID string) error { + req, err := s.repo.GetPermissionRequestByID(ctx, reqID) + if err != nil { + return err + } + sessRow, err := s.repo.GetSessionByID(ctx, req.SessionID) + if err != nil { + return err + } + if !c.IsAdmin && sessRow.UserID != c.UserID { + return errs.New(errs.CodeAcpSessionNotOwned, "无权处理该会话的权限请求") + } + + var options []handlers.PermOption + _ = json.Unmarshal(req.Options, &options) + + var chosen string + var status PermissionStatus + if approve { + chosen = optionID + if chosen == "" { + chosen = chooseAllowOption(options) + } else if !optionExists(options, chosen) { + return errs.New(errs.CodeAcpPermissionInvalidOption, "无效的选项 id") + } + status = PermissionApproved + } else { + chosen = chooseRejectOption(options) + status = PermissionDenied + } + + decidedBy := c.UserID + _, ok, err := s.repo.DecidePermissionRequest(ctx, reqID, status, strPtrOrNil(chosen), &decidedBy) + if err != nil { + return err + } + if !ok { + // 已被超时/会话终止/重复决策抢先落地。 + return errs.New(errs.CodeAcpPermissionAlreadyDecided, "该权限请求已被处理") + } + + s.wake(reqID, decision{optionID: chosen}) + s.fanout(req.SessionID, resolvedEnvelope(reqID, status, chosen)) + s.recordDecision(ctx, c.UserID, req.SessionID, req.ToolName, string(status), &c.UserID) + return nil +} + +// ListPendingBySession 返回某会话的待审请求。鉴权同 Resolve。 +func (s *PermissionService) ListPendingBySession(ctx context.Context, c Caller, sessionID uuid.UUID) ([]*PermissionRequest, error) { + sessRow, err := s.repo.GetSessionByID(ctx, sessionID) + if err != nil { + return nil, err + } + if !c.IsAdmin && sessRow.UserID != c.UserID { + return nil, errs.New(errs.CodeAcpSessionNotOwned, "无权查看该会话") + } + return s.repo.ListPendingPermissionRequestsBySession(ctx, sessionID) +} + +// CancelSession 在会话终止时把其全部 pending 请求标 expired,并唤醒阻塞的 Decide +// goroutine 使其立即返回(agent 收默认拒绝)。supervisor.onExit 调用。 +func (s *PermissionService) CancelSession(ctx context.Context, sessionID uuid.UUID) { + if _, err := s.repo.ExpirePendingPermissionRequestsBySession(ctx, sessionID); err != nil { + s.log.Error("acp.permission.cancel_session", "session_id", sessionID, "err", err.Error()) + } + s.mu.Lock() + for reqID, w := range s.waiters { + if w.sessionID == sessionID { + select { + case w.ch <- decision{optionID: ""}: + default: + } + delete(s.waiters, reqID) + } + } + s.mu.Unlock() +} + +// ExpireStale 把超过 before 仍 pending 的请求标 expired(启动 reaper / 兜底)。 +func (s *PermissionService) ExpireStale(ctx context.Context, before time.Time) (int64, error) { + return s.repo.ExpireStalePermissionRequests(ctx, before) +} + +// ===== 内部辅助 ===== + +func (s *PermissionService) removeWaiter(reqID uuid.UUID) { + s.mu.Lock() + delete(s.waiters, reqID) + s.mu.Unlock() +} + +func (s *PermissionService) wake(reqID uuid.UUID, d decision) { + s.mu.Lock() + w, ok := s.waiters[reqID] + if ok { + delete(s.waiters, reqID) + } + s.mu.Unlock() + if ok { + select { + case w.ch <- d: + default: + } + } +} + +func (s *PermissionService) fanout(sessionID uuid.UUID, env *EventEnvelope) { + s.locMu.RLock() + loc := s.loc + s.locMu.RUnlock() + if loc == nil { + return + } + if relay := loc.GetRelay(sessionID); relay != nil { + relay.FanoutControl(env) + } +} + +func (s *PermissionService) recordDecision(ctx context.Context, actor, sessionID uuid.UUID, toolName, outcome string, decidedBy *uuid.UUID) { + if s.audit == nil { + return + } + a := actor + _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ + UserID: &a, + Action: "acp.permission." + outcome, + TargetType: "acp_session", + TargetID: sessionID.String(), + Metadata: map[string]any{ + "tool_name": toolName, + "decided_by": decidedBy, + }, + }) +} + +func permissionRequestPayload(p *PermissionRequest) map[string]any { + return map[string]any{ + "request_id": p.ID.String(), + "session_id": p.SessionID.String(), + "tool_name": p.ToolName, + "tool_call": json.RawMessage(p.ToolCall), + "options": json.RawMessage(p.Options), + "status": string(p.Status), + "created_at": p.CreatedAt, + } +} + +func resolvedEnvelope(reqID uuid.UUID, status PermissionStatus, chosen string) *EventEnvelope { + return &EventEnvelope{ + Kind: "permission_resolved", + Payload: mustJSON(map[string]any{ + "request_id": reqID.String(), + "status": string(status), + "chosen_option_id": chosen, + }), + } +} + +// extractToolName 尝试从 toolCall JSON 中提取工具标识。多个 agent 实现的字段名不同, +// 依次尝试 name/toolName/kind/title;都取不到则返回空(保守走人工审批)。 +func extractToolName(toolCall json.RawMessage) string { + if len(toolCall) == 0 { + return "" + } + var m map[string]any + if err := json.Unmarshal(toolCall, &m); err != nil { + return "" + } + for _, k := range []string{"name", "toolName", "kind", "title"} { + if v, ok := m[k].(string); ok && v != "" { + return v + } + } + return "" +} + +// allowlistHit 判断工具是否在白名单内。含 "*" 表示全部放行;toolName 为空时不放行。 +func allowlistHit(allowlist []string, toolName string) bool { + for _, a := range allowlist { + if a == "*" { + return true + } + if toolName != "" && a == toolName { + return true + } + } + return false +} + +// chooseAllowOption 选一个“批准”类选项 id:优先包含 allow/yes 的,否则取首个。 +func chooseAllowOption(options []handlers.PermOption) string { + if id := matchOption(options, []string{"allow", "yes", "approve", "accept"}); id != "" { + return id + } + if len(options) > 0 { + return options[0].ID + } + return "" +} + +// chooseRejectOption 选一个“拒绝”类选项 id:优先包含 reject/deny/no 的,否则空串。 +func chooseRejectOption(options []handlers.PermOption) string { + return matchOption(options, []string{"reject", "deny", "no"}) +} + +func matchOption(options []handlers.PermOption, keywords []string) string { + for _, opt := range options { + lc := strings.ToLower(opt.ID) + for _, kw := range keywords { + if strings.Contains(lc, kw) { + return opt.ID + } + } + } + return "" +} + +func optionExists(options []handlers.PermOption, id string) bool { + for _, opt := range options { + if opt.ID == id { + return true + } + } + return false +} + +func strPtrOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} + +func jsonOrNull(b json.RawMessage) []byte { + if len(b) == 0 { + return []byte("null") + } + return b +} diff --git a/internal/acp/permission_service_test.go b/internal/acp/permission_service_test.go new file mode 100644 index 0000000..add3735 --- /dev/null +++ b/internal/acp/permission_service_test.go @@ -0,0 +1,267 @@ +package acp_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// permFakeRepo 内嵌 acp.Repository 接口(nil),仅实现 PermissionService 用到的方法; +// 其余方法被调用会 panic(测试不应触达)。 +type permFakeRepo struct { + acp.Repository + mu sync.Mutex + reqs map[uuid.UUID]*acp.PermissionRequest + sessions map[uuid.UUID]*acp.Session +} + +func newPermFakeRepo() *permFakeRepo { + return &permFakeRepo{ + reqs: map[uuid.UUID]*acp.PermissionRequest{}, + sessions: map[uuid.UUID]*acp.Session{}, + } +} + +func (r *permFakeRepo) InsertPermissionRequest(_ context.Context, p *acp.PermissionRequest) (*acp.PermissionRequest, error) { + r.mu.Lock() + defer r.mu.Unlock() + cp := *p + cp.CreatedAt = time.Unix(0, 0) + r.reqs[cp.ID] = &cp + out := cp + return &out, nil +} + +func (r *permFakeRepo) GetPermissionRequestByID(_ context.Context, id uuid.UUID) (*acp.PermissionRequest, error) { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.reqs[id] + if !ok { + return nil, errs.New(errs.CodeAcpPermissionNotFound, "not found") + } + out := *p + return &out, nil +} + +func (r *permFakeRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Session, error) { + r.mu.Lock() + defer r.mu.Unlock() + s, ok := r.sessions[id] + if !ok { + return nil, errs.New(errs.CodeAcpSessionNotFound, "not found") + } + out := *s + return &out, nil +} + +func (r *permFakeRepo) DecidePermissionRequest(_ context.Context, id uuid.UUID, status acp.PermissionStatus, chosen *string, decidedBy *uuid.UUID) (*acp.PermissionRequest, bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.reqs[id] + if !ok || p.Status != acp.PermissionPending { + return nil, false, nil // CAS 失败:已非 pending + } + p.Status = status + p.ChosenOptionID = chosen + p.DecidedBy = decidedBy + out := *p + return &out, true, nil +} + +func (r *permFakeRepo) ExpirePendingPermissionRequestsBySession(_ context.Context, sid uuid.UUID) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + var n int64 + for _, p := range r.reqs { + if p.SessionID == sid && p.Status == acp.PermissionPending { + p.Status = acp.PermissionExpired + n++ + } + } + return n, nil +} + +func (r *permFakeRepo) statusOf(id uuid.UUID) acp.PermissionStatus { + r.mu.Lock() + defer r.mu.Unlock() + return r.reqs[id].Status +} + +func (r *permFakeRepo) onlyReqID() uuid.UUID { + r.mu.Lock() + defer r.mu.Unlock() + for id := range r.reqs { + return id + } + return uuid.Nil +} + +func optionsAllowReject() []handlers.PermOption { + return []handlers.PermOption{{ID: "allow_once"}, {ID: "reject_once"}} +} + +func TestPermission_AutoAllow_WhenAllowlisted(t *testing.T) { + repo := newPermFakeRepo() + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + sess := handlers.SessionContext{ + SessionID: uuid.New(), + UserID: uuid.New(), + ToolAllowlist: []string{"safe.tool"}, + } + chosen := svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"safe.tool"}`), optionsAllowReject()) + require.Equal(t, "allow_once", chosen) + require.Equal(t, acp.PermissionAuto, repo.statusOf(repo.onlyReqID())) +} + +func TestPermission_Pending_ThenApprove(t *testing.T) { + repo := newPermFakeRepo() + uid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + sess := handlers.SessionContext{SessionID: sid, UserID: uid} // 无白名单 → 挂起 + + resCh := make(chan string, 1) + go func() { + resCh <- svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + }() + + // 等待 pending 落库 + var reqID uuid.UUID + require.Eventually(t, func() bool { + reqID = repo.onlyReqID() + return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending + }, time.Second, 5*time.Millisecond) + + require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once")) + select { + case chosen := <-resCh: + require.Equal(t, "allow_once", chosen) + case <-time.After(time.Second): + t.Fatal("Decide 未在批准后返回") + } + require.Equal(t, acp.PermissionApproved, repo.statusOf(reqID)) +} + +func TestPermission_Pending_ThenDeny(t *testing.T) { + repo := newPermFakeRepo() + uid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + sess := handlers.SessionContext{SessionID: sid, UserID: uid} + + resCh := make(chan string, 1) + go func() { + resCh <- svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + }() + var reqID uuid.UUID + require.Eventually(t, func() bool { + reqID = repo.onlyReqID() + return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending + }, time.Second, 5*time.Millisecond) + + require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, false, "")) + require.Equal(t, "reject_once", <-resCh) + require.Equal(t, acp.PermissionDenied, repo.statusOf(reqID)) +} + +func TestPermission_Timeout_DefaultsToReject(t *testing.T) { + repo := newPermFakeRepo() + uid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} + svc := acp.NewPermissionService(repo, nil, 30*time.Millisecond, nil) + sess := handlers.SessionContext{SessionID: sid, UserID: uid} + + chosen := svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + require.Equal(t, "", chosen) // 默认拒绝 + require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID())) +} + +func TestPermission_DuplicateDecide_Conflict(t *testing.T) { + repo := newPermFakeRepo() + uid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + sess := handlers.SessionContext{SessionID: sid, UserID: uid} + + go svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + var reqID uuid.UUID + require.Eventually(t, func() bool { + reqID = repo.onlyReqID() + return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending + }, time.Second, 5*time.Millisecond) + + require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once")) + err := svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once") + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeAcpPermissionAlreadyDecided, ae.Code) +} + +func TestPermission_Resolve_NotOwner_Forbidden(t *testing.T) { + repo := newPermFakeRepo() + owner := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner} + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + sess := handlers.SessionContext{SessionID: sid, UserID: owner} + + go svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + var reqID uuid.UUID + require.Eventually(t, func() bool { + reqID = repo.onlyReqID() + return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending + }, time.Second, 5*time.Millisecond) + + err := svc.Resolve(context.Background(), acp.Caller{UserID: uuid.New()}, reqID, true, "allow_once") + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeAcpSessionNotOwned, ae.Code) + // admin 可处理任意会话 + require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uuid.New(), IsAdmin: true}, reqID, true, "allow_once")) +} + +func TestPermission_CancelSession_UnblocksDecide(t *testing.T) { + repo := newPermFakeRepo() + uid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + sess := handlers.SessionContext{SessionID: sid, UserID: uid} + + resCh := make(chan string, 1) + go func() { + resCh <- svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + }() + require.Eventually(t, func() bool { + id := repo.onlyReqID() + return id != uuid.Nil && repo.statusOf(id) == acp.PermissionPending + }, time.Second, 5*time.Millisecond) + + svc.CancelSession(context.Background(), sid) + select { + case chosen := <-resCh: + require.Equal(t, "", chosen) + case <-time.After(time.Second): + t.Fatal("CancelSession 未解除 Decide 阻塞") + } + require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID())) +} diff --git a/internal/acp/queries/agent_kinds.sql b/internal/acp/queries/agent_kinds.sql index ed46b7f..f1747d1 100644 --- a/internal/acp/queries/agent_kinds.sql +++ b/internal/acp/queries/agent_kinds.sql @@ -1,47 +1,48 @@ -- name: CreateAgentKind :one INSERT INTO acp_agent_kinds ( - id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at; + enabled, created_by, created_at, updated_at, tool_allowlist; -- name: GetAgentKindByID :one SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist FROM acp_agent_kinds WHERE id = $1; -- name: GetAgentKindByName :one SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist FROM acp_agent_kinds WHERE name = $1; -- name: ListAgentKinds :many SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist FROM acp_agent_kinds ORDER BY created_at DESC; -- name: ListEnabledAgentKinds :many SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist FROM acp_agent_kinds WHERE enabled = TRUE ORDER BY name ASC; -- name: UpdateAgentKind :one UPDATE acp_agent_kinds -SET display_name = $2, - description = $3, - binary_path = $4, - args = $5, - encrypted_env = $6, - enabled = $7, - updated_at = now() +SET display_name = $2, + description = $3, + binary_path = $4, + args = $5, + encrypted_env = $6, + enabled = $7, + tool_allowlist = $8, + updated_at = now() WHERE id = $1 RETURNING id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at; + enabled, created_by, created_at, updated_at, tool_allowlist; -- name: DeleteAgentKind :exec DELETE FROM acp_agent_kinds WHERE id = $1; diff --git a/internal/acp/queries/permissions.sql b/internal/acp/queries/permissions.sql new file mode 100644 index 0000000..37d8f64 --- /dev/null +++ b/internal/acp/queries/permissions.sql @@ -0,0 +1,44 @@ +-- name: InsertPermissionRequest :one +INSERT INTO acp_permission_requests ( + id, session_id, agent_request_id, tool_name, tool_call, options, status, chosen_option_id, decided_by, decided_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, session_id, agent_request_id, tool_name, tool_call, options, status, + chosen_option_id, decided_by, decided_at, created_at; + +-- name: GetPermissionRequestByID :one +SELECT id, session_id, agent_request_id, tool_name, tool_call, options, status, + chosen_option_id, decided_by, decided_at, created_at +FROM acp_permission_requests +WHERE id = $1; + +-- name: ListPendingPermissionRequestsBySession :many +SELECT id, session_id, agent_request_id, tool_name, tool_call, options, status, + chosen_option_id, decided_by, decided_at, created_at +FROM acp_permission_requests +WHERE session_id = $1 AND status = 'pending' +ORDER BY created_at ASC; + +-- name: ListPendingPermissionRequestsByUser :many +SELECT p.id, p.session_id, p.agent_request_id, p.tool_name, p.tool_call, p.options, p.status, + p.chosen_option_id, p.decided_by, p.decided_at, p.created_at +FROM acp_permission_requests p +JOIN acp_sessions s ON s.id = p.session_id +WHERE s.user_id = $1 AND p.status = 'pending' +ORDER BY p.created_at ASC; + +-- name: DecidePermissionRequest :one +UPDATE acp_permission_requests +SET status = $2, chosen_option_id = $3, decided_by = $4, decided_at = now() +WHERE id = $1 AND status = 'pending' +RETURNING id, session_id, agent_request_id, tool_name, tool_call, options, status, + chosen_option_id, decided_by, decided_at, created_at; + +-- name: ExpirePendingPermissionRequestsBySession :execrows +UPDATE acp_permission_requests +SET status = 'expired', decided_at = now() +WHERE session_id = $1 AND status = 'pending'; + +-- name: ExpireStalePermissionRequests :execrows +UPDATE acp_permission_requests +SET status = 'expired', decided_at = now() +WHERE status = 'pending' AND created_at < $1; diff --git a/internal/acp/relay.go b/internal/acp/relay.go index bd9c155..78f7a46 100644 --- a/internal/acp/relay.go +++ b/internal/acp/relay.go @@ -29,8 +29,8 @@ import ( // EventEnvelope 是推到 WS 的事件结构(与落库的 Event 字段同步,但带 ID 数值化方便 JSON)。 type EventEnvelope struct { ID int64 `json:"id"` - Direction string `json:"direction"` // 'in' | 'out' - Kind string `json:"kind"` // 'request'|'response'|'notification'|'error'|'session_terminated'|'slow_consumer_disconnect' + Direction string `json:"direction"` // 'in' | 'out' + Kind string `json:"kind"` // 'request'|'response'|'notification'|'error'|'session_terminated'|'slow_consumer_disconnect' Method string `json:"method,omitempty"` Payload json.RawMessage `json:"payload"` Truncated bool `json:"truncated"` @@ -182,6 +182,16 @@ func (r *Relay) Close(status string, exitCode *int32) { close(r.done) } +// FanoutControl 把一条控制类 envelope(如 permission_request/permission_resolved) +// 推给所有 WS 订阅者。这类帧不落 acp_events、ID 恒为 0,由 PermissionService 调用。 +// relay 已关闭时静默丢弃,避免向已关 channel 写入。 +func (r *Relay) FanoutControl(env *EventEnvelope) { + if r.closed.Load() { + return + } + r.fanout(env) +} + // Done returns a channel closed when Relay is terminated. func (r *Relay) Done() <-chan struct{} { return r.done } @@ -249,7 +259,7 @@ func (r *Relay) handleAgentRequest(ctx context.Context, sess handlers.SessionCon out := r.fsHandler.Write.Handle(ctx, sess, req.Params) resp = r.fsResultToResponse(req.ID, out) case "session/request_permission": - out := r.permHandler.Handle(ctx, sess, req.Params) + out := r.permHandler.Handle(ctx, sess, string(req.ID), req.Params) resp = r.fsResultToResponse(req.ID, out) default: resp = NewResponseErr(req.ID, JSONRPCMethodNotFound, "method not implemented: "+req.Method) diff --git a/internal/acp/relay_test.go b/internal/acp/relay_test.go index 9c573f0..9be248b 100644 --- a/internal/acp/relay_test.go +++ b/internal/acp/relay_test.go @@ -71,7 +71,7 @@ func (f *fakeEventRepo) ListEnabledAgentKinds(context.Context) ([]*acp.AgentKind func (f *fakeEventRepo) UpdateAgentKind(context.Context, *acp.AgentKind) (*acp.AgentKind, error) { panic("n/a") } -func (f *fakeEventRepo) DeleteAgentKind(context.Context, uuid.UUID) error { panic("n/a") } +func (f *fakeEventRepo) DeleteAgentKind(context.Context, uuid.UUID) error { panic("n/a") } func (f *fakeEventRepo) CountAgentKindUsage(context.Context, uuid.UUID) (int64, error) { panic("n/a") } @@ -149,7 +149,7 @@ func newRelayTestRig(t *testing.T) *relayTestRig { repo := &fakeEventRepo{} fs := handlers.NewFsHandler() - perm := handlers.NewPermissionHandler() + perm := handlers.NewPermissionHandler(nil) cwd := t.TempDir() sess := handlers.SessionContext{ @@ -435,3 +435,26 @@ drain: assert.True(t, sawSlowDisc || sawClose, "expected slow_consumer_disconnect envelope or closed channel after flood") } + +// ===== PermissionRequest(权限审批框架,测试桩) ===== +func (f *fakeEventRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) { + return &acp.PermissionRequest{}, nil +} +func (f *fakeEventRepo) GetPermissionRequestByID(context.Context, uuid.UUID) (*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeEventRepo) ListPendingPermissionRequestsBySession(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeEventRepo) ListPendingPermissionRequestsByUser(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeEventRepo) DecidePermissionRequest(context.Context, uuid.UUID, acp.PermissionStatus, *string, *uuid.UUID) (*acp.PermissionRequest, bool, error) { + return nil, false, nil +} +func (f *fakeEventRepo) ExpirePendingPermissionRequestsBySession(context.Context, uuid.UUID) (int64, error) { + return 0, nil +} +func (f *fakeEventRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) { + return 0, nil +} diff --git a/internal/acp/repository.go b/internal/acp/repository.go index 8f117b8..54ed314 100644 --- a/internal/acp/repository.go +++ b/internal/acp/repository.go @@ -48,6 +48,15 @@ type Repository interface { ListEventsSince(ctx context.Context, sessionID uuid.UUID, sinceID int64, limit int32) ([]*Event, error) PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error) + // PermissionRequest + InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error) + GetPermissionRequestByID(ctx context.Context, id uuid.UUID) (*PermissionRequest, error) + ListPendingPermissionRequestsBySession(ctx context.Context, sessionID uuid.UUID) ([]*PermissionRequest, error) + ListPendingPermissionRequestsByUser(ctx context.Context, userID uuid.UUID) ([]*PermissionRequest, error) + DecidePermissionRequest(ctx context.Context, id uuid.UUID, status PermissionStatus, chosenOptionID *string, decidedBy *uuid.UUID) (*PermissionRequest, bool, error) + ExpirePendingPermissionRequestsBySession(ctx context.Context, sessionID uuid.UUID) (int64, error) + ExpireStalePermissionRequests(ctx context.Context, before time.Time) (int64, error) + InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error WithTx(tx pgx.Tx) Repository } @@ -129,15 +138,16 @@ func pgxNoRowsToErrNotFound(err error, code errs.Code, msg string) error { func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) { row, err := r.q.CreateAgentKind(ctx, acpsqlc.CreateAgentKindParams{ - ID: toPgUUID(k.ID), - Name: k.Name, - DisplayName: k.DisplayName, - Description: k.Description, - BinaryPath: k.BinaryPath, - Args: k.Args, - EncryptedEnv: k.EncryptedEnv, - Enabled: k.Enabled, - CreatedBy: toPgUUID(k.CreatedBy), + ID: toPgUUID(k.ID), + Name: k.Name, + DisplayName: k.DisplayName, + Description: k.Description, + BinaryPath: k.BinaryPath, + Args: k.Args, + EncryptedEnv: k.EncryptedEnv, + Enabled: k.Enabled, + CreatedBy: toPgUUID(k.CreatedBy), + ToolAllowlist: normalizeStrSlice(k.ToolAllowlist), }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind") @@ -187,13 +197,14 @@ func (r *pgRepo) ListEnabledAgentKinds(ctx context.Context) ([]*AgentKind, error func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) { row, err := r.q.UpdateAgentKind(ctx, acpsqlc.UpdateAgentKindParams{ - ID: toPgUUID(k.ID), - DisplayName: k.DisplayName, - Description: k.Description, - BinaryPath: k.BinaryPath, - Args: k.Args, - EncryptedEnv: k.EncryptedEnv, - Enabled: k.Enabled, + ID: toPgUUID(k.ID), + DisplayName: k.DisplayName, + Description: k.Description, + BinaryPath: k.BinaryPath, + Args: k.Args, + EncryptedEnv: k.EncryptedEnv, + Enabled: k.Enabled, + ToolAllowlist: normalizeStrSlice(k.ToolAllowlist), }) if err != nil { return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found") @@ -223,20 +234,30 @@ func (r *pgRepo) CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64, func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind { return &AgentKind{ - ID: fromPgUUID(row.ID), - Name: row.Name, - DisplayName: row.DisplayName, - Description: row.Description, - BinaryPath: row.BinaryPath, - Args: row.Args, - EncryptedEnv: row.EncryptedEnv, - Enabled: row.Enabled, - CreatedBy: fromPgUUID(row.CreatedBy), - CreatedAt: row.CreatedAt.Time, - UpdatedAt: row.UpdatedAt.Time, + ID: fromPgUUID(row.ID), + Name: row.Name, + DisplayName: row.DisplayName, + Description: row.Description, + BinaryPath: row.BinaryPath, + Args: row.Args, + EncryptedEnv: row.EncryptedEnv, + Enabled: row.Enabled, + ToolAllowlist: row.ToolAllowlist, + CreatedBy: fromPgUUID(row.CreatedBy), + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, } } +// normalizeStrSlice 把 nil 切片归一为非 nil 空切片,避免写入 TEXT[] NOT NULL 列时 +// 产生 NULL(pg 驱动对 nil []string 写 NULL,违反 NOT NULL 约束)。 +func normalizeStrSlice(s []string) []string { + if s == nil { + return []string{} + } + return s +} + // ===== Session ===== func (r *pgRepo) InsertSession(ctx context.Context, s *Session) (*Session, error) { @@ -454,3 +475,119 @@ func rowToEvent(row acpsqlc.AcpEvent) *Event { CreatedAt: row.CreatedAt.Time, } } + +// ===== PermissionRequest ===== + +func (r *pgRepo) InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error) { + row, err := r.q.InsertPermissionRequest(ctx, acpsqlc.InsertPermissionRequestParams{ + ID: toPgUUID(p.ID), + SessionID: toPgUUID(p.SessionID), + AgentRequestID: p.AgentRequestID, + ToolName: p.ToolName, + ToolCall: p.ToolCall, + Options: p.Options, + Status: string(p.Status), + ChosenOptionID: p.ChosenOptionID, + DecidedBy: toPgUUIDPtr(p.DecidedBy), + DecidedAt: timePtrToPg(p.DecidedAt), + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "insert permission request") + } + return rowToPermissionRequest(row), nil +} + +func (r *pgRepo) GetPermissionRequestByID(ctx context.Context, id uuid.UUID) (*PermissionRequest, error) { + row, err := r.q.GetPermissionRequestByID(ctx, toPgUUID(id)) + if err != nil { + return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpPermissionNotFound, "permission request not found") + } + return rowToPermissionRequest(row), nil +} + +func (r *pgRepo) ListPendingPermissionRequestsBySession(ctx context.Context, sessionID uuid.UUID) ([]*PermissionRequest, error) { + rows, err := r.q.ListPendingPermissionRequestsBySession(ctx, toPgUUID(sessionID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list pending permission requests by session") + } + out := make([]*PermissionRequest, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToPermissionRequest(row)) + } + return out, nil +} + +func (r *pgRepo) ListPendingPermissionRequestsByUser(ctx context.Context, userID uuid.UUID) ([]*PermissionRequest, error) { + rows, err := r.q.ListPendingPermissionRequestsByUser(ctx, toPgUUID(userID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list pending permission requests by user") + } + out := make([]*PermissionRequest, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToPermissionRequest(row)) + } + return out, nil +} + +// DecidePermissionRequest 用 CAS(WHERE status='pending')落地决策。第二个返回值 +// ok=false 表示该请求已非 pending(重复决策/已超时),调用方据此判 409 或忽略。 +func (r *pgRepo) DecidePermissionRequest(ctx context.Context, id uuid.UUID, status PermissionStatus, chosenOptionID *string, decidedBy *uuid.UUID) (*PermissionRequest, bool, error) { + row, err := r.q.DecidePermissionRequest(ctx, acpsqlc.DecidePermissionRequestParams{ + ID: toPgUUID(id), + Status: string(status), + ChosenOptionID: chosenOptionID, + DecidedBy: toPgUUIDPtr(decidedBy), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + return nil, false, errs.Wrap(err, errs.CodeInternal, "decide permission request") + } + return rowToPermissionRequest(row), true, nil +} + +func (r *pgRepo) ExpirePendingPermissionRequestsBySession(ctx context.Context, sessionID uuid.UUID) (int64, error) { + n, err := r.q.ExpirePendingPermissionRequestsBySession(ctx, toPgUUID(sessionID)) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "expire pending permission requests by session") + } + return n, nil +} + +func (r *pgRepo) ExpireStalePermissionRequests(ctx context.Context, before time.Time) (int64, error) { + n, err := r.q.ExpireStalePermissionRequests(ctx, pgtype.Timestamptz{Time: before, Valid: true}) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "expire stale permission requests") + } + return n, nil +} + +// timePtrToPg 把 *time.Time 转为 pgtype.Timestamptz(nil → Valid=false)。 +func timePtrToPg(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +func rowToPermissionRequest(row acpsqlc.AcpPermissionRequest) *PermissionRequest { + var decidedAt *time.Time + if row.DecidedAt.Valid { + t := row.DecidedAt.Time + decidedAt = &t + } + return &PermissionRequest{ + ID: fromPgUUID(row.ID), + SessionID: fromPgUUID(row.SessionID), + AgentRequestID: row.AgentRequestID, + ToolName: row.ToolName, + ToolCall: row.ToolCall, + Options: row.Options, + Status: PermissionStatus(row.Status), + ChosenOptionID: row.ChosenOptionID, + DecidedBy: fromPgUUIDPtr(row.DecidedBy), + DecidedAt: decidedAt, + CreatedAt: row.CreatedAt.Time, + } +} diff --git a/internal/acp/session_service_test.go b/internal/acp/session_service_test.go index 4223473..826866a 100644 --- a/internal/acp/session_service_test.go +++ b/internal/acp/session_service_test.go @@ -201,15 +201,17 @@ func (r *fakeAcpRepo) CreateAgentKind(_ context.Context, k *acp.AgentKind) (*acp func (r *fakeAcpRepo) GetAgentKindByName(_ context.Context, _ string) (*acp.AgentKind, error) { return nil, fmt.Errorf("not found") } -func (r *fakeAcpRepo) ListAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { return nil, nil } +func (r *fakeAcpRepo) ListAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { return nil, nil } func (r *fakeAcpRepo) ListEnabledAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { return r.kinds, nil } func (r *fakeAcpRepo) UpdateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) { return k, nil } -func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { return nil } -func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) { return 0, nil } +func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { return nil } +func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) { + return 0, nil +} func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, _ uuid.UUID) ([]*acp.Session, error) { return nil, nil } @@ -415,3 +417,26 @@ func TestSessionService_Create_TokenIssueFails_RollsBack(t *testing.T) { assert.Empty(t, repo.sessions) repo.mu.Unlock() } + +// ===== PermissionRequest(权限审批框架,测试桩) ===== +func (f *fakeAcpRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) { + return &acp.PermissionRequest{}, nil +} +func (f *fakeAcpRepo) GetPermissionRequestByID(context.Context, uuid.UUID) (*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeAcpRepo) ListPendingPermissionRequestsBySession(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeAcpRepo) ListPendingPermissionRequestsByUser(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) { + return nil, nil +} +func (f *fakeAcpRepo) DecidePermissionRequest(context.Context, uuid.UUID, acp.PermissionStatus, *string, *uuid.UUID) (*acp.PermissionRequest, bool, error) { + return nil, false, nil +} +func (f *fakeAcpRepo) ExpirePendingPermissionRequestsBySession(context.Context, uuid.UUID) (int64, error) { + return 0, nil +} +func (f *fakeAcpRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) { + return 0, nil +} diff --git a/internal/acp/sqlc/agent_kinds.sql.go b/internal/acp/sqlc/agent_kinds.sql.go index 845e3e8..91d7891 100644 --- a/internal/acp/sqlc/agent_kinds.sql.go +++ b/internal/acp/sqlc/agent_kinds.sql.go @@ -24,22 +24,23 @@ func (q *Queries) CountAgentKindUsage(ctx context.Context, agentKindID pgtype.UU const createAgentKind = `-- name: CreateAgentKind :one INSERT INTO acp_agent_kinds ( - id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist ` type CreateAgentKindParams struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + ToolAllowlist []string `json:"tool_allowlist"` } func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams) (AcpAgentKind, error) { @@ -53,6 +54,7 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams arg.EncryptedEnv, arg.Enabled, arg.CreatedBy, + arg.ToolAllowlist, ) var i AcpAgentKind err := row.Scan( @@ -67,6 +69,7 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ToolAllowlist, ) return i, err } @@ -82,7 +85,7 @@ func (q *Queries) DeleteAgentKind(ctx context.Context, id pgtype.UUID) error { const getAgentKindByID = `-- name: GetAgentKindByID :one SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist FROM acp_agent_kinds WHERE id = $1 ` @@ -102,13 +105,14 @@ func (q *Queries) GetAgentKindByID(ctx context.Context, id pgtype.UUID) (AcpAgen &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ToolAllowlist, ) return i, err } const getAgentKindByName = `-- name: GetAgentKindByName :one SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist FROM acp_agent_kinds WHERE name = $1 ` @@ -128,13 +132,14 @@ func (q *Queries) GetAgentKindByName(ctx context.Context, name string) (AcpAgent &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ToolAllowlist, ) return i, err } const listAgentKinds = `-- name: ListAgentKinds :many SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist FROM acp_agent_kinds ORDER BY created_at DESC ` @@ -160,6 +165,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) { &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ToolAllowlist, ); err != nil { return nil, err } @@ -173,7 +179,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) { const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist FROM acp_agent_kinds WHERE enabled = TRUE ORDER BY name ASC @@ -200,6 +206,7 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ToolAllowlist, ); err != nil { return nil, err } @@ -213,26 +220,28 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er const updateAgentKind = `-- name: UpdateAgentKind :one UPDATE acp_agent_kinds -SET display_name = $2, - description = $3, - binary_path = $4, - args = $5, - encrypted_env = $6, - enabled = $7, - updated_at = now() +SET display_name = $2, + description = $3, + binary_path = $4, + args = $5, + encrypted_env = $6, + enabled = $7, + tool_allowlist = $8, + updated_at = now() WHERE id = $1 RETURNING id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at + enabled, created_by, created_at, updated_at, tool_allowlist ` type UpdateAgentKindParams struct { - ID pgtype.UUID `json:"id"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` + ID pgtype.UUID `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + ToolAllowlist []string `json:"tool_allowlist"` } func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams) (AcpAgentKind, error) { @@ -244,6 +253,7 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams arg.Args, arg.EncryptedEnv, arg.Enabled, + arg.ToolAllowlist, ) var i AcpAgentKind err := row.Scan( @@ -258,6 +268,7 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.ToolAllowlist, ) return i, err } diff --git a/internal/acp/sqlc/models.go b/internal/acp/sqlc/models.go index 067a191..5c431d1 100644 --- a/internal/acp/sqlc/models.go +++ b/internal/acp/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/internal/acp/sqlc/permissions.sql.go b/internal/acp/sqlc/permissions.sql.go new file mode 100644 index 0000000..270b6a1 --- /dev/null +++ b/internal/acp/sqlc/permissions.sql.go @@ -0,0 +1,237 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: permissions.sql + +package acpsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const decidePermissionRequest = `-- name: DecidePermissionRequest :one +UPDATE acp_permission_requests +SET status = $2, chosen_option_id = $3, decided_by = $4, decided_at = now() +WHERE id = $1 AND status = 'pending' +RETURNING id, session_id, agent_request_id, tool_name, tool_call, options, status, + chosen_option_id, decided_by, decided_at, created_at +` + +type DecidePermissionRequestParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` +} + +func (q *Queries) DecidePermissionRequest(ctx context.Context, arg DecidePermissionRequestParams) (AcpPermissionRequest, error) { + row := q.db.QueryRow(ctx, decidePermissionRequest, + arg.ID, + arg.Status, + arg.ChosenOptionID, + arg.DecidedBy, + ) + var i AcpPermissionRequest + err := row.Scan( + &i.ID, + &i.SessionID, + &i.AgentRequestID, + &i.ToolName, + &i.ToolCall, + &i.Options, + &i.Status, + &i.ChosenOptionID, + &i.DecidedBy, + &i.DecidedAt, + &i.CreatedAt, + ) + return i, err +} + +const expirePendingPermissionRequestsBySession = `-- name: ExpirePendingPermissionRequestsBySession :execrows +UPDATE acp_permission_requests +SET status = 'expired', decided_at = now() +WHERE session_id = $1 AND status = 'pending' +` + +func (q *Queries) ExpirePendingPermissionRequestsBySession(ctx context.Context, sessionID pgtype.UUID) (int64, error) { + result, err := q.db.Exec(ctx, expirePendingPermissionRequestsBySession, sessionID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const expireStalePermissionRequests = `-- name: ExpireStalePermissionRequests :execrows +UPDATE acp_permission_requests +SET status = 'expired', decided_at = now() +WHERE status = 'pending' AND created_at < $1 +` + +func (q *Queries) ExpireStalePermissionRequests(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) { + result, err := q.db.Exec(ctx, expireStalePermissionRequests, createdAt) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getPermissionRequestByID = `-- name: GetPermissionRequestByID :one +SELECT id, session_id, agent_request_id, tool_name, tool_call, options, status, + chosen_option_id, decided_by, decided_at, created_at +FROM acp_permission_requests +WHERE id = $1 +` + +func (q *Queries) GetPermissionRequestByID(ctx context.Context, id pgtype.UUID) (AcpPermissionRequest, error) { + row := q.db.QueryRow(ctx, getPermissionRequestByID, id) + var i AcpPermissionRequest + err := row.Scan( + &i.ID, + &i.SessionID, + &i.AgentRequestID, + &i.ToolName, + &i.ToolCall, + &i.Options, + &i.Status, + &i.ChosenOptionID, + &i.DecidedBy, + &i.DecidedAt, + &i.CreatedAt, + ) + return i, err +} + +const insertPermissionRequest = `-- name: InsertPermissionRequest :one +INSERT INTO acp_permission_requests ( + id, session_id, agent_request_id, tool_name, tool_call, options, status, chosen_option_id, decided_by, decided_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, session_id, agent_request_id, tool_name, tool_call, options, status, + chosen_option_id, decided_by, decided_at, created_at +` + +type InsertPermissionRequestParams struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` +} + +func (q *Queries) InsertPermissionRequest(ctx context.Context, arg InsertPermissionRequestParams) (AcpPermissionRequest, error) { + row := q.db.QueryRow(ctx, insertPermissionRequest, + arg.ID, + arg.SessionID, + arg.AgentRequestID, + arg.ToolName, + arg.ToolCall, + arg.Options, + arg.Status, + arg.ChosenOptionID, + arg.DecidedBy, + arg.DecidedAt, + ) + var i AcpPermissionRequest + err := row.Scan( + &i.ID, + &i.SessionID, + &i.AgentRequestID, + &i.ToolName, + &i.ToolCall, + &i.Options, + &i.Status, + &i.ChosenOptionID, + &i.DecidedBy, + &i.DecidedAt, + &i.CreatedAt, + ) + return i, err +} + +const listPendingPermissionRequestsBySession = `-- name: ListPendingPermissionRequestsBySession :many +SELECT id, session_id, agent_request_id, tool_name, tool_call, options, status, + chosen_option_id, decided_by, decided_at, created_at +FROM acp_permission_requests +WHERE session_id = $1 AND status = 'pending' +ORDER BY created_at ASC +` + +func (q *Queries) ListPendingPermissionRequestsBySession(ctx context.Context, sessionID pgtype.UUID) ([]AcpPermissionRequest, error) { + rows, err := q.db.Query(ctx, listPendingPermissionRequestsBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AcpPermissionRequest + for rows.Next() { + var i AcpPermissionRequest + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.AgentRequestID, + &i.ToolName, + &i.ToolCall, + &i.Options, + &i.Status, + &i.ChosenOptionID, + &i.DecidedBy, + &i.DecidedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPendingPermissionRequestsByUser = `-- name: ListPendingPermissionRequestsByUser :many +SELECT p.id, p.session_id, p.agent_request_id, p.tool_name, p.tool_call, p.options, p.status, + p.chosen_option_id, p.decided_by, p.decided_at, p.created_at +FROM acp_permission_requests p +JOIN acp_sessions s ON s.id = p.session_id +WHERE s.user_id = $1 AND p.status = 'pending' +ORDER BY p.created_at ASC +` + +func (q *Queries) ListPendingPermissionRequestsByUser(ctx context.Context, userID pgtype.UUID) ([]AcpPermissionRequest, error) { + rows, err := q.db.Query(ctx, listPendingPermissionRequestsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AcpPermissionRequest + for rows.Next() { + var i AcpPermissionRequest + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.AgentRequestID, + &i.ToolName, + &i.ToolCall, + &i.Options, + &i.Status, + &i.ChosenOptionID, + &i.DecidedBy, + &i.DecidedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/acp/supervisor.go b/internal/acp/supervisor.go index 8a4c980..0dad597 100644 --- a/internal/acp/supervisor.go +++ b/internal/acp/supervisor.go @@ -47,17 +47,22 @@ type Supervisor struct { mu sync.RWMutex procs map[uuid.UUID]*Process - repo Repository - audit audit.Recorder - notify *notify.Dispatcher + repo Repository + audit audit.Recorder + 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 + permSvc *PermissionService // 工具调用权限审批;可为 nil(部分测试) + cfg SupervisorConfig + log *slog.Logger } +// SetPermissionService 注入权限审批服务。与 Supervisor 互相依赖,故装配时回填 +// (permSvc 需要 Supervisor 作为 RelayLocator,Supervisor 需要 permSvc 注入 handler)。 +func (s *Supervisor) SetPermissionService(p *PermissionService) { s.permSvc = p } + // NewSupervisor 构造空 Supervisor。Start 不需要 — supervisor 是被动的(spawn 时 // 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。 func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher, @@ -68,16 +73,16 @@ func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher, log = slog.Default() } return &Supervisor{ - procs: map[uuid.UUID]*Process{}, - repo: repo, - audit: rec, - notify: disp, - wtSvc: wtSvc, - wsRepo: wsRepo, + procs: map[uuid.UUID]*Process{}, + repo: repo, + audit: rec, + notify: disp, + wtSvc: wtSvc, + wsRepo: wsRepo, mcpTokens: mcpTokens, - crypto: enc, - cfg: cfg, - log: log, + crypto: enc, + cfg: cfg, + log: log, } } @@ -95,7 +100,7 @@ type Process struct { Stderr io.ReadCloser Relay *Relay - StderrBuf *stderrRing // 排障 ring buffer + StderrBuf *stderrRing // 排障 ring buffer StartedAt time.Time KilledByUs atomic.Bool // true → exited,false → crashed done chan struct{} // monitor 退出时 close @@ -185,13 +190,17 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, return nil, fmt.Errorf("start: %w", err) } + var decider handlers.PermissionDecider + if s.permSvc != nil { + decider = s.permSvc + } relay := NewRelay( sess.ID, NewDecoder(stdout, s.cfg.EventMaxPayload), NewEncoder(stdin), s.repo, handlers.NewFsHandler(), - handlers.NewPermissionHandler(), + handlers.NewPermissionHandler(decider), RelayConfig{ EventMaxPayload: s.cfg.EventMaxPayload, EventTruncateField: s.cfg.EventTruncateField, @@ -227,6 +236,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, UserID: sess.UserID, CwdPath: sess.CwdPath, AgentSessionID: sess.AgentSessionID, + ToolAllowlist: kind.ToolAllowlist, }) if err := s.handshake(ctx, sess, relay, proc); err != nil { @@ -441,6 +451,13 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt } } _ = waitErr // 已通过 KilledByUs / ExitError 编码到 status / exitCode + + // 终止会话的全部挂起权限请求:标 expired 并唤醒阻塞的 Decide goroutine, + // 避免 agent 端永久挂起。须在 Relay.Close 之前(Close 后 FanoutControl 静默丢弃)。 + if s.permSvc != nil { + s.permSvc.CancelSession(ctx, proc.SessionID) + } + // Revoke session's system MCP tokens (spec §6.3) if s.mcpTokens != nil { if err := s.mcpTokens.RevokeBySession(ctx, proc.SessionID); err != nil { diff --git a/internal/app/acp_integration_test.go b/internal/app/acp_integration_test.go index d393e70..0655e9a 100644 --- a/internal/app/acp_integration_test.go +++ b/internal/app/acp_integration_test.go @@ -176,6 +176,9 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite { ShutdownGrace: 5 * time.Second, } sup := acp.NewSupervisor(acpRepo, auditRec, disp, nil, nil, nil, enc, supCfg, log) + permSvc := acp.NewPermissionService(acpRepo, auditRec, 0, log) + sup.SetPermissionService(permSvc) + permSvc.SetRelayLocator(sup) // Minimal stubs for SessionService dependencies — sufficient for branch=main // path (no wtSvc / wsRepo calls) and tests that exercise quota/conflict logic. @@ -192,7 +195,7 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite { r.Use(middleware.RequestID) user.NewHandler(userSvc).Mount(r) acp.NewHandler( - akSvc, sessSvc, sup, acpRepo, + akSvc, sessSvc, sup, acpRepo, permSvc, userSvc, acpUserAdminAdapter{svc: userSvc}, enc, diff --git a/internal/app/app.go b/internal/app/app.go index b73e70e..f2917ad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -32,7 +32,6 @@ import ( "github.com/yan1h/agent-coding-workflow/internal/acp" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/chat" - mcp "github.com/yan1h/agent-coding-workflow/internal/mcp" "github.com/yan1h/agent-coding-workflow/internal/config" "github.com/yan1h/agent-coding-workflow/internal/infra/clock" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" @@ -45,6 +44,7 @@ import ( "github.com/yan1h/agent-coding-workflow/internal/jobs" "github.com/yan1h/agent-coding-workflow/internal/jobs/handlers" "github.com/yan1h/agent-coding-workflow/internal/jobs/runners" + mcp "github.com/yan1h/agent-coding-workflow/internal/mcp" "github.com/yan1h/agent-coding-workflow/internal/project" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" @@ -300,6 +300,11 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { "session_id", sess.ID, "err", rerr.Error()) } } + // 该会话已被标 crashed,其挂起的权限请求也应一并标 expired,避免悬挂。 + if _, rerr := acpRepo.ExpirePendingPermissionRequestsBySession(ctx, sess.ID); rerr != nil { + log.Warn("acp.startup_reaper.expire_permissions_failed", + "session_id", sess.ID, "err", rerr.Error()) + } uid := sess.UserID if rerr := auditRec.Record(ctx, audit.Entry{ UserID: &uid, Action: "acp.session.aborted_on_restart", @@ -369,6 +374,11 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { }, log, ) + // 权限审批服务:与 supervisor 互相依赖,构造后回填(SetPermissionService / + // SetRelayLocator),再注入 handler。 + acpPermSvc := acp.NewPermissionService(acpRepo, auditRec, cfg.Acp.PermissionTimeout, log) + acpSup.SetPermissionService(acpPermSvc) + acpPermSvc.SetRelayLocator(acpSup) sessSvc := acp.NewSessionService( acpRepo, wsSvc, wtSvc, wsRepo, acpProjAccess, acpSup, auditRec, @@ -380,7 +390,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { }, mcpTokenSvc, ) - acp.NewHandler(akSvc, sessSvc, acpSup, acpRepo, userSvc, userAdminAdapter{svc: userSvc}, enc, acp.WSConfig{ + acp.NewHandler(akSvc, sessSvc, acpSup, acpRepo, acpPermSvc, userSvc, userAdminAdapter{svc: userSvc}, enc, acp.WSConfig{ PingInterval: cfg.Acp.WSPingInterval, PongTimeout: cfg.Acp.WSPongTimeout, SendBuffer: cfg.Acp.WSSendBuffer, @@ -543,6 +553,16 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { } } + // 注册 system 作用域的 prompt 模板为 MCP prompts(spec)。需在 bootstrap 之后 + // 执行以保证存在 admin;失败仅 warn,不阻断启动。以第一个 admin 的视角加载模板。 + if admins, err := userSvc.ListAdmins(ctx); err != nil { + log.Warn("mcp register_system_prompts: list admins failed", "err", err.Error()) + } else if len(admins) > 0 { + if err := mcp.RegisterSystemPrompts(mcpServer, mcpDeps, admins[0].ID); err != nil { + log.Warn("mcp register_system_prompts failed", "err", err.Error()) + } + } + srv := &http.Server{ Addr: cfg.HTTP.Addr, Handler: r, diff --git a/internal/app/mcp_integration_test.go b/internal/app/mcp_integration_test.go index 1b852a0..0912625 100644 --- a/internal/app/mcp_integration_test.go +++ b/internal/app/mcp_integration_test.go @@ -47,9 +47,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" tcpg "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/yan1h/agent-coding-workflow/internal/audit" @@ -327,9 +327,9 @@ func (s *mcpIntegrationSuite) seedAdditionalProject(t *testing.T, ownerID uuid.U t.Helper() caller := project.Caller{UserID: ownerID, IsAdmin: ownerID == s.adminID} p, err := s.projectSvc.Create(s.ctx, caller, project.CreateProjectInput{ - Slug: slug, - Name: slug, - Visibility: project.VisibilityInternal, + Slug: slug, + Name: slug, + Visibility: project.VisibilityInternal, }) require.NoError(t, err) return p.ID @@ -342,9 +342,9 @@ func (s *mcpIntegrationSuite) seedPrivateProjectFor(t *testing.T, ownerID uuid.U slug := fmt.Sprintf("private-%s", uuid.New().String()[:8]) caller := project.Caller{UserID: ownerID, IsAdmin: false} p, err := s.projectSvc.Create(s.ctx, caller, project.CreateProjectInput{ - Slug: slug, - Name: "Private " + slug, - Visibility: project.VisibilityPrivate, + Slug: slug, + Name: "Private " + slug, + Visibility: project.VisibilityPrivate, }) require.NoError(t, err) return p.ID, p.Slug @@ -390,8 +390,8 @@ func (s *mcpIntegrationSuite) queryAuditMetadata(t *testing.T, action string) ma // bearerRoundTripper injects an Authorization: Bearer header into every request. type bearerRoundTripper struct { - inner http.RoundTripper - token string + inner http.RoundTripper + token string } func (rt *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/audit/sqlc/models.go b/internal/audit/sqlc/models.go index d84c186..0854ff2 100644 --- a/internal/audit/sqlc/models.go +++ b/internal/audit/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/internal/chat/sqlc/models.go b/internal/chat/sqlc/models.go index 0af3ed3..27c7979 100644 --- a/internal/chat/sqlc/models.go +++ b/internal/chat/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/internal/config/config.go b/internal/config/config.go index 0b09e89..ef4e203 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -197,14 +197,15 @@ type AcpConfig struct { EventTruncateField int `mapstructure:"event_truncate_field_bytes"` EventRetentionDays int `mapstructure:"event_retention_days"` ShutdownGrace time.Duration `mapstructure:"shutdown_grace"` + PermissionTimeout time.Duration `mapstructure:"permission_timeout"` } // MCPConfig 控制 MCP 模块的开关、对外 URL(注入 ACP 子进程 env)、system token TTL 与速率限制。 type MCPConfig struct { - Enabled bool `mapstructure:"enabled"` - PublicURL string `mapstructure:"public_url"` - SystemTokenTTL time.Duration `mapstructure:"system_token_ttl"` - RateLimit MCPRateLimitCfg `mapstructure:"rate_limit"` + Enabled bool `mapstructure:"enabled"` + PublicURL string `mapstructure:"public_url"` + SystemTokenTTL time.Duration `mapstructure:"system_token_ttl"` + RateLimit MCPRateLimitCfg `mapstructure:"rate_limit"` } // MCPRateLimitCfg 控制 token bucket 容量。两层独立桶:per-token + 全局。 @@ -278,6 +279,7 @@ func Load(configFile string) (*Config, error) { v.SetDefault("acp.event_truncate_field_bytes", 4096) v.SetDefault("acp.event_retention_days", 7) v.SetDefault("acp.shutdown_grace", "30s") + v.SetDefault("acp.permission_timeout", "5m") v.SetDefault("mcp.enabled", true) v.SetDefault("mcp.public_url", "http://localhost:8080/mcp") v.SetDefault("mcp.system_token_ttl", "24h") diff --git a/internal/infra/errs/errs.go b/internal/infra/errs/errs.go index 55e50af..2a3229f 100644 --- a/internal/infra/errs/errs.go +++ b/internal/infra/errs/errs.go @@ -75,27 +75,30 @@ const ( CodeWebhookNotConfigured Code = "webhook.not_configured" // ACP 域 - CodeAcpAgentKindNotFound Code = "acp.agent_kind_not_found" - CodeAcpAgentKindDisabled Code = "acp.agent_kind_disabled" - CodeAcpAgentKindInUse Code = "acp.agent_kind_in_use" - CodeAcpSessionNotFound Code = "acp.session_not_found" - CodeAcpSessionQuotaExceeded Code = "acp.session_quota_exceeded" - CodeAcpSessionNotOwned Code = "acp.session_not_owned" - CodeAcpSessionTerminated Code = "acp.session_terminated" - CodeAcpSessionBranchConflict Code = "acp.session_branch_conflict" - CodeAcpSpawnFailed Code = "acp.spawn_failed" - CodeAcpHandshakeTimeout Code = "acp.handshake_timeout" - CodeAcpFsPathOutsideWorktree Code = "acp.fs_path_outside_worktree" + CodeAcpAgentKindNotFound Code = "acp.agent_kind_not_found" + CodeAcpAgentKindDisabled Code = "acp.agent_kind_disabled" + CodeAcpAgentKindInUse Code = "acp.agent_kind_in_use" + CodeAcpSessionNotFound Code = "acp.session_not_found" + CodeAcpSessionQuotaExceeded Code = "acp.session_quota_exceeded" + CodeAcpSessionNotOwned Code = "acp.session_not_owned" + CodeAcpSessionTerminated Code = "acp.session_terminated" + CodeAcpSessionBranchConflict Code = "acp.session_branch_conflict" + CodeAcpSpawnFailed Code = "acp.spawn_failed" + CodeAcpHandshakeTimeout Code = "acp.handshake_timeout" + CodeAcpFsPathOutsideWorktree Code = "acp.fs_path_outside_worktree" + CodeAcpPermissionNotFound Code = "acp.permission_not_found" + CodeAcpPermissionAlreadyDecided Code = "acp.permission_already_decided" + CodeAcpPermissionInvalidOption Code = "acp.permission_invalid_option" // MCP 域 - CodeMcpTokenInvalid Code = "mcp.token_invalid" - CodeMcpTokenRevoked Code = "mcp.token_revoked" - CodeMcpTokenExpired Code = "mcp.token_expired" - CodeMcpTokenNotFound Code = "mcp.token_not_found" - CodeMcpTokenScopeViolation Code = "mcp.token_scope_violation" - CodeMcpTokenNameRequired Code = "mcp.token_name_required" - CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required" - CodeMcpRateLimited Code = "mcp.rate_limited" + CodeMcpTokenInvalid Code = "mcp.token_invalid" + CodeMcpTokenRevoked Code = "mcp.token_revoked" + CodeMcpTokenExpired Code = "mcp.token_expired" + CodeMcpTokenNotFound Code = "mcp.token_not_found" + CodeMcpTokenScopeViolation Code = "mcp.token_scope_violation" + CodeMcpTokenNameRequired Code = "mcp.token_name_required" + CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required" + CodeMcpRateLimited Code = "mcp.rate_limited" ) // AppError is the application's structured error type. It carries a stable @@ -218,6 +221,12 @@ func HTTPStatus(code Code) int { return http.StatusBadGateway case CodeAcpFsPathOutsideWorktree: return http.StatusInternalServerError + case CodeAcpPermissionNotFound: + return http.StatusNotFound + case CodeAcpPermissionAlreadyDecided: + return http.StatusConflict + case CodeAcpPermissionInvalidOption: + return http.StatusBadRequest case CodeWebhookNotConfigured: return http.StatusServiceUnavailable case CodeMcpTokenInvalid, CodeMcpTokenRevoked, CodeMcpTokenExpired: diff --git a/internal/infra/notify/sqlc/models.go b/internal/infra/notify/sqlc/models.go index 5246961..77645b3 100644 --- a/internal/infra/notify/sqlc/models.go +++ b/internal/infra/notify/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/internal/jobs/sqlc/models.go b/internal/jobs/sqlc/models.go index 69d5a81..9aa07e0 100644 --- a/internal/jobs/sqlc/models.go +++ b/internal/jobs/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/internal/mcp/caller_test.go b/internal/mcp/caller_test.go index d500ea8..ee6fcdd 100644 --- a/internal/mcp/caller_test.go +++ b/internal/mcp/caller_test.go @@ -33,6 +33,25 @@ func (s *fakeUserSvc) Bootstrap(_ context.Context, _, _ string) (*user.User, err } func (s *fakeUserSvc) ListAdmins(_ context.Context) ([]*user.User, error) { return nil, nil } +func (s *fakeUserSvc) ListUsers(_ context.Context) ([]*user.User, error) { return nil, nil } +func (s *fakeUserSvc) CreateUser(_ context.Context, _ user.CreateUserInput) (*user.User, error) { + return nil, nil +} +func (s *fakeUserSvc) UpdateProfile(_ context.Context, _ uuid.UUID, _ string) (*user.User, error) { + return nil, nil +} +func (s *fakeUserSvc) SetAdmin(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) { + return nil, nil +} +func (s *fakeUserSvc) SetEnabled(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) { + return nil, nil +} +func (s *fakeUserSvc) AdminResetPassword(_ context.Context, _ uuid.UUID) (string, error) { + return "", nil +} +func (s *fakeUserSvc) ChangePassword(_ context.Context, _ uuid.UUID, _, _ string) error { return nil } +func (s *fakeUserSvc) DeleteUser(_ context.Context, _, _ uuid.UUID) error { return nil } + func ctxWithSession(s *mcp.AuthSession) context.Context { return context.WithValue(context.Background(), mcp.AuthContextKey, s) } diff --git a/internal/mcp/sqlc/models.go b/internal/mcp/sqlc/models.go index a833144..3d7ba86 100644 --- a/internal/mcp/sqlc/models.go +++ b/internal/mcp/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/internal/mcp/tools_pm_test.go b/internal/mcp/tools_pm_test.go index 31f0418..1c0fa47 100644 --- a/internal/mcp/tools_pm_test.go +++ b/internal/mcp/tools_pm_test.go @@ -26,7 +26,7 @@ type fakeUserSvc struct{ users map[uuid.UUID]*user.User } func (s *fakeUserSvc) Login(_ context.Context, _, _, _ string) (string, *user.User, error) { return "", nil, nil } -func (s *fakeUserSvc) Logout(_ context.Context, _ string) error { return nil } +func (s *fakeUserSvc) Logout(_ context.Context, _ string) error { return nil } func (s *fakeUserSvc) ResolveSession(_ context.Context, _ string) (uuid.UUID, bool, error) { return uuid.Nil, false, nil } @@ -41,6 +41,25 @@ func (s *fakeUserSvc) Bootstrap(_ context.Context, _, _ string) (*user.User, err } func (s *fakeUserSvc) ListAdmins(_ context.Context) ([]*user.User, error) { return nil, nil } +func (s *fakeUserSvc) ListUsers(_ context.Context) ([]*user.User, error) { return nil, nil } +func (s *fakeUserSvc) CreateUser(_ context.Context, _ user.CreateUserInput) (*user.User, error) { + return nil, nil +} +func (s *fakeUserSvc) UpdateProfile(_ context.Context, _ uuid.UUID, _ string) (*user.User, error) { + return nil, nil +} +func (s *fakeUserSvc) SetAdmin(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) { + return nil, nil +} +func (s *fakeUserSvc) SetEnabled(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) { + return nil, nil +} +func (s *fakeUserSvc) AdminResetPassword(_ context.Context, _ uuid.UUID) (string, error) { + return "", nil +} +func (s *fakeUserSvc) ChangePassword(_ context.Context, _ uuid.UUID, _, _ string) error { return nil } +func (s *fakeUserSvc) DeleteUser(_ context.Context, _, _ uuid.UUID) error { return nil } + // fakeProjectSvc implements project.ProjectService. type fakeProjectSvc struct { projects []*project.Project @@ -78,8 +97,12 @@ func (f *fakeProjectSvc) List(_ context.Context, _ project.Caller, _ bool) ([]*p func (f *fakeProjectSvc) Update(_ context.Context, _ project.Caller, _ string, _ project.UpdateProjectInput) (*project.Project, error) { panic("not implemented") } -func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error { panic("not implemented") } -func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error { panic("not implemented") } +func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error { + panic("not implemented") +} +func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error { + panic("not implemented") +} // fakeRequirementSvc implements project.RequirementService. type fakeRequirementSvc struct { @@ -231,7 +254,9 @@ func (f *fakeWorkspaceSvc) List(_ context.Context, _ workspace.Caller, _ string) func (f *fakeWorkspaceSvc) Update(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) { panic("not implemented") } -func (f *fakeWorkspaceSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error { panic("not implemented") } +func (f *fakeWorkspaceSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error { + panic("not implemented") +} func (f *fakeWorkspaceSvc) Sync(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Workspace, error) { panic("not implemented") } diff --git a/internal/project/sqlc/models.go b/internal/project/sqlc/models.go index f6b2b32..3d9b0c9 100644 --- a/internal/project/sqlc/models.go +++ b/internal/project/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/internal/user/domain.go b/internal/user/domain.go index 5cdaf75..c4c1cad 100644 --- a/internal/user/domain.go +++ b/internal/user/domain.go @@ -22,6 +22,7 @@ type User struct { Email string DisplayName string IsAdmin bool + Enabled bool PasswordHash string CreatedAt time.Time UpdatedAt time.Time @@ -58,4 +59,33 @@ type Service interface { Bootstrap(ctx context.Context, email, password string) (*User, error) // ListAdmins 返回所有管理员用户,供内部模块(如 jobs runner)使用。 ListAdmins(ctx context.Context) ([]*User, error) + + // ===== 管理员用户管理(需调用方自行做 admin 鉴权)===== + + // ListUsers 返回全部用户,按创建时间升序,供管理后台展示。 + ListUsers(ctx context.Context) ([]*User, error) + // CreateUser 由管理员创建一个新账号。email 唯一冲突时返回 CodeConflict。 + CreateUser(ctx context.Context, in CreateUserInput) (*User, error) + // UpdateProfile 修改用户的 display_name。 + UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) + // SetAdmin 设置用户的管理员标志。降级最后一个启用管理员时返回 CodeConflict; + // actor==target 且降级自身时返回 CodeConflict(防自锁)。 + SetAdmin(ctx context.Context, actor, target uuid.UUID, isAdmin bool) (*User, error) + // SetEnabled 启用/禁用用户。禁用会撤销其全部会话。禁用最后一个启用管理员或 + // 禁用自身时返回 CodeConflict。 + SetEnabled(ctx context.Context, actor, target uuid.UUID, enabled bool) (*User, error) + // AdminResetPassword 由管理员重置目标用户密码,返回一次性明文临时密码并撤销其会话。 + AdminResetPassword(ctx context.Context, id uuid.UUID) (tempPassword string, err error) + // ChangePassword 用户自助改密:校验旧密码后更新,并撤销其它会话。 + ChangePassword(ctx context.Context, id uuid.UUID, oldPassword, newPassword string) error + // DeleteUser 删除用户。删除最后一个启用管理员或删除自身时返回 CodeConflict。 + DeleteUser(ctx context.Context, actor, target uuid.UUID) error +} + +// CreateUserInput 是管理员创建用户的入参。 +type CreateUserInput struct { + Email string + DisplayName string + Password string + IsAdmin bool } diff --git a/internal/user/handler.go b/internal/user/handler.go index b7f3b0f..c07cd75 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -18,8 +18,10 @@ import ( "encoding/json" "net/http" "strings" + "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" @@ -42,15 +44,47 @@ func NewHandler(svc Service) *Handler { return &Handler{svc: svc} } // Mount 把 user 模块的所有 HTTP 端点挂到给定 router 的 /api/v1/auth 子树。 // 调用方负责在挂载之前装好 RequestID/Recover/Logger 等通用中间件。 func (h *Handler) Mount(r chi.Router) { + auth := middleware.Auth(h.svc, middleware.AuthOptions{}) + r.Route("/api/v1/auth", func(r chi.Router) { r.Post("/login", h.login) // /me 与 /logout 必须经过 Auth 中间件:未带 token 直接 401,由中间件 // 落地响应;带了 token 才会走到下面的处理函数。 - auth := middleware.Auth(h.svc, middleware.AuthOptions{}) r.With(auth).Get("/me", h.me) r.With(auth).Post("/logout", h.logout) + // 自助改密:需认证,校验旧密码后撤销全部会话。 + r.With(auth).Post("/change-password", h.changePassword) }) + + // 管理员用户管理子树:全部经 Auth,handler 内部再用 requireAdmin 做 admin 鉴权。 + r.Route("/api/v1/admin/users", func(r chi.Router) { + r.Use(auth) + r.Get("/", h.listUsers) + r.Post("/", h.createUser) + r.Get("/{id}", h.getUser) + r.Patch("/{id}", h.updateUser) + r.Delete("/{id}", h.deleteUser) + r.Patch("/{id}/role", h.setRole) + r.Patch("/{id}/enabled", h.setEnabled) + r.Post("/{id}/reset-password", h.resetPassword) + }) +} + +// requireAdmin 解析当前认证用户并校验其为管理员。返回 actor 的 UserID。 +func (h *Handler) requireAdmin(r *http.Request) (uuid.UUID, error) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + return uuid.Nil, errs.New(errs.CodeUnauthorized, "not authenticated") + } + u, err := h.svc.Get(r.Context(), uid) + if err != nil { + return uuid.Nil, err + } + if !u.IsAdmin { + return uuid.Nil, errs.New(errs.CodeForbidden, "需要管理员权限") + } + return uid, nil } // loginReq 是 POST /api/v1/auth/login 的请求体形态。 @@ -161,3 +195,271 @@ func clientIP(r *http.Request) string { } return r.RemoteAddr } + +// ===== 管理员用户管理 ===== + +// adminUserDTO 是管理后台的用户视图,比 userDTO 多 enabled 与时间戳,但同样 +// 不暴露 password_hash。 +type adminUserDTO struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toAdminUserDTO(u *User) adminUserDTO { + return adminUserDTO{ + ID: u.ID.String(), + Email: u.Email, + DisplayName: u.DisplayName, + IsAdmin: u.IsAdmin, + Enabled: u.Enabled, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +// userIDParam 解析路由中的 {id} 为 uuid。 +func userIDParam(r *http.Request) (uuid.UUID, error) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + return uuid.Nil, errs.New(errs.CodeInvalidInput, "无效的用户 id") + } + return id, nil +} + +func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + if _, err := h.requireAdmin(r); err != nil { + httpx.WriteError(w, rid, err) + return + } + users, err := h.svc.ListUsers(r.Context()) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + out := make([]adminUserDTO, 0, len(users)) + for _, u := range users { + out = append(out, toAdminUserDTO(u)) + } + httpx.WriteJSON(w, http.StatusOK, out) +} + +type createUserReq struct { + Email string `json:"email"` + DisplayName string `json:"display_name"` + Password string `json:"password"` + IsAdmin bool `json:"is_admin"` +} + +func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + if _, err := h.requireAdmin(r); err != nil { + httpx.WriteError(w, rid, err) + return + } + var req createUserReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) + return + } + email := strings.TrimSpace(req.Email) + display := strings.TrimSpace(req.DisplayName) + if email == "" || display == "" { + httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "缺少邮箱或显示名")) + return + } + u, err := h.svc.CreateUser(r.Context(), CreateUserInput{ + Email: email, + DisplayName: display, + Password: req.Password, + IsAdmin: req.IsAdmin, + }) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, toAdminUserDTO(u)) +} + +func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + if _, err := h.requireAdmin(r); err != nil { + httpx.WriteError(w, rid, err) + return + } + id, err := userIDParam(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + u, err := h.svc.Get(r.Context(), id) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u)) +} + +type updateUserReq struct { + DisplayName string `json:"display_name"` +} + +func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + if _, err := h.requireAdmin(r); err != nil { + httpx.WriteError(w, rid, err) + return + } + id, err := userIDParam(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + var req updateUserReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) + return + } + display := strings.TrimSpace(req.DisplayName) + if display == "" { + httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "显示名不能为空")) + return + } + u, err := h.svc.UpdateProfile(r.Context(), id, display) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u)) +} + +func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + actor, err := h.requireAdmin(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + id, err := userIDParam(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + if err := h.svc.DeleteUser(r.Context(), actor, id); err != nil { + httpx.WriteError(w, rid, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type setRoleReq struct { + IsAdmin bool `json:"is_admin"` +} + +func (h *Handler) setRole(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + actor, err := h.requireAdmin(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + id, err := userIDParam(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + var req setRoleReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) + return + } + u, err := h.svc.SetAdmin(r.Context(), actor, id, req.IsAdmin) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u)) +} + +type setEnabledReq struct { + Enabled bool `json:"enabled"` +} + +func (h *Handler) setEnabled(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + actor, err := h.requireAdmin(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + id, err := userIDParam(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + var req setEnabledReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) + return + } + u, err := h.svc.SetEnabled(r.Context(), actor, id, req.Enabled) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toAdminUserDTO(u)) +} + +type resetPasswordResp struct { + TempPassword string `json:"temp_password"` +} + +func (h *Handler) resetPassword(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + if _, err := h.requireAdmin(r); err != nil { + httpx.WriteError(w, rid, err) + return + } + id, err := userIDParam(r) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + temp, err := h.svc.AdminResetPassword(r.Context(), id) + if err != nil { + httpx.WriteError(w, rid, err) + return + } + httpx.WriteJSON(w, http.StatusOK, resetPasswordResp{TempPassword: temp}) +} + +// ===== 自助改密 ===== + +type changePasswordReq struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +func (h *Handler) changePassword(w http.ResponseWriter, r *http.Request) { + rid := middleware.RequestIDFromContext(r.Context()) + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + httpx.WriteError(w, rid, errs.New(errs.CodeInternal, "missing auth context")) + return + } + var req changePasswordReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, rid, errs.New(errs.CodeInvalidInput, "请求体无效")) + return + } + if err := h.svc.ChangePassword(r.Context(), uid, req.OldPassword, req.NewPassword); err != nil { + httpx.WriteError(w, rid, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/user/queries/users.sql b/internal/user/queries/users.sql index 9c29974..b3412ac 100644 --- a/internal/user/queries/users.sql +++ b/internal/user/queries/users.sql @@ -1,30 +1,66 @@ -- name: CreateUser :one INSERT INTO users (id, email, password_hash, display_name, is_admin) VALUES ($1, $2, $3, $4, $5) -RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at; +RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled; -- name: GetUserByEmail :one -SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at +SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled FROM users WHERE email = $1; -- name: GetUserByID :one -SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at +SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled FROM users WHERE id = $1; +-- name: ListUsers :many +SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled +FROM users +ORDER BY created_at ASC; + -- name: CountUsers :one SELECT COUNT(*)::int FROM users; +-- name: CountAdmins :one +SELECT COUNT(*)::int FROM users WHERE is_admin = true AND enabled = true; + +-- name: UpdateUserProfile :one +UPDATE users +SET display_name = $2, updated_at = now() +WHERE id = $1 +RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled; + +-- name: SetUserAdmin :one +UPDATE users +SET is_admin = $2, updated_at = now() +WHERE id = $1 +RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled; + +-- name: SetUserEnabled :one +UPDATE users +SET enabled = $2, updated_at = now() +WHERE id = $1 +RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled; + +-- name: UpdateUserPassword :exec +UPDATE users +SET password_hash = $2, updated_at = now() +WHERE id = $1; + +-- name: DeleteUser :exec +DELETE FROM users WHERE id = $1; + -- name: CreateSession :exec INSERT INTO user_sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4); -- name: GetSessionByTokenHash :one -SELECT id, user_id, token_hash, expires_at, last_seen_at, created_at -FROM user_sessions -WHERE token_hash = $1 - AND expires_at > now(); +SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.created_at +FROM user_sessions s +JOIN users u ON u.id = s.user_id +WHERE s.token_hash = $1 + AND s.expires_at > now() + AND u.enabled = true; -- name: TouchSession :exec UPDATE user_sessions @@ -34,11 +70,14 @@ WHERE token_hash = $1; -- name: DeleteSessionByTokenHash :exec DELETE FROM user_sessions WHERE token_hash = $1; +-- name: DeleteSessionsByUserID :exec +DELETE FROM user_sessions WHERE user_id = $1; + -- name: DeleteExpiredSessions :exec DELETE FROM user_sessions WHERE expires_at <= now(); -- name: ListAdmins :many -SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at +SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled FROM users WHERE is_admin = true ORDER BY id; diff --git a/internal/user/repository.go b/internal/user/repository.go index 2cd5cb7..6b677e4 100644 --- a/internal/user/repository.go +++ b/internal/user/repository.go @@ -20,7 +20,9 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" @@ -35,12 +37,20 @@ type Repository interface { GetUserByEmail(ctx context.Context, email string) (*User, error) GetUserByID(ctx context.Context, id uuid.UUID) (*User, error) CountUsers(ctx context.Context) (int, error) + CountAdmins(ctx context.Context) (int, error) + ListUsers(ctx context.Context) ([]*User, error) ListAdmins(ctx context.Context) ([]*User, error) + UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) + SetAdmin(ctx context.Context, id uuid.UUID, isAdmin bool) (*User, error) + SetEnabled(ctx context.Context, id uuid.UUID, enabled bool) (*User, error) + UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error + DeleteUser(ctx context.Context, id uuid.UUID) error CreateSession(ctx context.Context, s *Session) error GetSessionByTokenHash(ctx context.Context, hash []byte) (*Session, error) TouchSession(ctx context.Context, hash []byte) error DeleteSessionByTokenHash(ctx context.Context, hash []byte) error + DeleteSessionsByUserID(ctx context.Context, userID uuid.UUID) error } // pgRepo 是 Repository 的 Postgres 实现,内部持有一个 sqlc 生成的 Queries。 @@ -80,6 +90,7 @@ func rowToUser(row usersqlc.User) *User { PasswordHash: row.PasswordHash, DisplayName: row.DisplayName, IsAdmin: row.IsAdmin, + Enabled: row.Enabled, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, } @@ -114,11 +125,20 @@ func (r *pgRepo) CreateUser(ctx context.Context, u *User) (*User, error) { IsAdmin: u.IsAdmin, }) if err != nil { + if isUniqueViolation(err) { + return nil, errs.New(errs.CodeConflict, "邮箱已被占用") + } return nil, errs.Wrap(err, errs.CodeInternal, "create user") } return rowToUser(row), nil } +// isUniqueViolation 判断底层错误是否为 Postgres 唯一约束冲突(如 email 重复)。 +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation +} + // GetUserByEmail 按邮箱精确查询用户。pgx.ErrNoRows 翻译为 NotFound,方便 // 上层(例如登录流程)分类响应;其它错误一律 Internal。 func (r *pgRepo) GetUserByEmail(ctx context.Context, email string) (*User, error) { @@ -212,3 +232,97 @@ func (r *pgRepo) ListAdmins(ctx context.Context) ([]*User, error) { } return out, nil } + +// CountAdmins 返回启用状态的管理员数量,用于 service 层的“最后一个管理员”保护。 +func (r *pgRepo) CountAdmins(ctx context.Context) (int, error) { + n, err := r.q.CountAdmins(ctx) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "count admins") + } + return int(n), nil +} + +// ListUsers 返回全部用户,按创建时间升序。 +func (r *pgRepo) ListUsers(ctx context.Context) ([]*User, error) { + rows, err := r.q.ListUsers(ctx) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list users") + } + out := make([]*User, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToUser(row)) + } + return out, nil +} + +// UpdateProfile 更新 display_name 并返回更新后的行。目标不存在时返回 NotFound。 +func (r *pgRepo) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) { + row, err := r.q.UpdateUserProfile(ctx, usersqlc.UpdateUserProfileParams{ + ID: toPgUUID(id), + DisplayName: displayName, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "用户不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "update user profile") + } + return rowToUser(row), nil +} + +// SetAdmin 设置 is_admin 并返回更新后的行。目标不存在时返回 NotFound。 +func (r *pgRepo) SetAdmin(ctx context.Context, id uuid.UUID, isAdmin bool) (*User, error) { + row, err := r.q.SetUserAdmin(ctx, usersqlc.SetUserAdminParams{ + ID: toPgUUID(id), + IsAdmin: isAdmin, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "用户不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "set user admin") + } + return rowToUser(row), nil +} + +// SetEnabled 设置 enabled 并返回更新后的行。目标不存在时返回 NotFound。 +func (r *pgRepo) SetEnabled(ctx context.Context, id uuid.UUID, enabled bool) (*User, error) { + row, err := r.q.SetUserEnabled(ctx, usersqlc.SetUserEnabledParams{ + ID: toPgUUID(id), + Enabled: enabled, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "用户不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "set user enabled") + } + return rowToUser(row), nil +} + +// UpdatePassword 覆写密码哈希。:exec 不读结果集,目标不存在不报错(幂等)。 +func (r *pgRepo) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error { + if err := r.q.UpdateUserPassword(ctx, usersqlc.UpdateUserPasswordParams{ + ID: toPgUUID(id), + PasswordHash: passwordHash, + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "update user password") + } + return nil +} + +// DeleteUser 删除用户。其会话经 ON DELETE CASCADE 一并清除。 +func (r *pgRepo) DeleteUser(ctx context.Context, id uuid.UUID) error { + if err := r.q.DeleteUser(ctx, toPgUUID(id)); err != nil { + return errs.Wrap(err, errs.CodeInternal, "delete user") + } + return nil +} + +// DeleteSessionsByUserID 撤销某用户的全部会话(改密/禁用时使用)。 +func (r *pgRepo) DeleteSessionsByUserID(ctx context.Context, userID uuid.UUID) error { + if err := r.q.DeleteSessionsByUserID(ctx, toPgUUID(userID)); err != nil { + return errs.Wrap(err, errs.CodeInternal, "delete sessions by user id") + } + return nil +} diff --git a/internal/user/service.go b/internal/user/service.go index 2a8661d..77e8409 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -177,3 +177,202 @@ func (s *service) Bootstrap(ctx context.Context, email, password string) (*User, func (s *service) ListAdmins(ctx context.Context) ([]*User, error) { return s.repo.ListAdmins(ctx) } + +// tempPasswordLen 是管理员重置密码时生成的临时明文长度(base64 字符数)。 +// 16 字符远超 minPasswordLen,且使用与会话 token 相同的强随机源。 +const tempPasswordLen = 16 + +// ListUsers 返回全部用户,供管理后台展示。调用方负责 admin 鉴权。 +func (s *service) ListUsers(ctx context.Context) ([]*User, error) { + return s.repo.ListUsers(ctx) +} + +// CreateUser 由管理员创建新账号。密码长度由 HashPassword 校验;email 冲突时 +// Repository 返回 CodeConflict。 +func (s *service) CreateUser(ctx context.Context, in CreateUserInput) (*User, error) { + hash, err := HashPassword(in.Password) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "password") + } + u := &User{ + ID: uuid.New(), + Email: in.Email, + PasswordHash: hash, + DisplayName: in.DisplayName, + IsAdmin: in.IsAdmin, + } + out, err := s.repo.CreateUser(ctx, u) + if err != nil { + return nil, err + } + s.record(ctx, &out.ID, "user.create", out.ID.String()) + return out, nil +} + +// UpdateProfile 修改用户 display_name。 +func (s *service) UpdateProfile(ctx context.Context, id uuid.UUID, displayName string) (*User, error) { + out, err := s.repo.UpdateProfile(ctx, id, displayName) + if err != nil { + return nil, err + } + s.record(ctx, &id, "user.update_profile", id.String()) + return out, nil +} + +// SetAdmin 调整用户管理员标志。两条护栏:actor 不能降级自己(防误操作把自己 +// 锁在门外);不能降级最后一个启用管理员(防系统失去管理员)。 +func (s *service) SetAdmin(ctx context.Context, actor, target uuid.UUID, isAdmin bool) (*User, error) { + if !isAdmin { + if actor == target { + return nil, errs.New(errs.CodeConflict, "不能取消自己的管理员权限") + } + if err := s.guardLastAdmin(ctx, target); err != nil { + return nil, err + } + } + out, err := s.repo.SetAdmin(ctx, target, isAdmin) + if err != nil { + return nil, err + } + s.record(ctx, &actor, "user.set_admin", target.String()) + return out, nil +} + +// SetEnabled 启用/禁用用户。禁用时撤销其全部会话使其立即下线,并施加与 +// SetAdmin 同样的自锁/最后管理员护栏。 +func (s *service) SetEnabled(ctx context.Context, actor, target uuid.UUID, enabled bool) (*User, error) { + if !enabled { + if actor == target { + return nil, errs.New(errs.CodeConflict, "不能禁用自己") + } + if err := s.guardLastAdmin(ctx, target); err != nil { + return nil, err + } + } + out, err := s.repo.SetEnabled(ctx, target, enabled) + if err != nil { + return nil, err + } + if !enabled { + // best-effort:撤销会话失败不应让禁用操作回滚(enabled=false 已生效, + // ResolveSession 会拒绝该用户)。 + _ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), target) + } + s.record(ctx, &actor, "user.set_enabled", target.String()) + return out, nil +} + +// AdminResetPassword 由管理员重置目标密码,生成强随机临时密码、撤销其全部 +// 会话,并返回临时明文(仅此一次)。 +func (s *service) AdminResetPassword(ctx context.Context, id uuid.UUID) (string, error) { + if _, err := s.repo.GetUserByID(ctx, id); err != nil { + return "", err + } + temp, err := randomPassword() + if err != nil { + return "", errs.Wrap(err, errs.CodeInternal, "generate temp password") + } + hash, err := HashPassword(temp) + if err != nil { + return "", errs.Wrap(err, errs.CodeInternal, "hash temp password") + } + if err := s.repo.UpdatePassword(ctx, id, hash); err != nil { + return "", err + } + _ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), id) + s.record(ctx, &id, "user.reset_password", id.String()) + return temp, nil +} + +// ChangePassword 用户自助改密:校验旧密码后更新,并撤销其全部会话(含当前), +// 强制用新密码重新登录。 +func (s *service) ChangePassword(ctx context.Context, id uuid.UUID, oldPassword, newPassword string) error { + u, err := s.repo.GetUserByID(ctx, id) + if err != nil { + return err + } + ok, err := VerifyPassword(oldPassword, u.PasswordHash) + if err != nil { + return errs.Wrap(err, errs.CodeInternal, "verify password") + } + if !ok { + return errs.New(errs.CodeUnauthorized, "旧密码错误") + } + hash, err := HashPassword(newPassword) + if err != nil { + return errs.Wrap(err, errs.CodeInvalidInput, "password") + } + if err := s.repo.UpdatePassword(ctx, id, hash); err != nil { + return err + } + _ = s.repo.DeleteSessionsByUserID(context.WithoutCancel(ctx), id) + s.record(ctx, &id, "user.change_password", id.String()) + return nil +} + +// DeleteUser 删除用户。同样施加自锁与最后管理员护栏。其会话经 DB 外键级联清除。 +func (s *service) DeleteUser(ctx context.Context, actor, target uuid.UUID) error { + if actor == target { + return errs.New(errs.CodeConflict, "不能删除自己") + } + t, err := s.repo.GetUserByID(ctx, target) + if err != nil { + return err + } + if t.IsAdmin && t.Enabled { + if err := s.guardLastAdmin(ctx, target); err != nil { + return err + } + } + if err := s.repo.DeleteUser(ctx, target); err != nil { + return err + } + s.record(ctx, &actor, "user.delete", target.String()) + return nil +} + +// guardLastAdmin 在降级/禁用/删除目标会导致“无启用管理员”时返回 CodeConflict。 +// 仅当目标本身是启用管理员且系统启用管理员数 <= 1 时触发。 +func (s *service) guardLastAdmin(ctx context.Context, target uuid.UUID) error { + t, err := s.repo.GetUserByID(ctx, target) + if err != nil { + return err + } + if !t.IsAdmin || !t.Enabled { + return nil + } + n, err := s.repo.CountAdmins(ctx) + if err != nil { + return err + } + if n <= 1 { + return errs.New(errs.CodeConflict, "必须保留至少一个启用的管理员") + } + return nil +} + +// record 是审计的薄封装:recorder 为 nil 时跳过;用 WithoutCancel 派生 ctx 保证 +// 响应返回后审计仍能落库。 +func (s *service) record(ctx context.Context, actor *uuid.UUID, action, targetID string) { + if s.audit == nil { + return + } + _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ + UserID: actor, + Action: action, + TargetType: "user", + TargetID: targetID, + }) +} + +// randomPassword 生成一段强随机 base64 临时密码,用于管理员重置场景。 +func randomPassword() (string, error) { + tok, _, err := NewSessionToken() + if err != nil { + return "", err + } + if len(tok) > tempPasswordLen { + tok = tok[:tempPasswordLen] + } + return tok, nil +} diff --git a/internal/user/service_test.go b/internal/user/service_test.go index adfa720..14b6ac4 100644 --- a/internal/user/service_test.go +++ b/internal/user/service_test.go @@ -48,7 +48,97 @@ func (r *fakeRepo) GetUserByID(_ context.Context, id uuid.UUID) (*User, error) { } func (r *fakeRepo) CountUsers(_ context.Context) (int, error) { return len(r.users), nil } -func (r *fakeRepo) ListAdmins(_ context.Context) ([]*User, error) { return nil, nil } +func (r *fakeRepo) ListAdmins(_ context.Context) ([]*User, error) { + out := make([]*User, 0) + for _, u := range r.users { + if u.IsAdmin { + out = append(out, u) + } + } + return out, nil +} + +func (r *fakeRepo) CountAdmins(_ context.Context) (int, error) { + n := 0 + for _, u := range r.users { + if u.IsAdmin && u.Enabled { + n++ + } + } + return n, nil +} + +func (r *fakeRepo) ListUsers(_ context.Context) ([]*User, error) { + out := make([]*User, 0, len(r.users)) + for _, u := range r.users { + out = append(out, u) + } + return out, nil +} + +func (r *fakeRepo) findByID(id uuid.UUID) (*User, bool) { + for _, u := range r.users { + if u.ID == id { + return u, true + } + } + return nil, false +} + +func (r *fakeRepo) UpdateProfile(_ context.Context, id uuid.UUID, displayName string) (*User, error) { + u, ok := r.findByID(id) + if !ok { + return nil, errs.New(errs.CodeNotFound, "no user") + } + u.DisplayName = displayName + return u, nil +} + +func (r *fakeRepo) SetAdmin(_ context.Context, id uuid.UUID, isAdmin bool) (*User, error) { + u, ok := r.findByID(id) + if !ok { + return nil, errs.New(errs.CodeNotFound, "no user") + } + u.IsAdmin = isAdmin + return u, nil +} + +func (r *fakeRepo) SetEnabled(_ context.Context, id uuid.UUID, enabled bool) (*User, error) { + u, ok := r.findByID(id) + if !ok { + return nil, errs.New(errs.CodeNotFound, "no user") + } + u.Enabled = enabled + return u, nil +} + +func (r *fakeRepo) UpdatePassword(_ context.Context, id uuid.UUID, passwordHash string) error { + u, ok := r.findByID(id) + if !ok { + return errs.New(errs.CodeNotFound, "no user") + } + u.PasswordHash = passwordHash + return nil +} + +func (r *fakeRepo) DeleteUser(_ context.Context, id uuid.UUID) error { + for email, u := range r.users { + if u.ID == id { + delete(r.users, email) + return nil + } + } + return nil +} + +func (r *fakeRepo) DeleteSessionsByUserID(_ context.Context, userID uuid.UUID) error { + for k, s := range r.sessions { + if s.UserID == userID { + delete(r.sessions, k) + } + } + return nil +} func (r *fakeRepo) CreateSession(_ context.Context, s *Session) error { r.sessions[string(s.TokenHash)] = s @@ -168,3 +258,124 @@ func TestLogout_RecordsAudit(t *testing.T) { require.Len(t, spy.entries, 1) require.Equal(t, "user.logout", spy.entries[0].Action) } + +// ===== 管理员用户管理测试 ===== + +// newAdminRepo 构造一个含一个启用管理员的 fakeRepo + service。 +func newAdminRepo(t *testing.T) (*service, *fakeRepo, *User) { + t.Helper() + repo := newFakeRepo() + hash, err := HashPassword("password123") + require.NoError(t, err) + admin := &User{ID: uuid.New(), Email: "admin@b.c", DisplayName: "Admin", PasswordHash: hash, IsAdmin: true, Enabled: true} + repo.users[admin.Email] = admin + svc := NewService(repo, nil).(*service) + return svc, repo, admin +} + +func TestCreateUser_AndListUsers(t *testing.T) { + svc, _, _ := newAdminRepo(t) + u, err := svc.CreateUser(context.Background(), CreateUserInput{ + Email: "new@b.c", DisplayName: "New", Password: "password123", IsAdmin: false, + }) + require.NoError(t, err) + require.Equal(t, "new@b.c", u.Email) + users, err := svc.ListUsers(context.Background()) + require.NoError(t, err) + require.Len(t, users, 2) +} + +func TestCreateUser_ShortPassword(t *testing.T) { + svc, _, _ := newAdminRepo(t) + _, err := svc.CreateUser(context.Background(), CreateUserInput{ + Email: "new@b.c", DisplayName: "New", Password: "short", + }) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestSetAdmin_CannotDemoteSelf(t *testing.T) { + svc, _, admin := newAdminRepo(t) + _, err := svc.SetAdmin(context.Background(), admin.ID, admin.ID, false) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeConflict, ae.Code) +} + +func TestSetAdmin_CannotDemoteLastAdmin(t *testing.T) { + svc, repo, admin := newAdminRepo(t) + // 加一个普通用户作 actor,避免触发自锁分支 + actor := &User{ID: uuid.New(), Email: "actor@b.c", DisplayName: "A", IsAdmin: true, Enabled: true} + repo.users[actor.Email] = actor + // 现在有 2 个 admin;降级其中一个应成功 + _, err := svc.SetAdmin(context.Background(), actor.ID, admin.ID, false) + require.NoError(t, err) + // 只剩 actor 一个 admin;再降级 actor(由自己以外触发不可能,构造另一普通用户) + plain := &User{ID: uuid.New(), Email: "p@b.c", DisplayName: "P", IsAdmin: false, Enabled: true} + repo.users[plain.Email] = plain + _, err = svc.SetAdmin(context.Background(), plain.ID, actor.ID, false) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeConflict, ae.Code) +} + +func TestSetEnabled_DisableRevokesSessions(t *testing.T) { + svc, repo, _ := newAdminRepo(t) + target := &User{ID: uuid.New(), Email: "t@b.c", DisplayName: "T", IsAdmin: false, Enabled: true} + repo.users[target.Email] = target + repo.sessions["s1"] = &Session{UserID: target.ID, TokenHash: []byte("s1")} + actor := &User{ID: uuid.New(), Email: "act@b.c", IsAdmin: true, Enabled: true} + repo.users[actor.Email] = actor + _, err := svc.SetEnabled(context.Background(), actor.ID, target.ID, false) + require.NoError(t, err) + require.False(t, repo.users["t@b.c"].Enabled) + require.Empty(t, repo.sessions) +} + +func TestChangePassword_WrongOld(t *testing.T) { + svc, _, admin := newAdminRepo(t) + err := svc.ChangePassword(context.Background(), admin.ID, "wrong-old", "newpassword123") + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeUnauthorized, ae.Code) +} + +func TestChangePassword_Success(t *testing.T) { + svc, repo, admin := newAdminRepo(t) + repo.sessions["s1"] = &Session{UserID: admin.ID, TokenHash: []byte("s1")} + err := svc.ChangePassword(context.Background(), admin.ID, "password123", "newpassword123") + require.NoError(t, err) + ok, err := VerifyPassword("newpassword123", repo.users["admin@b.c"].PasswordHash) + require.NoError(t, err) + require.True(t, ok) + require.Empty(t, repo.sessions) // 改密撤销全部会话 +} + +func TestAdminResetPassword_ReturnsTemp(t *testing.T) { + svc, repo, admin := newAdminRepo(t) + temp, err := svc.AdminResetPassword(context.Background(), admin.ID) + require.NoError(t, err) + require.GreaterOrEqual(t, len(temp), minPasswordLen) + ok, err := VerifyPassword(temp, repo.users["admin@b.c"].PasswordHash) + require.NoError(t, err) + require.True(t, ok) +} + +func TestDeleteUser_CannotDeleteSelf(t *testing.T) { + svc, _, admin := newAdminRepo(t) + err := svc.DeleteUser(context.Background(), admin.ID, admin.ID) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeConflict, ae.Code) +} + +func TestDeleteUser_CannotDeleteLastAdmin(t *testing.T) { + svc, repo, admin := newAdminRepo(t) + actor := &User{ID: uuid.New(), Email: "act@b.c", IsAdmin: false, Enabled: true} + repo.users[actor.Email] = actor + err := svc.DeleteUser(context.Background(), actor.ID, admin.ID) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeConflict, ae.Code) +} diff --git a/internal/user/sqlc/models.go b/internal/user/sqlc/models.go index 6c0ddba..182941d 100644 --- a/internal/user/sqlc/models.go +++ b/internal/user/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/internal/user/sqlc/users.sql.go b/internal/user/sqlc/users.sql.go index ca13df5..c8c97dc 100644 --- a/internal/user/sqlc/users.sql.go +++ b/internal/user/sqlc/users.sql.go @@ -11,6 +11,17 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countAdmins = `-- name: CountAdmins :one +SELECT COUNT(*)::int FROM users WHERE is_admin = true AND enabled = true +` + +func (q *Queries) CountAdmins(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, countAdmins) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + const countUsers = `-- name: CountUsers :one SELECT COUNT(*)::int FROM users ` @@ -47,7 +58,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er const createUser = `-- name: CreateUser :one INSERT INTO users (id, email, password_hash, display_name, is_admin) VALUES ($1, $2, $3, $4, $5) -RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at +RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled ` type CreateUserParams struct { @@ -75,6 +86,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.Enabled, ) return i, err } @@ -97,11 +109,31 @@ func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte return err } +const deleteSessionsByUserID = `-- name: DeleteSessionsByUserID :exec +DELETE FROM user_sessions WHERE user_id = $1 +` + +func (q *Queries) DeleteSessionsByUserID(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteSessionsByUserID, userID) + return err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users WHERE id = $1 +` + +func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteUser, id) + return err +} + const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one -SELECT id, user_id, token_hash, expires_at, last_seen_at, created_at -FROM user_sessions -WHERE token_hash = $1 - AND expires_at > now() +SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.created_at +FROM user_sessions s +JOIN users u ON u.id = s.user_id +WHERE s.token_hash = $1 + AND s.expires_at > now() + AND u.enabled = true ` func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (UserSession, error) { @@ -119,7 +151,7 @@ func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) ( } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at +SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled FROM users WHERE email = $1 ` @@ -135,12 +167,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.Enabled, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at +SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled FROM users WHERE id = $1 ` @@ -156,12 +189,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.Enabled, ) return i, err } const listAdmins = `-- name: ListAdmins :many -SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at +SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled FROM users WHERE is_admin = true ORDER BY id @@ -184,6 +218,7 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) { &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.Enabled, ); err != nil { return nil, err } @@ -195,6 +230,97 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) { return items, nil } +const listUsers = `-- name: ListUsers :many +SELECT id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled +FROM users +ORDER BY created_at ASC +` + +func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.Query(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.DisplayName, + &i.IsAdmin, + &i.CreatedAt, + &i.UpdatedAt, + &i.Enabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setUserAdmin = `-- name: SetUserAdmin :one +UPDATE users +SET is_admin = $2, updated_at = now() +WHERE id = $1 +RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled +` + +type SetUserAdminParams struct { + ID pgtype.UUID `json:"id"` + IsAdmin bool `json:"is_admin"` +} + +func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) (User, error) { + row := q.db.QueryRow(ctx, setUserAdmin, arg.ID, arg.IsAdmin) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.DisplayName, + &i.IsAdmin, + &i.CreatedAt, + &i.UpdatedAt, + &i.Enabled, + ) + return i, err +} + +const setUserEnabled = `-- name: SetUserEnabled :one +UPDATE users +SET enabled = $2, updated_at = now() +WHERE id = $1 +RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled +` + +type SetUserEnabledParams struct { + ID pgtype.UUID `json:"id"` + Enabled bool `json:"enabled"` +} + +func (q *Queries) SetUserEnabled(ctx context.Context, arg SetUserEnabledParams) (User, error) { + row := q.db.QueryRow(ctx, setUserEnabled, arg.ID, arg.Enabled) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.DisplayName, + &i.IsAdmin, + &i.CreatedAt, + &i.UpdatedAt, + &i.Enabled, + ) + return i, err +} + const touchSession = `-- name: TouchSession :exec UPDATE user_sessions SET last_seen_at = now() @@ -205,3 +331,47 @@ func (q *Queries) TouchSession(ctx context.Context, tokenHash []byte) error { _, err := q.db.Exec(ctx, touchSession, tokenHash) return err } + +const updateUserPassword = `-- name: UpdateUserPassword :exec +UPDATE users +SET password_hash = $2, updated_at = now() +WHERE id = $1 +` + +type UpdateUserPasswordParams struct { + ID pgtype.UUID `json:"id"` + PasswordHash string `json:"password_hash"` +} + +func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error { + _, err := q.db.Exec(ctx, updateUserPassword, arg.ID, arg.PasswordHash) + return err +} + +const updateUserProfile = `-- name: UpdateUserProfile :one +UPDATE users +SET display_name = $2, updated_at = now() +WHERE id = $1 +RETURNING id, email, password_hash, display_name, is_admin, created_at, updated_at, enabled +` + +type UpdateUserProfileParams struct { + ID pgtype.UUID `json:"id"` + DisplayName string `json:"display_name"` +} + +func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserProfile, arg.ID, arg.DisplayName) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.DisplayName, + &i.IsAdmin, + &i.CreatedAt, + &i.UpdatedAt, + &i.Enabled, + ) + return i, err +} diff --git a/internal/workspace/sqlc/models.go b/internal/workspace/sqlc/models.go index 94c0a48..fa36ea3 100644 --- a/internal/workspace/sqlc/models.go +++ b/internal/workspace/sqlc/models.go @@ -11,17 +11,18 @@ import ( ) type AcpAgentKind struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` } type AcpEvent struct { @@ -36,6 +37,20 @@ type AcpEvent struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type AcpSession struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -269,6 +284,7 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` } type UserSession struct { diff --git a/migrations/0008_user_admin.down.sql b/migrations/0008_user_admin.down.sql new file mode 100644 index 0000000..4864713 --- /dev/null +++ b/migrations/0008_user_admin.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN enabled; diff --git a/migrations/0008_user_admin.up.sql b/migrations/0008_user_admin.up.sql new file mode 100644 index 0000000..59b51fb --- /dev/null +++ b/migrations/0008_user_admin.up.sql @@ -0,0 +1,4 @@ +-- ============ 用户管理:账号启用状态 ============ +-- enabled 用于管理员禁用账号而不删除:禁用后该用户无法通过 ResolveSession +-- 鉴权。默认 TRUE 保证存量账号与 bootstrap 管理员不受影响。 +ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/migrations/0009_acp_permissions.down.sql b/migrations/0009_acp_permissions.down.sql new file mode 100644 index 0000000..2352a80 --- /dev/null +++ b/migrations/0009_acp_permissions.down.sql @@ -0,0 +1,2 @@ +DROP TABLE acp_permission_requests; +ALTER TABLE acp_agent_kinds DROP COLUMN tool_allowlist; diff --git a/migrations/0009_acp_permissions.up.sql b/migrations/0009_acp_permissions.up.sql new file mode 100644 index 0000000..f5d8f1a --- /dev/null +++ b/migrations/0009_acp_permissions.up.sql @@ -0,0 +1,25 @@ +-- ============ ACP 工具调用权限审批 ============ + +-- agent kind 的工具白名单:命中的工具调用自动放行(status=auto),未命中走人工审批。 +-- 空数组表示无白名单(全部需人工审批);含 '*' 表示全部自动放行。 +ALTER TABLE acp_agent_kinds ADD COLUMN tool_allowlist TEXT[] NOT NULL DEFAULT '{}'; + +-- 代理发起的 session/request_permission 请求落库,供人工审批与审计。 +CREATE TABLE acp_permission_requests ( + id UUID PRIMARY KEY, + session_id UUID NOT NULL REFERENCES acp_sessions(id) ON DELETE CASCADE, + agent_request_id TEXT NOT NULL DEFAULT '', -- 代理 JSON-RPC request id(排障/幂等) + tool_name TEXT NOT NULL DEFAULT '', -- 从 toolCall 提取的工具标识,便于展示 + tool_call JSONB NOT NULL, -- 原始 toolCall 对象 + options JSONB NOT NULL, -- 原始 options 数组 + status TEXT NOT NULL, -- pending|approved|denied|auto|expired + chosen_option_id TEXT, -- 最终选中的 option id + decided_by UUID REFERENCES users(id) ON DELETE SET NULL, + decided_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT acp_perm_status_check + CHECK (status IN ('pending','approved','denied','auto','expired')) +); + +CREATE INDEX idx_acp_perm_session_status ON acp_permission_requests(session_id, status); +CREATE INDEX idx_acp_perm_pending ON acp_permission_requests(status) WHERE status = 'pending'; diff --git a/web/src/api/acp.ts b/web/src/api/acp.ts index 4039caa..b2e54b2 100644 --- a/web/src/api/acp.ts +++ b/web/src/api/acp.ts @@ -10,6 +10,7 @@ export interface AgentKindAdmin { args: string[] env_keys: string[] enabled: boolean + tool_allowlist: string[] created_by: string created_at: string updated_at: string @@ -36,6 +37,7 @@ export interface CreateAgentKindReq { args?: string[] env?: Record enabled?: boolean + tool_allowlist?: string[] } export interface UpdateAgentKindReq { @@ -45,6 +47,18 @@ export interface UpdateAgentKindReq { args?: string[] env?: Record enabled?: boolean + tool_allowlist?: string[] +} + +// PermissionRequest mirrors backend PermissionRequestDTO. +export interface PermissionRequest { + id: string + session_id: string + tool_name: string + tool_call: unknown + options: { id: string; name?: string }[] + status: string + created_at: string } // AcpSession mirrors the backend session DTO returned by /api/v1/acp/sessions. @@ -90,6 +104,8 @@ export interface AcpEvent { | 'session_terminated' | 'slow_consumer_disconnect' | 'client_error' + | 'permission_request' + | 'permission_resolved' method?: string payload: unknown truncated: boolean @@ -121,6 +137,17 @@ export const acpApi = { request(`/api/v1/acp/sessions/${enc(id)}`, { method: 'DELETE' }), events: (id: string, since: number) => request(`/api/v1/acp/sessions/${enc(id)}/events?since=${since}`), + permissions: (id: string) => + request(`/api/v1/acp/sessions/${enc(id)}/permissions`), + }, + permissions: { + approve: (reqID: string, optionId?: string) => + request(`/api/v1/acp/permissions/${enc(reqID)}/approve`, { + method: 'POST', + body: optionId ? { option_id: optionId } : {}, + }), + deny: (reqID: string) => + request(`/api/v1/acp/permissions/${enc(reqID)}/deny`, { method: 'POST' }), }, } diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 386c650..e6614f1 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -14,4 +14,9 @@ export const authApi = { }), me: () => request('/api/v1/auth/me'), logout: () => request('/api/v1/auth/logout', { method: 'POST' }), + changePassword: (oldPassword: string, newPassword: string) => + request('/api/v1/auth/change-password', { + method: 'POST', + body: { old_password: oldPassword, new_password: newPassword }, + }), } diff --git a/web/src/api/usersAdmin.ts b/web/src/api/usersAdmin.ts new file mode 100644 index 0000000..6d89fb2 --- /dev/null +++ b/web/src/api/usersAdmin.ts @@ -0,0 +1,53 @@ +import { request } from './client' + +// AdminUser 对应后端 adminUserDTO:比登录用的 User 多 enabled 与时间戳。 +export interface AdminUser { + id: string + email: string + display_name: string + is_admin: boolean + enabled: boolean + created_at: string + updated_at: string +} + +export interface CreateUserBody { + email: string + display_name: string + password: string + is_admin: boolean +} + +export interface ResetPasswordResponse { + temp_password: string +} + +const BASE = '/api/v1/admin/users' + +export const userAdminApi = { + list: () => request(BASE), + get: (id: string) => request(`${BASE}/${id}`), + create: (body: CreateUserBody) => + request(BASE, { method: 'POST', body }), + updateProfile: (id: string, displayName: string) => + request(`${BASE}/${id}`, { + method: 'PATCH', + body: { display_name: displayName }, + }), + setRole: (id: string, isAdmin: boolean) => + request(`${BASE}/${id}/role`, { + method: 'PATCH', + body: { is_admin: isAdmin }, + }), + setEnabled: (id: string, enabled: boolean) => + request(`${BASE}/${id}/enabled`, { + method: 'PATCH', + body: { enabled }, + }), + resetPassword: (id: string) => + request(`${BASE}/${id}/reset-password`, { + method: 'POST', + }), + remove: (id: string) => + request(`${BASE}/${id}`, { method: 'DELETE' }), +} diff --git a/web/src/components/acp/AgentKindForm.vue b/web/src/components/acp/AgentKindForm.vue index 0b7fafa..ba0795a 100644 --- a/web/src/components/acp/AgentKindForm.vue +++ b/web/src/components/acp/AgentKindForm.vue @@ -11,6 +11,7 @@ const props = defineProps<{ args: string[] env: Record enabled: boolean + tool_allowlist: string[] } isEdit: boolean existingEnvKeys?: string[] @@ -38,6 +39,13 @@ const argsText = computed({ }, }) +const allowlistText = computed({ + get: () => (local.value.tool_allowlist ?? []).join('\n'), + set: (v: string) => { + local.value.tool_allowlist = v.split('\n').map((x) => x.trim()).filter((x) => x !== '') + }, +}) + interface EnvRow { k: string v: string @@ -157,6 +165,17 @@ function removeEnv(idx: number) { + Add +
+ + +
Enabled diff --git a/web/src/components/acp/PermissionApprovalPanel.vue b/web/src/components/acp/PermissionApprovalPanel.vue new file mode 100644 index 0000000..d888328 --- /dev/null +++ b/web/src/components/acp/PermissionApprovalPanel.vue @@ -0,0 +1,89 @@ + + + diff --git a/web/src/components/layout/NavBar.vue b/web/src/components/layout/NavBar.vue index 0bce1fb..9a8df11 100644 --- a/web/src/components/layout/NavBar.vue +++ b/web/src/components/layout/NavBar.vue @@ -39,6 +39,12 @@