You've already forked agentic-coding-workflow
Compare commits
10 Commits
146dca7738
...
0484e79978
| Author | SHA1 | Date | |
|---|---|---|---|
| 0484e79978 | |||
| 8b41ff9360 | |||
| e4cb55dae2 | |||
| f708e102d8 | |||
| e0e9ae7bda | |||
| 20b449bb37 | |||
| f2ed098f32 | |||
| 0644fc9b3b | |||
| 6917acb778 | |||
| 18f6c8bd2e |
+1
-1
@@ -34,7 +34,7 @@ Thumbs.db
|
|||||||
/data/
|
/data/
|
||||||
|
|
||||||
# Internal docs (specs / plans should never be committed)
|
# Internal docs (specs / plans should never be committed)
|
||||||
/docs/superpowers/
|
/docs/
|
||||||
|
|
||||||
# Local worktrees (created by superpowers:using-git-worktrees skill)
|
# Local worktrees (created by superpowers:using-git-worktrees skill)
|
||||||
/.worktrees/
|
/.worktrees/
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
// FAKE_ACP_FS_READ=path prompt 时发起 fs/read_text_file 请求该路径
|
// FAKE_ACP_FS_READ=path prompt 时发起 fs/read_text_file 请求该路径
|
||||||
// FAKE_ACP_FS_WRITE=path:content prompt 时发起 fs/write_text_file
|
// FAKE_ACP_FS_WRITE=path:content prompt 时发起 fs/write_text_file
|
||||||
// FAKE_ACP_PROMPT_UPDATES=N 每 prompt 发 N 个 update(默认 3)
|
// FAKE_ACP_PROMPT_UPDATES=N 每 prompt 发 N 个 update(默认 3)
|
||||||
|
// FAKE_ACP_PERMISSION=toolName prompt 时发起 session/request_permission
|
||||||
|
// (options: allow_once/reject_once),阻塞等响应后再结束
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -66,14 +68,14 @@ func main() {
|
|||||||
case "session/new":
|
case "session/new":
|
||||||
respond(out, msg.ID, map[string]any{"sessionId": "fake-session-id"})
|
respond(out, msg.ID, map[string]any{"sessionId": "fake-session-id"})
|
||||||
case "session/prompt":
|
case "session/prompt":
|
||||||
handlePrompt(out, msg.ID)
|
handlePrompt(in, out, msg.ID)
|
||||||
case "session/cancel":
|
case "session/cancel":
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePrompt(out *bufio.Writer, id json.RawMessage) {
|
func handlePrompt(in *bufio.Reader, out *bufio.Writer, id json.RawMessage) {
|
||||||
updates := 3
|
updates := 3
|
||||||
if v := os.Getenv("FAKE_ACP_PROMPT_UPDATES"); v != "" {
|
if v := os.Getenv("FAKE_ACP_PROMPT_UPDATES"); v != "" {
|
||||||
if n, _ := strconv.Atoi(v); n > 0 {
|
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"})
|
respond(out, id, map[string]any{"stopReason": "end_turn"})
|
||||||
|
|
||||||
if maxStr := os.Getenv("FAKE_ACP_CRASH_AFTER_PROMPTS"); maxStr != "" {
|
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
|
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) {
|
func respond(out *bufio.Writer, id json.RawMessage, result any) {
|
||||||
body := mustJSON(Message{JSONRPC: "2.0", ID: id, Result: mustJSON(result)})
|
body := mustJSON(Message{JSONRPC: "2.0", ID: id, Result: mustJSON(result)})
|
||||||
_, _ = out.Write(body)
|
_, _ = out.Write(body)
|
||||||
|
|||||||
-141
@@ -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`:表结构
|
|
||||||
@@ -37,10 +37,15 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK
|
|||||||
if args == nil {
|
if args == nil {
|
||||||
args = []string{}
|
args = []string{}
|
||||||
}
|
}
|
||||||
|
allowlist := in.ToolAllowlist
|
||||||
|
if allowlist == nil {
|
||||||
|
allowlist = []string{}
|
||||||
|
}
|
||||||
k := &AgentKind{
|
k := &AgentKind{
|
||||||
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
|
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
|
||||||
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
|
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
|
||||||
CreatedBy: c.UserID,
|
ToolAllowlist: allowlist,
|
||||||
|
CreatedBy: c.UserID,
|
||||||
}
|
}
|
||||||
out, err := s.repo.CreateAgentKind(ctx, k)
|
out, err := s.repo.CreateAgentKind(ctx, k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,6 +110,10 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i
|
|||||||
cur.EncryptedEnv = encrypted
|
cur.EncryptedEnv = encrypted
|
||||||
changed["env"] = "<changed>"
|
changed["env"] = "<changed>"
|
||||||
}
|
}
|
||||||
|
if in.ToolAllowlist != nil {
|
||||||
|
cur.ToolAllowlist = in.ToolAllowlist
|
||||||
|
changed["tool_allowlist"] = "<changed>"
|
||||||
|
}
|
||||||
if in.Enabled != nil && *in.Enabled != cur.Enabled {
|
if in.Enabled != nil && *in.Enabled != cur.Enabled {
|
||||||
cur.Enabled = *in.Enabled
|
cur.Enabled = *in.Enabled
|
||||||
changed["enabled"] = *in.Enabled
|
changed["enabled"] = *in.Enabled
|
||||||
|
|||||||
@@ -256,3 +256,26 @@ func TestSupervisor_BuildSpawnEnv_ExtraEnvOverrides(t *testing.T) {
|
|||||||
assert.Contains(t, env, "OTHER=kept")
|
assert.Contains(t, env, "OTHER=kept")
|
||||||
assert.Contains(t, env, "ACW_MCP_URL=http://x")
|
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
|
||||||
|
}
|
||||||
|
|||||||
+59
-30
@@ -58,6 +58,7 @@ type AgentKind struct {
|
|||||||
Args []string
|
Args []string
|
||||||
EncryptedEnv []byte
|
EncryptedEnv []byte
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
ToolAllowlist []string // 命中的工具调用自动放行;含 "*" 表示全部放行
|
||||||
CreatedBy uuid.UUID
|
CreatedBy uuid.UUID
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
@@ -66,23 +67,23 @@ type AgentKind struct {
|
|||||||
// Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自
|
// Session 是一次 ACP 会话。AgentSessionID 是 agent 侧的 session 标识(来自
|
||||||
// session/new response),与本表 ID 不同,仅用于 ACP 协议 method 内填充。
|
// session/new response),与本表 ID 不同,仅用于 ACP 协议 method 内填充。
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
WorkspaceID uuid.UUID
|
WorkspaceID uuid.UUID
|
||||||
ProjectID uuid.UUID
|
ProjectID uuid.UUID
|
||||||
AgentKindID uuid.UUID
|
AgentKindID uuid.UUID
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
IssueID *uuid.UUID
|
IssueID *uuid.UUID
|
||||||
RequirementID *uuid.UUID
|
RequirementID *uuid.UUID
|
||||||
AgentSessionID *string
|
AgentSessionID *string
|
||||||
Branch string
|
Branch string
|
||||||
CwdPath string
|
CwdPath string
|
||||||
IsMainWorktree bool
|
IsMainWorktree bool
|
||||||
Status SessionStatus
|
Status SessionStatus
|
||||||
PID *int32
|
PID *int32
|
||||||
ExitCode *int32
|
ExitCode *int32
|
||||||
LastError *string
|
LastError *string
|
||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
EndedAt *time.Time
|
EndedAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON
|
// Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON
|
||||||
@@ -136,22 +137,50 @@ type CreateSessionInput struct {
|
|||||||
|
|
||||||
// CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。
|
// CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。
|
||||||
type CreateAgentKindInput struct {
|
type CreateAgentKindInput struct {
|
||||||
Name string
|
Name string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Description string
|
Description string
|
||||||
BinaryPath string
|
BinaryPath string
|
||||||
Args []string
|
Args []string
|
||||||
Env map[string]string
|
Env map[string]string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
ToolAllowlist []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
|
// UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。
|
||||||
// Env 三态:nil = 不修改;空 map = 清空全部 env;非空 = 整体替换。
|
// Env 三态:nil = 不修改;空 map = 清空全部 env;非空 = 整体替换。
|
||||||
type UpdateAgentKindInput struct {
|
type UpdateAgentKindInput struct {
|
||||||
DisplayName *string
|
DisplayName *string
|
||||||
Description *string
|
Description *string
|
||||||
BinaryPath *string
|
BinaryPath *string
|
||||||
Args []string // nil = 不改;非 nil = 替换
|
Args []string // nil = 不改;非 nil = 替换
|
||||||
Env map[string]string // nil = 不改;非 nil(含空)= 替换
|
Env map[string]string // nil = 不改;非 nil(含空)= 替换
|
||||||
Enabled *bool
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-24
@@ -1,22 +1,26 @@
|
|||||||
package acp
|
package acp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentKindAdminDTO 是 admin 视图的完整字段。EnvKeys 仅返回 key 列表(不返回值)。
|
// AgentKindAdminDTO 是 admin 视图的完整字段。EnvKeys 仅返回 key 列表(不返回值)。
|
||||||
type AgentKindAdminDTO struct {
|
type AgentKindAdminDTO struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EnvKeys []string `json:"env_keys"`
|
EnvKeys []string `json:"env_keys"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy uuid.UUID `json:"created_by"`
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedBy uuid.UUID `json:"created_by"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。
|
// AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。
|
||||||
@@ -29,22 +33,47 @@ type AgentKindPublicDTO struct {
|
|||||||
|
|
||||||
// CreateAgentKindReq / UpdateAgentKindReq 是 HTTP 请求体。
|
// CreateAgentKindReq / UpdateAgentKindReq 是 HTTP 请求体。
|
||||||
type CreateAgentKindReq struct {
|
type CreateAgentKindReq struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
Env map[string]string `json:"env"`
|
Env map[string]string `json:"env"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateAgentKindReq struct {
|
type UpdateAgentKindReq struct {
|
||||||
DisplayName *string `json:"display_name,omitempty"`
|
DisplayName *string `json:"display_name,omitempty"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
BinaryPath *string `json:"binary_path,omitempty"`
|
BinaryPath *string `json:"binary_path,omitempty"`
|
||||||
Args []string `json:"args,omitempty"`
|
Args []string `json:"args,omitempty"`
|
||||||
Env map[string]string `json:"env,omitempty"`
|
Env map[string]string `json:"env,omitempty"`
|
||||||
Enabled *bool `json:"enabled,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.
|
// CreateSessionReq is the request body for POST /api/v1/acp/sessions.
|
||||||
|
|||||||
+98
-26
@@ -42,6 +42,7 @@ type Handler struct {
|
|||||||
sessSvc SessionService
|
sessSvc SessionService
|
||||||
sup *Supervisor
|
sup *Supervisor
|
||||||
repo Repository
|
repo Repository
|
||||||
|
permSvc *PermissionService
|
||||||
resolver middleware.SessionResolver
|
resolver middleware.SessionResolver
|
||||||
adminLookup AdminLookup
|
adminLookup AdminLookup
|
||||||
enc *crypto.Encryptor
|
enc *crypto.Encryptor
|
||||||
@@ -50,9 +51,10 @@ type Handler struct {
|
|||||||
|
|
||||||
// NewHandler constructs Handler with full dependencies.
|
// NewHandler constructs Handler with full dependencies.
|
||||||
func NewHandler(ak AgentKindService, ss SessionService, sup *Supervisor, repo Repository,
|
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{
|
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,
|
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.Delete("/{id}", h.terminateSession)
|
||||||
r.Get("/{id}/events", h.getEvents)
|
r.Get("/{id}/events", h.getEvents)
|
||||||
r.Get("/{id}/ws", h.sessionWS)
|
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) {
|
func (h *Handler) caller(r *http.Request) (Caller, error) {
|
||||||
@@ -132,13 +201,14 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) {
|
|||||||
enabled = *req.Enabled
|
enabled = *req.Enabled
|
||||||
}
|
}
|
||||||
in := CreateAgentKindInput{
|
in := CreateAgentKindInput{
|
||||||
Name: strings.TrimSpace(req.Name),
|
Name: strings.TrimSpace(req.Name),
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
BinaryPath: strings.TrimSpace(req.BinaryPath),
|
BinaryPath: strings.TrimSpace(req.BinaryPath),
|
||||||
Args: req.Args,
|
Args: req.Args,
|
||||||
Env: req.Env,
|
Env: req.Env,
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
|
ToolAllowlist: req.ToolAllowlist,
|
||||||
}
|
}
|
||||||
out, err := h.akSvc.Create(r.Context(), c, in)
|
out, err := h.akSvc.Create(r.Context(), c, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,12 +258,13 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
in := UpdateAgentKindInput{
|
in := UpdateAgentKindInput{
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
BinaryPath: req.BinaryPath,
|
BinaryPath: req.BinaryPath,
|
||||||
Args: req.Args,
|
Args: req.Args,
|
||||||
Env: req.Env,
|
Env: req.Env,
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
|
ToolAllowlist: req.ToolAllowlist,
|
||||||
}
|
}
|
||||||
out, err := h.akSvc.Update(r.Context(), c, id, in)
|
out, err := h.akSvc.Update(r.Context(), c, id, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -539,17 +610,18 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return AgentKindAdminDTO{
|
return AgentKindAdminDTO{
|
||||||
ID: k.ID,
|
ID: k.ID,
|
||||||
Name: k.Name,
|
Name: k.Name,
|
||||||
DisplayName: k.DisplayName,
|
DisplayName: k.DisplayName,
|
||||||
Description: k.Description,
|
Description: k.Description,
|
||||||
BinaryPath: k.BinaryPath,
|
BinaryPath: k.BinaryPath,
|
||||||
Args: append([]string(nil), k.Args...),
|
Args: append([]string(nil), k.Args...),
|
||||||
EnvKeys: envKeys,
|
EnvKeys: envKeys,
|
||||||
Enabled: k.Enabled,
|
Enabled: k.Enabled,
|
||||||
CreatedBy: k.CreatedBy,
|
ToolAllowlist: append([]string(nil), k.ToolAllowlist...),
|
||||||
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
CreatedBy: k.CreatedBy,
|
||||||
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,11 +152,15 @@ func TestHandler_Session_CreateAndWS_E2E(t *testing.T) {
|
|||||||
repo, wsStub, nil, nil, paStub, sup,
|
repo, wsStub, nil, nil, paStub, sup,
|
||||||
audit.NewPostgresRecorder(pool),
|
audit.NewPostgresRecorder(pool),
|
||||||
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5},
|
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5},
|
||||||
|
nil, // mcpTokens — not exercised by handler e2e
|
||||||
)
|
)
|
||||||
|
|
||||||
|
permSvc := acp.NewPermissionService(repo, nil, 0, nil)
|
||||||
|
sup.SetPermissionService(permSvc)
|
||||||
|
permSvc.SetRelayLocator(sup)
|
||||||
h := acp.NewHandler(
|
h := acp.NewHandler(
|
||||||
nil, // ak svc — not exercised by sessions endpoints
|
nil, // ak svc — not exercised by sessions endpoints
|
||||||
svc, sup, repo,
|
svc, sup, repo, permSvc,
|
||||||
fakeResolver{uid: uid},
|
fakeResolver{uid: uid},
|
||||||
fakeAdminLookup{admin: map[uuid.UUID]bool{uid: false}},
|
fakeAdminLookup{admin: map[uuid.UUID]bool{uid: false}},
|
||||||
enc,
|
enc,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func mountHandlerWithRepo(t *testing.T, callerUID uuid.UUID, isAdmin bool, repo
|
|||||||
nil, // sessSvc — not exercised by agent-kinds tests
|
nil, // sessSvc — not exercised by agent-kinds tests
|
||||||
nil, // sup — not exercised by agent-kinds tests
|
nil, // sup — not exercised by agent-kinds tests
|
||||||
nil, // repo — 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},
|
fakeResolver{uid: callerUID},
|
||||||
fakeAdminLookup{admin: map[uuid.UUID]bool{callerUID: isAdmin}},
|
fakeAdminLookup{admin: map[uuid.UUID]bool{callerUID: isAdmin}},
|
||||||
enc,
|
enc,
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ type SessionContext struct {
|
|||||||
WorkspaceID uuid.UUID
|
WorkspaceID uuid.UUID
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
CwdPath string
|
CwdPath string
|
||||||
AgentSessionID *string // 可能 nil(handshake 未完成时不应触发 fs/* 但兜底)
|
AgentSessionID *string // 可能 nil(handshake 未完成时不应触发 fs/* 但兜底)
|
||||||
|
ToolAllowlist []string // agent kind 配置的工具白名单,命中则自动放行
|
||||||
}
|
}
|
||||||
|
|
||||||
// FsResult 是 fs/* method 处理后返回给 relay 的结果。要么 OK(带 result),
|
// FsResult 是 fs/* method 处理后返回给 relay 的结果。要么 OK(带 result),
|
||||||
@@ -48,4 +49,3 @@ type FsHandler struct {
|
|||||||
func NewFsHandler() *FsHandler {
|
func NewFsHandler() *FsHandler {
|
||||||
return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()}
|
return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,38 @@ import (
|
|||||||
|
|
||||||
// PermissionHandler handles session/request_permission (spec §5.1).
|
// 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
|
// - fs.* 工具调用由服务端直接应答(不经过本 handler)。
|
||||||
// default to "reject_once" — fully automated, no human-in-the-loop.
|
// - 其他工具调用:若注入了 PermissionDecider(生产路径),交由其裁决——命中
|
||||||
type PermissionHandler struct{}
|
// agent kind 白名单则自动放行,否则挂起等待人工审批;未注入 decider 时
|
||||||
|
// (部分单测/向后兼容)回退到「默认拒绝」的纯本地逻辑。
|
||||||
|
type PermissionHandler struct {
|
||||||
|
decider PermissionDecider
|
||||||
|
}
|
||||||
|
|
||||||
// NewPermissionHandler constructs a PermissionHandler.
|
// PermOption 是 session/request_permission 提供的一个候选选项。
|
||||||
func NewPermissionHandler() *PermissionHandler { return &PermissionHandler{} }
|
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 {
|
type permissionParams struct {
|
||||||
SessionID string `json:"sessionId"`
|
SessionID string `json:"sessionId"`
|
||||||
ToolCall json.RawMessage `json:"toolCall"`
|
ToolCall json.RawMessage `json:"toolCall"`
|
||||||
Options []permOption `json:"options"`
|
Options []PermOption `json:"options"`
|
||||||
}
|
|
||||||
|
|
||||||
type permOption struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type permOutcomeSelected struct {
|
type permOutcomeSelected struct {
|
||||||
@@ -39,25 +54,21 @@ type permResult struct {
|
|||||||
Outcome permOutcome `json:"outcome"`
|
Outcome permOutcome `json:"outcome"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle picks the first option whose ID looks like a "reject" choice; if none
|
// Handle 解析参数后委托 decider 裁决;无 decider 时回退到本地默认拒绝逻辑。
|
||||||
// matches, falls back to the first option. Aligns with spec §10 misc #4
|
// agentRequestID 是代理侧 JSON-RPC request id,用于持久化与排障。
|
||||||
// ("default reject").
|
func (h *PermissionHandler) Handle(ctx context.Context, sess SessionContext, agentRequestID string, paramsJSON json.RawMessage) FsResult {
|
||||||
func (h *PermissionHandler) Handle(_ context.Context, _ SessionContext, paramsJSON json.RawMessage) FsResult {
|
|
||||||
var p permissionParams
|
var p permissionParams
|
||||||
if err := json.Unmarshal(paramsJSON, &p); err != nil {
|
if err := json.Unmarshal(paramsJSON, &p); err != nil {
|
||||||
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
|
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
|
||||||
}
|
}
|
||||||
chosen := ""
|
|
||||||
for _, opt := range p.Options {
|
var chosen string
|
||||||
lc := strings.ToLower(opt.ID)
|
if h.decider != nil {
|
||||||
if strings.Contains(lc, "reject") || strings.Contains(lc, "deny") || strings.Contains(lc, "no") {
|
chosen = h.decider.Decide(ctx, sess, agentRequestID, p.ToolCall, p.Options)
|
||||||
chosen = opt.ID
|
} else {
|
||||||
break
|
chosen = defaultRejectChoice(p.Options)
|
||||||
}
|
|
||||||
}
|
|
||||||
if chosen == "" && len(p.Options) > 0 {
|
|
||||||
chosen = p.Options[0].ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if chosen == "" {
|
if chosen == "" {
|
||||||
out, _ := json.Marshal(permResult{Outcome: permOutcome{}})
|
out, _ := json.Marshal(permResult{Outcome: permOutcome{}})
|
||||||
return FsResult{OK: out}
|
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}}})
|
out, _ := json.Marshal(permResult{Outcome: permOutcome{Selected: &permOutcomeSelected{ID: chosen}}})
|
||||||
return FsResult{OK: out}
|
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,9 +11,10 @@ import (
|
|||||||
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
|
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 无 decider(nil)时回退到「默认拒绝」逻辑:优先选 reject 类选项。
|
||||||
func TestPermission_AutoReject(t *testing.T) {
|
func TestPermission_AutoReject(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
h := handlers.NewPermissionHandler()
|
h := handlers.NewPermissionHandler(nil)
|
||||||
params := mustJSON(t, map[string]any{
|
params := mustJSON(t, map[string]any{
|
||||||
"sessionId": "x",
|
"sessionId": "x",
|
||||||
"toolCall": map[string]any{"name": "shell.exec"},
|
"toolCall": map[string]any{"name": "shell.exec"},
|
||||||
@@ -22,7 +23,7 @@ func TestPermission_AutoReject(t *testing.T) {
|
|||||||
map[string]any{"id": "reject_once"},
|
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)
|
require.Nil(t, res.Err)
|
||||||
|
|
||||||
var out struct {
|
var out struct {
|
||||||
@@ -36,9 +37,10 @@ func TestPermission_AutoReject(t *testing.T) {
|
|||||||
assert.Equal(t, "reject_once", out.Outcome.Selected.ID)
|
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()
|
t.Parallel()
|
||||||
h := handlers.NewPermissionHandler()
|
h := handlers.NewPermissionHandler(nil)
|
||||||
params := mustJSON(t, map[string]any{
|
params := mustJSON(t, map[string]any{
|
||||||
"sessionId": "x",
|
"sessionId": "x",
|
||||||
"options": []any{
|
"options": []any{
|
||||||
@@ -46,23 +48,16 @@ func TestPermission_NoRejectOption_FallbackFirst(t *testing.T) {
|
|||||||
map[string]any{"id": "allow_session"},
|
map[string]any{"id": "allow_session"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
res := h.Handle(context.Background(), handlers.SessionContext{}, params)
|
res := h.Handle(context.Background(), handlers.SessionContext{}, "", params)
|
||||||
var out struct {
|
require.Nil(t, res.Err)
|
||||||
Outcome struct {
|
assert.JSONEq(t, `{"outcome":{}}`, string(res.OK))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPermission_NoOptions_EmptyOutcome(t *testing.T) {
|
func TestPermission_NoOptions_EmptyOutcome(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
h := handlers.NewPermissionHandler()
|
h := handlers.NewPermissionHandler(nil)
|
||||||
params := mustJSON(t, map[string]any{"sessionId": "x"})
|
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)
|
require.Nil(t, res.Err)
|
||||||
assert.JSONEq(t, `{"outcome":{}}`, string(res.OK))
|
assert.JSONEq(t, `{"outcome":{}}`, string(res.OK))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// permission.go 实现 ACP fs/* method 的路径安全校验。
|
// permission.go 实现 ACP fs/* method 的路径安全校验。
|
||||||
//
|
//
|
||||||
// 规则(spec §5.6):
|
// 规则(spec §5.6):
|
||||||
// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd)
|
// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd)
|
||||||
// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸)
|
// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸)
|
||||||
//
|
//
|
||||||
// 三重校验:filepath.Abs + strings.HasPrefix + filepath.EvalSymlinks。
|
// 三重校验:filepath.Abs + strings.HasPrefix + filepath.EvalSymlinks。
|
||||||
package acp
|
package acp
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -1,47 +1,48 @@
|
|||||||
-- name: CreateAgentKind :one
|
-- name: CreateAgentKind :one
|
||||||
INSERT INTO acp_agent_kinds (
|
INSERT INTO acp_agent_kinds (
|
||||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by
|
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)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
-- name: GetAgentKindByID :one
|
||||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
FROM acp_agent_kinds
|
||||||
WHERE id = $1;
|
WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetAgentKindByName :one
|
-- name: GetAgentKindByName :one
|
||||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
FROM acp_agent_kinds
|
||||||
WHERE name = $1;
|
WHERE name = $1;
|
||||||
|
|
||||||
-- name: ListAgentKinds :many
|
-- name: ListAgentKinds :many
|
||||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
FROM acp_agent_kinds
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: ListEnabledAgentKinds :many
|
-- name: ListEnabledAgentKinds :many
|
||||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
FROM acp_agent_kinds
|
||||||
WHERE enabled = TRUE
|
WHERE enabled = TRUE
|
||||||
ORDER BY name ASC;
|
ORDER BY name ASC;
|
||||||
|
|
||||||
-- name: UpdateAgentKind :one
|
-- name: UpdateAgentKind :one
|
||||||
UPDATE acp_agent_kinds
|
UPDATE acp_agent_kinds
|
||||||
SET display_name = $2,
|
SET display_name = $2,
|
||||||
description = $3,
|
description = $3,
|
||||||
binary_path = $4,
|
binary_path = $4,
|
||||||
args = $5,
|
args = $5,
|
||||||
encrypted_env = $6,
|
encrypted_env = $6,
|
||||||
enabled = $7,
|
enabled = $7,
|
||||||
updated_at = now()
|
tool_allowlist = $8,
|
||||||
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
-- name: DeleteAgentKind :exec
|
||||||
DELETE FROM acp_agent_kinds WHERE id = $1;
|
DELETE FROM acp_agent_kinds WHERE id = $1;
|
||||||
|
|||||||
@@ -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;
|
||||||
+13
-3
@@ -29,8 +29,8 @@ import (
|
|||||||
// EventEnvelope 是推到 WS 的事件结构(与落库的 Event 字段同步,但带 ID 数值化方便 JSON)。
|
// EventEnvelope 是推到 WS 的事件结构(与落库的 Event 字段同步,但带 ID 数值化方便 JSON)。
|
||||||
type EventEnvelope struct {
|
type EventEnvelope struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Direction string `json:"direction"` // 'in' | 'out'
|
Direction string `json:"direction"` // 'in' | 'out'
|
||||||
Kind string `json:"kind"` // 'request'|'response'|'notification'|'error'|'session_terminated'|'slow_consumer_disconnect'
|
Kind string `json:"kind"` // 'request'|'response'|'notification'|'error'|'session_terminated'|'slow_consumer_disconnect'
|
||||||
Method string `json:"method,omitempty"`
|
Method string `json:"method,omitempty"`
|
||||||
Payload json.RawMessage `json:"payload"`
|
Payload json.RawMessage `json:"payload"`
|
||||||
Truncated bool `json:"truncated"`
|
Truncated bool `json:"truncated"`
|
||||||
@@ -182,6 +182,16 @@ func (r *Relay) Close(status string, exitCode *int32) {
|
|||||||
close(r.done)
|
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.
|
// Done returns a channel closed when Relay is terminated.
|
||||||
func (r *Relay) Done() <-chan struct{} { return r.done }
|
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)
|
out := r.fsHandler.Write.Handle(ctx, sess, req.Params)
|
||||||
resp = r.fsResultToResponse(req.ID, out)
|
resp = r.fsResultToResponse(req.ID, out)
|
||||||
case "session/request_permission":
|
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)
|
resp = r.fsResultToResponse(req.ID, out)
|
||||||
default:
|
default:
|
||||||
resp = NewResponseErr(req.ID, JSONRPCMethodNotFound, "method not implemented: "+req.Method)
|
resp = NewResponseErr(req.ID, JSONRPCMethodNotFound, "method not implemented: "+req.Method)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (f *fakeEventRepo) ListEnabledAgentKinds(context.Context) ([]*acp.AgentKind
|
|||||||
func (f *fakeEventRepo) UpdateAgentKind(context.Context, *acp.AgentKind) (*acp.AgentKind, error) {
|
func (f *fakeEventRepo) UpdateAgentKind(context.Context, *acp.AgentKind) (*acp.AgentKind, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
func (f *fakeEventRepo) DeleteAgentKind(context.Context, uuid.UUID) error { panic("n/a") }
|
func (f *fakeEventRepo) DeleteAgentKind(context.Context, uuid.UUID) error { panic("n/a") }
|
||||||
func (f *fakeEventRepo) CountAgentKindUsage(context.Context, uuid.UUID) (int64, error) {
|
func (f *fakeEventRepo) CountAgentKindUsage(context.Context, uuid.UUID) (int64, error) {
|
||||||
panic("n/a")
|
panic("n/a")
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ func newRelayTestRig(t *testing.T) *relayTestRig {
|
|||||||
|
|
||||||
repo := &fakeEventRepo{}
|
repo := &fakeEventRepo{}
|
||||||
fs := handlers.NewFsHandler()
|
fs := handlers.NewFsHandler()
|
||||||
perm := handlers.NewPermissionHandler()
|
perm := handlers.NewPermissionHandler(nil)
|
||||||
|
|
||||||
cwd := t.TempDir()
|
cwd := t.TempDir()
|
||||||
sess := handlers.SessionContext{
|
sess := handlers.SessionContext{
|
||||||
@@ -435,3 +435,26 @@ drain:
|
|||||||
assert.True(t, sawSlowDisc || sawClose,
|
assert.True(t, sawSlowDisc || sawClose,
|
||||||
"expected slow_consumer_disconnect envelope or closed channel after flood")
|
"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
|
||||||
|
}
|
||||||
|
|||||||
+164
-27
@@ -48,6 +48,15 @@ type Repository interface {
|
|||||||
ListEventsSince(ctx context.Context, sessionID uuid.UUID, sinceID int64, limit int32) ([]*Event, error)
|
ListEventsSince(ctx context.Context, sessionID uuid.UUID, sinceID int64, limit int32) ([]*Event, error)
|
||||||
PurgeEventsBefore(ctx context.Context, before time.Time) (int64, 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
|
InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error
|
||||||
WithTx(tx pgx.Tx) Repository
|
WithTx(tx pgx.Tx) Repository
|
||||||
}
|
}
|
||||||
@@ -129,15 +138,16 @@ func pgxNoRowsToErrNotFound(err error, code errs.Code, msg string) error {
|
|||||||
|
|
||||||
func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
|
func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
|
||||||
row, err := r.q.CreateAgentKind(ctx, acpsqlc.CreateAgentKindParams{
|
row, err := r.q.CreateAgentKind(ctx, acpsqlc.CreateAgentKindParams{
|
||||||
ID: toPgUUID(k.ID),
|
ID: toPgUUID(k.ID),
|
||||||
Name: k.Name,
|
Name: k.Name,
|
||||||
DisplayName: k.DisplayName,
|
DisplayName: k.DisplayName,
|
||||||
Description: k.Description,
|
Description: k.Description,
|
||||||
BinaryPath: k.BinaryPath,
|
BinaryPath: k.BinaryPath,
|
||||||
Args: k.Args,
|
Args: k.Args,
|
||||||
EncryptedEnv: k.EncryptedEnv,
|
EncryptedEnv: k.EncryptedEnv,
|
||||||
Enabled: k.Enabled,
|
Enabled: k.Enabled,
|
||||||
CreatedBy: toPgUUID(k.CreatedBy),
|
CreatedBy: toPgUUID(k.CreatedBy),
|
||||||
|
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind")
|
return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind")
|
||||||
@@ -187,13 +197,14 @@ func (r *pgRepo) ListEnabledAgentKinds(ctx context.Context) ([]*AgentKind, error
|
|||||||
|
|
||||||
func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
|
func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, error) {
|
||||||
row, err := r.q.UpdateAgentKind(ctx, acpsqlc.UpdateAgentKindParams{
|
row, err := r.q.UpdateAgentKind(ctx, acpsqlc.UpdateAgentKindParams{
|
||||||
ID: toPgUUID(k.ID),
|
ID: toPgUUID(k.ID),
|
||||||
DisplayName: k.DisplayName,
|
DisplayName: k.DisplayName,
|
||||||
Description: k.Description,
|
Description: k.Description,
|
||||||
BinaryPath: k.BinaryPath,
|
BinaryPath: k.BinaryPath,
|
||||||
Args: k.Args,
|
Args: k.Args,
|
||||||
EncryptedEnv: k.EncryptedEnv,
|
EncryptedEnv: k.EncryptedEnv,
|
||||||
Enabled: k.Enabled,
|
Enabled: k.Enabled,
|
||||||
|
ToolAllowlist: normalizeStrSlice(k.ToolAllowlist),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found")
|
return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found")
|
||||||
@@ -223,20 +234,30 @@ func (r *pgRepo) CountAgentKindUsage(ctx context.Context, id uuid.UUID) (int64,
|
|||||||
|
|
||||||
func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
|
func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind {
|
||||||
return &AgentKind{
|
return &AgentKind{
|
||||||
ID: fromPgUUID(row.ID),
|
ID: fromPgUUID(row.ID),
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
Description: row.Description,
|
Description: row.Description,
|
||||||
BinaryPath: row.BinaryPath,
|
BinaryPath: row.BinaryPath,
|
||||||
Args: row.Args,
|
Args: row.Args,
|
||||||
EncryptedEnv: row.EncryptedEnv,
|
EncryptedEnv: row.EncryptedEnv,
|
||||||
Enabled: row.Enabled,
|
Enabled: row.Enabled,
|
||||||
CreatedBy: fromPgUUID(row.CreatedBy),
|
ToolAllowlist: row.ToolAllowlist,
|
||||||
CreatedAt: row.CreatedAt.Time,
|
CreatedBy: fromPgUUID(row.CreatedBy),
|
||||||
UpdatedAt: row.UpdatedAt.Time,
|
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 =====
|
// ===== Session =====
|
||||||
|
|
||||||
func (r *pgRepo) InsertSession(ctx context.Context, s *Session) (*Session, error) {
|
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,
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,17 +10,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/mcp"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionServiceConfig 是 service 的限流配置(从 AcpConfig 派生)。
|
// SessionServiceConfig 是 service 的限流 + MCP 配置(从 AcpConfig / MCPConfig 派生)。
|
||||||
type SessionServiceConfig struct {
|
type SessionServiceConfig struct {
|
||||||
MaxActiveGlobal int
|
MaxActiveGlobal int
|
||||||
MaxActivePerUser int
|
MaxActivePerUser int
|
||||||
|
SystemTokenTTL time.Duration
|
||||||
|
MCPPublicURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectAccess 是 acp 模块对 project 模块的窄接口;adapter 在 app.go 实现。
|
// ProjectAccess 是 acp 模块对 project 模块的窄接口;adapter 在 app.go 实现。
|
||||||
@@ -31,24 +35,25 @@ type ProjectAccess interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sessionService struct {
|
type sessionService struct {
|
||||||
repo Repository
|
repo Repository
|
||||||
wsSvc workspace.WorkspaceService
|
wsSvc workspace.WorkspaceService
|
||||||
wtSvc workspace.WorktreeService
|
wtSvc workspace.WorktreeService
|
||||||
wsRepo workspace.Repository
|
wsRepo workspace.Repository
|
||||||
pa ProjectAccess
|
pa ProjectAccess
|
||||||
sup *Supervisor
|
sup *Supervisor
|
||||||
audit audit.Recorder
|
audit audit.Recorder
|
||||||
cfg SessionServiceConfig
|
cfg SessionServiceConfig
|
||||||
|
mcpTokens mcp.TokenService // spec §6.1 — system token issuance
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSessionService 构造 SessionService。
|
// NewSessionService 构造 SessionService。
|
||||||
func NewSessionService(repo Repository, wsSvc workspace.WorkspaceService,
|
func NewSessionService(repo Repository, wsSvc workspace.WorkspaceService,
|
||||||
wtSvc workspace.WorktreeService, wsRepo workspace.Repository,
|
wtSvc workspace.WorktreeService, wsRepo workspace.Repository,
|
||||||
pa ProjectAccess, sup *Supervisor, rec audit.Recorder,
|
pa ProjectAccess, sup *Supervisor, rec audit.Recorder,
|
||||||
cfg SessionServiceConfig) SessionService {
|
cfg SessionServiceConfig, mcpTokens mcp.TokenService) SessionService {
|
||||||
return &sessionService{
|
return &sessionService{
|
||||||
repo: repo, wsSvc: wsSvc, wtSvc: wtSvc, wsRepo: wsRepo,
|
repo: repo, wsSvc: wsSvc, wtSvc: wtSvc, wsRepo: wsRepo,
|
||||||
pa: pa, sup: sup, audit: rec, cfg: cfg,
|
pa: pa, sup: sup, audit: rec, cfg: cfg, mcpTokens: mcpTokens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +197,7 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. INSERT
|
// 7. INSERT acp_sessions + system MCP token(spec §6.1 atomic guarantee)
|
||||||
sess := &Session{
|
sess := &Session{
|
||||||
ID: sessionID, WorkspaceID: ws.ID, ProjectID: ws.ProjectID,
|
ID: sessionID, WorkspaceID: ws.ID, ProjectID: ws.ProjectID,
|
||||||
AgentKindID: kind.ID, UserID: c.UserID,
|
AgentKindID: kind.ID, UserID: c.UserID,
|
||||||
@@ -200,10 +205,32 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
|
|||||||
Branch: branch, CwdPath: cwdPath, IsMainWorktree: isMain,
|
Branch: branch, CwdPath: cwdPath, IsMainWorktree: isMain,
|
||||||
Status: SessionStarting,
|
Status: SessionStarting,
|
||||||
}
|
}
|
||||||
out, err := s.repo.InsertSession(ctx, sess)
|
var (
|
||||||
if err != nil {
|
out *Session
|
||||||
|
tokenRes mcp.IssueResult
|
||||||
|
)
|
||||||
|
txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error {
|
||||||
|
inserted, err := s.repo.WithTx(tx).InsertSession(txCtx, sess)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = inserted
|
||||||
|
|
||||||
|
res, err := s.mcpTokens.IssueSystemTokenWithTx(txCtx, tx, mcp.IssueSystemTokenInput{
|
||||||
|
UserID: c.UserID,
|
||||||
|
ACPSessionID: inserted.ID,
|
||||||
|
ProjectIDs: []uuid.UUID{ws.ProjectID},
|
||||||
|
ExpiresIn: s.cfg.SystemTokenTTL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tokenRes = res
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if txErr != nil {
|
||||||
s.releaseOnFailure(ctx, sess, sessCaller, worktreeID)
|
s.releaseOnFailure(ctx, sess, sessCaller, worktreeID)
|
||||||
return nil, err
|
return nil, txErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. audit
|
// 8. audit
|
||||||
@@ -215,11 +242,16 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI
|
|||||||
"requirement_id": reqID,
|
"requirement_id": reqID,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 9. async spawn(失败时回滚 worktree)
|
// 9. async spawn(失败时回滚 worktree + revoke MCP token)
|
||||||
|
extraEnv := map[string]string{
|
||||||
|
"ACW_MCP_TOKEN": tokenRes.Plaintext,
|
||||||
|
"ACW_MCP_URL": s.cfg.MCPPublicURL,
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
spawnCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := s.sup.Spawn(spawnCtx, out, kind, nil); err != nil {
|
if _, err := s.sup.Spawn(spawnCtx, out, kind, extraEnv); err != nil {
|
||||||
|
_ = s.mcpTokens.RevokeBySession(spawnCtx, out.ID)
|
||||||
s.releaseOnFailure(spawnCtx, out, sessCaller, worktreeID)
|
s.releaseOnFailure(spawnCtx, out, sessCaller, worktreeID)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -2,13 +2,18 @@ package acp_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/mcp"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/project"
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||||
)
|
)
|
||||||
@@ -104,3 +109,334 @@ func TestResolveBranch_AutoFallback(t *testing.T) {
|
|||||||
assert.Contains(t, got, "acp-auto/")
|
assert.Contains(t, got, "acp-auto/")
|
||||||
assert.False(t, isMain)
|
assert.False(t, isMain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== fakes for SessionService.Create tests =====
|
||||||
|
|
||||||
|
// fakeAcpRepo satisfies acp.Repository for Create-path unit tests.
|
||||||
|
type fakeAcpRepo struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions []*acp.Session
|
||||||
|
kinds []*acp.AgentKind
|
||||||
|
inserted *acp.Session
|
||||||
|
|
||||||
|
// txStaging holds sessions inserted during InTx; committed to sessions only
|
||||||
|
// when fn returns nil (simulating real transaction semantics).
|
||||||
|
txStaging []*acp.Session
|
||||||
|
inTx bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeAcpRepo) InTx(_ context.Context, fn func(context.Context, pgx.Tx) error) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.inTx = true
|
||||||
|
r.txStaging = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
err := fn(context.Background(), nil)
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.inTx = false
|
||||||
|
if err == nil {
|
||||||
|
r.sessions = append(r.sessions, r.txStaging...)
|
||||||
|
}
|
||||||
|
r.txStaging = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) WithTx(_ pgx.Tx) acp.Repository { return r }
|
||||||
|
|
||||||
|
func (r *fakeAcpRepo) InsertSession(_ context.Context, s *acp.Session) (*acp.Session, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.inserted = s
|
||||||
|
if r.inTx {
|
||||||
|
r.txStaging = append(r.txStaging, s)
|
||||||
|
} else {
|
||||||
|
r.sessions = append(r.sessions, s)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Session, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
for _, s := range r.sessions {
|
||||||
|
if s.ID == id {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("not found")
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) GetAgentKindByID(_ context.Context, id uuid.UUID) (*acp.AgentKind, error) {
|
||||||
|
for _, k := range r.kinds {
|
||||||
|
if k.ID == id {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("not found")
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) CountActiveSessions(_ context.Context) (int64, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
var n int64
|
||||||
|
for _, s := range r.sessions {
|
||||||
|
if s.Status == acp.SessionStarting || s.Status == acp.SessionRunning {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) CountActiveSessionsByUser(_ context.Context, _ uuid.UUID) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) AcquireMainWorktree(_ context.Context, _, _ uuid.UUID) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) ReleaseMainWorktree(_ context.Context, _, _ uuid.UUID) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub out remaining Repository methods (not used by Create path).
|
||||||
|
func (r *fakeAcpRepo) CreateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) GetAgentKindByName(_ context.Context, _ string) (*acp.AgentKind, error) {
|
||||||
|
return nil, fmt.Errorf("not found")
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) ListAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { return nil, nil }
|
||||||
|
func (r *fakeAcpRepo) ListEnabledAgentKinds(_ context.Context) ([]*acp.AgentKind, error) {
|
||||||
|
return r.kinds, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) UpdateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) DeleteAgentKind(_ context.Context, _ uuid.UUID) error { return nil }
|
||||||
|
func (r *fakeAcpRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) ListSessionsByUser(_ context.Context, _ uuid.UUID) ([]*acp.Session, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) ListAllSessions(_ context.Context) ([]*acp.Session, error) { return nil, nil }
|
||||||
|
func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) UpdateSessionFinished(_ context.Context, _ uuid.UUID, _ acp.SessionStatus, _ *int32, _ *string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) ResetStuckSessionsOnRestart(_ context.Context) ([]*acp.Session, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) InsertEvent(_ context.Context, e *acp.Event) (*acp.Event, error) { return e, nil }
|
||||||
|
func (r *fakeAcpRepo) ListEventsSince(_ context.Context, _ uuid.UUID, _ int64, _ int32) ([]*acp.Event, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *fakeAcpRepo) PurgeEventsBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil }
|
||||||
|
|
||||||
|
// fakeMCPTokenSvc satisfies mcp.TokenService for unit tests.
|
||||||
|
type fakeMCPTokenSvc struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
issued []mcp.IssueSystemTokenInput
|
||||||
|
issueErr error
|
||||||
|
revokedBySess []uuid.UUID
|
||||||
|
revokeBySessFn func(ctx context.Context, sessionID uuid.UUID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMCPTokenSvc) IssueAdmin(_ context.Context, _ mcp.IssueAdminInput) (mcp.IssueResult, error) {
|
||||||
|
return mcp.IssueResult{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMCPTokenSvc) IssueSystemToken(_ context.Context, _ mcp.IssueSystemTokenInput) (mcp.IssueResult, error) {
|
||||||
|
return mcp.IssueResult{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMCPTokenSvc) IssueSystemTokenWithTx(_ context.Context, _ pgx.Tx, in mcp.IssueSystemTokenInput) (mcp.IssueResult, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.issueErr != nil {
|
||||||
|
return mcp.IssueResult{}, f.issueErr
|
||||||
|
}
|
||||||
|
f.issued = append(f.issued, in)
|
||||||
|
return mcp.IssueResult{ID: uuid.New(), Plaintext: "tok_" + in.ACPSessionID.String()[:8]}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMCPTokenSvc) Authenticate(_ context.Context, _ string) (*mcp.Token, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMCPTokenSvc) Revoke(_ context.Context, _, _ uuid.UUID) error { return nil }
|
||||||
|
func (f *fakeMCPTokenSvc) RevokeBySession(_ context.Context, sessionID uuid.UUID) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.revokedBySess = append(f.revokedBySess, sessionID)
|
||||||
|
if f.revokeBySessFn != nil {
|
||||||
|
return f.revokeBySessFn(context.Background(), sessionID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeMCPTokenSvc) List(_ context.Context, _ *uuid.UUID, _ bool) ([]*mcp.Token, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeMCPTokenSvc) GetByID(_ context.Context, _ uuid.UUID) (*mcp.Token, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeWsSvc satisfies workspace.WorkspaceService for Create-path tests.
|
||||||
|
type fakeWsSvc struct {
|
||||||
|
ws *workspace.Workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeWsSvc) Get(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Workspace, error) {
|
||||||
|
return s.ws, nil
|
||||||
|
}
|
||||||
|
func (s *fakeWsSvc) Create(context.Context, workspace.Caller, string, workspace.CreateWorkspaceInput) (*workspace.Workspace, error) {
|
||||||
|
return nil, fmt.Errorf("unused")
|
||||||
|
}
|
||||||
|
func (s *fakeWsSvc) List(context.Context, workspace.Caller, string) ([]*workspace.Workspace, error) {
|
||||||
|
return nil, fmt.Errorf("unused")
|
||||||
|
}
|
||||||
|
func (s *fakeWsSvc) Update(context.Context, workspace.Caller, uuid.UUID, workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) {
|
||||||
|
return nil, fmt.Errorf("unused")
|
||||||
|
}
|
||||||
|
func (s *fakeWsSvc) Delete(context.Context, workspace.Caller, uuid.UUID) error {
|
||||||
|
return fmt.Errorf("unused")
|
||||||
|
}
|
||||||
|
func (s *fakeWsSvc) Sync(context.Context, workspace.Caller, uuid.UUID) (*workspace.Workspace, error) {
|
||||||
|
return nil, fmt.Errorf("unused")
|
||||||
|
}
|
||||||
|
func (s *fakeWsSvc) UpsertCredential(context.Context, workspace.Caller, uuid.UUID, workspace.UpsertCredentialInput) (*workspace.Credential, error) {
|
||||||
|
return nil, fmt.Errorf("unused")
|
||||||
|
}
|
||||||
|
func (s *fakeWsSvc) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) {
|
||||||
|
return nil, fmt.Errorf("unused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tests =====
|
||||||
|
|
||||||
|
func TestSessionService_Create_IssuesSystemToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
uid := uuid.New()
|
||||||
|
wsID := uuid.New()
|
||||||
|
pid := uuid.New()
|
||||||
|
akID := uuid.New()
|
||||||
|
|
||||||
|
repo := &fakeAcpRepo{
|
||||||
|
kinds: []*acp.AgentKind{
|
||||||
|
{ID: akID, Name: "claude", Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wsSvc := &fakeWsSvc{ws: &workspace.Workspace{
|
||||||
|
ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main",
|
||||||
|
}}
|
||||||
|
pa := fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "test-proj"}}
|
||||||
|
mcpTokens := &fakeMCPTokenSvc{}
|
||||||
|
|
||||||
|
sup := acp.NewSupervisor(
|
||||||
|
repo, nil, nil, nil, nil, mcpTokens, nil,
|
||||||
|
acp.SupervisorConfig{SpawnTimeout: 5 * time.Second, KillGrace: 1 * time.Second},
|
||||||
|
nil, // logger
|
||||||
|
)
|
||||||
|
|
||||||
|
svc := acp.NewSessionService(
|
||||||
|
repo, wsSvc, nil, nil, pa, sup, nil,
|
||||||
|
acp.SessionServiceConfig{
|
||||||
|
MaxActiveGlobal: 10,
|
||||||
|
MaxActivePerUser: 5,
|
||||||
|
SystemTokenTTL: 24 * time.Hour,
|
||||||
|
MCPPublicURL: "http://localhost:8080/mcp",
|
||||||
|
},
|
||||||
|
mcpTokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
branch := "main"
|
||||||
|
sess, err := svc.Create(context.Background(), acp.Caller{UserID: uid}, acp.CreateSessionInput{
|
||||||
|
WorkspaceID: wsID,
|
||||||
|
AgentKindID: akID,
|
||||||
|
Branch: &branch,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, sess)
|
||||||
|
assert.Equal(t, wsID, sess.WorkspaceID)
|
||||||
|
assert.Equal(t, uid, sess.UserID)
|
||||||
|
|
||||||
|
// Verify system token was issued inside the tx
|
||||||
|
mcpTokens.mu.Lock()
|
||||||
|
require.Len(t, mcpTokens.issued, 1)
|
||||||
|
assert.Equal(t, uid, mcpTokens.issued[0].UserID)
|
||||||
|
assert.Equal(t, sess.ID, mcpTokens.issued[0].ACPSessionID)
|
||||||
|
assert.Equal(t, []uuid.UUID{pid}, mcpTokens.issued[0].ProjectIDs)
|
||||||
|
assert.Equal(t, 24*time.Hour, mcpTokens.issued[0].ExpiresIn)
|
||||||
|
mcpTokens.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionService_Create_TokenIssueFails_RollsBack(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
uid := uuid.New()
|
||||||
|
wsID := uuid.New()
|
||||||
|
pid := uuid.New()
|
||||||
|
akID := uuid.New()
|
||||||
|
|
||||||
|
repo := &fakeAcpRepo{
|
||||||
|
kinds: []*acp.AgentKind{
|
||||||
|
{ID: akID, Name: "claude", Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
wsSvc := &fakeWsSvc{ws: &workspace.Workspace{
|
||||||
|
ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main",
|
||||||
|
}}
|
||||||
|
pa := fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "test-proj"}}
|
||||||
|
mcpTokens := &fakeMCPTokenSvc{
|
||||||
|
issueErr: fmt.Errorf("token issue boom"),
|
||||||
|
}
|
||||||
|
|
||||||
|
sup := acp.NewSupervisor(
|
||||||
|
repo, nil, nil, nil, nil, mcpTokens, nil,
|
||||||
|
acp.SupervisorConfig{SpawnTimeout: 5 * time.Second, KillGrace: 1 * time.Second},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
svc := acp.NewSessionService(
|
||||||
|
repo, wsSvc, nil, nil, pa, sup, nil,
|
||||||
|
acp.SessionServiceConfig{
|
||||||
|
MaxActiveGlobal: 10,
|
||||||
|
MaxActivePerUser: 5,
|
||||||
|
SystemTokenTTL: 24 * time.Hour,
|
||||||
|
MCPPublicURL: "http://localhost:8080/mcp",
|
||||||
|
},
|
||||||
|
mcpTokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
branch := "main"
|
||||||
|
sess, err := svc.Create(context.Background(), acp.Caller{UserID: uid}, acp.CreateSessionInput{
|
||||||
|
WorkspaceID: wsID,
|
||||||
|
AgentKindID: akID,
|
||||||
|
Branch: &branch,
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, sess)
|
||||||
|
assert.Contains(t, err.Error(), "token issue boom")
|
||||||
|
|
||||||
|
// Verify no session was committed (InTx rolled back)
|
||||||
|
repo.mu.Lock()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,22 +24,23 @@ func (q *Queries) CountAgentKindUsage(ctx context.Context, agentKindID pgtype.UU
|
|||||||
|
|
||||||
const createAgentKind = `-- name: CreateAgentKind :one
|
const createAgentKind = `-- name: CreateAgentKind :one
|
||||||
INSERT INTO acp_agent_kinds (
|
INSERT INTO acp_agent_kinds (
|
||||||
id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by
|
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)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
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 {
|
type CreateAgentKindParams struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams) (AcpAgentKind, error) {
|
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.EncryptedEnv,
|
||||||
arg.Enabled,
|
arg.Enabled,
|
||||||
arg.CreatedBy,
|
arg.CreatedBy,
|
||||||
|
arg.ToolAllowlist,
|
||||||
)
|
)
|
||||||
var i AcpAgentKind
|
var i AcpAgentKind
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -67,6 +69,7 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams
|
|||||||
&i.CreatedBy,
|
&i.CreatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ToolAllowlist,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -82,7 +85,7 @@ func (q *Queries) DeleteAgentKind(ctx context.Context, id pgtype.UUID) error {
|
|||||||
|
|
||||||
const getAgentKindByID = `-- name: GetAgentKindByID :one
|
const getAgentKindByID = `-- name: GetAgentKindByID :one
|
||||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
FROM acp_agent_kinds
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -102,13 +105,14 @@ func (q *Queries) GetAgentKindByID(ctx context.Context, id pgtype.UUID) (AcpAgen
|
|||||||
&i.CreatedBy,
|
&i.CreatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ToolAllowlist,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAgentKindByName = `-- name: GetAgentKindByName :one
|
const getAgentKindByName = `-- name: GetAgentKindByName :one
|
||||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
FROM acp_agent_kinds
|
||||||
WHERE name = $1
|
WHERE name = $1
|
||||||
`
|
`
|
||||||
@@ -128,13 +132,14 @@ func (q *Queries) GetAgentKindByName(ctx context.Context, name string) (AcpAgent
|
|||||||
&i.CreatedBy,
|
&i.CreatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ToolAllowlist,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listAgentKinds = `-- name: ListAgentKinds :many
|
const listAgentKinds = `-- name: ListAgentKinds :many
|
||||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
FROM acp_agent_kinds
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -160,6 +165,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
|
|||||||
&i.CreatedBy,
|
&i.CreatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ToolAllowlist,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -173,7 +179,7 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) {
|
|||||||
|
|
||||||
const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many
|
const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many
|
||||||
SELECT id, name, display_name, description, binary_path, args, encrypted_env,
|
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
|
FROM acp_agent_kinds
|
||||||
WHERE enabled = TRUE
|
WHERE enabled = TRUE
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
@@ -200,6 +206,7 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
|
|||||||
&i.CreatedBy,
|
&i.CreatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ToolAllowlist,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -213,26 +220,28 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er
|
|||||||
|
|
||||||
const updateAgentKind = `-- name: UpdateAgentKind :one
|
const updateAgentKind = `-- name: UpdateAgentKind :one
|
||||||
UPDATE acp_agent_kinds
|
UPDATE acp_agent_kinds
|
||||||
SET display_name = $2,
|
SET display_name = $2,
|
||||||
description = $3,
|
description = $3,
|
||||||
binary_path = $4,
|
binary_path = $4,
|
||||||
args = $5,
|
args = $5,
|
||||||
encrypted_env = $6,
|
encrypted_env = $6,
|
||||||
enabled = $7,
|
enabled = $7,
|
||||||
updated_at = now()
|
tool_allowlist = $8,
|
||||||
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, display_name, description, binary_path, args, encrypted_env,
|
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 {
|
type UpdateAgentKindParams struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams) (AcpAgentKind, error) {
|
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.Args,
|
||||||
arg.EncryptedEnv,
|
arg.EncryptedEnv,
|
||||||
arg.Enabled,
|
arg.Enabled,
|
||||||
|
arg.ToolAllowlist,
|
||||||
)
|
)
|
||||||
var i AcpAgentKind
|
var i AcpAgentKind
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -258,6 +268,7 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams
|
|||||||
&i.CreatedBy,
|
&i.CreatedBy,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ToolAllowlist,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-11
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+33
-16
@@ -47,17 +47,22 @@ type Supervisor struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
procs map[uuid.UUID]*Process
|
procs map[uuid.UUID]*Process
|
||||||
|
|
||||||
repo Repository
|
repo Repository
|
||||||
audit audit.Recorder
|
audit audit.Recorder
|
||||||
notify *notify.Dispatcher
|
notify *notify.Dispatcher
|
||||||
wtSvc workspace.WorktreeService
|
wtSvc workspace.WorktreeService
|
||||||
wsRepo workspace.Repository
|
wsRepo workspace.Repository
|
||||||
mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token
|
mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token
|
||||||
crypto *crypto.Encryptor
|
crypto *crypto.Encryptor
|
||||||
cfg SupervisorConfig
|
permSvc *PermissionService // 工具调用权限审批;可为 nil(部分测试)
|
||||||
log *slog.Logger
|
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 时
|
// NewSupervisor 构造空 Supervisor。Start 不需要 — supervisor 是被动的(spawn 时
|
||||||
// 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。
|
// 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。
|
||||||
func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher,
|
func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher,
|
||||||
@@ -68,16 +73,16 @@ func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher,
|
|||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
return &Supervisor{
|
return &Supervisor{
|
||||||
procs: map[uuid.UUID]*Process{},
|
procs: map[uuid.UUID]*Process{},
|
||||||
repo: repo,
|
repo: repo,
|
||||||
audit: rec,
|
audit: rec,
|
||||||
notify: disp,
|
notify: disp,
|
||||||
wtSvc: wtSvc,
|
wtSvc: wtSvc,
|
||||||
wsRepo: wsRepo,
|
wsRepo: wsRepo,
|
||||||
mcpTokens: mcpTokens,
|
mcpTokens: mcpTokens,
|
||||||
crypto: enc,
|
crypto: enc,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +100,7 @@ type Process struct {
|
|||||||
Stderr io.ReadCloser
|
Stderr io.ReadCloser
|
||||||
Relay *Relay
|
Relay *Relay
|
||||||
|
|
||||||
StderrBuf *stderrRing // 排障 ring buffer
|
StderrBuf *stderrRing // 排障 ring buffer
|
||||||
StartedAt time.Time
|
StartedAt time.Time
|
||||||
KilledByUs atomic.Bool // true → exited,false → crashed
|
KilledByUs atomic.Bool // true → exited,false → crashed
|
||||||
done chan struct{} // monitor 退出时 close
|
done chan struct{} // monitor 退出时 close
|
||||||
@@ -185,13 +190,17 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
|||||||
return nil, fmt.Errorf("start: %w", err)
|
return nil, fmt.Errorf("start: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var decider handlers.PermissionDecider
|
||||||
|
if s.permSvc != nil {
|
||||||
|
decider = s.permSvc
|
||||||
|
}
|
||||||
relay := NewRelay(
|
relay := NewRelay(
|
||||||
sess.ID,
|
sess.ID,
|
||||||
NewDecoder(stdout, s.cfg.EventMaxPayload),
|
NewDecoder(stdout, s.cfg.EventMaxPayload),
|
||||||
NewEncoder(stdin),
|
NewEncoder(stdin),
|
||||||
s.repo,
|
s.repo,
|
||||||
handlers.NewFsHandler(),
|
handlers.NewFsHandler(),
|
||||||
handlers.NewPermissionHandler(),
|
handlers.NewPermissionHandler(decider),
|
||||||
RelayConfig{
|
RelayConfig{
|
||||||
EventMaxPayload: s.cfg.EventMaxPayload,
|
EventMaxPayload: s.cfg.EventMaxPayload,
|
||||||
EventTruncateField: s.cfg.EventTruncateField,
|
EventTruncateField: s.cfg.EventTruncateField,
|
||||||
@@ -227,6 +236,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind,
|
|||||||
UserID: sess.UserID,
|
UserID: sess.UserID,
|
||||||
CwdPath: sess.CwdPath,
|
CwdPath: sess.CwdPath,
|
||||||
AgentSessionID: sess.AgentSessionID,
|
AgentSessionID: sess.AgentSessionID,
|
||||||
|
ToolAllowlist: kind.ToolAllowlist,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := s.handshake(ctx, sess, relay, proc); err != nil {
|
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
|
_ = 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)
|
// Revoke session's system MCP tokens (spec §6.3)
|
||||||
if s.mcpTokens != nil {
|
if s.mcpTokens != nil {
|
||||||
if err := s.mcpTokens.RevokeBySession(ctx, proc.SessionID); err != nil {
|
if err := s.mcpTokens.RevokeBySession(ctx, proc.SessionID); err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -257,6 +258,9 @@ func (f *fakeSupMCPTokens) IssueAdmin(_ context.Context, _ mcp.IssueAdminInput)
|
|||||||
func (f *fakeSupMCPTokens) IssueSystemToken(_ context.Context, _ mcp.IssueSystemTokenInput) (mcp.IssueResult, error) {
|
func (f *fakeSupMCPTokens) IssueSystemToken(_ context.Context, _ mcp.IssueSystemTokenInput) (mcp.IssueResult, error) {
|
||||||
return mcp.IssueResult{}, nil
|
return mcp.IssueResult{}, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeSupMCPTokens) IssueSystemTokenWithTx(_ context.Context, _ pgx.Tx, _ mcp.IssueSystemTokenInput) (mcp.IssueResult, error) {
|
||||||
|
return mcp.IssueResult{}, nil
|
||||||
|
}
|
||||||
func (f *fakeSupMCPTokens) Authenticate(_ context.Context, _ string) (*mcp.Token, error) {
|
func (f *fakeSupMCPTokens) Authenticate(_ context.Context, _ string) (*mcp.Token, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite {
|
|||||||
ShutdownGrace: 5 * time.Second,
|
ShutdownGrace: 5 * time.Second,
|
||||||
}
|
}
|
||||||
sup := acp.NewSupervisor(acpRepo, auditRec, disp, nil, nil, nil, enc, supCfg, log)
|
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
|
// Minimal stubs for SessionService dependencies — sufficient for branch=main
|
||||||
// path (no wtSvc / wsRepo calls) and tests that exercise quota/conflict logic.
|
// path (no wtSvc / wsRepo calls) and tests that exercise quota/conflict logic.
|
||||||
@@ -185,13 +188,14 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite {
|
|||||||
sessSvc := acp.NewSessionService(
|
sessSvc := acp.NewSessionService(
|
||||||
acpRepo, wsStub, nil, nil, paStub, sup, auditRec,
|
acpRepo, wsStub, nil, nil, paStub, sup, auditRec,
|
||||||
acp.SessionServiceConfig{MaxActiveGlobal: 20, MaxActivePerUser: 3},
|
acp.SessionServiceConfig{MaxActiveGlobal: 20, MaxActivePerUser: 3},
|
||||||
|
nil, // mcpTokens — not exercised by integration tests
|
||||||
)
|
)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
user.NewHandler(userSvc).Mount(r)
|
user.NewHandler(userSvc).Mount(r)
|
||||||
acp.NewHandler(
|
acp.NewHandler(
|
||||||
akSvc, sessSvc, sup, acpRepo,
|
akSvc, sessSvc, sup, acpRepo, permSvc,
|
||||||
userSvc,
|
userSvc,
|
||||||
acpUserAdminAdapter{svc: userSvc},
|
acpUserAdminAdapter{svc: userSvc},
|
||||||
enc,
|
enc,
|
||||||
|
|||||||
+65
-2
@@ -32,7 +32,6 @@ import (
|
|||||||
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/chat"
|
"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/config"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/clock"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/crypto"
|
"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"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/jobs/handlers"
|
"github.com/yan1h/agent-coding-workflow/internal/jobs/handlers"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
|
"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"
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
"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())
|
"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
|
uid := sess.UserID
|
||||||
if rerr := auditRec.Record(ctx, audit.Entry{
|
if rerr := auditRec.Record(ctx, audit.Entry{
|
||||||
UserID: &uid, Action: "acp.session.aborted_on_restart",
|
UserID: &uid, Action: "acp.session.aborted_on_restart",
|
||||||
@@ -329,6 +334,30 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 1.5 (post-ACP-reaper): revoke MCP system tokens still bound to crashed/exited
|
||||||
|
// sessions. spec §6.4 — must run AFTER ACP reaper so sessions are already marked crashed.
|
||||||
|
revokedTokenIDs, rerr := mcpRepo.ReapStaleSystemTokens(ctx)
|
||||||
|
if rerr != nil {
|
||||||
|
log.Warn("mcp.startup_reaper.failed", "err", rerr.Error())
|
||||||
|
} else if len(revokedTokenIDs) > 0 {
|
||||||
|
log.Info("mcp.startup_reaper.revoked", "count", len(revokedTokenIDs))
|
||||||
|
_ = auditRec.Record(ctx, audit.Entry{
|
||||||
|
Action: "mcp.token.expire_on_restart",
|
||||||
|
TargetType: "mcp_token",
|
||||||
|
TargetID: "(batch)",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"count": len(revokedTokenIDs),
|
||||||
|
"token_ids": func() []string {
|
||||||
|
out := make([]string, 0, len(revokedTokenIDs))
|
||||||
|
for _, id := range revokedTokenIDs {
|
||||||
|
out = append(out, id.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2: build supervisor + session service.
|
// Phase 2: build supervisor + session service.
|
||||||
acpProjAccess := acpProjectAccessAdapter{wsRepo: wsRepo, projectRepo: projectRepo}
|
acpProjAccess := acpProjectAccessAdapter{wsRepo: wsRepo, projectRepo: projectRepo}
|
||||||
acpSup := acp.NewSupervisor(
|
acpSup := acp.NewSupervisor(
|
||||||
@@ -345,15 +374,23 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
)
|
)
|
||||||
|
// 权限审批服务:与 supervisor 互相依赖,构造后回填(SetPermissionService /
|
||||||
|
// SetRelayLocator),再注入 handler。
|
||||||
|
acpPermSvc := acp.NewPermissionService(acpRepo, auditRec, cfg.Acp.PermissionTimeout, log)
|
||||||
|
acpSup.SetPermissionService(acpPermSvc)
|
||||||
|
acpPermSvc.SetRelayLocator(acpSup)
|
||||||
sessSvc := acp.NewSessionService(
|
sessSvc := acp.NewSessionService(
|
||||||
acpRepo, wsSvc, wtSvc, wsRepo,
|
acpRepo, wsSvc, wtSvc, wsRepo,
|
||||||
acpProjAccess, acpSup, auditRec,
|
acpProjAccess, acpSup, auditRec,
|
||||||
acp.SessionServiceConfig{
|
acp.SessionServiceConfig{
|
||||||
MaxActiveGlobal: cfg.Acp.MaxActiveGlobal,
|
MaxActiveGlobal: cfg.Acp.MaxActiveGlobal,
|
||||||
MaxActivePerUser: cfg.Acp.MaxActivePerUser,
|
MaxActivePerUser: cfg.Acp.MaxActivePerUser,
|
||||||
|
SystemTokenTTL: cfg.MCP.SystemTokenTTL,
|
||||||
|
MCPPublicURL: cfg.MCP.PublicURL,
|
||||||
},
|
},
|
||||||
|
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,
|
PingInterval: cfg.Acp.WSPingInterval,
|
||||||
PongTimeout: cfg.Acp.WSPongTimeout,
|
PongTimeout: cfg.Acp.WSPongTimeout,
|
||||||
SendBuffer: cfg.Acp.WSSendBuffer,
|
SendBuffer: cfg.Acp.WSSendBuffer,
|
||||||
@@ -401,6 +438,8 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
jobPurgeRunner := runners.NewJobPurge(jobsRepo, cfg.Jobs.JobPurge.CompletedRetention, log)
|
jobPurgeRunner := runners.NewJobPurge(jobsRepo, cfg.Jobs.JobPurge.CompletedRetention, log)
|
||||||
acpEventsPurgeRunner := runners.NewAcpEventsPurge(
|
acpEventsPurgeRunner := runners.NewAcpEventsPurge(
|
||||||
acpEventsPurgeAdapter{repo: acpRepo}, cfg.Jobs.AcpEventsPurge.Retention, log)
|
acpEventsPurgeAdapter{repo: acpRepo}, cfg.Jobs.AcpEventsPurge.Retention, log)
|
||||||
|
mcpTokensPurgeRunner := runners.NewMCPTokensPurge(
|
||||||
|
mcpTokensPurgeAdapter{repo: mcpRepo}, cfg.Jobs.MCPTokensPurge.Retention, log)
|
||||||
|
|
||||||
deadLetterFn := func(ctx context.Context, job jobs.Job, herr error) {
|
deadLetterFn := func(ctx context.Context, job jobs.Job, herr error) {
|
||||||
errMsg := herr.Error()
|
errMsg := herr.Error()
|
||||||
@@ -476,6 +515,11 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
Interval: cfg.Jobs.AcpEventsPurge.Interval,
|
Interval: cfg.Jobs.AcpEventsPurge.Interval,
|
||||||
Retention: cfg.Jobs.AcpEventsPurge.Retention,
|
Retention: cfg.Jobs.AcpEventsPurge.Retention,
|
||||||
},
|
},
|
||||||
|
MCPTokensPurge: jobs.MCPTokensPurgeConfig{
|
||||||
|
Enabled: cfg.Jobs.MCPTokensPurge.Enabled,
|
||||||
|
Interval: cfg.Jobs.MCPTokensPurge.Interval,
|
||||||
|
Retention: cfg.Jobs.MCPTokensPurge.Retention,
|
||||||
|
},
|
||||||
}, jobs.ModuleDeps{
|
}, jobs.ModuleDeps{
|
||||||
Repo: jobsRepo,
|
Repo: jobsRepo,
|
||||||
Handlers: map[jobs.JobType]jobs.Handler{
|
Handlers: map[jobs.JobType]jobs.Handler{
|
||||||
@@ -488,6 +532,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) {
|
|||||||
"job_reaper": jobReaperRunner.Run,
|
"job_reaper": jobReaperRunner.Run,
|
||||||
"job_purge": jobPurgeRunner.Run,
|
"job_purge": jobPurgeRunner.Run,
|
||||||
"acp_events_purge": acpEventsPurgeRunner.Run,
|
"acp_events_purge": acpEventsPurgeRunner.Run,
|
||||||
|
"mcp_tokens_purge": mcpTokensPurgeRunner.Run,
|
||||||
},
|
},
|
||||||
Clock: clock.Real(),
|
Clock: clock.Real(),
|
||||||
Log: log,
|
Log: log,
|
||||||
@@ -508,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{
|
srv := &http.Server{
|
||||||
Addr: cfg.HTTP.Addr,
|
Addr: cfg.HTTP.Addr,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
@@ -845,3 +900,11 @@ type acpEventsPurgeAdapter struct{ repo acp.Repository }
|
|||||||
func (a acpEventsPurgeAdapter) PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error) {
|
func (a acpEventsPurgeAdapter) PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error) {
|
||||||
return a.repo.PurgeEventsBefore(ctx, before)
|
return a.repo.PurgeEventsBefore(ctx, before)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mcpTokensPurgeAdapter exposes mcp.Repository.PurgeExpired as the narrow
|
||||||
|
// interface required by runners.MCPTokensPurge.
|
||||||
|
type mcpTokensPurgeAdapter struct{ repo mcp.Repository }
|
||||||
|
|
||||||
|
func (a mcpTokensPurgeAdapter) PurgeExpired(ctx context.Context, before time.Time) (int64, error) {
|
||||||
|
return a.repo.PurgeExpired(ctx, before)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -197,14 +197,15 @@ type AcpConfig struct {
|
|||||||
EventTruncateField int `mapstructure:"event_truncate_field_bytes"`
|
EventTruncateField int `mapstructure:"event_truncate_field_bytes"`
|
||||||
EventRetentionDays int `mapstructure:"event_retention_days"`
|
EventRetentionDays int `mapstructure:"event_retention_days"`
|
||||||
ShutdownGrace time.Duration `mapstructure:"shutdown_grace"`
|
ShutdownGrace time.Duration `mapstructure:"shutdown_grace"`
|
||||||
|
PermissionTimeout time.Duration `mapstructure:"permission_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCPConfig 控制 MCP 模块的开关、对外 URL(注入 ACP 子进程 env)、system token TTL 与速率限制。
|
// MCPConfig 控制 MCP 模块的开关、对外 URL(注入 ACP 子进程 env)、system token TTL 与速率限制。
|
||||||
type MCPConfig struct {
|
type MCPConfig struct {
|
||||||
Enabled bool `mapstructure:"enabled"`
|
Enabled bool `mapstructure:"enabled"`
|
||||||
PublicURL string `mapstructure:"public_url"`
|
PublicURL string `mapstructure:"public_url"`
|
||||||
SystemTokenTTL time.Duration `mapstructure:"system_token_ttl"`
|
SystemTokenTTL time.Duration `mapstructure:"system_token_ttl"`
|
||||||
RateLimit MCPRateLimitCfg `mapstructure:"rate_limit"`
|
RateLimit MCPRateLimitCfg `mapstructure:"rate_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCPRateLimitCfg 控制 token bucket 容量。两层独立桶:per-token + 全局。
|
// MCPRateLimitCfg 控制 token bucket 容量。两层独立桶:per-token + 全局。
|
||||||
@@ -278,6 +279,7 @@ func Load(configFile string) (*Config, error) {
|
|||||||
v.SetDefault("acp.event_truncate_field_bytes", 4096)
|
v.SetDefault("acp.event_truncate_field_bytes", 4096)
|
||||||
v.SetDefault("acp.event_retention_days", 7)
|
v.SetDefault("acp.event_retention_days", 7)
|
||||||
v.SetDefault("acp.shutdown_grace", "30s")
|
v.SetDefault("acp.shutdown_grace", "30s")
|
||||||
|
v.SetDefault("acp.permission_timeout", "5m")
|
||||||
v.SetDefault("mcp.enabled", true)
|
v.SetDefault("mcp.enabled", true)
|
||||||
v.SetDefault("mcp.public_url", "http://localhost:8080/mcp")
|
v.SetDefault("mcp.public_url", "http://localhost:8080/mcp")
|
||||||
v.SetDefault("mcp.system_token_ttl", "24h")
|
v.SetDefault("mcp.system_token_ttl", "24h")
|
||||||
|
|||||||
+28
-19
@@ -75,27 +75,30 @@ const (
|
|||||||
CodeWebhookNotConfigured Code = "webhook.not_configured"
|
CodeWebhookNotConfigured Code = "webhook.not_configured"
|
||||||
|
|
||||||
// ACP 域
|
// ACP 域
|
||||||
CodeAcpAgentKindNotFound Code = "acp.agent_kind_not_found"
|
CodeAcpAgentKindNotFound Code = "acp.agent_kind_not_found"
|
||||||
CodeAcpAgentKindDisabled Code = "acp.agent_kind_disabled"
|
CodeAcpAgentKindDisabled Code = "acp.agent_kind_disabled"
|
||||||
CodeAcpAgentKindInUse Code = "acp.agent_kind_in_use"
|
CodeAcpAgentKindInUse Code = "acp.agent_kind_in_use"
|
||||||
CodeAcpSessionNotFound Code = "acp.session_not_found"
|
CodeAcpSessionNotFound Code = "acp.session_not_found"
|
||||||
CodeAcpSessionQuotaExceeded Code = "acp.session_quota_exceeded"
|
CodeAcpSessionQuotaExceeded Code = "acp.session_quota_exceeded"
|
||||||
CodeAcpSessionNotOwned Code = "acp.session_not_owned"
|
CodeAcpSessionNotOwned Code = "acp.session_not_owned"
|
||||||
CodeAcpSessionTerminated Code = "acp.session_terminated"
|
CodeAcpSessionTerminated Code = "acp.session_terminated"
|
||||||
CodeAcpSessionBranchConflict Code = "acp.session_branch_conflict"
|
CodeAcpSessionBranchConflict Code = "acp.session_branch_conflict"
|
||||||
CodeAcpSpawnFailed Code = "acp.spawn_failed"
|
CodeAcpSpawnFailed Code = "acp.spawn_failed"
|
||||||
CodeAcpHandshakeTimeout Code = "acp.handshake_timeout"
|
CodeAcpHandshakeTimeout Code = "acp.handshake_timeout"
|
||||||
CodeAcpFsPathOutsideWorktree Code = "acp.fs_path_outside_worktree"
|
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 域
|
// MCP 域
|
||||||
CodeMcpTokenInvalid Code = "mcp.token_invalid"
|
CodeMcpTokenInvalid Code = "mcp.token_invalid"
|
||||||
CodeMcpTokenRevoked Code = "mcp.token_revoked"
|
CodeMcpTokenRevoked Code = "mcp.token_revoked"
|
||||||
CodeMcpTokenExpired Code = "mcp.token_expired"
|
CodeMcpTokenExpired Code = "mcp.token_expired"
|
||||||
CodeMcpTokenNotFound Code = "mcp.token_not_found"
|
CodeMcpTokenNotFound Code = "mcp.token_not_found"
|
||||||
CodeMcpTokenScopeViolation Code = "mcp.token_scope_violation"
|
CodeMcpTokenScopeViolation Code = "mcp.token_scope_violation"
|
||||||
CodeMcpTokenNameRequired Code = "mcp.token_name_required"
|
CodeMcpTokenNameRequired Code = "mcp.token_name_required"
|
||||||
CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required"
|
CodeMcpTokenExpiresRequired Code = "mcp.token_expires_required"
|
||||||
CodeMcpRateLimited Code = "mcp.rate_limited"
|
CodeMcpRateLimited Code = "mcp.rate_limited"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppError is the application's structured error type. It carries a stable
|
// AppError is the application's structured error type. It carries a stable
|
||||||
@@ -218,6 +221,12 @@ func HTTPStatus(code Code) int {
|
|||||||
return http.StatusBadGateway
|
return http.StatusBadGateway
|
||||||
case CodeAcpFsPathOutsideWorktree:
|
case CodeAcpFsPathOutsideWorktree:
|
||||||
return http.StatusInternalServerError
|
return http.StatusInternalServerError
|
||||||
|
case CodeAcpPermissionNotFound:
|
||||||
|
return http.StatusNotFound
|
||||||
|
case CodeAcpPermissionAlreadyDecided:
|
||||||
|
return http.StatusConflict
|
||||||
|
case CodeAcpPermissionInvalidOption:
|
||||||
|
return http.StatusBadRequest
|
||||||
case CodeWebhookNotConfigured:
|
case CodeWebhookNotConfigured:
|
||||||
return http.StatusServiceUnavailable
|
return http.StatusServiceUnavailable
|
||||||
case CodeMcpTokenInvalid, CodeMcpTokenRevoked, CodeMcpTokenExpired:
|
case CodeMcpTokenInvalid, CodeMcpTokenRevoked, CodeMcpTokenExpired:
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ type AcpEventsPurgeConfig struct {
|
|||||||
Retention time.Duration
|
Retention time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCPTokensPurgeConfig extends RunnerConfig with mcp_tokens retention.
|
||||||
|
type MCPTokensPurgeConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
Interval time.Duration
|
||||||
|
Retention time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
// Config is the full jobs.Module configuration.
|
// Config is the full jobs.Module configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
@@ -138,4 +145,5 @@ type Config struct {
|
|||||||
JobReaper JobReaperConfig
|
JobReaper JobReaperConfig
|
||||||
JobPurge JobPurgeConfig
|
JobPurge JobPurgeConfig
|
||||||
AcpEventsPurge AcpEventsPurgeConfig
|
AcpEventsPurge AcpEventsPurgeConfig
|
||||||
|
MCPTokensPurge MCPTokensPurgeConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ func scheduleEntry(cfg Config, name string) (schedEntry, bool) {
|
|||||||
return schedEntry{cfg.JobPurge.Interval, cfg.JobPurge.Enabled}, true
|
return schedEntry{cfg.JobPurge.Interval, cfg.JobPurge.Enabled}, true
|
||||||
case "acp_events_purge":
|
case "acp_events_purge":
|
||||||
return schedEntry{cfg.AcpEventsPurge.Interval, cfg.AcpEventsPurge.Enabled}, true
|
return schedEntry{cfg.AcpEventsPurge.Interval, cfg.AcpEventsPurge.Enabled}, true
|
||||||
|
case "mcp_tokens_purge":
|
||||||
|
return schedEntry{cfg.MCPTokensPurge.Interval, cfg.MCPTokensPurge.Enabled}, true
|
||||||
}
|
}
|
||||||
return schedEntry{0, false}, false
|
return schedEntry{0, false}, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -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) 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 {
|
func ctxWithSession(s *mcp.AuthSession) context.Context {
|
||||||
return context.WithValue(context.Background(), mcp.AuthContextKey, s)
|
return context.WithValue(context.Background(), mcp.AuthContextKey, s)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-11
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type fakeUserSvc struct{ users map[uuid.UUID]*user.User }
|
|||||||
func (s *fakeUserSvc) Login(_ context.Context, _, _, _ string) (string, *user.User, error) {
|
func (s *fakeUserSvc) Login(_ context.Context, _, _, _ string) (string, *user.User, error) {
|
||||||
return "", nil, nil
|
return "", nil, nil
|
||||||
}
|
}
|
||||||
func (s *fakeUserSvc) Logout(_ context.Context, _ string) error { return nil }
|
func (s *fakeUserSvc) Logout(_ context.Context, _ string) error { return nil }
|
||||||
func (s *fakeUserSvc) ResolveSession(_ context.Context, _ string) (uuid.UUID, bool, error) {
|
func (s *fakeUserSvc) ResolveSession(_ context.Context, _ string) (uuid.UUID, bool, error) {
|
||||||
return uuid.Nil, false, nil
|
return uuid.Nil, false, nil
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,25 @@ func (s *fakeUserSvc) Bootstrap(_ context.Context, _, _ string) (*user.User, err
|
|||||||
}
|
}
|
||||||
func (s *fakeUserSvc) ListAdmins(_ context.Context) ([]*user.User, error) { return nil, nil }
|
func (s *fakeUserSvc) 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.
|
// fakeProjectSvc implements project.ProjectService.
|
||||||
type fakeProjectSvc struct {
|
type fakeProjectSvc struct {
|
||||||
projects []*project.Project
|
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) {
|
func (f *fakeProjectSvc) Update(_ context.Context, _ project.Caller, _ string, _ project.UpdateProjectInput) (*project.Project, error) {
|
||||||
panic("not implemented")
|
panic("not implemented")
|
||||||
}
|
}
|
||||||
func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error { panic("not implemented") }
|
func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error {
|
||||||
func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error { panic("not implemented") }
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
// fakeRequirementSvc implements project.RequirementService.
|
// fakeRequirementSvc implements project.RequirementService.
|
||||||
type fakeRequirementSvc struct {
|
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) {
|
func (f *fakeWorkspaceSvc) Update(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) {
|
||||||
panic("not implemented")
|
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) {
|
func (f *fakeWorkspaceSvc) Sync(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Workspace, error) {
|
||||||
panic("not implemented")
|
panic("not implemented")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type User struct {
|
|||||||
Email string
|
Email string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
Enabled bool
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
@@ -58,4 +59,33 @@ type Service interface {
|
|||||||
Bootstrap(ctx context.Context, email, password string) (*User, error)
|
Bootstrap(ctx context.Context, email, password string) (*User, error)
|
||||||
// ListAdmins 返回所有管理员用户,供内部模块(如 jobs runner)使用。
|
// ListAdmins 返回所有管理员用户,供内部模块(如 jobs runner)使用。
|
||||||
ListAdmins(ctx context.Context) ([]*User, error)
|
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
@@ -18,8 +18,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
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 子树。
|
// Mount 把 user 模块的所有 HTTP 端点挂到给定 router 的 /api/v1/auth 子树。
|
||||||
// 调用方负责在挂载之前装好 RequestID/Recover/Logger 等通用中间件。
|
// 调用方负责在挂载之前装好 RequestID/Recover/Logger 等通用中间件。
|
||||||
func (h *Handler) Mount(r chi.Router) {
|
func (h *Handler) Mount(r chi.Router) {
|
||||||
|
auth := middleware.Auth(h.svc, middleware.AuthOptions{})
|
||||||
|
|
||||||
r.Route("/api/v1/auth", func(r chi.Router) {
|
r.Route("/api/v1/auth", func(r chi.Router) {
|
||||||
r.Post("/login", h.login)
|
r.Post("/login", h.login)
|
||||||
|
|
||||||
// /me 与 /logout 必须经过 Auth 中间件:未带 token 直接 401,由中间件
|
// /me 与 /logout 必须经过 Auth 中间件:未带 token 直接 401,由中间件
|
||||||
// 落地响应;带了 token 才会走到下面的处理函数。
|
// 落地响应;带了 token 才会走到下面的处理函数。
|
||||||
auth := middleware.Auth(h.svc, middleware.AuthOptions{})
|
|
||||||
r.With(auth).Get("/me", h.me)
|
r.With(auth).Get("/me", h.me)
|
||||||
r.With(auth).Post("/logout", h.logout)
|
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 的请求体形态。
|
// loginReq 是 POST /api/v1/auth/login 的请求体形态。
|
||||||
@@ -161,3 +195,271 @@ func clientIP(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return r.RemoteAddr
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,66 @@
|
|||||||
-- name: CreateUser :one
|
-- name: CreateUser :one
|
||||||
INSERT INTO users (id, email, password_hash, display_name, is_admin)
|
INSERT INTO users (id, email, password_hash, display_name, is_admin)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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
|
-- 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
|
FROM users
|
||||||
WHERE email = $1;
|
WHERE email = $1;
|
||||||
|
|
||||||
-- name: GetUserByID :one
|
-- 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
|
FROM users
|
||||||
WHERE id = $1;
|
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
|
-- name: CountUsers :one
|
||||||
SELECT COUNT(*)::int FROM users;
|
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
|
-- name: CreateSession :exec
|
||||||
INSERT INTO user_sessions (id, user_id, token_hash, expires_at)
|
INSERT INTO user_sessions (id, user_id, token_hash, expires_at)
|
||||||
VALUES ($1, $2, $3, $4);
|
VALUES ($1, $2, $3, $4);
|
||||||
|
|
||||||
-- name: GetSessionByTokenHash :one
|
-- name: GetSessionByTokenHash :one
|
||||||
SELECT id, user_id, token_hash, expires_at, last_seen_at, created_at
|
SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.created_at
|
||||||
FROM user_sessions
|
FROM user_sessions s
|
||||||
WHERE token_hash = $1
|
JOIN users u ON u.id = s.user_id
|
||||||
AND expires_at > now();
|
WHERE s.token_hash = $1
|
||||||
|
AND s.expires_at > now()
|
||||||
|
AND u.enabled = true;
|
||||||
|
|
||||||
-- name: TouchSession :exec
|
-- name: TouchSession :exec
|
||||||
UPDATE user_sessions
|
UPDATE user_sessions
|
||||||
@@ -34,11 +70,14 @@ WHERE token_hash = $1;
|
|||||||
-- name: DeleteSessionByTokenHash :exec
|
-- name: DeleteSessionByTokenHash :exec
|
||||||
DELETE FROM user_sessions WHERE token_hash = $1;
|
DELETE FROM user_sessions WHERE token_hash = $1;
|
||||||
|
|
||||||
|
-- name: DeleteSessionsByUserID :exec
|
||||||
|
DELETE FROM user_sessions WHERE user_id = $1;
|
||||||
|
|
||||||
-- name: DeleteExpiredSessions :exec
|
-- name: DeleteExpiredSessions :exec
|
||||||
DELETE FROM user_sessions WHERE expires_at <= now();
|
DELETE FROM user_sessions WHERE expires_at <= now();
|
||||||
|
|
||||||
-- name: ListAdmins :many
|
-- 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
|
FROM users
|
||||||
WHERE is_admin = true
|
WHERE is_admin = true
|
||||||
ORDER BY id;
|
ORDER BY id;
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgerrcode"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
@@ -35,12 +37,20 @@ type Repository interface {
|
|||||||
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||||
GetUserByID(ctx context.Context, id uuid.UUID) (*User, error)
|
GetUserByID(ctx context.Context, id uuid.UUID) (*User, error)
|
||||||
CountUsers(ctx context.Context) (int, 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)
|
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
|
CreateSession(ctx context.Context, s *Session) error
|
||||||
GetSessionByTokenHash(ctx context.Context, hash []byte) (*Session, error)
|
GetSessionByTokenHash(ctx context.Context, hash []byte) (*Session, error)
|
||||||
TouchSession(ctx context.Context, hash []byte) error
|
TouchSession(ctx context.Context, hash []byte) error
|
||||||
DeleteSessionByTokenHash(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。
|
// pgRepo 是 Repository 的 Postgres 实现,内部持有一个 sqlc 生成的 Queries。
|
||||||
@@ -80,6 +90,7 @@ func rowToUser(row usersqlc.User) *User {
|
|||||||
PasswordHash: row.PasswordHash,
|
PasswordHash: row.PasswordHash,
|
||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
IsAdmin: row.IsAdmin,
|
IsAdmin: row.IsAdmin,
|
||||||
|
Enabled: row.Enabled,
|
||||||
CreatedAt: row.CreatedAt.Time,
|
CreatedAt: row.CreatedAt.Time,
|
||||||
UpdatedAt: row.UpdatedAt.Time,
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
}
|
}
|
||||||
@@ -114,11 +125,20 @@ func (r *pgRepo) CreateUser(ctx context.Context, u *User) (*User, error) {
|
|||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return nil, errs.New(errs.CodeConflict, "邮箱已被占用")
|
||||||
|
}
|
||||||
return nil, errs.Wrap(err, errs.CodeInternal, "create user")
|
return nil, errs.Wrap(err, errs.CodeInternal, "create user")
|
||||||
}
|
}
|
||||||
return rowToUser(row), nil
|
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,方便
|
// GetUserByEmail 按邮箱精确查询用户。pgx.ErrNoRows 翻译为 NotFound,方便
|
||||||
// 上层(例如登录流程)分类响应;其它错误一律 Internal。
|
// 上层(例如登录流程)分类响应;其它错误一律 Internal。
|
||||||
func (r *pgRepo) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -177,3 +177,202 @@ func (s *service) Bootstrap(ctx context.Context, email, password string) (*User,
|
|||||||
func (s *service) ListAdmins(ctx context.Context) ([]*User, error) {
|
func (s *service) ListAdmins(ctx context.Context) ([]*User, error) {
|
||||||
return s.repo.ListAdmins(ctx)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) 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 {
|
func (r *fakeRepo) CreateSession(_ context.Context, s *Session) error {
|
||||||
r.sessions[string(s.TokenHash)] = s
|
r.sessions[string(s.TokenHash)] = s
|
||||||
@@ -168,3 +258,124 @@ func TestLogout_RecordsAudit(t *testing.T) {
|
|||||||
require.Len(t, spy.entries, 1)
|
require.Len(t, spy.entries, 1)
|
||||||
require.Equal(t, "user.logout", spy.entries[0].Action)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const countUsers = `-- name: CountUsers :one
|
||||||
SELECT COUNT(*)::int FROM users
|
SELECT COUNT(*)::int FROM users
|
||||||
`
|
`
|
||||||
@@ -47,7 +58,7 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er
|
|||||||
const createUser = `-- name: CreateUser :one
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (id, email, password_hash, display_name, is_admin)
|
INSERT INTO users (id, email, password_hash, display_name, is_admin)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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 {
|
type CreateUserParams struct {
|
||||||
@@ -75,6 +86,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Enabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -97,11 +109,31 @@ func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte
|
|||||||
return err
|
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
|
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
|
||||||
SELECT id, user_id, token_hash, expires_at, last_seen_at, created_at
|
SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.created_at
|
||||||
FROM user_sessions
|
FROM user_sessions s
|
||||||
WHERE token_hash = $1
|
JOIN users u ON u.id = s.user_id
|
||||||
AND expires_at > now()
|
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) {
|
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
|
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
|
FROM users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
`
|
`
|
||||||
@@ -135,12 +167,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
|
|||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Enabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByID = `-- name: GetUserByID :one
|
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
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -156,12 +189,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
|||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Enabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const listAdmins = `-- name: ListAdmins :many
|
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
|
FROM users
|
||||||
WHERE is_admin = true
|
WHERE is_admin = true
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -184,6 +218,7 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
|
|||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.Enabled,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -195,6 +230,97 @@ func (q *Queries) ListAdmins(ctx context.Context) ([]User, error) {
|
|||||||
return items, nil
|
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
|
const touchSession = `-- name: TouchSession :exec
|
||||||
UPDATE user_sessions
|
UPDATE user_sessions
|
||||||
SET last_seen_at = now()
|
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)
|
_, err := q.db.Exec(ctx, touchSession, tokenHash)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,17 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AcpAgentKind struct {
|
type AcpAgentKind struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
BinaryPath string `json:"binary_path"`
|
BinaryPath string `json:"binary_path"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
EncryptedEnv []byte `json:"encrypted_env"`
|
EncryptedEnv []byte `json:"encrypted_env"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedBy pgtype.UUID `json:"created_by"`
|
CreatedBy pgtype.UUID `json:"created_by"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AcpEvent struct {
|
type AcpEvent struct {
|
||||||
@@ -36,6 +37,20 @@ type AcpEvent struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
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 {
|
type AcpSession struct {
|
||||||
ID pgtype.UUID `json:"id"`
|
ID pgtype.UUID `json:"id"`
|
||||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||||
@@ -269,6 +284,7 @@ type User struct {
|
|||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSession struct {
|
type UserSession struct {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN enabled;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- ============ 用户管理:账号启用状态 ============
|
||||||
|
-- enabled 用于管理员禁用账号而不删除:禁用后该用户无法通过 ResolveSession
|
||||||
|
-- 鉴权。默认 TRUE 保证存量账号与 bootstrap 管理员不受影响。
|
||||||
|
ALTER TABLE users ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE acp_permission_requests;
|
||||||
|
ALTER TABLE acp_agent_kinds DROP COLUMN tool_allowlist;
|
||||||
@@ -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';
|
||||||
@@ -10,6 +10,7 @@ export interface AgentKindAdmin {
|
|||||||
args: string[]
|
args: string[]
|
||||||
env_keys: string[]
|
env_keys: string[]
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
tool_allowlist: string[]
|
||||||
created_by: string
|
created_by: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -36,6 +37,7 @@ export interface CreateAgentKindReq {
|
|||||||
args?: string[]
|
args?: string[]
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
tool_allowlist?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAgentKindReq {
|
export interface UpdateAgentKindReq {
|
||||||
@@ -45,6 +47,18 @@ export interface UpdateAgentKindReq {
|
|||||||
args?: string[]
|
args?: string[]
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>
|
||||||
enabled?: boolean
|
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.
|
// AcpSession mirrors the backend session DTO returned by /api/v1/acp/sessions.
|
||||||
@@ -90,6 +104,8 @@ export interface AcpEvent {
|
|||||||
| 'session_terminated'
|
| 'session_terminated'
|
||||||
| 'slow_consumer_disconnect'
|
| 'slow_consumer_disconnect'
|
||||||
| 'client_error'
|
| 'client_error'
|
||||||
|
| 'permission_request'
|
||||||
|
| 'permission_resolved'
|
||||||
method?: string
|
method?: string
|
||||||
payload: unknown
|
payload: unknown
|
||||||
truncated: boolean
|
truncated: boolean
|
||||||
@@ -121,6 +137,17 @@ export const acpApi = {
|
|||||||
request<void>(`/api/v1/acp/sessions/${enc(id)}`, { method: 'DELETE' }),
|
request<void>(`/api/v1/acp/sessions/${enc(id)}`, { method: 'DELETE' }),
|
||||||
events: (id: string, since: number) =>
|
events: (id: string, since: number) =>
|
||||||
request<AcpEvent[]>(`/api/v1/acp/sessions/${enc(id)}/events?since=${since}`),
|
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' }),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,9 @@ export const authApi = {
|
|||||||
}),
|
}),
|
||||||
me: () => request<User>('/api/v1/auth/me'),
|
me: () => request<User>('/api/v1/auth/me'),
|
||||||
logout: () => request<void>('/api/v1/auth/logout', { method: 'POST' }),
|
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 },
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
|||||||
args: string[]
|
args: string[]
|
||||||
env: Record<string, string>
|
env: Record<string, string>
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
tool_allowlist: string[]
|
||||||
}
|
}
|
||||||
isEdit: boolean
|
isEdit: boolean
|
||||||
existingEnvKeys?: string[]
|
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 {
|
interface EnvRow {
|
||||||
k: string
|
k: string
|
||||||
v: string
|
v: string
|
||||||
@@ -157,6 +165,17 @@ function removeEnv(idx: number) {
|
|||||||
+ Add
|
+ Add
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</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 safe.tool"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NCheckbox v-model:checked="local.enabled">
|
<NCheckbox v-model:checked="local.enabled">
|
||||||
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>
|
||||||
@@ -39,6 +39,12 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
<template v-if="auth.isAdmin">
|
<template v-if="auth.isAdmin">
|
||||||
<span class="text-neutral-300">|</span>
|
<span class="text-neutral-300">|</span>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'admin-users' }"
|
||||||
|
class="text-sm hover:underline"
|
||||||
|
>
|
||||||
|
用户
|
||||||
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'admin-llm-endpoints' }"
|
:to="{ name: 'admin-llm-endpoints' }"
|
||||||
class="text-sm hover:underline"
|
class="text-sm hover:underline"
|
||||||
@@ -94,10 +100,15 @@ const auth = useAuthStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const menu = computed(() => [
|
const menu = computed(() => [
|
||||||
|
{ label: '修改密码', key: 'change-password' },
|
||||||
{ label: '退出登录', key: 'logout' },
|
{ label: '退出登录', key: 'logout' },
|
||||||
])
|
])
|
||||||
|
|
||||||
async function onSelect(key: string) {
|
async function onSelect(key: string) {
|
||||||
|
if (key === 'change-password') {
|
||||||
|
router.push({ name: 'me-change-password' }).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (key === 'logout') {
|
if (key === 'logout') {
|
||||||
try {
|
try {
|
||||||
await authApi.logout()
|
await authApi.logout()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// - accumulate events into a ref array
|
// - accumulate events into a ref array
|
||||||
// - expose sessionStatus + connection-state status
|
// - expose sessionStatus + connection-state status
|
||||||
import { ref, onScopeDispose, type Ref } from 'vue'
|
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'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error'
|
export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error'
|
||||||
@@ -15,12 +15,26 @@ export interface UseAcpStreamReturn {
|
|||||||
status: Ref<StreamStatus>
|
status: Ref<StreamStatus>
|
||||||
sessionStatus: Ref<SessionStatus>
|
sessionStatus: Ref<SessionStatus>
|
||||||
events: Ref<AcpEvent[]>
|
events: Ref<AcpEvent[]>
|
||||||
|
pendingPermissions: Ref<PermissionRequest[]>
|
||||||
prompt: (text: string, agentSessionID: string) => void
|
prompt: (text: string, agentSessionID: string) => void
|
||||||
cancel: (agentSessionID: string) => void
|
cancel: (agentSessionID: string) => void
|
||||||
reconnect: () => void
|
reconnect: () => void
|
||||||
close: () => 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 {
|
interface ServerStateEvent {
|
||||||
kind: 'state'
|
kind: 'state'
|
||||||
status: SessionStatus
|
status: SessionStatus
|
||||||
@@ -34,8 +48,18 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
|
|||||||
const status = ref<StreamStatus>('idle')
|
const status = ref<StreamStatus>('idle')
|
||||||
const sessionStatus = ref<SessionStatus>('starting')
|
const sessionStatus = ref<SessionStatus>('starting')
|
||||||
const events = ref<AcpEvent[]>([])
|
const events = ref<AcpEvent[]>([])
|
||||||
|
const pendingPermissions = ref<PermissionRequest[]>([])
|
||||||
const lastEventID = ref(0)
|
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 ws: WebSocket | null = null
|
||||||
let retried = 0
|
let retried = 0
|
||||||
let manualClose = false
|
let manualClose = false
|
||||||
@@ -53,6 +77,13 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
|
|||||||
sock.onopen = () => {
|
sock.onopen = () => {
|
||||||
status.value = 'streaming'
|
status.value = 'streaming'
|
||||||
retried = 0
|
retried = 0
|
||||||
|
// 控制帧(permission_*)不落 acp_events,重连不补发;连上后主动拉取当前待审。
|
||||||
|
acpApi.sessions
|
||||||
|
.permissions(sessionId)
|
||||||
|
.then((list) => {
|
||||||
|
for (const p of list) upsertPermission(p)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
sock.onmessage = (ev: MessageEvent) => {
|
sock.onmessage = (ev: MessageEvent) => {
|
||||||
@@ -65,6 +96,28 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
|
|||||||
if (!parsed || typeof parsed !== 'object') return
|
if (!parsed || typeof parsed !== 'object') return
|
||||||
const obj = parsed as Record<string, unknown>
|
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
|
// Initial state event from server
|
||||||
if (obj.kind === 'state') {
|
if (obj.kind === 'state') {
|
||||||
const s = (parsed as ServerStateEvent).status
|
const s = (parsed as ServerStateEvent).status
|
||||||
@@ -156,5 +209,5 @@ export function useAcpStream(sessionId: string): UseAcpStreamReturn {
|
|||||||
connect()
|
connect()
|
||||||
onScopeDispose(close)
|
onScopeDispose(close)
|
||||||
|
|
||||||
return { status, sessionStatus, events, prompt, cancel, reconnect, close }
|
return { status, sessionStatus, events, pendingPermissions, prompt, cancel, reconnect, close }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/admin/UsageView.vue'),
|
component: () => import('@/views/admin/UsageView.vue'),
|
||||||
meta: { requiresAuth: true },
|
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',
|
path: '/admin/llm-endpoints',
|
||||||
name: 'admin-llm-endpoints',
|
name: 'admin-llm-endpoints',
|
||||||
|
|||||||
@@ -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>
|
||||||
+155
-5
@@ -1,16 +1,166 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div class="space-y-4">
|
<div class="space-y-6">
|
||||||
<h2 class="text-xl font-semibold">
|
<div class="flex items-center justify-between">
|
||||||
欢迎,{{ auth.user?.display_name }}
|
<h2 class="text-xl font-semibold">
|
||||||
</h2>
|
欢迎,{{ auth.user?.display_name }}
|
||||||
<p>这是占位 dashboard。后续 plan 会接入项目/工作区/Issue。</p>
|
</h2>
|
||||||
|
<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>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 AppShell from '@/layouts/AppShell.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { projectsApi, type Project, type Issue } from '@/api/projects'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
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>
|
</script>
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { ref, shallowRef, computed, onMounted, onUnmounted, watch, nextTick } fr
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAcpStore } from '@/stores/acp'
|
import { useAcpStore } from '@/stores/acp'
|
||||||
import { useAcpStream, type UseAcpStreamReturn } from '@/composables/useAcpStream'
|
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 SessionStatusBadge from '@/components/acp/SessionStatusBadge.vue'
|
||||||
import AgentEventCard from '@/components/acp/AgentEventCard.vue'
|
import AgentEventCard from '@/components/acp/AgentEventCard.vue'
|
||||||
import PromptInput from '@/components/acp/PromptInput.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 route = useRoute()
|
||||||
const store = useAcpStore()
|
const store = useAcpStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const sessionId = computed(() => route.params.sessionId as string)
|
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 errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
const events = computed<AcpEvent[]>(() => stream.value?.events.value ?? [])
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -86,6 +114,33 @@ async function terminate() {
|
|||||||
const sessionStatus = computed(
|
const sessionStatus = computed(
|
||||||
() => stream.value?.sessionStatus.value ?? store.currentSession?.status ?? 'starting',
|
() => stream.value?.sessionStatus.value ?? store.currentSession?.status ?? 'starting',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ===== 提交代码:复用 workspace 的 CommitDialog =====
|
||||||
|
// session 行只存 is_main_worktree + branch(无 worktree_id),因此:
|
||||||
|
// - main worktree → target=main,targetId=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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -103,6 +158,12 @@ const sessionStatus = computed(
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<SessionStatusBadge :status="sessionStatus" />
|
<SessionStatusBadge :status="sessionStatus" />
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 text-xs rounded border text-blue-600 hover:bg-blue-50"
|
||||||
|
@click="openCommit"
|
||||||
|
>
|
||||||
|
提交代码
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="sessionStatus === 'starting' || sessionStatus === 'running'"
|
v-if="sessionStatus === 'starting' || sessionStatus === 'running'"
|
||||||
class="px-2 py-1 text-xs rounded border text-red-600 hover:bg-red-50"
|
class="px-2 py-1 text-xs rounded border text-red-600 hover:bg-red-50"
|
||||||
@@ -117,6 +178,14 @@ const sessionStatus = computed(
|
|||||||
{{ errorMsg }}
|
{{ errorMsg }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- 待审批工具调用 -->
|
||||||
|
<PermissionApprovalPanel
|
||||||
|
:requests="pendingPermissions"
|
||||||
|
:can-decide="canDecide"
|
||||||
|
@approve="approvePermission"
|
||||||
|
@deny="denyPermission"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Events stream -->
|
<!-- Events stream -->
|
||||||
<div
|
<div
|
||||||
ref="eventsContainer"
|
ref="eventsContainer"
|
||||||
@@ -144,5 +213,13 @@ const sessionStatus = computed(
|
|||||||
@cancel="onCancel"
|
@cancel="onCancel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CommitDialog
|
||||||
|
v-if="commitTarget"
|
||||||
|
:target="commitTarget.target"
|
||||||
|
:target-id="commitTarget.targetId"
|
||||||
|
@close="commitTarget = null"
|
||||||
|
@committed="commitTarget = null"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const form = ref({
|
|||||||
args: [] as string[],
|
args: [] as string[],
|
||||||
env: {} as Record<string, string>,
|
env: {} as Record<string, string>,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
tool_allowlist: [] as string[],
|
||||||
})
|
})
|
||||||
const existingEnvKeys = ref<string[]>([])
|
const existingEnvKeys = ref<string[]>([])
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ onMounted(async () => {
|
|||||||
args: [...k.args],
|
args: [...k.args],
|
||||||
env: {}, // Edit mode: do not prefill values; user must replace entirely if they want to change.
|
env: {}, // Edit mode: do not prefill values; user must replace entirely if they want to change.
|
||||||
enabled: k.enabled,
|
enabled: k.enabled,
|
||||||
|
tool_allowlist: [...(k.tool_allowlist ?? [])],
|
||||||
}
|
}
|
||||||
existingEnvKeys.value = [...k.env_keys]
|
existingEnvKeys.value = [...k.env_keys]
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,7 @@ async function submit() {
|
|||||||
binary_path: form.value.binary_path,
|
binary_path: form.value.binary_path,
|
||||||
args: form.value.args,
|
args: form.value.args,
|
||||||
enabled: form.value.enabled,
|
enabled: form.value.enabled,
|
||||||
|
tool_allowlist: form.value.tool_allowlist,
|
||||||
}
|
}
|
||||||
// Only send env if user actually entered some values; an empty map would
|
// Only send env if user actually entered some values; an empty map would
|
||||||
// wipe existing env on the backend (per service patch semantics).
|
// wipe existing env on the backend (per service patch semantics).
|
||||||
@@ -63,6 +66,7 @@ async function submit() {
|
|||||||
args: form.value.args,
|
args: form.value.args,
|
||||||
env: form.value.env,
|
env: form.value.env,
|
||||||
enabled: form.value.enabled,
|
enabled: form.value.enabled,
|
||||||
|
tool_allowlist: form.value.tool_allowlist,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
router.push('/admin/acp/agent-kinds')
|
router.push('/admin/acp/agent-kinds')
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user