Files
Agent-System-Backend/internal/execenv/root.go
T
weichenandCursor b85a1e1ca3 Add immutable layered Python environments for the sandbox.
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>
2026-10-15 13:52:48 +00:00

134 lines
3.8 KiB
Go

// Package execenv 是系统托管的 Python 环境:一个解释器、一份不可变基础层、
// 按内容哈希缓存的扩展层。工作区不各自建 venv,也不往一个可变全局环境里装包。
package execenv
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Root 是环境根目录。多个服务实例可以共用同一份。
type Root struct {
Path string
}
// Open 创建环境根的目录骨架。已存在的层与锁不会被改写。
func Open(path string) (*Root, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("execenv: empty root")
}
r := &Root{Path: path}
for _, d := range []string{
r.PythonDir(), r.LayersDir(), r.Wheelhouse(), r.LocksDir(), r.BinDir(),
} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
}
if err := r.ensureCatalog(); err != nil {
return nil, err
}
if err := writePipShim(r.BinDir()); err != nil {
return nil, err
}
return r, nil
}
func (r *Root) PythonDir() string { return filepath.Join(r.Path, "python") }
func (r *Root) LayersDir() string { return filepath.Join(r.Path, "layers") }
func (r *Root) Wheelhouse() string { return filepath.Join(r.Path, "wheelhouse") }
func (r *Root) LocksDir() string { return filepath.Join(r.Path, "locks") }
func (r *Root) BinDir() string { return filepath.Join(r.Path, "bin") }
func (r *Root) CatalogPath() string { return filepath.Join(r.Path, "catalog.json") }
func (r *Root) ensureCatalog() error {
if _, err := os.Stat(r.CatalogPath()); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
raw, err := json.MarshalIndent(DefaultCatalog(), "", " ")
if err != nil {
return err
}
return os.WriteFile(r.CatalogPath(), append(raw, '\n'), 0o644)
}
// LayerDir 返回某一层的 site-packages 目录(不一定已经存在)。
func (r *Root) LayerDir(id string) string {
return filepath.Join(r.LayersDir(), id, "site-packages")
}
// CurrentBase 读取 current 符号链接指向的基础层 ID。未建立时返回空串。
func (r *Root) CurrentBase() (string, error) {
dest, err := os.Readlink(filepath.Join(r.Path, "current"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
id := filepath.Base(dest)
if id == "" || id == "." {
return "", fmt.Errorf("execenv: current link %q is empty", dest)
}
return id, nil
}
// SwitchCurrent 把 current 指到已存在的层。旧链接被替换,层目录本身不动。
func (r *Root) SwitchCurrent(id string) error {
if _, err := os.Stat(r.LayerDir(id)); err != nil {
return fmt.Errorf("execenv: layer %s: %w", id, err)
}
link := filepath.Join(r.Path, "current")
tmp := link + ".tmp"
_ = os.Remove(tmp)
if err := os.Symlink(filepath.Join("layers", id), tmp); err != nil {
return err
}
return os.Rename(tmp, link)
}
// ReadLock 读取某一层的锁。
func (r *Root) ReadLock(id string) (Lock, error) {
raw, err := os.ReadFile(filepath.Join(r.LocksDir(), id+".json"))
if err != nil {
return Lock{}, err
}
var l Lock
if err := json.Unmarshal(raw, &l); err != nil {
return Lock{}, err
}
return l, nil
}
// WriteLock 写入锁文件。内容按哈希寻址,已存在且字节相同则跳过。
func (r *Root) WriteLock(l Lock) (string, error) {
id := l.ID()
path := filepath.Join(r.LocksDir(), id+".json")
raw, err := json.MarshalIndent(l, "", " ")
if err != nil {
return "", err
}
raw = append(raw, '\n')
if prev, err := os.ReadFile(path); err == nil {
if string(prev) != string(raw) {
return "", fmt.Errorf("execenv: lock %s already exists with different bytes", id)
}
return id, nil
}
return id, os.WriteFile(path, raw, 0o644)
}
// HashBytes 是层键与 wheel 去重共用的 sha256。
func HashBytes(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}