You've already forked agentic-coding-workflow
186 lines
5.3 KiB
Go
186 lines
5.3 KiB
Go
// Package terminal 提供一个仅限管理员的网页端交互式终端:在 server 宿主进程
|
|
// (部署时即 runtime 容器)内拉起一个 shell,经 WebSocket 双向桥接 stdin/stdout,
|
|
// 主要用途是运维(例如容器内 npm i -g 安装 ACP 客户端 CLI)。
|
|
//
|
|
// 会话纯内存、不落库:开即用、断即清,server 重启即全部销毁(沿用 run profiles
|
|
// 「运行态不持久化」的取舍)。进程树终止复用 internal/infra/proc,断连时整树清理,
|
|
// 避免 shell 派生的子孙进程(npm→node…)残留为孤儿。
|
|
//
|
|
// 跨平台:Unix 走真 PTY(github.com/creack/pty);Windows 走降级的 stdio 管道
|
|
// 实现(无 PTY、无 resize),仅供本地开发调试,不追求生产可用。
|
|
package terminal
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// SupervisorConfig 是从 config.TerminalConfig 派生的 supervisor 子集。
|
|
type SupervisorConfig struct {
|
|
Shell string // 启动的 shell,如 /bin/bash
|
|
MaxSessions int // 同时存在的会话上限
|
|
KillGrace time.Duration // 终止整组时 SIGTERM→SIGKILL 的宽限
|
|
// EnvWhitelist 是允许从宿主进程透传给 shell 的环境变量名白名单。
|
|
// APP_MASTER_KEY、DB DSN、git 凭据等敏感变量绝不应出现在此名单中。
|
|
EnvWhitelist []string
|
|
}
|
|
|
|
// ErrTooManySessions 在达到 MaxSessions 上限时返回,由 Handler 翻译为 errs code。
|
|
var ErrTooManySessions = errSentinel("terminal: too many active sessions")
|
|
|
|
type errSentinel string
|
|
|
|
func (e errSentinel) Error() string { return string(e) }
|
|
|
|
// Session 是单个终端会话的运行时句柄。pty 持有平台相关的 shell 进程与 IO,
|
|
// done 在进程退出(monitor)时关闭。
|
|
type Session struct {
|
|
ID uuid.UUID
|
|
pty *ptySession
|
|
done chan struct{} // monitor 在 shell 退出后 close
|
|
}
|
|
|
|
// Read 从 shell 读输出(PTY master / 管道读端)。
|
|
func (s *Session) Read(p []byte) (int, error) { return s.pty.rw.Read(p) }
|
|
|
|
// Write 向 shell 写输入(PTY master / stdin 管道)。
|
|
func (s *Session) Write(p []byte) (int, error) { return s.pty.rw.Write(p) }
|
|
|
|
// Resize 调整终端窗口尺寸(Windows 降级实现为 no-op)。
|
|
func (s *Session) Resize(rows, cols uint16) error { return s.pty.resize(rows, cols) }
|
|
|
|
// Done 在 shell 进程退出后被关闭,供调用方感知进程自行结束(如用户输入 exit)。
|
|
func (s *Session) Done() <-chan struct{} { return s.done }
|
|
|
|
// Supervisor 是终端会话总管。被动结构:Open 时起 monitor goroutine,无主循环。
|
|
// 并发骨架照搬 run.RunSupervisor 已被测试验证的不变量(sessions map 写锁仅在
|
|
// Open 插入 / monitor 删除时持有;done 严格 close-once,monitor 唯一持有)。
|
|
type Supervisor struct {
|
|
mu sync.RWMutex
|
|
sessions map[uuid.UUID]*Session
|
|
|
|
cfg SupervisorConfig
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewSupervisor 构造空 Supervisor。
|
|
func NewSupervisor(cfg SupervisorConfig, log *slog.Logger) *Supervisor {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &Supervisor{
|
|
sessions: map[uuid.UUID]*Session{},
|
|
cfg: cfg,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// OpenParams 描述一次终端会话的初始参数。
|
|
type OpenParams struct {
|
|
Rows uint16
|
|
Cols uint16
|
|
}
|
|
|
|
// Open 启动一个 shell 会话并托管。达到 MaxSessions 上限时返回 ErrTooManySessions。
|
|
func (s *Supervisor) Open(p OpenParams) (*Session, error) {
|
|
s.mu.RLock()
|
|
full := len(s.sessions) >= s.cfg.MaxSessions
|
|
s.mu.RUnlock()
|
|
if full {
|
|
return nil, ErrTooManySessions
|
|
}
|
|
|
|
rows, cols := p.Rows, p.Cols
|
|
if rows == 0 {
|
|
rows = defaultRows
|
|
}
|
|
if cols == 0 {
|
|
cols = defaultCols
|
|
}
|
|
|
|
ps, err := startPTY(s.cfg.Shell, buildEnv(s.cfg.EnvWhitelist), rows, cols)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id, err := uuid.NewRandom()
|
|
if err != nil {
|
|
ps.terminate()
|
|
return nil, err
|
|
}
|
|
sess := &Session{ID: id, pty: ps, done: make(chan struct{})}
|
|
|
|
s.mu.Lock()
|
|
s.sessions[id] = sess
|
|
s.mu.Unlock()
|
|
|
|
go s.monitor(sess)
|
|
return sess, nil
|
|
}
|
|
|
|
// monitor 阻塞等待 shell 退出后回收:关闭 done、移除映射、释放平台句柄。
|
|
func (s *Supervisor) monitor(sess *Session) {
|
|
_ = sess.pty.cmd.Wait() // reap,避免僵尸进程
|
|
close(sess.done)
|
|
s.mu.Lock()
|
|
delete(s.sessions, sess.ID)
|
|
s.mu.Unlock()
|
|
sess.pty.group.Close()
|
|
}
|
|
|
|
// Close 终止指定会话(整树)。幂等:会话已不存在时直接返回。阻塞直到 done 关闭
|
|
// 或宽限耗尽。
|
|
func (s *Supervisor) Close(ctx context.Context, id uuid.UUID) {
|
|
s.mu.RLock()
|
|
sess, ok := s.sessions[id]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
sess.pty.group.TerminateGroup(sess.done, s.cfg.KillGrace)
|
|
select {
|
|
case <-sess.done:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
// Count 返回当前活跃会话数。
|
|
func (s *Supervisor) Count() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return len(s.sessions)
|
|
}
|
|
|
|
// CloseAll 在服务关停时终止全部会话。
|
|
func (s *Supervisor) CloseAll(ctx context.Context) {
|
|
s.mu.RLock()
|
|
ids := make([]uuid.UUID, 0, len(s.sessions))
|
|
for id := range s.sessions {
|
|
ids = append(ids, id)
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
var wg sync.WaitGroup
|
|
for _, id := range ids {
|
|
wg.Add(1)
|
|
go func(sid uuid.UUID) {
|
|
defer wg.Done()
|
|
s.Close(ctx, sid)
|
|
}(id)
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|