package terminal import ( "context" "encoding/json" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" ws "github.com/coder/websocket" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" ) // readBufSize 是从 shell 读输出的缓冲块大小。 const readBufSize = 32 * 1024 // wsReadLimit 放宽单条消息上限以容纳较大的粘贴输入(默认 32KB 对粘贴偏小)。 const wsReadLimit = 1 << 20 // AdminLookup 解析用户是否为管理员(与 run/acp 模块同一模式)。 type AdminLookup interface { IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error) } // WSConfig 控制 WebSocket 心跳。 type WSConfig struct { PingInterval time.Duration PongTimeout time.Duration } // Handler 暴露仅限管理员的终端 WS 端点。 type Handler struct { sup *Supervisor resolver middleware.SessionResolver adminLookup AdminLookup audit audit.Recorder shell string cfg WSConfig } // NewHandler 构造 terminal.Handler。 func NewHandler(sup *Supervisor, resolver middleware.SessionResolver, al AdminLookup, rec audit.Recorder, shell string, cfg WSConfig) *Handler { return &Handler{sup: sup, resolver: resolver, adminLookup: al, audit: rec, shell: shell, cfg: cfg} } // Mount 注册终端路由(仅 admin)。WS 鉴权经 middleware.Auth(浏览器原生 WebSocket // 无法带自定义 header,token 走 ?token= 查询参数,沿用 run/acp 的 WS 约定)。 func (h *Handler) Mount(r chi.Router) { r.Route("/api/v1/admin/terminal", func(r chi.Router) { r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) r.Get("/stream", h.stream) }) } // requireAdmin 从请求解析用户并要求其为管理员,否则返回 forbidden。 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") } admin, err := h.adminLookup.IsAdmin(r.Context(), uid) if err != nil { return uuid.Nil, err } if !admin { return uuid.Nil, errs.New(errs.CodeForbidden, "admin only") } return uid, nil } // resizeMsg 是客户端经文本帧上报的窗口尺寸控制消息。 type resizeMsg struct { Type string `json:"type"` Rows uint16 `json:"rows"` Cols uint16 `json:"cols"` } // stream 升级为 WebSocket,开一个 shell 会话并双向桥接: // - server→client:shell 输出作为二进制帧推送 // - client→server:二进制帧 = stdin 原始字节;文本帧 = JSON 控制(resize) // // 二进制/文本帧天然区分 stdin 与控制消息,无需额外前缀字节。 func (h *Handler) stream(w http.ResponseWriter, r *http.Request) { uid, err := h.requireAdmin(r) if err != nil { writeErr(w, r, err) return } rows := uint16(parseUint(r.URL.Query().Get("rows"), uint64(defaultRows))) cols := uint16(parseUint(r.URL.Query().Get("cols"), uint64(defaultCols))) conn, aerr := ws.Accept(w, r, &ws.AcceptOptions{}) if aerr != nil { return } defer conn.Close(ws.StatusInternalError, "internal") conn.SetReadLimit(wsReadLimit) sess, oerr := h.sup.Open(OpenParams{Rows: rows, Cols: cols}) if oerr != nil { conn.Close(ws.StatusTryAgainLater, "cannot open terminal session") return } h.record(r.Context(), r.RemoteAddr, uid, "admin.terminal.opened", sess.ID) defer func() { // 连接已断时 r.Context() 多半已取消,审计与终止改用独立 context。 closeCtx, cancel := context.WithTimeout(context.Background(), h.cfg.PongTimeout) defer cancel() h.sup.Close(closeCtx, sess.ID) h.record(closeCtx, r.RemoteAddr, uid, "admin.terminal.closed", sess.ID) }() ctx, cancel := context.WithCancel(r.Context()) defer cancel() // shell 进程自行退出(如用户输入 exit)→ 取消,触发主循环退出。 go func() { select { case <-ctx.Done(): case <-sess.Done(): cancel() } }() // read pump:shell 输出 → WS 二进制帧。 go func() { buf := make([]byte, readBufSize) for { n, rerr := sess.Read(buf) if n > 0 { if werr := conn.Write(ctx, ws.MessageBinary, buf[:n]); werr != nil { cancel() return } } if rerr != nil { cancel() return } } }() // 心跳。 pingTicker := time.NewTicker(h.cfg.PingInterval) defer pingTicker.Stop() go func() { for { select { case <-ctx.Done(): return case <-pingTicker.C: pingCtx, pcancel := context.WithTimeout(ctx, h.cfg.PongTimeout) perr := conn.Ping(pingCtx) pcancel() if perr != nil { cancel() return } } } }() // write pump(主循环):WS → shell stdin / resize。 for { typ, data, rerr := conn.Read(ctx) if rerr != nil { return } switch typ { case ws.MessageBinary: if _, werr := sess.Write(data); werr != nil { return } case ws.MessageText: var m resizeMsg if json.Unmarshal(data, &m) == nil && m.Type == "resize" { _ = sess.Resize(m.Rows, m.Cols) } } } } // record 写一条审计日志,吞错(审计失败不应影响终端会话)。 func (h *Handler) record(ctx context.Context, remoteAddr string, uid uuid.UUID, action string, sessID uuid.UUID) { if h.audit == nil { return } u := uid _ = h.audit.Record(ctx, audit.Entry{ UserID: &u, Action: action, TargetType: "terminal_session", TargetID: sessID.String(), IP: remoteAddr, Metadata: map[string]any{"shell": h.shell}, }) } func parseUint(s string, def uint64) uint64 { if s == "" { return def } n, err := strconv.ParseUint(s, 10, 16) if err != nil { return def } return n } func writeErr(w http.ResponseWriter, r *http.Request, err error) { httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) }