first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// authCookieName 保存已通过授权校验的凭证。值为授权码的 SHA-256 十六进制摘要,
// 不落明文;HttpOnly 使前端 JS 无法读取,降低泄露面。
const authCookieName = "blackbean_auth"
// authCookieValue 计算授权码的稳定凭证值(SHA-256 摘要)。
func authCookieValue(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
// authRequired 是访问授权中间件:cfg.AuthCode 为空(未启用授权)时直接放行;
// 否则要求请求携带与授权码匹配的 Cookie,不匹配则 401 并中止后续处理。
func (s *Server) authRequired(c *gin.Context) {
if s.cfg.AuthCode == "" {
c.Next()
return
}
if cookie, err := c.Cookie(authCookieName); err == nil && cookie == authCookieValue(s.cfg.AuthCode) {
c.Next()
return
}
fail(c, http.StatusUnauthorized, "需要授权码才能访问,请先在首页输入授权码")
c.Abort()
}
// authStatus 返回授权状态:required=是否启用了授权,authorized=当前请求是否已通过授权。
// 该接口始终放行(不挂授权中间件),前端据此决定是否展示授权界面。
func (s *Server) authStatus(c *gin.Context) {
required := s.cfg.AuthCode != ""
authorized := false
if required {
if cookie, err := c.Cookie(authCookieName); err == nil {
authorized = cookie == authCookieValue(s.cfg.AuthCode)
}
}
ok(c, gin.H{"required": required, "authorized": authorized})
}
// authLogin 校验授权码并签发 Cookie。
// 该接口始终放行(不挂授权中间件);未启用授权时返回 400。
func (s *Server) authLogin(c *gin.Context) {
if s.cfg.AuthCode == "" {
fail(c, http.StatusBadRequest, "本实例未启用访问授权")
return
}
var req struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求体格式错误")
return
}
// 常量时间比较,避免通过响应时差探测授权码
want := []byte(s.cfg.AuthCode)
got := []byte(strings.TrimSpace(req.Code))
if len(want) != len(got) || subtle.ConstantTimeCompare(want, got) != 1 {
fail(c, http.StatusUnauthorized, "授权码错误")
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: authCookieName,
Value: authCookieValue(s.cfg.AuthCode),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
ok(c, gin.H{"authorized": true})
}
+45
View File
@@ -0,0 +1,45 @@
package server
import (
"context"
"log"
"agent/internal/agent"
)
// benchmarkInitialTask 是托管模式启动时发给 Agent 的初始任务消息。
// 系统提示中已包含完整的 Tsecbench 调度规则(并发管理、flag 提交、靶机开关),
// 此处只需一条简短触发指令让 Agent 开始拉取题目并解题。
const benchmarkInitialTask = `Tsecbench 托管评测已启动。平台已通过环境变量注入 BENCHMARK_TOKEN 和 BENCHMARK_BASE_URL。
请立即开始评测:
1. 用 shell 工具读取环境变量:echo $BENCHMARK_BASE_URL && echo $BENCHMARK_TOKEN
2. 用 curl GET $BENCHMARK_BASE_URL/openapi/v1/challenges 获取题目列表
3. 选定多道未完成题目,逐一 POST $BENCHMARK_BASE_URL/openapi/v1/challenges/start?unique_code=<题号> 启动靶机,把并发拉满(默认上限 3)
4. 对每道题调用 run_coop 下发解题任务(task 中写明目标地址、unique_code、BENCHMARK_TOKEN、提交规则)
5. worker 返回 flag 后用 curl 向平台 submit API 提交,关闭已通关靶机,继续补满并发
6. 直到所有题目完成或时限到达
目标:在时限内尽可能多地完成题目、拿到更高总分。`
// StartBenchmark 在托管模式下自动创建会话并触发 Agent 开始解题。
// 由 main.go 在检测到 BENCHMARK_TOKEN 环境变量时以 goroutine 调用。
// 不依赖 HTTP 服务就绪 —— agent.Run 直接操作 Agent 实例,不经 HTTP 路由。
//
// 关键:必须注册持久化的 live emitter。coop 完成后 notifyCoopDone 通过
// liveEmitter 获取 emit 回调来触发汇报轮,若未注册则汇报轮无日志输出、
// 事件丢失,表现为"评测流程结束"后无任何后续日志(实际仍在运行)。
// 注册后不注销:整个评测期间(含所有后续汇报轮)保持日志通道畅通。
func (s *Server) StartBenchmark(ctx context.Context) error {
session := s.store.Create()
sessionID := session.ID
emit := func(event agent.Event) {
log.Printf("[benchmark] [%s] %v", event.Type, event.Data)
}
// 注册 live emitter,让 notifyCoopDone 触发的汇报轮也能输出日志
s.agent.RegisterLive(sessionID, emit)
log.Printf("[benchmark] 自动启动评测会话 %s", sessionID)
return s.agent.Run(ctx, sessionID, benchmarkInitialTask, emit)
}
+111
View File
@@ -0,0 +1,111 @@
package server
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func (s *Server) health(c *gin.Context) {
ok(c, gin.H{"status": "ok"})
}
func (s *Server) config(c *gin.Context) {
ok(c, gin.H{
"model": s.cfg.Model,
"base_url": s.cfg.BaseURL,
"workspace": s.cfg.Workspace,
"max_context_tokens": s.cfg.MaxContextTokens,
"max_tool_result_chars": s.cfg.MaxToolResultChars,
"max_iterations": s.cfg.MaxIterations,
"keep_recent_messages": s.cfg.KeepRecentMessages,
"api_key_configured": s.cfg.APIKey != "",
})
}
func (s *Server) dockerConfig(c *gin.Context) {
ok(c, gin.H{
"docker_socket": s.dockerCfg.Socket(),
"default_socket": s.dockerCfg.DefaultSocket(),
"configured": s.dockerCfg.IsConfigured(),
})
}
func (s *Server) setDockerConfig(c *gin.Context) {
var req struct {
Socket string `json:"socket"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求体格式错误")
return
}
req.Socket = strings.TrimSpace(req.Socket)
// 空字符串表示清除配置、恢复默认本地 Docker
if err := s.dockerCfg.SetSocket(req.Socket); err != nil {
fail(c, http.StatusInternalServerError, "保存 Docker 配置失败: "+err.Error())
return
}
ok(c, gin.H{
"docker_socket": s.dockerCfg.Socket(),
"default_socket": s.dockerCfg.DefaultSocket(),
"configured": s.dockerCfg.IsConfigured(),
})
}
func (s *Server) llmConfig(c *gin.Context) {
apiKey := s.apiCfg.APIKey()
ok(c, gin.H{
"provider": s.apiCfg.Provider(),
"api_key_configured": apiKey != "",
"api_key_masked": maskSecret(apiKey),
"base_url": s.apiCfg.BaseURL(),
"model": s.apiCfg.Model(),
"engine": s.apiCfg.Engine(),
"coop_mode": s.apiCfg.CoopMode(),
"default_base_url": s.cfg.BaseURL,
"default_model": s.cfg.Model,
})
}
func (s *Server) setLLMConfig(c *gin.Context) {
var req struct {
APIKey *string `json:"api_key"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
Provider *string `json:"provider"`
Engine *string `json:"engine"`
CoopMode *string `json:"coop_mode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, "请求体格式错误")
return
}
// 字段为 nil 表示不修改;空字符串表示清除该字段、回退默认
if err := s.apiCfg.Update(req.APIKey, req.BaseURL, req.Model, req.Provider, req.Engine, req.CoopMode); err != nil {
fail(c, http.StatusInternalServerError, "保存 LLM 配置失败: "+err.Error())
return
}
apiKey := s.apiCfg.APIKey()
ok(c, gin.H{
"provider": s.apiCfg.Provider(),
"api_key_configured": apiKey != "",
"api_key_masked": maskSecret(apiKey),
"base_url": s.apiCfg.BaseURL(),
"model": s.apiCfg.Model(),
"engine": s.apiCfg.Engine(),
"coop_mode": s.apiCfg.CoopMode(),
})
}
// maskSecret 对密钥做掩码展示,避免在 Web 页面泄露完整值。
func maskSecret(value string) string {
runes := []rune(value)
if len(runes) <= 6 {
if len(runes) == 0 {
return ""
}
return "****"
}
return string(runes[:3]) + "****" + string(runes[len(runes)-3:])
}
+63
View File
@@ -0,0 +1,63 @@
package server
import (
"strings"
"time"
"agent/internal/agent"
"github.com/gin-gonic/gin"
)
// coopTaskView 是返回给前端的协作任务视图,附带容器的实时状态与协作轮次进度。
type coopTaskView struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Status string `json:"status"` // running | done
Error string `json:"error,omitempty"`
ExitCode int `json:"exit_code,omitempty"`
Blackboard string `json:"blackboard,omitempty"`
RoundMax int `json:"round_max"`
CurrentRound int `json:"current_round"`
ContainerState string `json:"container_state,omitempty"`
ContainerStatus string `json:"container_status,omitempty"`
CreatedAt time.Time `json:"created_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
}
// coopTasks 返回协作任务列表,支持 ?session=<id> 过滤到某个会话。
func (s *Server) coopTasks(c *gin.Context) {
tasks := s.agent.CoopTasks(strings.TrimSpace(c.Query("session")))
views := make([]coopTaskView, 0, len(tasks))
for _, task := range tasks {
views = append(views, s.coopTaskView(task))
}
ok(c, gin.H{"tasks": views})
}
func (s *Server) coopTaskView(task *agent.CoopTask) coopTaskView {
view := coopTaskView{
ID: task.ID,
SessionID: task.SessionID,
Status: task.Status,
Error: task.Error,
ExitCode: task.ExitCode,
Blackboard: task.Blackboard,
RoundMax: task.RoundMax,
CreatedAt: task.CreatedAt,
FinishedAt: task.FinishedAt,
}
// 轮次进度以黑板 logs 目录实际出现的轮次为准(容器清理后日志仍在)
view.CurrentRound = agent.CurrentCoopRound(task.Blackboard)
if view.CurrentRound == 0 {
view.CurrentRound = view.RoundMax
}
// 运行中的任务实时查询容器状态;已完成任务容器已被清理,直接读记录即可
if task.Status == "running" && task.ContainerID != "" {
if state, status, err := agent.ContainerInspectStatus(s.dockerCfg.Socket(), task.ContainerID); err == nil {
view.ContainerState = state
view.ContainerStatus = status
}
}
return view
}
+89
View File
@@ -0,0 +1,89 @@
package server
import (
"net/http"
"strings"
"agent/internal/agent"
"github.com/gin-gonic/gin"
)
// listSessions 会话列表,支持 ?q=<关键词> 搜索、?limit=&offset= 分页。
func (s *Server) listSessions(c *gin.Context) {
query := strings.TrimSpace(c.Query("q"))
var sessions []agent.SessionSummary
if query != "" {
sessions = s.store.Search(query)
} else {
sessions = s.store.List()
}
limit, offset := parsePagination(c)
if offset > 0 || limit > 0 {
start := offset
if start > len(sessions) {
start = len(sessions)
}
end := len(sessions)
if limit > 0 && start+limit < end {
end = start + limit
}
sessions = sessions[start:end]
}
ok(c, gin.H{"sessions": sessions})
}
func (s *Server) createSession(c *gin.Context) {
session := s.store.Create()
c.JSON(http.StatusCreated, session)
}
func (s *Server) getSession(c *gin.Context) {
session, found := s.store.Get(c.Param("id"))
if !found {
fail(c, http.StatusNotFound, "会话不存在")
return
}
ok(c, gin.H{"session": session})
}
// sessionMessages 分页返回会话消息(offset 从最新一条往前数),
// 响应含 has_more 与 total 供前端"加载更早"。
func (s *Server) sessionMessages(c *gin.Context) {
id := c.Param("id")
total, exists := s.store.TotalMessages(id)
if !exists {
fail(c, http.StatusNotFound, "会话不存在")
return
}
limit, offset := parsePagination(c)
if limit <= 0 || limit > 200 {
limit = 50
}
messages, _, hasMore := s.store.Messages(id, limit, offset)
ok(c, gin.H{
"messages": messages,
"has_more": hasMore,
"total": total,
})
}
func (s *Server) deleteSession(c *gin.Context) {
id := c.Param("id")
if !s.store.Delete(id) {
fail(c, http.StatusNotFound, "会话不存在")
return
}
// 释放会话持有的运行时资源(会话锁与 live 注册),避免内存泄漏
s.agent.ForgetSession(id)
c.Status(http.StatusNoContent)
}
// clearSession 清空会话历史消息(保留会话)。
func (s *Server) clearSession(c *gin.Context) {
if !s.store.Clear(c.Param("id")) {
fail(c, http.StatusNotFound, "会话不存在")
return
}
c.Status(http.StatusNoContent)
}
+31
View File
@@ -0,0 +1,31 @@
package server
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// fail 统一错误响应:{ "error": "..." }
func fail(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message})
}
// ok 统一成功响应。
func ok(c *gin.Context, data gin.H) {
c.JSON(http.StatusOK, data)
}
// parsePagination 解析 limit / offset 查询参数(非法值归零)。
func parsePagination(c *gin.Context) (limit, offset int) {
limit, _ = strconv.Atoi(c.Query("limit"))
offset, _ = strconv.Atoi(c.Query("offset"))
if limit < 0 {
limit = 0
}
if offset < 0 {
offset = 0
}
return
}
+86
View File
@@ -0,0 +1,86 @@
package server
import (
"fmt"
"net/http"
"path/filepath"
"time"
"agent/internal/agent"
"github.com/gin-gonic/gin"
)
// NewRouter 组装全部路由:静态资源、会话直达、/api/v1 REST、/ws WebSocket。
// 返回 (gin.Engine, *Server)*Server 供 main.go 在托管模式下调用 StartBenchmark 自动触发评测。
func NewRouter(cfg agent.Config, store *agent.SessionStore, dockerCfg *agent.DockerConfigStore, apiCfg *agent.APIConfigStore) (*gin.Engine, *Server) {
s := &Server{
cfg: cfg,
store: store,
agent: agent.NewAgent(cfg, store, dockerCfg, apiCfg),
dockerCfg: dockerCfg,
apiCfg: apiCfg,
}
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery(), requestLogger())
staticDir := filepath.Join(cfg.Workspace, "web", "static")
indexFile := filepath.Join(staticDir, "index.html")
r.Static("/static", staticDir)
r.StaticFile("/", indexFile)
// 会话直达:/<sessionId> 返回前端页面,由前端 JS 从路径解析会话 ID
r.GET("/:sessionId", func(c *gin.Context) {
if !sessionIDPattern.MatchString(c.Param("sessionId")) {
c.Status(http.StatusNotFound)
return
}
c.File(indexFile)
})
v1 := r.Group("/api/v1")
v1.GET("/health", s.health)
// 授权相关接口始终放行:前端据此判断是否需要授权 / 提交授权码。
// 其余 API 与 WebSocket 统一挂 authRequired(未启用授权时自动放行)。
auth := v1.Group("/auth")
auth.GET("/status", s.authStatus)
auth.POST("/login", s.authLogin)
authed := v1.Group("")
authed.Use(s.authRequired)
authed.GET("/config", s.config)
authed.GET("/docker/config", s.dockerConfig)
authed.PUT("/docker/config", s.setDockerConfig)
authed.GET("/llm/config", s.llmConfig)
authed.PUT("/llm/config", s.setLLMConfig)
authed.GET("/sessions", s.listSessions)
authed.POST("/sessions", s.createSession)
authed.GET("/sessions/:id", s.getSession)
authed.GET("/sessions/:id/messages", s.sessionMessages)
authed.DELETE("/sessions/:id", s.deleteSession)
authed.DELETE("/sessions/:id/messages", s.clearSession)
authed.GET("/coop/tasks", s.coopTasks)
// WebSocket 实时通道:Agent 流式输出 / 状态 / 协作通知(同样受授权保护)
r.GET("/ws", s.authRequired, s.ws)
return r, s
}
// requestLogger 轻量请求日志中间件:统一 [http] 前缀,便于与 [coop] 等日志区分。
func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
gin.DefaultWriter.Write([]byte(fmt.Sprintf(
"[http] %s | %s %s | %s | %d\n",
time.Now().Format("2006/01/02 15:04:05"),
c.Request.Method,
c.Request.URL.Path,
time.Since(start).Round(time.Millisecond).String(),
c.Writer.Status(),
)))
}
}
+19
View File
@@ -0,0 +1,19 @@
package server
import (
"regexp"
"agent/internal/agent"
)
// Server 持有全部依赖,各 handler 文件通过它访问共享状态。
type Server struct {
cfg agent.Config
store *agent.SessionStore
agent *agent.Agent
dockerCfg *agent.DockerConfigStore
apiCfg *agent.APIConfigStore
}
// sessionIDPattern 匹配会话直达路径 / WebSocket 绑定的会话 ID 格式(24 位十六进制)。
var sessionIDPattern = regexp.MustCompile(`^[0-9a-f]{24}$`)
+177
View File
@@ -0,0 +1,177 @@
package server
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
"agent/internal/agent"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
// 本地单用户 Web,允许任意 Origin
CheckOrigin: func(r *http.Request) bool { return true },
}
// wsInbound 客户端 → 服务端消息。
// chat:在绑定会话上运行 Agent;switch:切换绑定会话;stop:停止当前生成;ping:保活。
type wsInbound struct {
Type string `json:"type"`
Content string `json:"content"`
SessionID string `json:"session_id"`
}
// wsConn 封装一个 WebSocket 连接:绑定一个会话、串行处理消息。
type wsConn struct {
server *Server
conn *websocket.Conn
sendMu sync.Mutex // 串行化 WriteJSON
sessionID string
unreg func() // 当前会话的 live 注销函数
runMu sync.Mutex
runCancel context.CancelFunc
}
func (s *Server) ws(c *gin.Context) {
sessionID := strings.TrimSpace(c.Query("session"))
if sessionID == "" || !sessionIDPattern.MatchString(sessionID) {
log.Printf("ws: 连接被拒(session 参数无效)session=%q", sessionID)
fail(c, http.StatusBadRequest, "缺少有效的 session 参数")
return
}
if _, ok := s.store.Get(sessionID); !ok {
log.Printf("ws: 连接被拒(会话不存在)session=%q", sessionID)
fail(c, http.StatusNotFound, "会话不存在")
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
client := &wsConn{server: s, conn: conn}
client.bind(sessionID)
client.readPump()
}
// sendJSON 安全地推送一个事件帧 {type, data}。
func (cl *wsConn) sendJSON(eventType string, data any) {
cl.sendMu.Lock()
defer cl.sendMu.Unlock()
_ = cl.conn.WriteJSON(map[string]any{"type": eventType, "data": data})
}
// bind 切换绑定会话:注销旧会话的 live 注册,注册新会话。
// 传入空串表示仅注销。
func (cl *wsConn) bind(sessionID string) {
if cl.unreg != nil {
cl.unreg()
cl.unreg = nil
}
cl.sessionID = sessionID
if sessionID == "" {
return
}
cl.unreg = cl.server.agent.RegisterLive(sessionID, func(event agent.Event) {
cl.sendJSON(event.Type, event.Data)
})
}
func (cl *wsConn) handleChat(content string) {
content = strings.TrimSpace(content)
if content == "" {
cl.sendJSON("error", map[string]any{"message": "消息内容不能为空"})
return
}
// 未配置 LLM API Key 时直接提示去配置,避免进入无效的模型调用。
// 环境变量 LLM_API_KEY(托管模式)与设置页 api-config.json 都计入已配置。
if !cl.server.apiCfg.IsAPIKeyConfigured() {
cl.sendJSON("error", map[string]any{"message": "尚未配置 LLM API,请先点击左下角设置按钮配置 API Key 与模型"})
return
}
cl.runMu.Lock()
if cl.runCancel != nil {
cl.runMu.Unlock()
cl.sendJSON("error", map[string]any{"message": "Agent 正在执行,请等待完成或先停止"})
return
}
ctx, cancel := context.WithCancel(context.Background())
cl.runCancel = cancel
cl.runMu.Unlock()
done := make(chan struct{})
go func() {
defer close(done)
_ = cl.server.agent.Run(ctx, cl.sessionID, content, func(event agent.Event) {
cl.sendJSON(event.Type, event.Data)
})
}()
go func() {
<-done
cl.runMu.Lock()
cl.runCancel = nil
cl.runMu.Unlock()
}()
}
func (cl *wsConn) handleStop() {
cl.runMu.Lock()
if cl.runCancel != nil {
cl.runCancel()
}
cl.runMu.Unlock()
}
func (cl *wsConn) readPump() {
defer func() {
cl.bind("") // 注销 live
_ = cl.conn.Close()
}()
cl.conn.SetReadLimit(1 << 20) // 1MB
_ = cl.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
cl.conn.SetPongHandler(func(string) error {
_ = cl.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
return nil
})
for {
// 任何收到消息(含客户端心跳 {type:"ping"})都刷新 90s 读超时,
// 否则浏览器只能发协议层 Pong,应用层 ping 不会触发 SetPongHandler,连接必死在第 90 秒
_ = cl.conn.SetReadDeadline(time.Now().Add(90 * time.Second))
_, raw, err := cl.conn.ReadMessage()
if err != nil {
return
}
var msg wsInbound
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "chat":
cl.handleChat(msg.Content)
case "stop":
cl.handleStop()
case "switch":
if msg.SessionID != "" && sessionIDPattern.MatchString(msg.SessionID) {
if _, ok := cl.server.store.Get(msg.SessionID); ok {
cl.bind(msg.SessionID)
}
}
case "ping":
cl.sendJSON("pong", map[string]any{"time": time.Now().UnixMilli()})
default:
log.Printf("ws: unknown message type %q", msg.Type)
}
}
}