Selaa lähdekoodia

fix:改用用户权限运行kiro-cli

Jerry Liang 2 kuukautta sitten
vanhempi
commit
3581a21f25

+ 4 - 0
service/.env

@@ -50,3 +50,7 @@ PLANE_WEB_PROJECT_ID="55efdd5f-214d-43b3-bed5-a6b71bd04673"
 PLANE_WEB_LABEL_BUG="415ede7f-a698-426d-b385-ab875a5725dd"
 PLANE_WEB_LABEL_FEATURE="11c51a92-6a76-40d0-aa6c-82f6b52b27eb"
 PLANE_WEB_LABEL_FEEDBACK="f85c7c59-bb11-4035-bce1-3bc417655b70"
+
+# KIRO CONFIG
+KIRO_RUN_AS_USER=jerry-remote
+KIRO_HOME=/home/jerry-remote

+ 15 - 0
service/config/config.go

@@ -56,6 +56,16 @@ type PlaneWebApiConfig struct {
 	LabelFeedbackId string
 }
 
+type KiroConfig struct {
+	// RunAsUser 指定拉起 kiro-cli 子进程时降权到的系统用户名。
+	// 为空时表示不降权,沿用后台服务自身的运行用户。
+	RunAsUser string
+	// Home 指定拉起 kiro-cli 子进程时使用的 HOME 目录(即 $HOME 环境变量)。
+	// kiro-cli 依赖 HOME 定位其配置与凭证。为空时回退到降权用户的家目录,
+	// 若也无法推导则使用默认值。
+	Home string
+}
+
 type Config struct {
 	App         AppConfig
 	FwhApi      FwhApiConfig
@@ -64,6 +74,7 @@ type Config struct {
 	FwhTestMqtt FwhMqttConfig
 	PlaneApi    PlaneApiConfig
 	PlaneWebApi PlaneWebApiConfig
+	Kiro        KiroConfig
 }
 
 // 需先执行LoadConfig后才能读取ENV
@@ -187,5 +198,9 @@ func LoadConfig() (*Config, error) {
 			LabelFeatureId:  v.GetString("PLANE_WEB_LABEL_FEATURE"),
 			LabelFeedbackId: v.GetString("PLANE_WEB_LABEL_FEEDBACK"),
 		},
+		Kiro: KiroConfig{
+			RunAsUser: v.GetString("KIRO_RUN_AS_USER"),
+			Home:      v.GetString("KIRO_HOME"),
+		},
 	}, nil
 }

+ 26 - 2
service/controllers/aiChatControllers.go

@@ -6,13 +6,16 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"os/user"
 	"path/filepath"
 	"sort"
+	"strings"
 	"time"
 
 	"github.com/gofiber/fiber/v2"
 	"github.com/gofiber/fiber/v2/utils"
 	"github.com/minio/minio-go/v7"
+	"go.uber.org/zap"
 
 	"fwh-toolbox-server/middleware"
 	"fwh-toolbox-server/services"
@@ -23,8 +26,29 @@ import (
 var acpManager *services.ACPManager
 
 // InitAIChat 初始化 AI Chat 模块
-func InitAIChat(serviceDir string) {
-	acpManager = services.NewACPManager(serviceDir, []string{"HOME=/home/jerry-remote"})
+// runAsUser 指定拉起 kiro-cli 子进程时降权到的系统用户名,为空则不降权。
+// home 指定 kiro-cli 子进程的 HOME 目录;为空时回退到降权用户的家目录,
+// 若仍无法推导则使用默认值。
+func InitAIChat(serviceDir string, runAsUser string, home string) {
+	home = strings.TrimSpace(home)
+
+	// 未显式配置 HOME 时的回退:优先推导降权用户的家目录,否则用默认值。
+	if home == "" {
+		home = "/home/jerry-remote"
+		if u := strings.TrimSpace(runAsUser); u != "" {
+			if usr, err := user.Lookup(u); err == nil {
+				home = usr.HomeDir
+			} else {
+				zap.L().Warn("[AIChat] 查找降权用户失败,沿用默认 HOME",
+					zap.String("user", u), zap.String("home", home), zap.Error(err))
+			}
+		}
+	}
+
+	zap.L().Info("[AIChat] 初始化 ACP",
+		zap.String("runAsUser", runAsUser), zap.String("home", home))
+
+	acpManager = services.NewACPManager(serviceDir, []string{"HOME=" + home}, runAsUser)
 }
 
 // AiChat 发送消息(SSE 流式响应)

+ 1 - 1
service/main.go

@@ -96,7 +96,7 @@ func main() {
 	routes.Setup(app)
 
 	// 初始化 AI Chat (ACP Client)
-	controllers.InitAIChat(".")
+	controllers.InitAIChat(".", cfg.Kiro.RunAsUser, cfg.Kiro.Home)
 
 	// 启动第一个监听
 	go func() {

+ 14 - 1
service/services/aiChatServices.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"fwh-toolbox-server/repositories"
 	"fwh-toolbox-server/utils/acp"
+	"os"
 	"os/exec"
 	"sync"
 	"time"
@@ -57,7 +58,8 @@ type ACPManager struct {
 }
 
 // NewACPManager 创建 ACP 管理器
-func NewACPManager(serviceDir string, env []string) *ACPManager {
+// runAsUser 指定拉起 kiro-cli 子进程时降权到的系统用户名,为空则不降权。
+func NewACPManager(serviceDir string, env []string, runAsUser string) *ACPManager {
 	return &ACPManager{
 		sessions:   make(map[string]*ACPSession),
 		serviceDir: serviceDir,
@@ -66,6 +68,7 @@ func NewACPManager(serviceDir string, env []string) *ACPManager {
 			Args:       []string{"acp", "--agent", "fwh-toolbox-agent"},
 			WorkingDir: serviceDir,
 			Env:        env,
+			RunAsUser:  runAsUser,
 		},
 	}
 }
@@ -587,6 +590,16 @@ func (m *ACPManager) getSessionListFromRedisHistory(userId string, sessionId str
 func (m *ACPManager) deleteKiroSession(sessionId string) {
 	cmd := exec.Command("kiro-cli", "chat", "--delete-session", sessionId)
 	cmd.Dir = m.serviceDir
+	// 复用与 ACP 拉起一致的环境变量(如 HOME),保证操作的是同一份会话数据
+	cmd.Env = append(os.Environ(), m.acpConfig.Env...)
+	// 与拉起子进程一致地降权,避免以 root 身份写入目标用户的会话文件
+	if err := acp.ApplyRunAsUser(cmd, m.acpConfig.RunAsUser); err != nil {
+		zap.L().Warn("[AIChat] 删除Kiro会话降权失败,跳过删除",
+			zap.String("sessionId", sessionId),
+			zap.Error(err),
+		)
+		return
+	}
 	if output, err := cmd.CombinedOutput(); err != nil {
 		zap.L().Warn("[AIChat] 删除Kiro会话失败",
 			zap.String("sessionId", sessionId),

+ 124 - 0
service/utils/acp/acp.go

@@ -9,9 +9,13 @@ import (
 	"io"
 	"os"
 	"os/exec"
+	"os/user"
 	"path/filepath"
+	"strconv"
+	"strings"
 	"sync"
 	"sync/atomic"
+	"syscall"
 
 	"fwh-toolbox-server/utils/filestore"
 
@@ -247,6 +251,121 @@ type ClientConfig struct {
 	Args       []string // 命令参数(如 ["acp"])
 	WorkingDir string   // 工作目录
 	Env        []string // 额外环境变量(如 "HOME=/home/user")
+	RunAsUser  string   // 降权运行的目标系统用户名,为空则不降权
+}
+
+// ApplyRunAsUser 让 cmd 以指定系统用户身份运行(降权)。
+// runAsUser 为空时不做任何处理(沿用当前进程用户)。
+// 该函数会:
+//  1. 解析目标用户的 uid/gid,并设置 cmd.SysProcAttr.Credential;
+//  2. 附带解析该用户的补充组(supplementary groups);
+//  3. 修正 HOME/USER/LOGNAME 环境变量为目标用户的值,避免 kiro-cli
+//     仍读取 root 的家目录导致配置/凭证路径错误。
+//
+// 注意:设置 Credential 需要当前进程具备相应权限(通常为 root)。
+func ApplyRunAsUser(cmd *exec.Cmd, runAsUser string) error {
+	runAsUser = strings.TrimSpace(runAsUser)
+	if runAsUser == "" {
+		return nil
+	}
+
+	u, err := user.Lookup(runAsUser)
+	if err != nil {
+		return fmt.Errorf("查找降权用户 %q 失败: %w", runAsUser, err)
+	}
+
+	uid, err := strconv.ParseUint(u.Uid, 10, 32)
+	if err != nil {
+		return fmt.Errorf("解析用户 %q 的 uid(%s) 失败: %w", runAsUser, u.Uid, err)
+	}
+	gid, err := strconv.ParseUint(u.Gid, 10, 32)
+	if err != nil {
+		return fmt.Errorf("解析用户 %q 的 gid(%s) 失败: %w", runAsUser, u.Gid, err)
+	}
+
+	// 解析补充组(失败不致命,仅记录告警)
+	var groups []uint32
+	if gidStrs, gErr := u.GroupIds(); gErr == nil {
+		for _, gs := range gidStrs {
+			if g, pErr := strconv.ParseUint(gs, 10, 32); pErr == nil {
+				groups = append(groups, uint32(g))
+			}
+		}
+	} else {
+		zap.L().Warn("[ACP] 读取降权用户补充组失败", zap.String("user", runAsUser), zap.Error(gErr))
+	}
+
+	if cmd.SysProcAttr == nil {
+		cmd.SysProcAttr = &syscall.SysProcAttr{}
+	}
+	cmd.SysProcAttr.Credential = &syscall.Credential{
+		Uid:    uint32(uid),
+		Gid:    uint32(gid),
+		Groups: groups,
+	}
+
+	// 修正环境变量,确保子进程使用目标用户的身份。
+	// USER/LOGNAME 强制设为目标用户;HOME 尊重上层(配置)已传入的值,
+	// 仅在未提供时才回退到用户家目录。
+	if cmd.Env == nil {
+		cmd.Env = os.Environ()
+	}
+	cmd.Env = overrideEnv(cmd.Env, map[string]string{
+		"USER":    u.Username,
+		"LOGNAME": u.Username,
+	})
+	cmd.Env = fillEnvIfAbsent(cmd.Env, "HOME", u.HomeDir)
+
+	effectiveHome := u.HomeDir
+	for _, e := range cmd.Env {
+		if strings.HasPrefix(e, "HOME=") {
+			effectiveHome = strings.TrimPrefix(e, "HOME=")
+			break
+		}
+	}
+
+	zap.L().Info("[ACP] 子进程降权运行",
+		zap.String("user", u.Username),
+		zap.String("uid", u.Uid),
+		zap.String("gid", u.Gid),
+		zap.String("home", effectiveHome),
+	)
+	return nil
+}
+
+// fillEnvIfAbsent 仅当 env 中不存在 key 时才追加 key=value,否则原样返回。
+func fillEnvIfAbsent(env []string, key, value string) []string {
+	prefix := key + "="
+	for _, e := range env {
+		if strings.HasPrefix(e, prefix) {
+			return env
+		}
+	}
+	return append(env, prefix+value)
+}
+
+// overrideEnv 用 kv 覆盖 env 中已存在的同名变量,不存在则追加。
+func overrideEnv(env []string, kv map[string]string) []string {
+	out := make([]string, 0, len(env)+len(kv))
+	seen := make(map[string]bool, len(kv))
+	for _, e := range env {
+		key := e
+		if idx := strings.IndexByte(e, '='); idx >= 0 {
+			key = e[:idx]
+		}
+		if v, ok := kv[key]; ok {
+			out = append(out, key+"="+v)
+			seen[key] = true
+		} else {
+			out = append(out, e)
+		}
+	}
+	for k, v := range kv {
+		if !seen[k] {
+			out = append(out, k+"="+v)
+		}
+	}
+	return out
 }
 
 // NewClient 创建并启动 ACP Client
@@ -263,6 +382,11 @@ func NewClient(cfg ClientConfig) (*Client, error) {
 	// 设置环境变量
 	cmd.Env = append(os.Environ(), cfg.Env...)
 
+	// 若配置了降权用户,则以该用户身份拉起子进程
+	if err := ApplyRunAsUser(cmd, cfg.RunAsUser); err != nil {
+		return nil, err
+	}
+
 	stdin, err := cmd.StdinPipe()
 	if err != nil {
 		return nil, fmt.Errorf("创建 stdin pipe 失败: %w", err)