Files
Agent-System-Backend/internal/execenv/resolve.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

222 lines
4.9 KiB
Go

package execenv
import (
"fmt"
"strconv"
"strings"
)
// Wheel 是索引里的一个候选。Kind 为 wheel 或 sdist。
type Wheel struct {
Name string
Version string
Filename string
SHA256 string
Kind string
Requires []string
Size int64
}
// Result 是一次解析。Packages 只含相对约束新增的包,不含基础层已有的。
type Result struct {
Packages []LockedPkg
Override []string
SDist []string
Missing []string
Denied []string
}
func (r Result) OK() bool {
return len(r.Override) == 0 && len(r.SDist) == 0 && len(r.Missing) == 0 && len(r.Denied) == 0
}
// ResolveMem 用内存索引解析。基础层里已有且版本满足的包不会进入 Packages。
// 版本不满足时记入 Override,不改约束。
func ResolveMem(index []Wheel, reqs []string, constraints []LockedPkg) Result {
byName := map[string][]Wheel{}
for _, w := range index {
byName[normName(w.Name)] = append(byName[normName(w.Name)], w)
}
pinned := map[string]LockedPkg{}
for _, c := range constraints {
pinned[normName(c.Name)] = c
}
chosen := map[string]LockedPkg{}
var res Result
queue := append([]string{}, reqs...)
seen := map[string]bool{}
for len(queue) > 0 {
req := strings.TrimSpace(queue[0])
queue = queue[1:]
if req == "" {
continue
}
name, spec := splitReq(req)
key := normName(name)
mark := key + "\x00" + spec
if seen[mark] {
continue
}
seen[mark] = true
if pin, ok := pinned[key]; ok {
if spec != "" && !matchSpec(pin.Version, spec) {
res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,基础层固定为 %s", name, spec, pin.Version))
}
continue
}
if prev, ok := chosen[key]; ok {
if spec != "" && !matchSpec(prev.Version, spec) {
res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,已选 %s", name, spec, prev.Version))
}
continue
}
best, sdist := pickWheel(byName[key], spec)
if sdist && best == nil {
res.SDist = append(res.SDist, name)
continue
}
if best == nil {
res.Missing = append(res.Missing, name)
continue
}
pkg := LockedPkg{
Name: best.Name, Version: best.Version, Filename: best.Filename,
SHA256: best.SHA256, Size: best.Size,
}
chosen[key] = pkg
for _, dep := range best.Requires {
queue = append(queue, dep)
}
}
names := make([]string, 0, len(chosen))
for n := range chosen {
names = append(names, n)
}
sortStrings(names)
for _, n := range names {
res.Packages = append(res.Packages, chosen[n])
}
return res
}
func pickWheel(cands []Wheel, spec string) (best *Wheel, sawSDist bool) {
for i := range cands {
w := &cands[i]
if spec != "" && !matchSpec(w.Version, spec) {
continue
}
if w.Kind == "sdist" {
sawSDist = true
continue
}
if best == nil || cmpVer(w.Version, best.Version) > 0 {
best = w
}
}
return best, sawSDist
}
func splitReq(req string) (name, spec string) {
for i, r := range req {
if r == '=' || r == '<' || r == '>' || r == '!' || r == '~' || r == ' ' {
return strings.TrimSpace(req[:i]), strings.TrimSpace(req[i:])
}
}
return req, ""
}
func matchSpec(version, spec string) bool {
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" || part == "*" {
continue
}
switch {
case strings.HasPrefix(part, "=="):
if version != strings.TrimPrefix(part, "==") {
return false
}
case strings.HasPrefix(part, "!="):
if version == strings.TrimPrefix(part, "!=") {
return false
}
case strings.HasPrefix(part, ">="):
if cmpVer(version, strings.TrimPrefix(part, ">=")) < 0 {
return false
}
case strings.HasPrefix(part, "<="):
if cmpVer(version, strings.TrimPrefix(part, "<=")) > 0 {
return false
}
case strings.HasPrefix(part, ">"):
if cmpVer(version, strings.TrimPrefix(part, ">")) <= 0 {
return false
}
case strings.HasPrefix(part, "<"):
if cmpVer(version, strings.TrimPrefix(part, "<")) >= 0 {
return false
}
default:
if version != part {
return false
}
}
}
return true
}
func cmpVer(a, b string) int {
as := splitVer(a)
bs := splitVer(b)
n := len(as)
if len(bs) > n {
n = len(bs)
}
for i := 0; i < n; i++ {
ai, bi := 0, 0
if i < len(as) {
ai = as[i]
}
if i < len(bs) {
bi = bs[i]
}
if ai != bi {
return ai - bi
}
}
return 0
}
func splitVer(v string) []int {
v = strings.TrimPrefix(v, "v")
if i := strings.IndexAny(v, "abrc"); i > 0 {
// 丢掉本地/预发布后缀,比较只看数字段。
head := v[:i]
if _, err := strconv.Atoi(string(head[len(head)-1])); err == nil {
v = head
}
}
var out []int
for _, p := range strings.Split(v, ".") {
n := 0
for _, r := range p {
if r < '0' || r > '9' {
break
}
n = n*10 + int(r-'0')
}
out = append(out, n)
}
return out
}
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
j := i
for j > 0 && s[j] < s[j-1] {
s[j], s[j-1] = s[j-1], s[j]
j--
}
}
}