This commit is contained in:
2026-06-09 21:19:30 +08:00
parent 8b41ff9360
commit 0484e79978
66 changed files with 4043 additions and 533 deletions
+1 -1
View File
@@ -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/
+45 -2
View File
@@ -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)
View File
-141
View File
@@ -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=<hex>`,HMAC-SHA256(`<ts>.` + 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 = '<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 = '<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`:表结构
+9
View File
@@ -37,9 +37,14 @@ 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,
ToolAllowlist: allowlist,
CreatedBy: c.UserID,
}
out, err := s.repo.CreateAgentKind(ctx, k)
@@ -105,6 +110,10 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i
cur.EncryptedEnv = encrypted
changed["env"] = "<changed>"
}
if in.ToolAllowlist != nil {
cur.ToolAllowlist = in.ToolAllowlist
changed["tool_allowlist"] = "<changed>"
}
if in.Enabled != nil && *in.Enabled != cur.Enabled {
cur.Enabled = *in.Enabled
changed["enabled"] = *in.Enabled
+23
View File
@@ -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
}
+29
View File
@@ -58,6 +58,7 @@ type AgentKind struct {
Args []string
EncryptedEnv []byte
Enabled bool
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示全部放行
CreatedBy uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
@@ -143,6 +144,7 @@ type CreateAgentKindInput struct {
Args []string
Env map[string]string
Enabled bool
ToolAllowlist []string
}
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
@@ -154,4 +156,31 @@ type UpdateAgentKindInput struct {
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
}
+29
View File
@@ -1,6 +1,9 @@
package acp
import (
"encoding/json"
"time"
"github.com/google/uuid"
)
@@ -14,6 +17,7 @@ type AgentKindAdminDTO struct {
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"`
@@ -36,6 +40,7 @@ type CreateAgentKindReq struct {
Args []string `json:"args"`
Env map[string]string `json:"env"`
Enabled *bool `json:"enabled"`
ToolAllowlist []string `json:"tool_allowlist"`
}
type UpdateAgentKindReq struct {
@@ -45,6 +50,30 @@ type UpdateAgentKindReq struct {
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.
+74 -2
View File
@@ -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) {
@@ -139,6 +208,7 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
Args: req.Args,
Env: req.Env,
Enabled: enabled,
ToolAllowlist: req.ToolAllowlist,
}
out, err := h.akSvc.Create(r.Context(), c, in)
if err != nil {
@@ -194,6 +264,7 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
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 {
@@ -547,6 +618,7 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
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"),
+4 -1
View File
@@ -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,
+1
View File
@@ -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,
+1 -1
View File
@@ -23,6 +23,7 @@ type SessionContext struct {
UserID uuid.UUID
CwdPath string
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()}
}
+48 -26
View File
@@ -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 ""
}
+11 -16
View File
@@ -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))
}
+397
View File
@@ -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
}
+267
View File
@@ -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()))
}
+9 -8
View File
@@ -1,31 +1,31 @@
-- 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;
@@ -38,10 +38,11 @@ SET display_name = $2,
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;
+44
View File
@@ -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;
+11 -1
View File
@@ -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)
+24 -1
View File
@@ -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
}
+137
View File
@@ -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
}
@@ -138,6 +147,7 @@ func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind,
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")
@@ -194,6 +204,7 @@ func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind,
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")
@@ -231,12 +242,22 @@ func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
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,
}
}
+26 -1
View File
@@ -209,7 +209,9 @@ func (r *fakeAcpRepo) UpdateAgentKind(_ context.Context, k *acp.AgentKind) (*acp
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) 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
}
+19 -8
View File
@@ -24,10 +24,10 @@ 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 {
@@ -40,6 +40,7 @@ type CreateAgentKindParams struct {
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
}
@@ -219,10 +226,11 @@ SET display_name = $2,
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 {
@@ -233,6 +241,7 @@ type UpdateAgentKindParams struct {
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
}
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+237
View File
@@ -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
}
+18 -1
View File
@@ -54,10 +54,15 @@ type Supervisor struct {
wsRepo workspace.Repository
mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token
crypto *crypto.Encryptor
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,
@@ -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 {
+4 -1
View File
@@ -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,
+22 -2
View File
@@ -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,
+1 -1
View File
@@ -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"
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+2
View File
@@ -197,6 +197,7 @@ 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 与速率限制。
@@ -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")
+9
View File
@@ -86,6 +86,9 @@ const (
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"
@@ -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:
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+19
View File
@@ -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)
}
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+28 -3
View File
@@ -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")
}
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+30
View File
@@ -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
}
+303 -1
View File
@@ -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)
}
+47 -8
View File
@@ -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;
+114
View File
@@ -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
}
+199
View File
@@ -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
}
+212 -1
View File
@@ -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)
}
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+178 -8
View File
@@ -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
}
+16
View File
@@ -22,6 +22,7 @@ type AcpAgentKind struct {
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 {
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN enabled;
+4
View File
@@ -0,0 +1,4 @@
-- ============ 用户管理:账号启用状态 ============
-- enabled 用于管理员禁用账号而不删除:禁用后该用户无法通过 ResolveSession
-- 鉴权。默认 TRUE 保证存量账号与 bootstrap 管理员不受影响。
ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE acp_permission_requests;
ALTER TABLE acp_agent_kinds DROP COLUMN tool_allowlist;
+25
View File
@@ -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';
+27
View File
@@ -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<string, string>
enabled?: boolean
tool_allowlist?: string[]
}
export interface UpdateAgentKindReq {
@@ -45,6 +47,18 @@ export interface UpdateAgentKindReq {
args?: string[]
env?: Record<string, string>
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<void>(`/api/v1/acp/sessions/${enc(id)}`, { method: 'DELETE' }),
events: (id: string, since: number) =>
request<AcpEvent[]>(`/api/v1/acp/sessions/${enc(id)}/events?since=${since}`),
permissions: (id: string) =>
request<PermissionRequest[]>(`/api/v1/acp/sessions/${enc(id)}/permissions`),
},
permissions: {
approve: (reqID: string, optionId?: string) =>
request<void>(`/api/v1/acp/permissions/${enc(reqID)}/approve`, {
method: 'POST',
body: optionId ? { option_id: optionId } : {},
}),
deny: (reqID: string) =>
request<void>(`/api/v1/acp/permissions/${enc(reqID)}/deny`, { method: 'POST' }),
},
}
+5
View File
@@ -14,4 +14,9 @@ export const authApi = {
}),
me: () => request<User>('/api/v1/auth/me'),
logout: () => request<void>('/api/v1/auth/logout', { method: 'POST' }),
changePassword: (oldPassword: string, newPassword: string) =>
request<void>('/api/v1/auth/change-password', {
method: 'POST',
body: { old_password: oldPassword, new_password: newPassword },
}),
}
+53
View File
@@ -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<AdminUser[]>(BASE),
get: (id: string) => request<AdminUser>(`${BASE}/${id}`),
create: (body: CreateUserBody) =>
request<AdminUser>(BASE, { method: 'POST', body }),
updateProfile: (id: string, displayName: string) =>
request<AdminUser>(`${BASE}/${id}`, {
method: 'PATCH',
body: { display_name: displayName },
}),
setRole: (id: string, isAdmin: boolean) =>
request<AdminUser>(`${BASE}/${id}/role`, {
method: 'PATCH',
body: { is_admin: isAdmin },
}),
setEnabled: (id: string, enabled: boolean) =>
request<AdminUser>(`${BASE}/${id}/enabled`, {
method: 'PATCH',
body: { enabled },
}),
resetPassword: (id: string) =>
request<ResetPasswordResponse>(`${BASE}/${id}/reset-password`, {
method: 'POST',
}),
remove: (id: string) =>
request<void>(`${BASE}/${id}`, { method: 'DELETE' }),
}
+19
View File
@@ -11,6 +11,7 @@ const props = defineProps<{
args: string[]
env: Record<string, string>
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
</NButton>
</div>
<div>
<label class="block text-sm font-medium mb-1">
工具白名单每行一个命中则自动放行 * 全部放行留空则全部需人工审批
</label>
<NInput
v-model:value="allowlistText"
type="textarea"
:autosize="{ minRows: 2, maxRows: 8 }"
placeholder="fs/read_text_file&#10;safe.tool"
/>
</div>
<div class="flex items-center gap-2">
<NCheckbox v-model:checked="local.enabled">
Enabled
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { PermissionRequest } from '@/api/acp'
const props = defineProps<{
requests: PermissionRequest[]
canDecide: boolean
}>()
const emit = defineEmits<{
approve: [reqID: string, optionId?: string]
deny: [reqID: string]
}>()
const list = computed(() => props.requests)
// allowOptions Deny
function allowOptions(r: PermissionRequest) {
return r.options.filter((o) => {
const id = o.id.toLowerCase()
return id.includes('allow') || id.includes('yes') || id.includes('approve') || id.includes('accept')
})
}
function summarize(toolCall: unknown): string {
try {
return JSON.stringify(toolCall)
} catch {
return ''
}
}
</script>
<template>
<div
v-if="list.length"
class="border-b bg-amber-50 px-4 py-3 space-y-3"
>
<div class="text-sm font-semibold text-amber-800">
待审批的工具调用{{ list.length }}
</div>
<div
v-for="r in list"
:key="r.id"
class="rounded border border-amber-300 bg-white px-3 py-2"
>
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-sm">{{ r.tool_name || '(未命名工具)' }}</span>
<div
v-if="canDecide"
class="flex items-center gap-2"
>
<template v-if="allowOptions(r).length">
<button
v-for="opt in allowOptions(r)"
:key="opt.id"
class="px-2 py-1 text-xs rounded border text-green-700 border-green-300 hover:bg-green-50"
@click="emit('approve', r.id, opt.id)"
>
{{ opt.name || opt.id }}
</button>
</template>
<button
v-else
class="px-2 py-1 text-xs rounded border text-green-700 border-green-300 hover:bg-green-50"
@click="emit('approve', r.id)"
>
批准
</button>
<button
class="px-2 py-1 text-xs rounded border text-red-600 border-red-300 hover:bg-red-50"
@click="emit('deny', r.id)"
>
拒绝
</button>
</div>
<span
v-else
class="text-xs text-gray-400"
>
等待 owner 审批
</span>
</div>
<pre
class="mt-1 text-xs text-gray-500 whitespace-pre-wrap break-all max-h-24 overflow-y-auto"
>{{ summarize(r.tool_call) }}</pre>
</div>
</div>
</template>
+11
View File
@@ -39,6 +39,12 @@
</RouterLink>
<template v-if="auth.isAdmin">
<span class="text-neutral-300">|</span>
<RouterLink
:to="{ name: 'admin-users' }"
class="text-sm hover:underline"
>
用户
</RouterLink>
<RouterLink
:to="{ name: 'admin-llm-endpoints' }"
class="text-sm hover:underline"
@@ -94,10 +100,15 @@ const auth = useAuthStore()
const router = useRouter()
const menu = computed(() => [
{ label: '修改密码', key: 'change-password' },
{ label: '退出登录', key: 'logout' },
])
async function onSelect(key: string) {
if (key === 'change-password') {
router.push({ name: 'me-change-password' }).catch(() => {})
return
}
if (key === 'logout') {
try {
await authApi.logout()
+55 -2
View File
@@ -5,7 +5,7 @@
// - accumulate events into a ref array
// - expose sessionStatus + connection-state status
import { ref, onScopeDispose, type Ref } from 'vue'
import { openSessionWS, type AcpEvent } from '@/api/acp'
import { openSessionWS, acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp'
import { useAuthStore } from '@/stores/auth'
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error'
@@ -15,12 +15,26 @@ export interface UseAcpStreamReturn {
status: Ref<StreamStatus>
sessionStatus: Ref<SessionStatus>
events: Ref<AcpEvent[]>
pendingPermissions: Ref<PermissionRequest[]>
prompt: (text: string, agentSessionID: string) => void
cancel: (agentSessionID: string) => void
reconnect: () => void
close: () => void
}
// 把控制帧(ID=0)的 payload 归一为对象,无论 WS 传来字符串还是已解析对象。
function decodePayload(raw: unknown): Record<string, unknown> {
if (typeof raw === 'string') {
try {
return JSON.parse(raw) as Record<string, unknown>
} catch {
return {}
}
}
if (raw && typeof raw === 'object') return raw as Record<string, unknown>
return {}
}
interface ServerStateEvent {
kind: 'state'
status: SessionStatus
@@ -34,8 +48,18 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
const status = ref<StreamStatus>('idle')
const sessionStatus = ref<SessionStatus>('starting')
const events = ref<AcpEvent[]>([])
const pendingPermissions = ref<PermissionRequest[]>([])
const lastEventID = ref(0)
function upsertPermission(p: PermissionRequest) {
if (!pendingPermissions.value.some((x) => x.id === p.id)) {
pendingPermissions.value.push(p)
}
}
function removePermission(reqID: string) {
pendingPermissions.value = pendingPermissions.value.filter((x) => x.id !== reqID)
}
let ws: WebSocket | null = null
let retried = 0
let manualClose = false
@@ -53,6 +77,13 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
sock.onopen = () => {
status.value = 'streaming'
retried = 0
// 控制帧(permission_*)不落 acp_events,重连不补发;连上后主动拉取当前待审。
acpApi.sessions
.permissions(sessionId)
.then((list) => {
for (const p of list) upsertPermission(p)
})
.catch(() => {})
}
sock.onmessage = (ev: MessageEvent) => {
@@ -65,6 +96,28 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
if (!parsed || typeof parsed !== 'object') return
const obj = parsed as Record<string, unknown>
// 权限请求/决议控制帧(不进 events 流,单独维护 pendingPermissions)。
if (obj.kind === 'permission_request') {
const p = decodePayload(obj.payload)
upsertPermission({
id: String(p.request_id ?? ''),
session_id: String(p.session_id ?? ''),
tool_name: String(p.tool_name ?? ''),
tool_call: p.tool_call,
options: Array.isArray(p.options)
? (p.options as { id: string; name?: string }[])
: [],
status: String(p.status ?? 'pending'),
created_at: String(p.created_at ?? ''),
})
return
}
if (obj.kind === 'permission_resolved') {
const p = decodePayload(obj.payload)
removePermission(String(p.request_id ?? ''))
return
}
// Initial state event from server
if (obj.kind === 'state') {
const s = (parsed as ServerStateEvent).status
@@ -156,5 +209,5 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
connect()
onScopeDispose(close)
return { status, sessionStatus, events, prompt, cancel, reconnect, close }
return { status, sessionStatus, events, pendingPermissions, prompt, cancel, reconnect, close }
}
+12
View File
@@ -117,6 +117,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/admin/UsageView.vue'),
meta: { requiresAuth: true },
},
{
path: '/me/change-password',
name: 'me-change-password',
component: () => import('@/views/ChangePasswordView.vue'),
meta: { requiresAuth: true },
},
{
path: '/admin/users',
name: 'admin-users',
component: () => import('@/views/admin/UserListView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/llm-endpoints',
name: 'admin-llm-endpoints',
+82
View File
@@ -0,0 +1,82 @@
<template>
<AppShell>
<div class="max-w-md space-y-4">
<h2 class="text-lg font-semibold">
修改密码
</h2>
<NForm>
<NFormItem label="当前密码">
<NInput
v-model:value="oldPassword"
type="password"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="新密码(至少 8 位)">
<NInput
v-model:value="newPassword"
type="password"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="确认新密码">
<NInput
v-model:value="confirm"
type="password"
show-password-on="click"
/>
</NFormItem>
</NForm>
<NButton
type="primary"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
>
修改密码
</NButton>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { NButton, NForm, NFormItem, NInput, useMessage } from 'naive-ui'
import { useRouter } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
const msg = useMessage()
const router = useRouter()
const auth = useAuthStore()
const oldPassword = ref('')
const newPassword = ref('')
const confirm = ref('')
const saving = ref(false)
const canSubmit = computed(
() =>
!!oldPassword.value &&
newPassword.value.length >= 8 &&
newPassword.value === confirm.value,
)
async function submit() {
if (!canSubmit.value) return
saving.value = true
try {
await authApi.changePassword(oldPassword.value, newPassword.value)
msg.success('密码已修改,请重新登录')
//
auth.clearSession()
router.replace({ name: 'login' }).catch(() => {})
} catch (e: unknown) {
msg.error((e as Error).message || '修改失败')
} finally {
saving.value = false
}
}
</script>
+152 -2
View File
@@ -1,16 +1,166 @@
<template>
<AppShell>
<div class="space-y-4">
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">
欢迎{{ auth.user?.display_name }}
</h2>
<p>这是占位 dashboard后续 plan 会接入项目/工作区/Issue</p>
<NButton
type="primary"
@click="router.push({ name: 'project-list' })"
>
新建 / 管理项目
</NButton>
</div>
<!-- 统计卡片 -->
<NGrid
:cols="3"
:x-gap="12"
>
<NGi>
<NCard>
<NStatistic
label="活跃项目"
:value="activeCount"
/>
</NCard>
</NGi>
<NGi>
<NCard>
<NStatistic
label="归档项目"
:value="archivedCount"
/>
</NCard>
</NGi>
<NGi>
<NCard>
<NStatistic
label="分配给我的待办议题"
:value="myIssues.length"
/>
</NCard>
</NGi>
</NGrid>
<!-- 分配给我的议题 -->
<NCard title="分配给我的议题(未关闭)">
<NSpin :show="loading">
<div
v-if="myIssues.length === 0"
class="text-sm text-gray-400 py-2"
>
暂无分配给你的未关闭议题
</div>
<ul
v-else
class="divide-y"
>
<li
v-for="row in myIssues"
:key="row.issue.id"
class="py-2 flex items-center justify-between cursor-pointer hover:bg-gray-50 px-1"
@click="goIssue(row)"
>
<span class="text-sm">
<span class="font-mono text-gray-500 mr-2">{{ row.projectSlug }}#{{ row.issue.number }}</span>
{{ row.issue.title }}
</span>
<span class="text-xs text-gray-400">{{ row.projectName }}</span>
</li>
</ul>
</NSpin>
</NCard>
<!-- 最近项目 -->
<NCard title="项目">
<NSpin :show="loading">
<div
v-if="activeProjects.length === 0"
class="text-sm text-gray-400 py-2"
>
还没有项目去创建一个吧
</div>
<ul
v-else
class="divide-y"
>
<li
v-for="p in activeProjects"
:key="p.id"
class="py-2 flex items-center justify-between cursor-pointer hover:bg-gray-50 px-1"
@click="router.push({ name: 'project-home', params: { slug: p.slug } })"
>
<span class="text-sm font-medium">{{ p.name }}</span>
<span class="font-mono text-xs text-gray-400">{{ p.slug }}</span>
</li>
</ul>
</NSpin>
</NCard>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NCard, NGrid, NGi, NStatistic, NSpin } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useAuthStore } from '@/stores/auth'
import { projectsApi, type Project, type Issue } from '@/api/projects'
const auth = useAuthStore()
const router = useRouter()
const projects = ref<Project[]>([])
const loading = ref(false)
interface MyIssueRow {
issue: Issue
projectSlug: string
projectName: string
}
const myIssues = ref<MyIssueRow[]>([])
const activeProjects = computed(() => projects.value.filter((p) => !p.archived_at))
const activeCount = computed(() => activeProjects.value.length)
const archivedCount = computed(() => projects.value.filter((p) => p.archived_at).length)
onMounted(async () => {
loading.value = true
try {
const out = await projectsApi.list(true)
projects.value = out.items
const uid = auth.user?.id
if (uid) {
//
const lists = await Promise.all(
activeProjects.value.map(async (p) => {
try {
const res = await projectsApi.listIssues(p.slug, { assignee: uid, status: 'open' })
return res.items.map((issue) => ({
issue,
projectSlug: p.slug,
projectName: p.name,
}))
} catch {
return []
}
}),
)
myIssues.value = lists.flat()
}
} finally {
loading.value = false
}
})
function goIssue(row: MyIssueRow) {
router.push({
name: 'issue-detail',
params: { slug: row.projectSlug, number: row.issue.number },
})
}
</script>
+78 -1
View File
@@ -3,13 +3,18 @@ import { ref, shallowRef, computed, onMounted, onUnmounted, watch, nextTick } fr
import { useRoute } from 'vue-router'
import { useAcpStore } from '@/stores/acp'
import { useAcpStream, type UseAcpStreamReturn } from '@/composables/useAcpStream'
import type { AcpEvent } from '@/api/acp'
import { acpApi, type AcpEvent, type PermissionRequest } from '@/api/acp'
import { useAuthStore } from '@/stores/auth'
import { workspacesApi } from '@/api/workspaces'
import SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
import AgentEventCard from '@/components/acp/AgentEventCard.vue'
import PromptInput from '@/components/acp/PromptInput.vue'
import PermissionApprovalPanel from '@/components/acp/PermissionApprovalPanel.vue'
import CommitDialog from '@/views/workspaces/components/CommitDialog.vue'
const route = useRoute()
const store = useAcpStore()
const auth = useAuthStore()
const sessionId = computed(() => route.params.sessionId as string)
@@ -18,6 +23,29 @@ const eventsContainer = ref<HTMLElement | null>(null)
const errorMsg = ref<string | null>(null)
const events = computed<AcpEvent[]>(() => stream.value?.events.value ?? [])
const pendingPermissions = computed<PermissionRequest[]>(
() => stream.value?.pendingPermissions.value ?? [],
)
// session owner admin
const canDecide = computed(
() => auth.user?.is_admin === true || store.currentSession?.user_id === auth.user?.id,
)
async function approvePermission(reqID: string, optionId?: string) {
try {
await acpApi.permissions.approve(reqID, optionId)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'approve failed'
}
}
async function denyPermission(reqID: string) {
try {
await acpApi.permissions.deny(reqID)
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : 'deny failed'
}
}
onMounted(async () => {
try {
@@ -86,6 +114,33 @@ async function terminate() {
const sessionStatus = computed(
() => stream.value?.sessionStatus.value ?? store.currentSession?.status ?? 'starting',
)
// ===== workspace CommitDialog =====
// session is_main_worktree + branch worktree_id
// - main worktree target=maintargetId=workspace_id
// - worktree branch workspace worktrees id
const commitTarget = ref<{ target: 'main' | 'worktree'; targetId: string } | null>(null)
async function openCommit() {
const sess = store.currentSession
if (!sess) return
errorMsg.value = null
if (sess.is_main_worktree) {
commitTarget.value = { target: 'main', targetId: sess.workspace_id }
return
}
try {
const worktrees = await workspacesApi.listWorktrees(sess.workspace_id)
const wt = worktrees.find((w) => w.branch === sess.branch)
if (!wt) {
errorMsg.value = '未找到该会话对应的 worktree'
return
}
commitTarget.value = { target: 'worktree', targetId: wt.id }
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '加载 worktree 失败'
}
}
</script>
<template>
@@ -103,6 +158,12 @@ const sessionStatus = computed(
</div>
<div class="flex items-center gap-2">
<SessionStatusBadge :status="sessionStatus" />
<button
class="px-2 py-1 text-xs rounded border text-blue-600 hover:bg-blue-50"
@click="openCommit"
>
提交代码
</button>
<button
v-if="sessionStatus === 'starting' || sessionStatus === 'running'"
class="px-2 py-1 text-xs rounded border text-red-600 hover:bg-red-50"
@@ -117,6 +178,14 @@ const sessionStatus = computed(
{{ errorMsg }}
</p>
<!-- 待审批工具调用 -->
<PermissionApprovalPanel
:requests="pendingPermissions"
:can-decide="canDecide"
@approve="approvePermission"
@deny="denyPermission"
/>
<!-- Events stream -->
<div
ref="eventsContainer"
@@ -144,5 +213,13 @@ const sessionStatus = computed(
@cancel="onCancel"
/>
</div>
<CommitDialog
v-if="commitTarget"
:target="commitTarget.target"
:target-id="commitTarget.targetId"
@close="commitTarget = null"
@committed="commitTarget = null"
/>
</div>
</template>
@@ -21,6 +21,7 @@ const form = ref({
args: [] as string[],
env: {} as Record<string, string>,
enabled: true,
tool_allowlist: [] as string[],
})
const existingEnvKeys = ref<string[]>([])
@@ -35,6 +36,7 @@ onMounted(async () => {
args: [...k.args],
env: {}, // Edit mode: do not prefill values; user must replace entirely if they want to change.
enabled: k.enabled,
tool_allowlist: [...(k.tool_allowlist ?? [])],
}
existingEnvKeys.value = [...k.env_keys]
}
@@ -49,6 +51,7 @@ async function submit() {
binary_path: form.value.binary_path,
args: form.value.args,
enabled: form.value.enabled,
tool_allowlist: form.value.tool_allowlist,
}
// Only send env if user actually entered some values; an empty map would
// wipe existing env on the backend (per service patch semantics).
@@ -63,6 +66,7 @@ async function submit() {
args: form.value.args,
env: form.value.env,
enabled: form.value.enabled,
tool_allowlist: form.value.tool_allowlist,
})
}
router.push('/admin/acp/agent-kinds')
+355
View File
@@ -0,0 +1,355 @@
<template>
<AppShell>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold">
用户管理
</h2>
<NButton
type="primary"
@click="openCreate"
>
+ 新增用户
</NButton>
</div>
<NDataTable
:data="users"
:columns="columns"
:row-key="(row: AdminUser) => row.id"
:loading="loading"
/>
<!-- 新增用户 -->
<NModal
v-model:show="showCreate"
preset="card"
title="新增用户"
style="width: 480px"
>
<NForm>
<NFormItem label="邮箱">
<NInput
v-model:value="createForm.email"
placeholder="user@example.com"
/>
</NFormItem>
<NFormItem label="显示名">
<NInput v-model:value="createForm.display_name" />
</NFormItem>
<NFormItem label="初始密码(至少 8 位)">
<NInput
v-model:value="createForm.password"
type="password"
show-password-on="click"
/>
</NFormItem>
<NFormItem label="管理员">
<NSwitch v-model:value="createForm.is_admin" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="showCreate = false">
取消
</NButton>
<NButton
type="primary"
:loading="creating"
:disabled="!canCreate"
@click="doCreate"
>
创建
</NButton>
</div>
</template>
</NModal>
<!-- 编辑显示名 -->
<NModal
v-model:show="showEdit"
preset="card"
title="编辑用户"
style="width: 420px"
>
<NForm>
<NFormItem label="显示名">
<NInput v-model:value="editName" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="showEdit = false">
取消
</NButton>
<NButton
type="primary"
:loading="saving"
@click="doEdit"
>
保存
</NButton>
</div>
</template>
</NModal>
<!-- 临时密码展示 -->
<NModal
v-model:show="showTemp"
preset="card"
title="密码已重置"
style="width: 480px"
>
<NAlert
type="warning"
:bordered="false"
>
请立即复制临时密码并交给用户关闭后无法再次查看用户登录后应尽快自助改密
</NAlert>
<NInput
:value="tempPassword"
readonly
class="mt-3"
/>
<template #footer>
<div class="flex justify-end">
<NButton @click="copyTemp">
复制
</NButton>
</div>
</template>
</NModal>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import {
NAlert,
NButton,
NDataTable,
NForm,
NFormItem,
NInput,
NModal,
NSpace,
NSwitch,
NTag,
useDialog,
useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import { useAuthStore } from '@/stores/auth'
import { userAdminApi, type AdminUser } from '@/api/usersAdmin'
const auth = useAuthStore()
const msg = useMessage()
const dlg = useDialog()
const users = ref<AdminUser[]>([])
const loading = ref(false)
const showCreate = ref(false)
const creating = ref(false)
const createForm = ref({ email: '', display_name: '', password: '', is_admin: false })
const canCreate = computed(() => {
const f = createForm.value
return !!f.email.trim() && !!f.display_name.trim() && f.password.length >= 8
})
const showEdit = ref(false)
const saving = ref(false)
const editName = ref('')
const editTarget = ref<AdminUser | null>(null)
const showTemp = ref(false)
const tempPassword = ref('')
// isSelf //
function isSelf(row: AdminUser): boolean {
return row.id === auth.user?.id
}
const columns: DataTableColumns<AdminUser> = [
{ title: '邮箱', key: 'email' },
{ title: '显示名', key: 'display_name' },
{
title: '角色',
key: 'is_admin',
width: 90,
render: (row) =>
h(
NTag,
{ type: row.is_admin ? 'success' : 'default', size: 'small' },
{ default: () => (row.is_admin ? '管理员' : '用户') },
),
},
{
title: '状态',
key: 'enabled',
width: 90,
render: (row) =>
h(
NTag,
{ type: row.enabled ? 'info' : 'error', size: 'small' },
{ default: () => (row.enabled ? '启用' : '禁用') },
),
},
{
title: '创建时间',
key: 'created_at',
render: (row) => new Date(row.created_at).toLocaleString(),
},
{
title: '操作',
key: 'actions',
width: 360,
render: (row) =>
h(NSpace, { size: 'small' }, {
default: () => [
h(NButton, { size: 'small', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(
NButton,
{ size: 'small', disabled: isSelf(row), onClick: () => toggleRole(row) },
{ default: () => (row.is_admin ? '取消管理员' : '设为管理员') },
),
h(
NButton,
{ size: 'small', disabled: isSelf(row), onClick: () => toggleEnabled(row) },
{ default: () => (row.enabled ? '禁用' : '启用') },
),
h(NButton, { size: 'small', onClick: () => resetPassword(row) }, { default: () => '重置密码' }),
h(
NButton,
{ size: 'small', type: 'error', disabled: isSelf(row), onClick: () => removeUser(row) },
{ default: () => '删除' },
),
],
}),
},
]
onMounted(reload)
async function reload() {
loading.value = true
try {
users.value = await userAdminApi.list()
} catch (e: unknown) {
msg.error((e as Error).message || '加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
createForm.value = { email: '', display_name: '', password: '', is_admin: false }
showCreate.value = true
}
async function doCreate() {
if (!canCreate.value) return
creating.value = true
try {
await userAdminApi.create({
email: createForm.value.email.trim(),
display_name: createForm.value.display_name.trim(),
password: createForm.value.password,
is_admin: createForm.value.is_admin,
})
showCreate.value = false
msg.success('已创建')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '创建失败')
} finally {
creating.value = false
}
}
function openEdit(row: AdminUser) {
editTarget.value = row
editName.value = row.display_name
showEdit.value = true
}
async function doEdit() {
if (!editTarget.value || !editName.value.trim()) return
saving.value = true
try {
await userAdminApi.updateProfile(editTarget.value.id, editName.value.trim())
showEdit.value = false
msg.success('已保存')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '保存失败')
} finally {
saving.value = false
}
}
async function toggleRole(row: AdminUser) {
try {
await userAdminApi.setRole(row.id, !row.is_admin)
msg.success('已更新角色')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '操作失败')
}
}
async function toggleEnabled(row: AdminUser) {
try {
await userAdminApi.setEnabled(row.id, !row.enabled)
msg.success(row.enabled ? '已禁用' : '已启用')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '操作失败')
}
}
function resetPassword(row: AdminUser) {
dlg.warning({
title: '重置密码',
content: `确定要重置 "${row.email}" 的密码吗?将生成新的临时密码并使该用户的全部会话失效。`,
positiveText: '重置',
negativeText: '取消',
onPositiveClick: async () => {
try {
const resp = await userAdminApi.resetPassword(row.id)
tempPassword.value = resp.temp_password
showTemp.value = true
} catch (e: unknown) {
msg.error((e as Error).message || '重置失败')
}
},
})
}
function removeUser(row: AdminUser) {
dlg.error({
title: '删除用户',
content: `确定要删除 "${row.email}" 吗?此操作不可撤销。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await userAdminApi.remove(row.id)
msg.success('已删除')
await reload()
} catch (e: unknown) {
msg.error((e as Error).message || '删除失败')
}
},
})
}
function copyTemp() {
navigator.clipboard.writeText(tempPassword.value).then(
() => msg.success('已复制'),
() => msg.error('复制失败'),
)
}
</script>