Why: The sandbox hides the host home directory and has no network, so pip install --user cannot supply python-docx, and the service PATH resolved python3 to a linuxbrew path that does not exist inside the sandbox. Evidence: SELF go test of execenv, tools, store, app, server, runmgr, config, and cmd/agent-system on this worktree. CI has not run. Impact: Sessions inherit a read-only base layer. Extra packages are requested through python_env, auto-approved only from the allowlist, and mounted per conversation. Schema 35 adds the request and binding tables. Refs: .task/feature-layered-python-env.md Co-authored-by: Cursor <cursoragent@cursor.com>
350 lines
11 KiB
Go
350 lines
11 KiB
Go
package execenv
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Store 是申请与绑定的持久化。实现在 store 包,避免本包依赖数据库驱动。
|
||
type Store interface {
|
||
InsertExecEnvRequest(Request) error
|
||
GetExecEnvRequest(id string) (Request, error)
|
||
ListExecEnvRequests(conversationID string, limit int) ([]Request, error)
|
||
UpdateExecEnvRequest(id, status, layerID, detail, approver string) error
|
||
GetExecEnvBinding(conversationID string) (Binding, error)
|
||
PutExecEnvBinding(Binding) error
|
||
ListExecEnvBindings() ([]Binding, error)
|
||
InsertExecEnvAudit(owner, action, actor, target, outcome, entry string) error
|
||
}
|
||
|
||
// Request 是一次扩展包申请。
|
||
type Request struct {
|
||
ID string `json:"id"`
|
||
ConversationID string `json:"conversationId"`
|
||
Owner string `json:"owner"`
|
||
Spec string `json:"spec"`
|
||
Reason string `json:"reason"`
|
||
Status string `json:"status"`
|
||
LayerID string `json:"layerId"`
|
||
Detail string `json:"detail"`
|
||
Approver string `json:"approver"`
|
||
CreatedAt string `json:"createdAt"`
|
||
UpdatedAt string `json:"updatedAt"`
|
||
}
|
||
|
||
// Binding 是会话当前挂载的层。Isolated 非空时只挂隔离层,不挂基础层。
|
||
type Binding struct {
|
||
ConversationID string `json:"conversationId"`
|
||
BaseID string `json:"baseId"`
|
||
Extensions []string `json:"extensions"`
|
||
Isolated string `json:"isolated"`
|
||
UpdatedAt string `json:"updatedAt"`
|
||
}
|
||
|
||
// Outcome 是工具直接回给模型的结果。
|
||
type Outcome struct {
|
||
Status string `json:"status"`
|
||
RequestID string `json:"requestId,omitempty"`
|
||
LayerID string `json:"layerId,omitempty"`
|
||
Detail string `json:"detail,omitempty"`
|
||
}
|
||
|
||
// Resolver 在宿主侧解析并下载 wheel。沙箱不实现这个接口。
|
||
type Resolver interface {
|
||
Resolve(ctx context.Context, reqs []string, base Lock) (Result, error)
|
||
Fetch(ctx context.Context, wheelhouse string, pkg LockedPkg) (string, error)
|
||
}
|
||
|
||
// Broker 在宿主侧解析、分级、构建。沙箱不调用它的构建路径。
|
||
type Broker struct {
|
||
Root *Root
|
||
Store Store
|
||
Pip Resolver
|
||
Now func() time.Time
|
||
}
|
||
|
||
func NewBroker(root *Root, st Store, pip Resolver) *Broker {
|
||
return &Broker{Root: root, Store: st, Pip: pip, Now: time.Now}
|
||
}
|
||
|
||
// StatusText 给模型看的紧凑状态。
|
||
func (b *Broker) StatusText(conv string) (string, error) {
|
||
var buf strings.Builder
|
||
baseID, err := b.Root.CurrentBase()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if baseID == "" {
|
||
buf.WriteString("基础层:未建立(需要 agent-system env ensure)\n")
|
||
} else {
|
||
lock, err := b.Root.ReadLock(baseID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
buf.WriteString("基础层:" + baseID + "\n")
|
||
for _, p := range lock.Packages {
|
||
fmt.Fprintf(&buf, "- %s==%s\n", p.Name, p.Version)
|
||
}
|
||
}
|
||
if conv != "" && b.Store != nil {
|
||
bind, err := b.Store.GetExecEnvBinding(conv)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if bind.Isolated != "" {
|
||
fmt.Fprintf(&buf, "本会话隔离层:%s\n", bind.Isolated)
|
||
} else if len(bind.Extensions) > 0 {
|
||
fmt.Fprintf(&buf, "本会话扩展层:%s\n", strings.Join(bind.Extensions, ", "))
|
||
} else {
|
||
buf.WriteString("本会话扩展层:无\n")
|
||
}
|
||
reqs, err := b.Store.ListExecEnvRequests(conv, 10)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for _, r := range reqs {
|
||
if r.Status == "ready" || r.Status == "rejected" || r.Status == "failed" {
|
||
continue
|
||
}
|
||
fmt.Fprintf(&buf, "申请 %s 状态 %s %s\n", r.ID, r.Status, r.Detail)
|
||
}
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// RequestPackages 解析并按分级处理。wheelPath 非空表示用户上传的 wheel,一律待审批。
|
||
func (b *Broker) RequestPackages(ctx context.Context, conv, owner, reason string, packages []string, wheelPath string) (Outcome, error) {
|
||
if b.Store == nil {
|
||
return Outcome{}, errors.New("execenv: store is not configured")
|
||
}
|
||
baseID, err := b.Root.CurrentBase()
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
var base Lock
|
||
if baseID != "" {
|
||
base, err = b.Root.ReadLock(baseID)
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
}
|
||
cat, err := b.Root.LoadCatalog()
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
var res Result
|
||
uploaded := wheelPath != ""
|
||
if uploaded {
|
||
pkg, err := b.stageUpload(wheelPath)
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
res.Packages = []LockedPkg{pkg}
|
||
} else if len(packages) == 0 {
|
||
return Outcome{}, errors.New("execenv: packages or wheel required")
|
||
} else {
|
||
res, err = b.Pip.Resolve(ctx, packages, base)
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
}
|
||
grade, detail := grade(cat, res, uploaded)
|
||
specRaw, _ := json.Marshal(map[string]any{"packages": packages, "wheel": wheelPath, "resolved": res.Packages})
|
||
rec := Request{
|
||
ID: newID("env"), ConversationID: conv, Owner: owner,
|
||
Spec: string(specRaw), Reason: reason, Status: grade, Detail: detail,
|
||
CreatedAt: b.now(), UpdatedAt: b.now(),
|
||
}
|
||
if err := b.Store.InsertExecEnvRequest(rec); err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
_ = b.Store.InsertExecEnvAudit(owner, "execenv.request", owner, rec.ID, grade, detail)
|
||
switch grade {
|
||
case "rejected":
|
||
return Outcome{Status: "rejected", RequestID: rec.ID, Detail: detail}, nil
|
||
case "pending_approval":
|
||
return Outcome{Status: "pending_approval", RequestID: rec.ID, Detail: detail}, nil
|
||
default:
|
||
id, err := b.materialize(ctx, conv, owner, base, res, false)
|
||
if err != nil {
|
||
_ = b.Store.UpdateExecEnvRequest(rec.ID, "failed", "", err.Error(), "")
|
||
return Outcome{Status: "failed", RequestID: rec.ID, Detail: err.Error()}, nil
|
||
}
|
||
_ = b.Store.UpdateExecEnvRequest(rec.ID, "ready", id, "已绑定", "auto")
|
||
return Outcome{Status: "ready", RequestID: rec.ID, LayerID: id, Detail: "已绑定,下一次 run_python 即可导入"}, nil
|
||
}
|
||
}
|
||
|
||
func grade(cat Catalog, res Result, uploaded bool) (status, detail string) {
|
||
if len(res.Denied) > 0 || hasDenied(cat, res) {
|
||
return "rejected", "命中黑名单:" + strings.Join(deniedNames(cat, res), ", ")
|
||
}
|
||
if len(res.SDist) > 0 {
|
||
return "rejected", "只有源码包,拒绝执行构建脚本:" + strings.Join(res.SDist, ", ")
|
||
}
|
||
if len(res.Missing) > 0 {
|
||
return "rejected", "解析失败:" + strings.Join(res.Missing, "; ")
|
||
}
|
||
if len(res.Packages) == 0 && len(res.Override) == 0 {
|
||
return "rejected", "没有需要安装的新包(基础层已满足,或规格为空)"
|
||
}
|
||
var why []string
|
||
if uploaded {
|
||
why = append(why, "用户上传的 wheel")
|
||
}
|
||
if len(res.Override) > 0 {
|
||
why = append(why, strings.Join(res.Override, "; "))
|
||
}
|
||
for _, p := range res.Packages {
|
||
if !cat.allowed(p.Name) {
|
||
why = append(why, p.Name+" 不在白名单")
|
||
}
|
||
if cat.MaxWheelBytes > 0 && p.Size > cat.MaxWheelBytes {
|
||
why = append(why, fmt.Sprintf("%s 体积 %d 超过上限", p.Name, p.Size))
|
||
}
|
||
if p.SHA256 == "" && !uploaded {
|
||
why = append(why, p.Name+" 缺少哈希")
|
||
}
|
||
}
|
||
if len(why) > 0 {
|
||
return "pending_approval", strings.Join(why, ";")
|
||
}
|
||
return "approved", "白名单内自动批准"
|
||
}
|
||
|
||
func hasDenied(cat Catalog, res Result) bool {
|
||
return len(deniedNames(cat, res)) > 0
|
||
}
|
||
|
||
func deniedNames(cat Catalog, res Result) []string {
|
||
var out []string
|
||
for _, p := range res.Packages {
|
||
if cat.denied(p.Name) {
|
||
out = append(out, p.Name)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Decide 处理审批。isolate 仅在申请确实与基础层冲突时建立隔离层。
|
||
func (b *Broker) Decide(ctx context.Context, id, actor string, admin, allow, isolate bool) (Outcome, error) {
|
||
rec, err := b.Store.GetExecEnvRequest(id)
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
if rec.Status != "pending_approval" {
|
||
return Outcome{}, fmt.Errorf("execenv: request %s is %s", id, rec.Status)
|
||
}
|
||
if !admin && actor != rec.Owner {
|
||
return Outcome{}, errors.New("execenv: only the session owner or an admin can decide")
|
||
}
|
||
if !allow {
|
||
if err := b.Store.UpdateExecEnvRequest(id, "rejected", "", "已拒绝", actor); err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
_ = b.Store.InsertExecEnvAudit(rec.Owner, "execenv.decide", actor, id, "rejected", "")
|
||
return Outcome{Status: "rejected", RequestID: id, Detail: "已拒绝"}, nil
|
||
}
|
||
var spec struct {
|
||
Packages []string `json:"packages"`
|
||
Resolved []LockedPkg `json:"resolved"`
|
||
}
|
||
_ = json.Unmarshal([]byte(rec.Spec), &spec)
|
||
baseID, err := b.Root.CurrentBase()
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
var base Lock
|
||
if baseID != "" {
|
||
base, err = b.Root.ReadLock(baseID)
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
}
|
||
var res Result
|
||
if isolate {
|
||
// 隔离层不继承基础层约束,整份闭包单独成层。
|
||
res, err = b.Pip.Resolve(ctx, spec.Packages, Lock{})
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
} else if len(spec.Resolved) > 0 {
|
||
res.Packages = spec.Resolved
|
||
} else if len(spec.Packages) > 0 {
|
||
res, err = b.Pip.Resolve(ctx, spec.Packages, base)
|
||
if err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
}
|
||
if len(res.Override) > 0 && !isolate {
|
||
msg := "与基础层版本冲突,批准时需要选择隔离层:" + strings.Join(res.Override, "; ")
|
||
_ = b.Store.UpdateExecEnvRequest(id, "pending_approval", "", msg, "")
|
||
return Outcome{Status: "pending_approval", RequestID: id, Detail: msg}, nil
|
||
}
|
||
layer, err := b.materialize(ctx, rec.ConversationID, rec.Owner, base, res, isolate)
|
||
if err != nil {
|
||
_ = b.Store.UpdateExecEnvRequest(id, "failed", "", err.Error(), actor)
|
||
return Outcome{Status: "failed", RequestID: id, Detail: err.Error()}, nil
|
||
}
|
||
if err := b.Store.UpdateExecEnvRequest(id, "ready", layer, "已批准并绑定", actor); err != nil {
|
||
return Outcome{}, err
|
||
}
|
||
_ = b.Store.InsertExecEnvAudit(rec.Owner, "execenv.decide", actor, id, "ready", layer)
|
||
return Outcome{Status: "ready", RequestID: id, LayerID: layer, Detail: "已批准并绑定"}, nil
|
||
}
|
||
|
||
func (b *Broker) now() string { return b.Now().UTC().Format(time.RFC3339Nano) }
|
||
|
||
func newID(prefix string) string {
|
||
var buf [8]byte
|
||
_, _ = rand.Read(buf[:])
|
||
return prefix + "_" + hex.EncodeToString(buf[:])
|
||
}
|
||
|
||
func (b *Broker) stageUpload(path string) (LockedPkg, error) {
|
||
if filepath.Ext(path) != ".whl" {
|
||
return LockedPkg{}, errors.New("execenv: upload must be a .whl")
|
||
}
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return LockedPkg{}, err
|
||
}
|
||
defer f.Close()
|
||
sum := sha256New()
|
||
n, err := io.Copy(sum, f)
|
||
if err != nil {
|
||
return LockedPkg{}, err
|
||
}
|
||
sha := sum.Sum()
|
||
dest := filepath.Join(b.Root.Wheelhouse(), sha+".whl")
|
||
if _, err := os.Stat(dest); err != nil {
|
||
in, err := os.Open(path)
|
||
if err != nil {
|
||
return LockedPkg{}, err
|
||
}
|
||
defer in.Close()
|
||
out, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||
if err != nil && !errors.Is(err, os.ErrExist) {
|
||
return LockedPkg{}, err
|
||
}
|
||
if err == nil {
|
||
if _, err := io.Copy(out, in); err != nil {
|
||
out.Close()
|
||
return LockedPkg{}, err
|
||
}
|
||
out.Close()
|
||
}
|
||
}
|
||
name := strings.Split(filepath.Base(path), "-")[0]
|
||
return LockedPkg{Name: name, Version: "0", Filename: filepath.Base(path), SHA256: sha, Size: n}, nil
|
||
}
|