Add immutable layered Python environments #206

Merged
weichen merged 3 commits from feature/layered-python-env into main 2026-10-15 14:24:46 +00:00
39 changed files with 3114 additions and 52 deletions
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"time"
"agent-system-backend/internal/execenv"
)
func runEnv(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system env <ensure|lock|gc|doctor|rebase|python> --root <dir>")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("env "+sub, flag.ExitOnError)
rootPath := fs.String("root", "", "环境根目录")
ttl := fs.Duration("ttl", 7*24*time.Hour, "gc 保留窗口")
bin := fs.String("bin", "", "python link 要指向的解释器")
_ = fs.Parse(args[1:])
if *rootPath == "" {
fmt.Fprintln(os.Stderr, "env: --root 必填")
return 2
}
root, err := execenv.Open(*rootPath)
if err != nil {
fmt.Fprintln(os.Stderr, "env:", err)
return 1
}
ctx := context.Background()
switch sub {
case "ensure":
py, err := root.EnsurePython(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env ensure:", err)
return 1
}
id, err := execenv.NewBroker(root, nil, execenv.Pip{Python: py}).EnsureBase(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env ensure:", err)
return 1
}
fmt.Println(id)
return runDoctor(ctx, root, py, true)
case "lock":
id, err := root.CurrentBase()
if err != nil || id == "" {
fmt.Fprintln(os.Stderr, "env lock: 没有 current 基础层")
return 1
}
lock, err := root.ReadLock(id)
if err != nil {
fmt.Fprintln(os.Stderr, "env lock:", err)
return 1
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(lock); err != nil {
fmt.Fprintln(os.Stderr, "env lock:", err)
return 1
}
return 0
case "gc":
removed, err := execenv.NewBroker(root, nil, execenv.Pip{}).GC(*ttl)
if err != nil {
fmt.Fprintln(os.Stderr, "env gc:", err)
return 1
}
for _, id := range removed {
fmt.Println(id)
}
return 0
case "doctor":
py, ok := root.PythonBin()
if !ok {
py = "/usr/bin/python3"
}
return runDoctor(ctx, root, py, true)
case "rebase":
py, err := root.EnsurePython(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env rebase:", err)
return 1
}
id, err := execenv.NewBroker(root, nil, execenv.Pip{Python: py}).Rebase(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env rebase:", err)
return 1
}
fmt.Println(id)
return 0
case "python":
action := "install"
if fs.NArg() > 0 {
action = fs.Arg(0)
}
switch action {
case "install":
py, err := root.EnsurePython(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "env python:", err)
return 1
}
fmt.Println(py)
return 0
case "link":
if *bin == "" {
fmt.Fprintln(os.Stderr, "env python link: --bin 必填")
return 2
}
if err := root.LinkPython(*bin); err != nil {
fmt.Fprintln(os.Stderr, "env python link:", err)
return 1
}
fmt.Println(*bin)
return 0
default:
fmt.Fprintln(os.Stderr, "env python: install | link")
return 2
}
default:
fmt.Fprintf(os.Stderr, "env: 未知动作 %s\n", sub)
return 2
}
}
func runDoctor(ctx context.Context, root *execenv.Root, py string, sandbox bool) int {
var pyPath []string
if id, err := root.CurrentBase(); err == nil && id != "" {
pyPath = append(pyPath, root.LayerDir(id))
}
mounts := append([]string{}, pyPath...)
if prefix := root.PythonPrefix(); prefix != "" {
mounts = append(mounts, prefix)
}
if err := execenv.Doctor(ctx, py, pyPath, nil, sandbox, mounts); err != nil {
fmt.Fprintln(os.Stderr, "env doctor:", err)
return 1
}
fmt.Println("doctor ok")
return 0
}
+17 -5
View File
@@ -46,9 +46,11 @@ func main() {
os.Exit(runIdentity(os.Args[2:]))
case "capabilities":
os.Exit(runCapabilities(os.Args[2:]))
case "env":
os.Exit(runEnv(os.Args[2:]))
default:
fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1])
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities> [参数]")
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities|env> [参数]")
os.Exit(2)
}
}
@@ -68,6 +70,7 @@ func main() {
execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)")
execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)")
execWorkspace := fs.String("exec-workspace", "", "sandbox 档工作区模式:空=直写会话目录 | overlay=冻结基底+私有 upper+CAS 回收(S0)")
execEnvRoot := fs.String("exec-env-root", "", "分层 Python 环境根(解释器、基础层、扩展层;空=不启用)")
tier := fs.String("tier", "", "部署档:空=auto(DSN→PG,否则 SQLite)| local(SQLite,不允许 DSN)| team(PG fail-closed:缺 DSN/未认证拒绝启动,PG 故障拒绝就绪)")
pgMaxOpen := fs.Int("pg-max-open", 0, "PG 最大打开连接(0=默认 16)")
pgMaxIdle := fs.Int("pg-max-idle", 0, "PG 最大空闲连接(0=默认 8)")
@@ -93,7 +96,7 @@ func main() {
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, ExecEnvRoot: *execEnvRoot,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, cliSet)
@@ -137,8 +140,8 @@ func main() {
DisableUploadIndexing: extOpt.DisableUploadIndexing,
DisableConvCardMirror: extOpt.DisableConvCardMirror,
DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
ExecWorkspace: resolved.ExecWorkspace,
Tier: resolved.Tier,
ExecWorkspace: resolved.ExecWorkspace, ExecEnvRoot: resolved.ExecEnvRoot,
Tier: resolved.Tier,
PGPool: store.PoolOptions{
MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle,
ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec,
@@ -162,6 +165,14 @@ func visited(fs *flag.FlagSet) map[string]bool {
name = "allowedHosts"
case "disable-exec-tools":
name = "disableExecTools"
case "exec-backend":
name = "execBackend"
case "exec-deps-dir":
name = "execDepsDir"
case "exec-workspace":
name = "execWorkspace"
case "exec-env-root":
name = "execEnvRoot"
case "pg-max-open":
name = "pgMaxOpen"
case "pg-max-idle":
@@ -200,6 +211,7 @@ func runConfig(args []string) int {
execBackend := fs.String("exec-backend", "", "")
execDepsDir := fs.String("exec-deps-dir", "", "")
execWorkspace := fs.String("exec-workspace", "", "")
execEnvRoot := fs.String("exec-env-root", "", "")
tier := fs.String("tier", "", "")
pgMaxOpen := fs.Int("pg-max-open", 0, "")
pgMaxIdle := fs.Int("pg-max-idle", 0, "")
@@ -211,7 +223,7 @@ func runConfig(args []string) int {
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace,
ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, ExecEnvRoot: *execEnvRoot,
Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle,
PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec,
}, visited(fs))
+2
View File
@@ -298,6 +298,8 @@ var copyOrder = []string{
"account_events",
"redeem_attempts",
"recovery_proposals", "recovery_proposal_proofs",
// v35 分层 Python 环境(无外键;申请与会话绑定要随库迁走)
"exec_env_requests", "exec_env_bindings",
}
const controlDDL = `
+21
View File
@@ -86,6 +86,9 @@ type Options struct {
// ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复
// --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。
ExecDepsDir string
// ExecEnvRoot 分层 Python 环境根(解释器、基础层、扩展层、白名单)。
// 设置后,未显式给出 ExecDepsDir 时使用其中的 current 基础层。
ExecEnvRoot string
// ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有
// upper+CAS 回收);空 = EXT-16 现状直写会话目录。
ExecWorkspace string
@@ -249,9 +252,17 @@ func Run(opt Options) error {
if tools.ExecBackendName() == "sandbox" {
log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)")
}
broker, err := wireExecEnv(st, &opt)
if err != nil {
return fmt.Errorf("exec env: %w", err)
}
if opt.ExecDepsDir != "" {
tools.SetExecDepsDir(opt.ExecDepsDir)
log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir)
if err := tools.ExecDoctor(); err != nil {
return fmt.Errorf("exec doctor: %w", err)
}
log.Printf("exec tools: doctor ok (docx/openpyxl/pptx)")
}
if opt.ExecWorkspace == "overlay" {
tools.SetWorkspaceOverlay(true)
@@ -453,6 +464,15 @@ func Run(opt Options) error {
subRunner := &runtime.SubLLMRunner{Prompts: promptSvc, Trace: traceFn}
mockRunner := &runtime.MockRunner{}
mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{})
if broker != nil {
mgr.SetExecEnvDecider(func(actor, id string, allow, isolate bool) (string, error) {
out, err := broker.Decide(context.Background(), id, actor, false, allow, isolate)
if err != nil {
return "", err
}
return out.Detail, nil
})
}
authOn := resolved.AuthEnabled()
effectiveAccount := accountEffectiveAuthorization(st, authOn)
mgr.SetAccountEffectiveAuthorization(effectiveAccount, !authOn)
@@ -697,6 +717,7 @@ func Run(opt Options) error {
}
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
srv.ExecEnv = broker
projectSlot.wire(srv, caps)
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
srv.SetProjectAuthorizer(authzSlot.Authorize)
+132
View File
@@ -0,0 +1,132 @@
package app
import (
"agent-system-backend/internal/execenv"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
core "agent-system-core"
)
// envStore 把 store 的行类型适配成 execenv.Store。
type envStore struct{ *store.Store }
func (s envStore) InsertExecEnvRequest(r execenv.Request) error {
return s.Store.InsertExecEnvRequest(store.ExecEnvRequest{
ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec,
Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail,
Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
}
func (s envStore) GetExecEnvRequest(id string) (execenv.Request, error) {
r, err := s.Store.GetExecEnvRequest(id)
if err != nil {
return execenv.Request{}, err
}
return execenv.Request{
ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec,
Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail,
Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
}, nil
}
func (s envStore) ListExecEnvRequests(conversationID string, limit int) ([]execenv.Request, error) {
rows, err := s.Store.ListExecEnvRequests(conversationID, limit)
if err != nil {
return nil, err
}
out := make([]execenv.Request, 0, len(rows))
for _, r := range rows {
out = append(out, execenv.Request{
ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec,
Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail,
Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
})
}
return out, nil
}
func (s envStore) UpdateExecEnvRequest(id, status, layerID, detail, approver string) error {
return s.Store.UpdateExecEnvRequest(id, status, layerID, detail, approver)
}
func (s envStore) GetExecEnvBinding(conversationID string) (execenv.Binding, error) {
b, err := s.Store.GetExecEnvBinding(conversationID)
if err != nil {
return execenv.Binding{}, err
}
return execenv.Binding{
ConversationID: b.ConversationID, BaseID: b.BaseID, Extensions: b.Layers,
Isolated: b.Isolated, UpdatedAt: b.UpdatedAt,
}, nil
}
func (s envStore) PutExecEnvBinding(b execenv.Binding) error {
return s.Store.PutExecEnvBinding(store.ExecEnvBinding{
ConversationID: b.ConversationID, BaseID: b.BaseID, Layers: b.Extensions,
Isolated: b.Isolated, UpdatedAt: b.UpdatedAt,
})
}
func (s envStore) ListExecEnvBindings() ([]execenv.Binding, error) {
rows, err := s.Store.ListExecEnvBindings()
if err != nil {
return nil, err
}
out := make([]execenv.Binding, 0, len(rows))
for _, b := range rows {
out = append(out, execenv.Binding{
ConversationID: b.ConversationID, BaseID: b.BaseID, Extensions: b.Layers,
Isolated: b.Isolated, UpdatedAt: b.UpdatedAt,
})
}
return out, nil
}
func (s envStore) InsertExecEnvAudit(owner, action, actor, target, outcome, entry string) error {
return s.Store.InsertExecEnvAudit(owner, action, actor, target, outcome, entry)
}
// wireExecEnv 打开环境根,把基础层交给执行器,并接上申请代理。
// 两者都空时不改变现状(解释器仍固定为 /usr/bin/python3,避免 linuxbrew 路径)。
func wireExecEnv(st *store.Store, opt *Options) (*execenv.Broker, error) {
if opt.ExecEnvRoot == "" {
return nil, nil
}
root, err := execenv.Open(opt.ExecEnvRoot)
if err != nil {
return nil, err
}
if bin, ok := root.PythonBin(); ok {
tools.SetExecPython(bin, root.PythonPrefix())
}
tools.SetExecShimDir(root.BinDir())
if opt.ExecDepsDir == "" {
if id, err := root.CurrentBase(); err != nil {
return nil, err
} else if id != "" {
opt.ExecDepsDir = root.LayerDir(id)
}
}
py, _ := root.PythonBin()
if py == "" {
py = "/usr/bin/python3"
}
broker := execenv.NewBroker(root, envStore{st}, execenv.Pip{Python: py})
tools.SetEnvBroker(broker)
tools.SetExecLayers(broker.LayersFor)
tools.SetEnvApproval(func(owner, conv, requestID, detail string) error {
card := &core.HITLCard{
ID: core.ID("hitl_" + requestID), RunID: "execenv", ConversationID: core.ID(conv),
Question: "批准为这个会话安装额外的 Python 包?", Kind: core.HITLKindChoice,
Detail: "exec-env:" + requestID + "\n" + detail,
Choices: []core.HITLChoice{
{ID: "approve", Label: "批准", Description: detail, Impact: "只挂到这个会话,不改变基础层", Recommended: true},
{ID: "isolate", Label: "批准并使用隔离层", Description: "与基础层版本冲突时用这份独立闭包", Impact: "这个会话不再叠基础层"},
{ID: "reject", Label: "拒绝", Description: "不安装", Impact: "会话保持现状"},
},
}
return st.SaveHITLCard(owner, card)
})
return broker, nil
}
+10 -1
View File
@@ -36,6 +36,8 @@ type Config struct {
ExecDepsDir string `json:"execDepsDir"`
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
ExecWorkspace string `json:"execWorkspace"`
// ExecEnvRoot 分层 Python 环境根。空 = 不启用申请代理。
ExecEnvRoot string `json:"execEnvRoot"`
// Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite);
// local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动,
// PG 故障拒绝就绪,绝不回落 SQLite)。
@@ -69,6 +71,7 @@ var envKey = map[string]string{
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
"execEnvRoot": "AGENT_SYSTEM_EXEC_ENV_ROOT",
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
@@ -213,6 +216,8 @@ func loadFile(path string) (Config, map[string]bool, error) {
dst = &c.ExecDepsDir
case "execWorkspace":
dst = &c.ExecWorkspace
case "execEnvRoot":
dst = &c.ExecEnvRoot
case "tier":
dst = &c.Tier
case "pgMaxOpen":
@@ -260,6 +265,8 @@ func stringField(c Config, f string) string {
return c.ExecDepsDir
case "execWorkspace":
return c.ExecWorkspace
case "execEnvRoot":
return c.ExecEnvRoot
case "tier":
return c.Tier
}
@@ -355,6 +362,8 @@ func setString(c *Config, f, v string) {
c.ExecDepsDir = v
case "execWorkspace":
c.ExecWorkspace = v
case "execEnvRoot":
c.ExecEnvRoot = v
case "tier":
c.Tier = v
}
@@ -386,7 +395,7 @@ func (r Resolved) Explain(w io.Writer, file string) {
fmt.Fprintln(w, "config file: (none)")
}
fmt.Fprintln(w, "field value source")
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "execDepsDir", "execWorkspace", "execEnvRoot", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
var v string
if isBoolField(f) {
v = fmt.Sprintf("%t", boolField(r.Config, f))
+11
View File
@@ -0,0 +1,11 @@
python-docx
openpyxl
python-pptx
lxml
pillow
numpy
matplotlib
pypdf
reportlab
markdown
pdfplumber
+349
View File
@@ -0,0 +1,349 @@
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
}
+171
View File
@@ -0,0 +1,171 @@
package execenv
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"
)
type memStore struct {
reqs map[string]Request
bind map[string]Binding
}
func newMem() *memStore { return &memStore{reqs: map[string]Request{}, bind: map[string]Binding{}} }
func (m *memStore) InsertExecEnvRequest(r Request) error {
m.reqs[r.ID] = r
return nil
}
func (m *memStore) GetExecEnvRequest(id string) (Request, error) { return m.reqs[id], nil }
func (m *memStore) ListExecEnvRequests(conv string, limit int) ([]Request, error) {
var out []Request
for _, r := range m.reqs {
if conv == "" || r.ConversationID == conv {
out = append(out, r)
}
}
return out, nil
}
func (m *memStore) UpdateExecEnvRequest(id, status, layerID, detail, approver string) error {
r := m.reqs[id]
r.Status, r.LayerID, r.Detail, r.Approver = status, layerID, detail, approver
m.reqs[id] = r
return nil
}
func (m *memStore) GetExecEnvBinding(conv string) (Binding, error) { return m.bind[conv], nil }
func (m *memStore) PutExecEnvBinding(b Binding) error {
m.bind[b.ConversationID] = b
return nil
}
func (m *memStore) ListExecEnvBindings() ([]Binding, error) {
var out []Binding
for _, b := range m.bind {
out = append(out, b)
}
return out, nil
}
func (m *memStore) InsertExecEnvAudit(string, string, string, string, string, string) error {
return nil
}
type scripted struct{ byReq map[string]Result }
func (s scripted) Resolve(_ context.Context, reqs []string, _ Lock) (Result, error) {
if len(reqs) == 0 {
return Result{}, nil
}
return s.byReq[reqs[0]], nil
}
func (s scripted) Fetch(context.Context, string, LockedPkg) (string, error) {
return "", os.ErrNotExist
}
func TestConversationsGetDistinctLayers(t *testing.T) {
root, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
cat := DefaultCatalog()
cat.Allow = append(cat.Allow, CatalogEntry{Name: "foo", Reason: "test"})
raw, _ := json.Marshal(cat)
if err := os.WriteFile(root.CatalogPath(), raw, 0o644); err != nil {
t.Fatal(err)
}
baseSum := placeWheel(t, root, "bar.py")
baseID, err := root.BuildLayer(Lock{
Schema: 1, Kind: "base", PythonTag: "cp314", Platform: "any",
Packages: []LockedPkg{{Name: "bar", Version: "1", Filename: baseSum + ".whl", SHA256: baseSum}},
})
if err != nil {
t.Fatal(err)
}
if err := root.SwitchCurrent(baseID); err != nil {
t.Fatal(err)
}
sum1 := placeWheel(t, root, "foo1.py")
sum2 := placeWheel(t, root, "foo2.py")
broker := NewBroker(root, newMem(), scripted{byReq: map[string]Result{
"foo==1": {Packages: []LockedPkg{{Name: "foo", Version: "1", Filename: sum1 + ".whl", SHA256: sum1}}},
"foo==2": {Packages: []LockedPkg{{Name: "foo", Version: "2", Filename: sum2 + ".whl", SHA256: sum2}}},
}})
a, err := broker.RequestPackages(context.Background(), "conv-a", "owner", "need 1", []string{"foo==1"}, "")
if err != nil || a.Status != "ready" {
t.Fatalf("A: %+v %v", a, err)
}
b, err := broker.RequestPackages(context.Background(), "conv-b", "owner", "need 2", []string{"foo==2"}, "")
if err != nil || b.Status != "ready" {
t.Fatalf("B: %+v %v", b, err)
}
if a.LayerID == b.LayerID {
t.Fatalf("layers should differ: %s", a.LayerID)
}
cur, err := root.CurrentBase()
if err != nil || cur != baseID {
t.Fatalf("base changed: %s -> %s (%v)", baseID, cur, err)
}
extraA, isoA := broker.LayersFor("conv-a")
extraB, isoB := broker.LayersFor("conv-b")
if isoA != "" || isoB != "" || len(extraA) != 1 || len(extraB) != 1 || extraA[0] == extraB[0] {
t.Fatalf("mounts A=%v %s B=%v %s", extraA, isoA, extraB, isoB)
}
}
func TestRequestGrades(t *testing.T) {
root, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
broker := NewBroker(root, newMem(), scripted{byReq: map[string]Result{
"onlysrc": {SDist: []string{"onlysrc"}},
"secret": {Packages: []LockedPkg{{Name: "secret", Version: "1", SHA256: "abc"}}},
}})
sdist, err := broker.RequestPackages(context.Background(), "c", "o", "", []string{"onlysrc"}, "")
if err != nil || sdist.Status != "rejected" {
t.Fatalf("sdist: %+v %v", sdist, err)
}
outside, err := broker.RequestPackages(context.Background(), "c", "o", "", []string{"secret"}, "")
if err != nil || outside.Status != "pending_approval" {
t.Fatalf("allowlist: %+v %v", outside, err)
}
}
func placeWheel(t *testing.T, root *Root, member string) string {
t.Helper()
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
f, err := zw.Create(member)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte("x = 1\n")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(buf.Bytes())
hexSum := hex.EncodeToString(sum[:])
if err := os.WriteFile(filepath.Join(root.Wheelhouse(), hexSum+".whl"), buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
return hexSum
}
func osWriteWheel(path, member string) error {
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
if _, err := zw.Create(member); err != nil {
return err
}
if err := zw.Close(); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}
+77
View File
@@ -0,0 +1,77 @@
package execenv
import (
"encoding/json"
"os"
)
// Catalog 是管理员可编辑的白名单。不在白名单里的包不会自动安装。
type Catalog struct {
MaxWheelBytes int64 `json:"maxWheelBytes"`
Allow []CatalogEntry `json:"allow"`
Deny []string `json:"deny"`
}
// CatalogEntry 的 Spec 为空表示任意版本;非空时是给人类看的范围说明,
// 自动批准只看包名是否在列,版本冲突另由基础层锁判定。
type CatalogEntry struct {
Name string `json:"name"`
Spec string `json:"spec,omitempty"`
Reason string `json:"reason,omitempty"`
}
// DefaultCatalog 是第一次创建环境根时落下的名单。之后以磁盘上的 catalog.json 为准。
func DefaultCatalog() Catalog {
return Catalog{
MaxWheelBytes: 80 << 20,
Allow: []CatalogEntry{
{Name: "python-docx", Reason: "Word 交付物"},
{Name: "openpyxl", Reason: "Excel"},
{Name: "python-pptx", Reason: "PPT"},
{Name: "lxml", Reason: "OOXML"},
{Name: "pillow", Reason: "图片"},
{Name: "numpy", Reason: "数值"},
{Name: "matplotlib", Reason: "图表"},
{Name: "pypdf", Reason: "PDF 读取"},
{Name: "reportlab", Reason: "PDF 生成"},
{Name: "markdown", Reason: "Markdown"},
{Name: "pdfplumber", Reason: "PDF 抽取"},
},
Deny: []string{"setuptools", "pip"},
}
}
func (r *Root) LoadCatalog() (Catalog, error) {
raw, err := os.ReadFile(r.CatalogPath())
if err != nil {
return Catalog{}, err
}
var c Catalog
if err := json.Unmarshal(raw, &c); err != nil {
return Catalog{}, err
}
if c.MaxWheelBytes <= 0 {
c.MaxWheelBytes = 80 << 20
}
return c, nil
}
func (c Catalog) allowed(name string) bool {
n := normName(name)
for _, e := range c.Allow {
if normName(e.Name) == n {
return true
}
}
return false
}
func (c Catalog) denied(name string) bool {
n := normName(name)
for _, d := range c.Deny {
if normName(d) == n {
return true
}
}
return false
}
+97
View File
@@ -0,0 +1,97 @@
package execenv
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Doctor 用给定解释器导入模块。sandbox 为真且系统有 bwrap 时,按与执行后端相同的隔离方式跑。
func Doctor(ctx context.Context, python string, pyPath []string, modules []string, sandbox bool, mounts []string) error {
if python == "" {
return fmt.Errorf("execenv: doctor: python is empty")
}
if len(modules) == 0 {
modules = []string{"docx", "openpyxl", "pptx"}
}
script := "import " + strings.Join(modules, ", ")
if !sandbox {
cmd := exec.CommandContext(ctx, python, "-c", script)
cmd.Env = doctorEnv(pyPath)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("execenv: doctor: %v: %s", err, trim(out))
}
return nil
}
bwrap, err := exec.LookPath("bwrap")
if err != nil {
return fmt.Errorf("execenv: doctor: sandbox requested but bwrap is missing: %w", err)
}
argv := []string{"--unshare-all", "--die-with-parent", "--ro-bind", "/", "/", "--tmpfs", "/home", "--tmpfs", "/tmp", "--proc", "/proc", "--dev", "/dev"}
for _, m := range mounts {
if m == "" {
continue
}
argv = append(argv, "--ro-bind", m, m)
}
argv = append(argv, "--clearenv")
for _, kv := range doctorEnv(pyPath) {
if i := strings.IndexByte(kv, '='); i > 0 {
argv = append(argv, "--setenv", kv[:i], kv[i+1:])
}
}
argv = append(argv, python, "-c", script)
cmd := exec.CommandContext(ctx, bwrap, argv...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("execenv: doctor sandbox: %v: %s", err, trim(out))
}
return nil
}
func doctorEnv(pyPath []string) []string {
env := []string{
"PATH=/usr/local/bin:/usr/bin:/bin",
"PYTHONDONTWRITEBYTECODE=1",
"PIP_NO_INDEX=1",
}
if len(pyPath) > 0 {
env = append(env, "PYTHONPATH="+strings.Join(pyPath, string(os.PathListSeparator)))
}
return env
}
func trim(b []byte) string {
s := string(bytes.TrimSpace(b))
if len(s) > 500 {
s = s[:500]
}
return s
}
// EnsurePython 优先下载独立 CPython;失败则用系统 python 建一份 venv。
func (r *Root) EnsurePython(ctx context.Context) (string, error) {
if bin, ok := r.PythonBin(); ok {
return bin, nil
}
pctx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
if err := r.InstallStandalone(pctx, ""); err == nil {
if bin, ok := r.PythonBin(); ok {
return bin, nil
}
}
if err := r.BootstrapVenv("/usr/bin/python3"); err != nil {
return "", err
}
bin, ok := r.PythonBin()
if !ok {
return "", fmt.Errorf("execenv: managed python missing after bootstrap")
}
return bin, nil
}
+61
View File
@@ -0,0 +1,61 @@
package execenv
import (
"os"
"path/filepath"
"time"
)
// GC 删除未被 current、绑定或锁引用、且早于 ttl 的层目录。ttl<=0 时用 7 天。
func (b *Broker) GC(ttl time.Duration) ([]string, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
keep := map[string]bool{}
if id, err := b.Root.CurrentBase(); err != nil {
return nil, err
} else if id != "" {
keep[id] = true
}
if b.Store != nil {
binds, err := b.Store.ListExecEnvBindings()
if err != nil {
return nil, err
}
for _, bind := range binds {
if bind.BaseID != "" {
keep[bind.BaseID] = true
}
if bind.Isolated != "" {
keep[bind.Isolated] = true
}
for _, id := range bind.Extensions {
keep[id] = true
}
}
}
ents, err := os.ReadDir(b.Root.LayersDir())
if err != nil {
return nil, err
}
cut := time.Now().Add(-ttl)
var removed []string
for _, ent := range ents {
if !ent.IsDir() || keep[ent.Name()] {
continue
}
info, err := ent.Info()
if err != nil {
return removed, err
}
if info.ModTime().After(cut) {
continue
}
if err := os.RemoveAll(filepath.Join(b.Root.LayersDir(), ent.Name())); err != nil {
return removed, err
}
_ = os.Remove(filepath.Join(b.Root.LocksDir(), ent.Name()+".json"))
removed = append(removed, ent.Name())
}
return removed, nil
}
+20
View File
@@ -0,0 +1,20 @@
package execenv
import (
"crypto/sha256"
"encoding/hex"
"hash"
"io"
)
type shaWriter struct{ h hash.Hash }
func sha256New() shaWriter { return shaWriter{h: sha256.New()} }
func (s shaWriter) Sum() string { return hex.EncodeToString(s.h.Sum(nil)) }
// Write 满足 io.Writer,便于 fileSHA256 之外的流式哈希。
func (s shaWriter) Write(p []byte) (int, error) { return s.h.Write(p) }
// 确认 shaWriter 可传给 io.Copy。
var _ io.Writer = shaWriter{}
+62
View File
@@ -0,0 +1,62 @@
package execenv
import (
"encoding/json"
"sort"
"strings"
)
// Lock 是一层的不可变清单。ID 由规范 JSON 决定,与写盘时的空白无关。
type Lock struct {
Schema int `json:"schema"`
Kind string `json:"kind"` // base | extension | isolated
PythonTag string `json:"pythonTag"`
Platform string `json:"platform"`
BaseID string `json:"baseId,omitempty"`
Packages []LockedPkg `json:"packages"`
}
// LockedPkg 是一个 wheel。SHA256 是文件字节的哈希,不是文件名。
type LockedPkg struct {
Name string `json:"name"`
Version string `json:"version"`
Filename string `json:"filename"`
SHA256 string `json:"sha256"`
Size int64 `json:"size,omitempty"`
}
// ID 返回 base-<hash> / x-<hash> / iso-<hash>。
func (l Lock) ID() string {
canon := l
canon.Packages = append([]LockedPkg(nil), l.Packages...)
sort.Slice(canon.Packages, func(i, j int) bool {
return normName(canon.Packages[i].Name) < normName(canon.Packages[j].Name)
})
raw, _ := json.Marshal(canon)
sum := HashBytes(raw)
prefix := "x-"
switch l.Kind {
case "base":
prefix = "base-"
case "isolated":
prefix = "iso-"
}
return prefix + sum[:16]
}
func normName(name string) string {
var b strings.Builder
prevDash := false
for _, r := range strings.ToLower(name) {
if r == '-' || r == '_' || r == '.' {
if !prevDash && b.Len() > 0 {
b.WriteByte('-')
prevDash = true
}
continue
}
prevDash = false
b.WriteRune(r)
}
return strings.Trim(b.String(), "-")
}
+206
View File
@@ -0,0 +1,206 @@
package execenv
import (
"context"
"errors"
"fmt"
"os"
"time"
)
func (b *Broker) materialize(ctx context.Context, conv, owner string, base Lock, res Result, isolate bool) (string, error) {
pkgs := append([]LockedPkg(nil), res.Packages...)
for i := range pkgs {
if pkgs[i].SHA256 == "" || !wheelExists(b.Root, pkgs[i]) {
path, err := b.Pip.Fetch(ctx, b.Root.Wheelhouse(), pkgs[i])
if err != nil {
return "", err
}
sum, err := fileSHA256(path)
if err != nil {
return "", err
}
if pkgs[i].SHA256 != "" && sum != pkgs[i].SHA256 {
return "", fmt.Errorf("execenv: hash mismatch for %s", pkgs[i].Name)
}
pkgs[i].SHA256 = sum
pkgs[i].Filename = pkgs[i].SHA256 + ".whl"
stable := b.Root.Wheelhouse() + "/" + pkgs[i].Filename
if path != stable {
if err := os.Rename(path, stable); err != nil && !errors.Is(err, os.ErrExist) {
// Fetch 可能已经改名。忽略已就位的文件。
if _, statErr := os.Stat(stable); statErr != nil {
return "", err
}
}
}
}
}
kind := "extension"
baseID := ""
if id, _ := b.Root.CurrentBase(); id != "" {
baseID = id
}
lockPkgs := pkgs
if isolate {
kind = "isolated"
} else if baseID == "" {
kind = "base"
}
if !isolate && baseID != "" {
lockPkgs = res.Packages
}
lock := Lock{
Schema: 1, Kind: kind, PythonTag: base.PythonTag, Platform: base.Platform,
BaseID: baseID, Packages: lockPkgs,
}
if lock.PythonTag == "" {
lock.PythonTag = "cp314"
}
if lock.Platform == "" {
lock.Platform = "any"
}
id, err := b.Root.BuildLayer(lock)
if err != nil {
return "", err
}
if kind == "base" {
if err := b.Root.SwitchCurrent(id); err != nil {
return "", err
}
return id, nil
}
bind, err := b.Store.GetExecEnvBinding(conv)
if err != nil {
return "", err
}
bind.ConversationID = conv
bind.BaseID = baseID
bind.UpdatedAt = b.now()
if isolate {
bind.Isolated = id
bind.Extensions = nil
} else {
bind.Isolated = ""
bind.Extensions = appendUnique(bind.Extensions, id)
}
if err := b.Store.PutExecEnvBinding(bind); err != nil {
return "", err
}
_ = owner
return id, nil
}
func wheelExists(r *Root, pkg LockedPkg) bool {
if pkg.SHA256 == "" {
return false
}
_, err := os.Stat(r.Wheelhouse() + "/" + pkg.SHA256 + ".whl")
return err == nil
}
func appendUnique(ss []string, id string) []string {
for _, s := range ss {
if s == id {
return ss
}
}
return append(ss, id)
}
// LayersFor 返回本次执行要挂载的 site-packages。isolated 非空时调用方不要再挂基础层。
func (b *Broker) LayersFor(conv string) (extra []string, isolated string) {
if b.Store == nil || conv == "" {
return nil, ""
}
bind, err := b.Store.GetExecEnvBinding(conv)
if err != nil {
return nil, ""
}
if bind.Isolated != "" {
return nil, b.Root.LayerDir(bind.Isolated)
}
for _, id := range bind.Extensions {
extra = append(extra, b.Root.LayerDir(id))
}
return extra, ""
}
// Wait 轮询申请直到终态或超时。
func (b *Broker) Wait(ctx context.Context, id string, timeout time.Duration) (Outcome, error) {
deadline := time.Now().Add(timeout)
if timeout <= 0 {
deadline = time.Now().Add(2 * time.Minute)
}
for {
rec, err := b.Store.GetExecEnvRequest(id)
if err != nil {
return Outcome{}, err
}
switch rec.Status {
case "ready", "rejected", "failed":
return Outcome{Status: rec.Status, RequestID: id, LayerID: rec.LayerID, Detail: rec.Detail}, nil
}
if time.Now().After(deadline) || ctx.Err() != nil {
return Outcome{Status: rec.Status, RequestID: id, Detail: "仍在等待审批或构建"}, nil
}
select {
case <-ctx.Done():
return Outcome{Status: rec.Status, RequestID: id, Detail: "等待被取消"}, nil
case <-time.After(200 * time.Millisecond):
}
}
}
// EnsureBase 解析基础层需求并建立 current。已有 current 时直接返回其 ID。
func (b *Broker) EnsureBase(ctx context.Context) (string, error) {
if id, err := b.Root.CurrentBase(); err != nil {
return "", err
} else if id != "" {
return id, nil
}
res, err := b.Pip.Resolve(ctx, BaseRequirements(), Lock{})
if err != nil {
return "", err
}
if !res.OK() {
return "", fmt.Errorf("execenv: base resolve: override=%v sdist=%v missing=%v", res.Override, res.SDist, res.Missing)
}
return b.materialize(ctx, "", "", Lock{Kind: "base"}, res, false)
}
// Rebase 强制按当前需求重建基础层并移动 current。已有会话绑定保持旧 ID。
func (b *Broker) Rebase(ctx context.Context) (string, error) {
res, err := b.Pip.Resolve(ctx, BaseRequirements(), Lock{})
if err != nil {
return "", err
}
if !res.OK() {
return "", fmt.Errorf("execenv: rebase: override=%v sdist=%v missing=%v", res.Override, res.SDist, res.Missing)
}
lock := Lock{Schema: 1, Kind: "base", PythonTag: "cp314", Platform: "any", Packages: res.Packages}
for i := range lock.Packages {
path, err := b.Pip.Fetch(ctx, b.Root.Wheelhouse(), lock.Packages[i])
if err != nil {
return "", err
}
sum, err := fileSHA256(path)
if err != nil {
return "", err
}
lock.Packages[i].SHA256 = sum
lock.Packages[i].Filename = sum + ".whl"
stable := b.Root.Wheelhouse() + "/" + sum + ".whl"
if path != stable {
_ = os.Rename(path, stable)
}
}
id, err := b.Root.BuildLayer(lock)
if err != nil {
return "", err
}
if err := b.Root.SwitchCurrent(id); err != nil {
return "", err
}
return id, nil
}
+183
View File
@@ -0,0 +1,183 @@
package execenv
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
//go:embed base-requirements.txt
var baseRequirements string
// BaseRequirements 是基础层的包名清单(不带版本;版本由第一次解析锁定)。
func BaseRequirements() []string {
var out []string
for _, line := range strings.Split(baseRequirements, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
out = append(out, line)
}
}
return out
}
// Pip 用宿主侧的 pip 下载 wheel。它不在沙箱里运行。
type Pip struct {
Python string // 带 pip 的解释器
}
func (p Pip) python() string {
if p.Python != "" {
return p.Python
}
return "python3"
}
// Resolve 以基础层锁为 constraints 做 dry-run。冲突记入 Override,不改锁。
func (p Pip) Resolve(ctx context.Context, reqs []string, base Lock) (Result, error) {
if len(reqs) == 0 {
return Result{}, nil
}
dir, err := os.MkdirTemp("", "execenv-resolve-")
if err != nil {
return Result{}, err
}
defer os.RemoveAll(dir)
cons := filepath.Join(dir, "constraints.txt")
if err := os.WriteFile(cons, []byte(constraintText(base)), 0o644); err != nil {
return Result{}, err
}
args := []string{"-m", "pip", "install", "--dry-run", "--report", "-", "--only-binary=:all:", "--constraint", cons}
args = append(args, reqs...)
cmd := exec.CommandContext(ctx, p.python(), args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
text := stderr.String() + stdout.String()
if err != nil {
if strings.Contains(text, "ResolutionImpossible") || strings.Contains(text, "conflicting") {
return Result{Override: []string{firstLine(text)}}, nil
}
if strings.Contains(text, "only-binary") || strings.Contains(text, "No matching distribution") {
return Result{SDist: reqs, Missing: []string{firstLine(text)}}, nil
}
return Result{}, fmt.Errorf("execenv: pip resolve: %v: %s", err, firstLine(text))
}
return parsePipReport(stdout.Bytes(), base)
}
func parsePipReport(raw []byte, base Lock) (Result, error) {
var doc struct {
Install []struct {
Metadata struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"metadata"`
DownloadInfo struct {
ArchiveInfo struct {
Hash string `json:"hash"`
} `json:"archive_info"`
URL string `json:"url"`
} `json:"download_info"`
} `json:"install"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return Result{}, fmt.Errorf("execenv: pip report: %w", err)
}
pinned := map[string]LockedPkg{}
for _, c := range base.Packages {
pinned[normName(c.Name)] = c
}
var res Result
for _, item := range doc.Install {
name := item.Metadata.Name
if pin, ok := pinned[normName(name)]; ok {
if pin.Version != item.Metadata.Version {
res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,基础层固定为 %s", name, item.Metadata.Version, pin.Version))
}
continue
}
hash := strings.TrimPrefix(item.DownloadInfo.ArchiveInfo.Hash, "sha256=")
filename := name + "-" + item.Metadata.Version + ".whl"
if item.DownloadInfo.URL != "" {
filename = filepath.Base(strings.Split(item.DownloadInfo.URL, "?")[0])
}
res.Packages = append(res.Packages, LockedPkg{
Name: name, Version: item.Metadata.Version, Filename: filename, SHA256: hash,
})
}
return res, nil
}
// Fetch 把一个包下载进 wheelhouse,并返回落地路径。哈希不符则删除并报错。
func (p Pip) Fetch(ctx context.Context, wheelhouse string, pkg LockedPkg) (string, error) {
if err := os.MkdirAll(wheelhouse, 0o755); err != nil {
return "", err
}
dest := filepath.Join(wheelhouse, pkg.Filename)
if pkg.SHA256 != "" {
if sum, err := fileSHA256(dest); err == nil && strings.EqualFold(sum, pkg.SHA256) {
return dest, nil
}
}
cmd := exec.CommandContext(ctx, p.python(), "-m", "pip", "download",
"--only-binary=:all:", "--no-deps", "-d", wheelhouse, pkg.Name+"=="+pkg.Version)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("execenv: pip download %s: %v: %s", pkg.Name, err, firstLine(string(out)))
}
found := dest
if _, err := os.Stat(found); err != nil {
matches, _ := filepath.Glob(filepath.Join(wheelhouse, normName(pkg.Name)+"*.whl"))
if len(matches) == 0 {
matches, _ = filepath.Glob(filepath.Join(wheelhouse, "*.whl"))
}
if len(matches) == 0 {
return "", fmt.Errorf("execenv: download produced no wheel for %s", pkg.Name)
}
found = matches[len(matches)-1]
}
sum, err := fileSHA256(found)
if err != nil {
return "", err
}
if pkg.SHA256 != "" && !strings.EqualFold(sum, pkg.SHA256) {
_ = os.Remove(found)
return "", fmt.Errorf("execenv: hash mismatch for %s", pkg.Name)
}
stable := filepath.Join(wheelhouse, sum+".whl")
if found != stable {
if err := os.Rename(found, stable); err != nil {
return found, nil
}
}
return stable, nil
}
func constraintText(base Lock) string {
var b strings.Builder
for _, p := range base.Packages {
if p.Name != "" && p.Version != "" {
fmt.Fprintf(&b, "%s==%s\n", p.Name, p.Version)
}
}
return b.String()
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 400 {
s = s[:400]
}
return s
}
+241
View File
@@ -0,0 +1,241 @@
package execenv
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// PythonBin 返回托管解释器。未安装时 ok=false。
func (r *Root) PythonBin() (string, bool) {
bin := filepath.Join(r.PythonDir(), "current", "bin", "python3")
st, err := os.Stat(bin)
if err != nil || st.IsDir() {
return "", false
}
return bin, true
}
// PythonPrefix 是需要在沙箱里重新挂载的前缀(current 的真实目录)。
func (r *Root) PythonPrefix() string {
dest, err := filepath.EvalSymlinks(filepath.Join(r.PythonDir(), "current"))
if err != nil {
return ""
}
return dest
}
// LinkPython 把已有解释器收成托管前缀:python/host/bin/python3 -> 绝对路径。
// 这是下载独立 CPython 失败时的退化,仍然全系统只有一份,不是每个工作区一份。
func (r *Root) LinkPython(bin string) error {
if !filepath.IsAbs(bin) {
return fmt.Errorf("execenv: python bin must be absolute: %s", bin)
}
if _, err := os.Stat(bin); err != nil {
return err
}
host := filepath.Join(r.PythonDir(), "host")
bindir := filepath.Join(host, "bin")
if err := os.MkdirAll(bindir, 0o755); err != nil {
return err
}
link := filepath.Join(bindir, "python3")
_ = os.Remove(link)
if err := os.Symlink(bin, link); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(r.PythonDir(), "SOURCE"), []byte("link "+bin+"\n"), 0o644); err != nil {
return err
}
return r.switchPython("host")
}
func (r *Root) switchPython(name string) error {
link := filepath.Join(r.PythonDir(), "current")
tmp := link + ".tmp"
_ = os.Remove(tmp)
if err := os.Symlink(name, tmp); err != nil {
return err
}
return os.Rename(tmp, link)
}
// InstallStandalone 下载 python-build-standalone 的 install_only 包并解到 python/。
// url 为空时向 GitHub latest 要与本机架构匹配的 cpython 3.14 资产。
func (r *Root) InstallStandalone(ctx context.Context, url string) error {
if url == "" {
var err error
url, err = latestStandaloneURL(ctx)
if err != nil {
return err
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("execenv: download python: %s", resp.Status)
}
dest := filepath.Join(r.PythonDir(), "cpython-standalone")
if err := os.RemoveAll(dest); err != nil {
return err
}
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
if err := extractTarGz(resp.Body, dest); err != nil {
return err
}
bin := filepath.Join(dest, "python", "bin", "python3")
if _, err := os.Stat(bin); err != nil {
return fmt.Errorf("execenv: standalone archive has no python/bin/python3")
}
// 让 current/bin/python3 成立:current -> cpython-standalone/python。
inner := filepath.Join("cpython-standalone", "python")
if err := os.WriteFile(filepath.Join(r.PythonDir(), "SOURCE"), []byte("standalone "+url+"\n"), 0o644); err != nil {
return err
}
return r.switchPython(inner)
}
func latestStandaloneURL(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "agent-system-execenv")
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("execenv: github releases: %s", resp.Status)
}
var doc struct {
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return "", err
}
arch := "x86_64"
if runtime.GOARCH == "arm64" {
arch = "aarch64"
}
needle := arch + "-unknown-linux-gnu-install_only.tar.gz"
var fallback string
for _, a := range doc.Assets {
if !strings.Contains(a.Name, needle) || !strings.HasPrefix(a.Name, "cpython-3.14") {
continue
}
if strings.Contains(a.Name, "freethreaded") {
fallback = a.URL
continue
}
return a.URL, nil
}
if fallback != "" {
return fallback, nil
}
return "", errors.New("execenv: no cpython 3.14 install_only asset")
}
func extractTarGz(rd io.Reader, dest string) error {
gz, err := gzip.NewReader(rd)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
name := filepath.Clean(hdr.Name)
if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
return fmt.Errorf("execenv: tar path escapes: %s", hdr.Name)
}
target := filepath.Join(dest, name)
if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) && target != filepath.Clean(dest) {
return fmt.Errorf("execenv: tar path escapes: %s", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
mode := os.FileMode(hdr.Mode) & 0o777
if mode == 0 {
mode = 0o644
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
_, cerr := io.Copy(f, tr)
f.Close()
if cerr != nil {
return cerr
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
_ = os.Remove(target)
if err := os.Symlink(hdr.Linkname, target); err != nil {
return err
}
}
}
}
// BootstrapVenv 用系统 python3 做一份唯一的托管前缀。解释器仍指向系统 python,
// 但 pip 在这份前缀里,构建器可以下载 wheel。
func (r *Root) BootstrapVenv(python string) error {
if python == "" {
python = "/usr/bin/python3"
}
dest := filepath.Join(r.PythonDir(), "venv")
if _, err := os.Stat(filepath.Join(dest, "bin", "python3")); err != nil {
cmd := exec.Command(python, "-m", "venv", dest)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("execenv: venv: %v: %s", err, out)
}
}
if err := os.WriteFile(filepath.Join(r.PythonDir(), "SOURCE"), []byte("venv "+python+"\n"), 0o644); err != nil {
return err
}
return r.switchPython("venv")
}
+221
View File
@@ -0,0 +1,221 @@
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--
}
}
}
+48
View File
@@ -0,0 +1,48 @@
package execenv
import (
"strings"
"testing"
)
func TestResolveMemConflictAllowlistAndSDist(t *testing.T) {
index := []Wheel{
{Name: "Foo", Version: "1.0.0", Filename: "foo-1.whl", SHA256: "aaa", Kind: "wheel"},
{Name: "Foo", Version: "2.0.0", Filename: "foo-2.whl", SHA256: "bbb", Kind: "wheel"},
{Name: "OnlySrc", Version: "1", Filename: "onlysrc-1.tar.gz", Kind: "sdist"},
}
got := ResolveMem(index, []string{"foo==1.0.0"}, []LockedPkg{{Name: "foo", Version: "2.0.0"}})
if len(got.Override) != 1 || len(got.Packages) != 0 {
t.Fatalf("conflict should keep the pin and record an override: %+v", got)
}
fresh := ResolveMem(index, []string{"foo"}, nil)
if len(fresh.Packages) != 1 || fresh.Packages[0].Version != "2.0.0" {
t.Fatalf("unpinned foo should pick the newest wheel: %+v", fresh.Packages)
}
sdist := ResolveMem(index, []string{"onlysrc"}, nil)
if len(sdist.SDist) != 1 || len(sdist.Packages) != 0 {
t.Fatalf("sdist-only must be refused: %+v", sdist)
}
missing := ResolveMem(index, []string{"nope"}, nil)
if len(missing.Missing) != 1 {
t.Fatalf("missing: %+v", missing)
}
}
func TestBuildLayerRejectsHashMismatch(t *testing.T) {
root, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
wheel := root.Wheelhouse() + "/foo.whl"
if err := osWriteWheel(wheel, "foo.py"); err != nil {
t.Fatal(err)
}
_, err = root.BuildLayer(Lock{
Schema: 1, Kind: "base", PythonTag: "cp314", Platform: "any",
Packages: []LockedPkg{{Name: "foo", Version: "1", Filename: "foo.whl", SHA256: "deadbeef"}},
})
if err == nil || !strings.Contains(err.Error(), "hash mismatch") {
t.Fatalf("err = %v", err)
}
}
+133
View File
@@ -0,0 +1,133 @@
// 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[:])
}
+44
View File
@@ -0,0 +1,44 @@
package execenv
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSandboxImportPipAndOffline(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("bwrap not available")
}
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not available")
}
root, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
site := t.TempDir()
if err := os.WriteFile(filepath.Join(site, "hello.py"), []byte("value = 1\n"), 0o644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := Doctor(ctx, "/usr/bin/python3", []string{site}, []string{"hello"}, true, []string{site}); err != nil {
t.Fatal(err)
}
shim := filepath.Join(root.BinDir(), "pip")
out, err := exec.Command("bwrap", "--unshare-all", "--die-with-parent", "--ro-bind", "/", "/",
"--tmpfs", "/tmp", "--ro-bind", root.BinDir(), root.BinDir(), "--", shim).CombinedOutput()
if err == nil || !strings.Contains(string(out), "python_env") {
t.Fatalf("pip shim err=%v out=%s", err, out)
}
netOut, err := exec.Command("bwrap", "--unshare-net", "--die-with-parent", "--ro-bind", "/", "/",
"--", "/usr/bin/python3", "-c", "import socket; socket.create_connection(('1.1.1.1', 443), 2)").CombinedOutput()
if err == nil {
t.Fatalf("sandbox network should be unreachable, out=%s", netOut)
}
}
+22
View File
@@ -0,0 +1,22 @@
package execenv
import (
"os"
"path/filepath"
)
const pipShim = `#!/bin/sh
echo "沙箱不联网安装。请使用 python_env 工具申请扩展包:python_env request,packages 写包名,reason 写用途。" >&2
exit 1
`
func writePipShim(dir string) error {
body := []byte(pipShim)
for _, name := range []string{"pip", "pip3"} {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, body, 0o755); err != nil {
return err
}
}
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package execenv
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// UnpackWheel 把 wheel 解进 site-packages。wheel 是 zip;这里不执行任何入口脚本。
func UnpackWheel(wheelPath, sitePackages string) error {
zr, err := zip.OpenReader(wheelPath)
if err != nil {
return fmt.Errorf("execenv: open wheel: %w", err)
}
defer zr.Close()
if err := os.MkdirAll(sitePackages, 0o755); err != nil {
return err
}
root := filepath.Clean(sitePackages)
for _, f := range zr.File {
name := filepath.Clean(f.Name)
if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
return fmt.Errorf("execenv: wheel path escapes: %s", f.Name)
}
// 安装器脚本不落地,避免把「解包」变成「执行」。
if strings.Contains(name, ".data/scripts/") {
continue
}
dest := filepath.Join(root, name)
if !strings.HasPrefix(dest, root+string(os.PathSeparator)) && dest != root {
return fmt.Errorf("execenv: wheel path escapes: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
rc.Close()
return err
}
_, cerr := io.Copy(out, rc)
out.Close()
rc.Close()
if cerr != nil {
return cerr
}
}
return nil
}
// BuildLayer 把已经在 wheelhouse 里的 wheel 解进新层。目录已存在则直接复用。
func (r *Root) BuildLayer(l Lock) (string, error) {
id, err := r.WriteLock(l)
if err != nil {
return "", err
}
dest := r.LayerDir(id)
if st, err := os.Stat(dest); err == nil && st.IsDir() {
return id, nil
}
for _, pkg := range l.Packages {
wheel := filepath.Join(r.Wheelhouse(), pkg.SHA256+".whl")
if _, err := os.Stat(wheel); err != nil {
alt := filepath.Join(r.Wheelhouse(), pkg.Filename)
if _, err2 := os.Stat(alt); err2 != nil {
return "", fmt.Errorf("execenv: wheel missing for %s", pkg.Name)
}
wheel = alt
}
sum, err := fileSHA256(wheel)
if err != nil {
return "", err
}
if pkg.SHA256 != "" && !strings.EqualFold(sum, pkg.SHA256) {
return "", fmt.Errorf("execenv: hash mismatch for %s: got %s want %s", pkg.Name, sum, pkg.SHA256)
}
if err := UnpackWheel(wheel, dest); err != nil {
_ = os.RemoveAll(filepath.Dir(dest))
return "", err
}
}
if err := os.MkdirAll(dest, 0o755); err != nil {
return "", err
}
return id, nil
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return h.Sum(), nil
}
@@ -1,7 +1,7 @@
---
id: base.deliverables
layer: base
version: 9
version: 10
title: 交付物工作流
description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座)
---
@@ -38,7 +38,7 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
- **docx-author / xlsx-author / pptx-author**:各类交付物的版面范式和最小示例。
- **frontend-design / canvas-design / copywriting / copy-editing**(第三方 vendor,溯源见各自 UPSTREAM.md):视觉方向与排版审美、海报/信息图等 png·pdf 视觉产物(PIL 渲染,字体已随包)、营销/商务文案的撰写改写与编辑润色。交付物主题与配色仍以 house-style 为权威;需要超出主题规范的视觉判断或文案功力时 `load_skill` 对应技能。
- 小队角色用 `load_skill`;其他路径用 `run_shell` 查看技能文件,例如 `cat "$AGENT_SKILLS_DIR/house-style/SKILL.md"`。不要假设工作区里有 `skills/` 目录。
- 缺库时:`pip install --user python-docx openpyxl python-pptx`。PDF 仿制可选 `pdfplumber`;没装就说明只能近似,并请用户确认主色和字体。
- 缺库时用 `python_env`:先 `status` 看基础层是否已有,没有再 `request` 并写明理由。不要 `pip install`。PDF 仿制可用基础层里的 `pdfplumber`;基础层没有且申请未通过时说明只能近似,并请用户确认主色和字体。
- 缺库又装不上时,如实告知用户当前环境无法生成带排版的 Office 交付物并说明缺什么;禁止改用 zipfile/字符串手工拼 OOXML 充数——那样生成的包缺母版与主题,结构自检会拒绝登记。
要求与边界:
+2 -2
View File
@@ -1,8 +1,8 @@
---
id: tool.run_python
layer: tool
version: 3
version: 4
title: run_python 工具描述
description: Python 执行原语的模型侧说明文案
---
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`。任何 Office 交付物(docx/xlsx/pptx)都禁止用 zipfile 或字符串手工拼 OOXML——手拼的包缺母版与主题,register_artifact 的结构自检会直接拒绝;一律走 housestyle kit 构件。缺库先 `pip install --user python-pptx python-docx openpyxl`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。kit 路线执行失败(报错、超时、后台任务溢出)时,修复后仍走 kit 路线重试——任何失败都不构成退回手工拼 OOXML 的理由。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`。任何 Office 交付物(docx/xlsx/pptx)都禁止用 zipfile 或字符串手工拼 OOXML——手拼的包缺母版与主题,register_artifact 的结构自检会直接拒绝;一律走 housestyle kit 构件。基础层已有 python-docx、openpyxl、python-pptx 等,直接 import。缺的包用 python_env 申请(status 先看清单,request 再等),不要 pip install:沙箱不联网。申请被拒绝就如实说明缺什么,不要退回手拼。kit 路线执行失败(报错、超时、后台任务溢出)时,修复后仍走 kit 路线重试——任何失败都不构成退回手工拼 OOXML 的理由。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
+2 -2
View File
@@ -1,8 +1,8 @@
---
id: tool.run_shell
layer: tool
version: 1
version: 2
title: run_shell 工具描述
description: bash 执行原语的模型侧说明文案
---
在会话工作区内执行 bash 脚本(环境最小化:PATH/HOME/LANG;AGENT_SKILLS_DIR 与 PYTHONPATH 已注入)。适用:housestyle build/lint、pip install --user、文件批操作。读文件用 read_file、找内容用 grep——不要用 cat/sed/grep 命令行代替专用工具。同一命令失败两次换方法。
在会话工作区内执行 bash 脚本(环境最小化:PATH/HOME/LANG;AGENT_SKILLS_DIR 与 PYTHONPATH 已注入)。适用:housestyle build/lint、文件批操作。Python 缺包用 python_env,不要 pip install。读文件用 read_file、找内容用 grep——不要用 cat/sed/grep 命令行代替专用工具。同一命令失败两次换方法。
+33 -1
View File
@@ -50,7 +50,9 @@ type Manager struct {
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.CompiledWorkflowRunner
workflows *workflow.Registry // run_workflow 按 id 查找已注册定义
gate IntentGate
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
// execEnvDecide 处理不挂在活动 run 上的 Python 环境审批卡(Detail 前缀 exec-env:)。
execEnvDecide func(actor, requestID string, allow, isolate bool) (string, error)
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
OnUsage func(owner string, tokens int)
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
@@ -191,6 +193,11 @@ func (m *Manager) EmitProjectSharedCardEvent(owner, producer, channel, runID str
// SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。
func (m *Manager) SetGate(g IntentGate) { m.gate = g }
// SetExecEnvDecider 接上分层环境的审批。卡片不要求 run 仍在本进程执行。
func (m *Manager) SetExecEnvDecider(fn func(actor, requestID string, allow, isolate bool) (string, error)) {
m.execEnvDecide = fn
}
// SetCards 注入收件箱卡服务(nil = 不产卡;T0 退回基线仍成立)。
func (m *Manager) SetCards(s *cardsvc.Service) { m.cards = s }
@@ -1960,6 +1967,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
if card.Resolved {
return "", false, fmt.Errorf("runmgr: card %s already resolved", cardID)
}
if strings.HasPrefix(card.Detail, "exec-env:") {
return m.resolveExecEnvCard(owner, card, resp)
}
run, err := m.st.GetRunByID(card.RunID)
if err != nil {
return "", false, err
@@ -2061,6 +2071,28 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
return "", false, nil
}
func (m *Manager) resolveExecEnvCard(owner string, card core.HITLCard, resp core.HITLResponse) (string, bool, error) {
if m.execEnvDecide == nil {
return "", false, fmt.Errorf("runmgr: exec env decider is not configured")
}
id := strings.TrimPrefix(card.Detail, "exec-env:")
if i := strings.IndexByte(id, '\n'); i >= 0 {
id = id[:i]
}
choice := resp.ChoiceID
allow := choice == "approve" || choice == "isolate"
detail, err := m.execEnvDecide(owner, id, allow, choice == "isolate")
if err != nil {
return "", false, err
}
if err := m.resolveHITLCardForEffectiveAccount(owner, string(card.ID), core.HITLResolvedPayload{
CardID: card.ID, Kind: "choice", Answer: choice,
}); err != nil {
return "", false, err
}
return detail, false, nil
}
// ---- 子代理(任务树)----
func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agentID, name, task, hint string) (core.AgentResultPayload, error) {
+151
View File
@@ -0,0 +1,151 @@
package server
import (
"io"
"net/http"
"os"
"path/filepath"
"time"
)
func (s *Server) adminExecEnv(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
if s.ExecEnv == nil {
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
return
}
base, err := s.ExecEnv.Root.CurrentBase()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
reqs, err := s.ExecEnv.Store.ListExecEnvRequests("", 50)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
binds, err := s.ExecEnv.Store.ListExecEnvBindings()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
bin, _ := s.ExecEnv.Root.PythonBin()
ents, _ := os.ReadDir(s.ExecEnv.Root.LayersDir())
layers := make([]string, 0, len(ents))
for _, ent := range ents {
if ent.IsDir() {
layers = append(layers, ent.Name())
}
}
writeJSON(w, http.StatusOK, map[string]any{
"configured": true,
"root": s.ExecEnv.Root.Path,
"python": bin,
"base": base,
"layers": layers,
"requests": reqs,
"bindings": binds,
})
}
func (s *Server) adminExecEnvDecision(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
if s.ExecEnv == nil {
http.Error(w, "exec env is not configured", http.StatusConflict)
return
}
var body struct {
Allow bool `json:"allow"`
Isolate bool `json:"isolate"`
}
if err := decodeJSON(r, &body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
out, err := s.ExecEnv.Decide(r.Context(), r.PathValue("id"), actor, true, body.Allow, body.Isolate)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) adminExecEnvRebase(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
if s.ExecEnv == nil {
http.Error(w, "exec env is not configured", http.StatusConflict)
return
}
id, err := s.ExecEnv.Rebase(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]any{"base": id})
}
func (s *Server) adminExecEnvGC(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
if s.ExecEnv == nil {
http.Error(w, "exec env is not configured", http.StatusConflict)
return
}
removed, err := s.ExecEnv.GC(7 * 24 * time.Hour)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"removed": removed})
}
func (s *Server) adminExecEnvWheel(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
if s.ExecEnv == nil {
http.Error(w, "exec env is not configured", http.StatusConflict)
return
}
if err := r.ParseMultipartForm(64 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
file, hdr, err := r.FormFile("wheel")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
dir := filepath.Join(s.ExecEnv.Root.Path, "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dst, err := os.CreateTemp(dir, "upload-*.whl")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, err := io.Copy(dst, file); err != nil {
dst.Close()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dst.Close()
out, err := s.ExecEnv.RequestPackages(r.Context(), r.FormValue("conversationId"), actor, r.FormValue("reason"), nil, dst.Name())
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]any{"file": hdr.Filename, "outcome": out})
}
+19 -11
View File
@@ -22,6 +22,7 @@ import (
agentcommand "agent-command"
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/execenv"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/plugins"
@@ -53,17 +54,19 @@ type Server struct {
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
mgr *runmgr.Manager
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
reg *tools.Registry
prof *profiles.Service
set *settings.Service
plugins *plugins.Host
static fs.FS
// ExecEnv 分层 Python 环境代理(nil = 未配置)。管理区用它审批、回收和重建基础层。
ExecEnv *execenv.Broker
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
reg *tools.Registry
prof *profiles.Service
set *settings.Service
plugins *plugins.Host
static fs.FS
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
@@ -290,6 +293,11 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/auth/logout", s.logout)
mux.HandleFunc("POST /api/auth/password", s.changePassword)
mux.HandleFunc("GET /api/admin/users", s.listAdminUsers)
mux.HandleFunc("GET /api/admin/exec-env", s.adminExecEnv)
mux.HandleFunc("POST /api/admin/exec-env/requests/{id}/decision", s.adminExecEnvDecision)
mux.HandleFunc("POST /api/admin/exec-env/rebase", s.adminExecEnvRebase)
mux.HandleFunc("POST /api/admin/exec-env/gc", s.adminExecEnvGC)
mux.HandleFunc("POST /api/admin/exec-env/wheels", s.adminExecEnvWheel)
mux.HandleFunc("GET /api/admin/users/search", s.searchAdminUsers)
mux.HandleFunc("POST /api/admin/users", s.createAdminUser)
mux.HandleFunc("GET /api/me/profile", s.meProfile)
+149
View File
@@ -0,0 +1,149 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
)
// ExecEnvRequest 是一次扩展包申请(分层 Python 环境)。
type ExecEnvRequest struct {
ID string
ConversationID string
Owner string
Spec string
Reason string
Status string
LayerID string
Detail string
Approver string
CreatedAt string
UpdatedAt string
}
// ExecEnvBinding 是会话挂载的层。Layers 是扩展层 ID;Isolated 非空时替换基础层。
type ExecEnvBinding struct {
ConversationID string
BaseID string
Layers []string
Isolated string
UpdatedAt string
}
func (s *Store) InsertExecEnvRequest(r ExecEnvRequest) error {
_, err := s.db.Exec(s.q(`INSERT INTO exec_env_requests(
id,conversation_id,owner,spec,reason,status,layer_id,detail,approver,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
r.ID, r.ConversationID, r.Owner, r.Spec, r.Reason, r.Status, r.LayerID, r.Detail, r.Approver, r.CreatedAt, r.UpdatedAt)
return err
}
func (s *Store) GetExecEnvRequest(id string) (ExecEnvRequest, error) {
var r ExecEnvRequest
err := s.db.QueryRow(s.q(`SELECT id,conversation_id,owner,spec,reason,status,layer_id,detail,approver,created_at,updated_at
FROM exec_env_requests WHERE id=?`), id).Scan(
&r.ID, &r.ConversationID, &r.Owner, &r.Spec, &r.Reason, &r.Status, &r.LayerID, &r.Detail, &r.Approver, &r.CreatedAt, &r.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return ExecEnvRequest{}, ErrNotFound
}
return r, err
}
func (s *Store) ListExecEnvRequests(conversationID string, limit int) ([]ExecEnvRequest, error) {
if limit <= 0 {
limit = 20
}
q := `SELECT id,conversation_id,owner,spec,reason,status,layer_id,detail,approver,created_at,updated_at
FROM exec_env_requests`
var args []any
if conversationID != "" {
q += ` WHERE conversation_id=?`
args = append(args, conversationID)
}
q += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.Query(s.q(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanExecEnvRequests(rows)
}
func scanExecEnvRequests(rows *sql.Rows) ([]ExecEnvRequest, error) {
var out []ExecEnvRequest
for rows.Next() {
var r ExecEnvRequest
if err := rows.Scan(&r.ID, &r.ConversationID, &r.Owner, &r.Spec, &r.Reason, &r.Status, &r.LayerID, &r.Detail, &r.Approver, &r.CreatedAt, &r.UpdatedAt); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) UpdateExecEnvRequest(id, status, layerID, detail, approver string) error {
res, err := s.db.Exec(s.q(`UPDATE exec_env_requests SET status=?, layer_id=?, detail=?, approver=?, updated_at=? WHERE id=?`),
status, layerID, detail, approver, nowISO(), id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) GetExecEnvBinding(conversationID string) (ExecEnvBinding, error) {
var b ExecEnvBinding
var layers string
err := s.db.QueryRow(s.q(`SELECT conversation_id,base_id,layers,isolated,updated_at FROM exec_env_bindings WHERE conversation_id=?`), conversationID).
Scan(&b.ConversationID, &b.BaseID, &layers, &b.Isolated, &b.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return ExecEnvBinding{}, nil
}
if err != nil {
return ExecEnvBinding{}, err
}
_ = json.Unmarshal([]byte(layers), &b.Layers)
return b, nil
}
func (s *Store) PutExecEnvBinding(b ExecEnvBinding) error {
raw, err := json.Marshal(b.Layers)
if err != nil {
return err
}
_, err = s.db.Exec(s.q(`INSERT INTO exec_env_bindings(conversation_id,base_id,layers,isolated,updated_at)
VALUES(?,?,?,?,?)
ON CONFLICT(conversation_id) DO UPDATE SET base_id=excluded.base_id, layers=excluded.layers, isolated=excluded.isolated, updated_at=excluded.updated_at`),
b.ConversationID, b.BaseID, string(raw), b.Isolated, b.UpdatedAt)
return err
}
func (s *Store) ListExecEnvBindings() ([]ExecEnvBinding, error) {
rows, err := s.db.Query(s.q(`SELECT conversation_id,base_id,layers,isolated,updated_at FROM exec_env_bindings`))
if err != nil {
return nil, err
}
defer rows.Close()
var out []ExecEnvBinding
for rows.Next() {
var b ExecEnvBinding
var layers string
if err := rows.Scan(&b.ConversationID, &b.BaseID, &layers, &b.Isolated, &b.UpdatedAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(layers), &b.Layers)
out = append(out, b)
}
return out, rows.Err()
}
func (s *Store) InsertExecEnvAudit(owner, action, actor, target, outcome, entry string) error {
_, err := s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,evidence,trace_id,entry,instance_id,operation_id,outcome)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
nowISO(), owner, action, actor, target, entry, "", "execenv", s.instanceID, "", outcome)
return err
}
+35
View File
@@ -897,9 +897,44 @@ func init() {
`ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`,
`ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`,
}}},
migration{version: 35, name: "exec_env_layers",
up: map[string][]string{"sqlite": execEnvUp, "postgres": execEnvUp},
down: map[string][]string{"sqlite": execEnvDown, "postgres": execEnvDown},
},
)
}
// v35 分层 Python 环境:扩展包申请与会话绑定。层目录本身在环境根,不入库。
var execEnvUp = []string{
`CREATE TABLE IF NOT EXISTS exec_env_requests (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
spec TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
layer_id TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
approver TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_exec_env_requests_conv ON exec_env_requests(conversation_id, created_at)`,
`CREATE TABLE IF NOT EXISTS exec_env_bindings (
conversation_id TEXT PRIMARY KEY,
base_id TEXT NOT NULL DEFAULT '',
layers TEXT NOT NULL DEFAULT '[]',
isolated TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
)`,
}
var execEnvDown = []string{
`DROP TABLE IF EXISTS exec_env_bindings`,
`DROP INDEX IF EXISTS idx_exec_env_requests_conv`,
`DROP TABLE IF EXISTS exec_env_requests`,
}
var recoveryFoundationUp = []string{
`ALTER TABLE login_keys ADD COLUMN grant_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`,
`ALTER TABLE login_keys ADD COLUMN generation BIGINT NOT NULL DEFAULT 0`,
+1
View File
@@ -39,6 +39,7 @@ var migrationBreaksOldReaders = map[int]bool{
32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除
33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除
34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段
35: false, // exec_env 新表;旧读者不读这些表
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+47
View File
@@ -0,0 +1,47 @@
package tools
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestHousestyleBuildPassesOOXMLGate(t *testing.T) {
py, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 not available")
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
skill := filepath.Join(wd, "..", "skills", "recipes", "house-style")
if _, err := os.Stat(filepath.Join(skill, "housestyle", "build.py")); err != nil {
t.Skip("house-style skill is not in this checkout")
}
probe := exec.Command(py, "-c", "import docx")
if out, err := probe.CombinedOutput(); err != nil {
t.Skipf("python-docx is not installed: %s", out)
}
dir := t.TempDir()
src := filepath.Join(dir, "summary.md")
body := "# 摘要\n\n## 结论\n\n- 沙箱里可以直接生成 docx\n"
if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cmd := exec.Command(py, "-m", "housestyle.build", src)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "PYTHONPATH="+skill)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("housestyle.build: %v\n%s", err, out)
}
docx := filepath.Join(dir, "summary.docx")
content, err := os.ReadFile(docx)
if err != nil {
t.Fatal(err)
}
if err := ooxmlStructuralCheck("summary.docx", content); err != nil {
t.Fatal(err)
}
}
+18
View File
@@ -34,6 +34,12 @@ type ExecSpec struct {
// ~/.local 下会被 --tmpfs /home 遮蔽,只读挂回其原路径并并入 PYTHONPATH,
// 交付物生成链路(F3)在隔离档下才可用。
DepsDir string
// LayerDirs 是本次执行要只读挂载的 site-packages(基础层与扩展层,或单独的隔离层)。
// 路径若位于 /home 或 /tmp,必须在对应 tmpfs 之后挂载。
LayerDirs []string
// PythonPrefix / ShimDir 同样可能落在被 tmpfs 遮住的路径上,需要挂回。
PythonPrefix string
ShimDir string
// S0 工作区 overlay(sandbox 档;三者须同批非空):冻结基底(--overlay-src)、
// 私有 upper(--overlay RWSRC)、overlayfs work 目录。空 = EXT-16 现状直写。
WorkspaceFrozen string
@@ -199,6 +205,18 @@ func sandboxArgv(spec ExecSpec) []string {
// 必须后于 --tmpfs /home:先遮蔽再只读挂回依赖层(挂载顺序敏感)。
argv = append(argv, "--ro-bind", spec.DepsDir, spec.DepsDir)
}
for _, dir := range spec.LayerDirs {
if dir == "" || dir == spec.DepsDir {
continue
}
argv = append(argv, "--ro-bind", dir, dir)
}
if spec.PythonPrefix != "" {
argv = append(argv, "--ro-bind", spec.PythonPrefix, spec.PythonPrefix)
}
if spec.ShimDir != "" && spec.ShimDir != spec.PythonPrefix {
argv = append(argv, "--ro-bind", spec.ShimDir, spec.ShimDir)
}
argv = append(argv, "--chdir", spec.Dir, "--clearenv")
for _, kv := range spec.Env {
if i := strings.IndexByte(kv, '='); i > 0 {
+31 -2
View File
@@ -270,7 +270,7 @@ func TestDirectBackendRunInterpreterUnchanged(t *testing.T) {
if err := os.WriteFile(p, []byte("echo hi; exit 7\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := runInterpreter(context.Background(), "bash", p, dir, 30, nil)
got, err := runInterpreter(context.Background(), "bash", p, dir, "", 30, nil)
if err != nil || !strings.HasPrefix(got, "exit=7\n") || !strings.Contains(got, "hi") {
t.Fatalf("direct regression: err=%v got=%q", err, got)
}
@@ -278,7 +278,7 @@ func TestDirectBackendRunInterpreterUnchanged(t *testing.T) {
if err := os.WriteFile(p2, []byte("sleep 60\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err = runInterpreter(context.Background(), "bash", p2, dir, 2, nil)
got, err = runInterpreter(context.Background(), "bash", p2, dir, "", 2, nil)
if err != nil || !strings.HasPrefix(got, "exit=timeout(2s)") {
t.Fatalf("direct timeout regression: err=%v got=%q", err, got)
}
@@ -303,6 +303,35 @@ func TestSandboxArgvDepsLayer(t *testing.T) {
}
}
func TestSandboxArgvLayersAndPath(t *testing.T) {
argv := sandboxArgv(ExecSpec{
Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"},
Dir: "/data/s",
LayerDirs: []string{"/opt/base/site-packages", "/opt/ext/site-packages"},
PythonPrefix: "/opt/py",
ShimDir: "/opt/env/bin",
Env: []string{"PATH=/opt/py/bin:/opt/env/bin:/usr/bin:/bin", "PIP_NO_INDEX=1"},
})
joined := strings.Join(argv, "\x00")
for _, m := range []string{
"--ro-bind\x00/opt/base/site-packages\x00/opt/base/site-packages",
"--ro-bind\x00/opt/ext/site-packages\x00/opt/ext/site-packages",
"--ro-bind\x00/opt/py\x00/opt/py",
"--ro-bind\x00/opt/env/bin\x00/opt/env/bin",
"--setenv\x00PATH\x00/opt/py/bin:/opt/env/bin:/usr/bin:/bin",
"--setenv\x00PIP_NO_INDEX\x001",
} {
if !strings.Contains(joined, m) {
t.Fatalf("sandbox argv missing %q: %v", m, argv)
}
}
baseAt := strings.Index(joined, "/opt/base/site-packages")
extAt := strings.Index(joined, "/opt/ext/site-packages")
if baseAt < 0 || extAt < baseAt {
t.Fatalf("extension layer must mount after the base layer: %v", argv)
}
}
// TestExecEnvJoinsPythonPath F3:依赖层与技能根合成一条 PYTHONPATH,
// 不允许重复键互相覆盖(否则只挂载了依赖层却 import 不到)。
func TestExecEnvJoinsPythonPath(t *testing.T) {
+124
View File
@@ -0,0 +1,124 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"agent-system-backend/internal/execenv"
core "agent-system-core"
)
// envBroker 由组合根注入。未注入时 python_env 明确报错,不假装能 pip install。
var envBroker *execenv.Broker
// envApproval 在申请需要人批时写一张选择卡。空则只把 pending 文本回给模型。
var envApproval func(owner, conversationID, requestID, detail string) error
// SetEnvBroker 注入分层环境代理。
func SetEnvBroker(b *execenv.Broker) { envBroker = b }
// SetEnvApproval 注入审批卡写入。
func SetEnvApproval(fn func(owner, conversationID, requestID, detail string) error) {
envApproval = fn
}
func registerPythonEnvTool(r *Registry) error {
return r.Register(core.ToolDesc{
Name: "python_env", Source: core.SourceBuiltin, Risk: "medium",
Description: "向系统申请 Python 包,或查看本会话已经能用的环境。沙箱不能联网安装。action=status 查看基础层与本会话的层;action=request 申请包(packages)或登记工作区内的 wheel;白名单内的包会自动装好并在下一次 run_python 生效,其余会进入审批。action=wait 等待 requestId 结束。不要用 pip install。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"action":{"type":"string","enum":["status","request","wait"],"description":"status | request | wait"},` +
`"packages":{"type":"array","items":{"type":"string"},"description":"包规格,如 pdfplumber 或 numpy==2.2.0"},` +
`"reason":{"type":"string","description":"为什么需要这些包"},` +
`"wheel":{"type":"string","description":"工作区内的 .whl 相对路径,上传的包需要审批"},` +
`"request_id":{"type":"string","description":"wait 时的申请 ID"},` +
`"timeout_sec":{"type":"integer","description":"wait 的秒数,默认 120"}}` +
`,"required":["action"]}`),
}, execPythonEnv)
}
func execPythonEnv(rc ToolExecCtx, args json.RawMessage) (string, error) {
if err := execGate(rc); err != nil {
return "", err
}
if envBroker == nil {
return "", fmt.Errorf("python_env: 环境代理未配置(启动参数 execEnvRoot)")
}
var a struct {
Action string `json:"action"`
Packages []string `json:"packages"`
Reason string `json:"reason"`
Wheel string `json:"wheel"`
RequestID string `json:"request_id"`
TimeoutS int `json:"timeout_sec"`
}
if len(args) > 0 {
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("python_env: bad args: %w", err)
}
}
switch a.Action {
case "status":
return envBroker.StatusText(rc.ConversationID())
case "wait":
if strings.TrimSpace(a.RequestID) == "" {
return "", fmt.Errorf("python_env: request_id required")
}
timeout := time.Duration(a.TimeoutS) * time.Second
out, err := envBroker.Wait(execParentCtx(rc), a.RequestID, timeout)
if err != nil {
return "", err
}
raw, _ := json.Marshal(out)
return string(raw), nil
case "request":
wheel := ""
if strings.TrimSpace(a.Wheel) != "" {
sandbox, err := execSandbox(rc)
if err != nil {
return "", err
}
wheel, err = filepath.Abs(filepath.Join(sandbox, a.Wheel))
if err != nil {
return "", err
}
if !strings.HasPrefix(wheel, sandbox+string(filepath.Separator)) {
return "", fmt.Errorf("python_env: wheel must stay inside the workspace")
}
}
out, err := envBroker.RequestPackages(execParentCtx(rc), rc.ConversationID(), rc.OwnerID(), a.Reason, a.Packages, wheel)
if err != nil {
return "", err
}
if out.Status == "pending_approval" && envApproval != nil {
if err := envApproval(rc.OwnerID(), rc.ConversationID(), out.RequestID, out.Detail); err != nil {
out.Detail += ";审批卡未写入:" + err.Error()
}
}
raw, _ := json.Marshal(out)
return string(raw), nil
default:
return "", fmt.Errorf("python_env: unknown action %q", a.Action)
}
}
// ExecDoctor 用当前执行后端探针导入基础交付物库。依赖层已配置但导入失败时返回错误。
func ExecDoctor() error {
bin, err := resolveInterpreter("python3")
if err != nil {
return err
}
dirs, _ := pythonPathFor("")
mounts := append([]string{}, dirs...)
if execPythonPrefix != "" {
mounts = append(mounts, execPythonPrefix)
}
if execShimDir != "" {
mounts = append(mounts, execShimDir)
}
return execenv.Doctor(context.Background(), bin, dirs, nil, ExecBackendName() == "sandbox", mounts)
}
+111 -24
View File
@@ -31,7 +31,7 @@ import (
func RegisterInterpreterTools(r *Registry) error {
if err := r.Register(core.ToolDesc{
Name: "run_shell", Source: core.SourceBuiltin, Risk: "medium",
Description: "在会话工作区目录内执行 bash 脚本:检查环境、安装依赖(pip install --user …)、处理文件等。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。以当前用户身份运行。",
Description: "在会话工作区目录内执行 bash 脚本:检查环境、处理文件、调用 python3 -m housestyle.build。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。沙箱断网且不继承宿主家目录,不要用 pip/apt 安装;缺的 Python 包用 python_env 向系统申请。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"script":{"type":"string","description":"bash 脚本(可多行)"},` +
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` +
@@ -39,9 +39,12 @@ func RegisterInterpreterTools(r *Registry) error {
}, execShell); err != nil {
return err
}
if err := registerPythonEnvTool(r); err != nil {
return err
}
return r.Register(core.ToolDesc{
Name: "run_python", Source: core.SourceBuiltin, Risk: "medium",
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。",
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。基础层已提供 python-docx、openpyxl、python-pptx 等,可直接 import。缺的包用 python_env 申请,不要 pip install。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。",
Parameters: json.RawMessage(`{"type":"object","properties":{` +
`"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` +
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` +
@@ -55,39 +58,95 @@ var skillsRoot string
// SetSkillsRoot 让 run_python / run_shell 能 import housestyle,并看到技能目录。
func SetSkillsRoot(dir string) { skillsRoot = dir }
// execDepsDir 是 sandbox 档的只读依赖层(site-packages 等);组合根启动时注入。
// execDepsDir 是 sandbox 档的只读基础依赖层(site-packages);组合根启动时注入。
var execDepsDir string
// SetExecDepsDir 配置 sandbox 档依赖层目录(空 = 不挂载,现状)。
// SetExecDepsDir 配置 sandbox 档基础依赖层目录(空 = 不挂载,现状)。
func SetExecDepsDir(dir string) { execDepsDir = dir }
// execEnv 最小环境:PATH/HOME/LANG/TZ/TERM 继承(pip --user、字体、中文 locale),
// 其余变量不带入子进程。技能根配置后追加 AGENT_SKILLS_DIR 与 PYTHONPATH。
func execEnv() []string {
// 托管解释器与 pip 垫片。空表示尚未配置,python3 回落到 /usr/bin/python3,
// 不再使用 PATH 里可能指向 /home 的解释器(沙箱会把 /home 遮掉)。
var (
execPythonBin string
execPythonPrefix string
execShimDir string
execLayers func(conversationID string) (extra []string, isolated string)
)
// SetExecPython 配置托管解释器。prefix 是需要在沙箱里重新挂载的目录。
func SetExecPython(bin, prefix string) {
execPythonBin = bin
execPythonPrefix = prefix
}
// SetExecShimDir 把 pip 垫片目录放到 PATH 最前。
func SetExecShimDir(dir string) { execShimDir = dir }
// SetExecLayers 按会话返回扩展层或隔离层的 site-packages 目录。
func SetExecLayers(fn func(conversationID string) (extra []string, isolated string)) {
execLayers = fn
}
// execEnv 最小环境。PATH 固定,不继承宿主 PATH:宿主 PATH 里的 linuxbrew
// 位于 /home 下,沙箱 tmpfs 会把它遮掉,解析到那里的 python3 无法启动。
func execEnv() []string { return execEnvFor("") }
func execEnvFor(conv string) []string {
var out []string
for _, kv := range os.Environ() {
for _, keep := range []string{"PATH=", "HOME=", "LANG=", "LC_", "TZ=", "TERM="} {
for _, keep := range []string{"HOME=", "LANG=", "LC_", "TZ=", "TERM="} {
if strings.HasPrefix(kv, keep) {
out = append(out, kv)
break
}
}
}
out = append(out, "PYTHONDONTWRITEBYTECODE=1")
var pypaths []string
if execDepsDir != "" {
pypaths = append(pypaths, execDepsDir)
}
out = append(out,
"PATH="+execPATH(),
"PYTHONDONTWRITEBYTECODE=1",
"PIP_NO_INDEX=1",
)
dirs, _ := pythonPathFor(conv)
if skillsRoot != "" {
out = append(out, "AGENT_SKILLS_DIR="+skillsRoot)
pypaths = append(pypaths, filepath.Join(skillsRoot, "house-style"))
dirs = append(dirs, filepath.Join(skillsRoot, "house-style"))
}
if len(pypaths) > 0 { // 合成一条,避免重复键互相覆盖
out = append(out, "PYTHONPATH="+strings.Join(pypaths, string(os.PathListSeparator)))
if len(dirs) > 0 {
out = append(out, "PYTHONPATH="+strings.Join(dirs, string(os.PathListSeparator)))
}
return out
}
func execPATH() string {
var parts []string
if execPythonBin != "" {
parts = append(parts, filepath.Dir(execPythonBin))
}
if execShimDir != "" {
parts = append(parts, execShimDir)
}
parts = append(parts, "/usr/local/bin", "/usr/bin", "/bin")
return strings.Join(parts, ":")
}
// pythonPathFor 返回要放进 PYTHONPATH、并且在沙箱里只读挂载的目录。
// 隔离层替换基础层,不与基础层叠放。
func pythonPathFor(conv string) (dirs []string, isolated bool) {
var extra []string
var iso string
if execLayers != nil && conv != "" {
extra, iso = execLayers(conv)
}
if iso != "" {
return []string{iso}, true
}
if execDepsDir != "" {
dirs = append(dirs, execDepsDir)
}
dirs = append(dirs, extra...)
return dirs, false
}
// truncateOutput 头 2/3 + 尾 1/3 保留,中部标记省略。
func truncateOutput(b []byte) string {
if len(b) <= execMaxOutput {
@@ -130,10 +189,27 @@ func execParentCtx(rc ToolExecCtx) context.Context {
return context.Background()
}
// resolveInterpreter 把白名单解释器名钉成绝对路径,避免 PATH 在子进程里被换掉。
// resolveInterpreter 把白名单解释器名钉成绝对路径。
// python3 优先用托管解释器,其次 /usr/bin/python3。不用 LookPath:
// 服务进程的 PATH 常把 /home 下的 linuxbrew 放在前面,那个路径在沙箱里不存在。
func resolveInterpreter(name string) (string, error) {
switch name {
case "bash", "python3":
case "bash":
for _, p := range []string{"/bin/bash", "/usr/bin/bash"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, nil
}
}
case "python3":
if execPythonBin != "" {
if st, err := os.Stat(execPythonBin); err == nil && !st.IsDir() {
return execPythonBin, nil
}
return "", fmt.Errorf("python3: configured interpreter missing: %s", execPythonBin)
}
if st, err := os.Stat("/usr/bin/python3"); err == nil && !st.IsDir() {
return "/usr/bin/python3", nil
}
default:
return "", fmt.Errorf("interpreter %s is not allowlisted", name)
}
@@ -144,13 +220,16 @@ func resolveInterpreter(name string) (string, error) {
if !filepath.IsAbs(p) {
return "", fmt.Errorf("%s: resolved path is not absolute: %s", name, p)
}
if strings.HasPrefix(p, "/home/") || strings.HasPrefix(p, "/root/") {
return "", fmt.Errorf("%s: refusing %s because the sandbox hides that path", name, p)
}
return p, nil
}
// runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。
// 进程启动经 EXT-16 执行后端(direct=现状直启 / sandbox=bwrap 隔离档)。
// 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。
func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string), checks ...func() error) (string, error) {
func runInterpreter(parent context.Context, interpreter, scriptPath, dir, convID string, timeoutS int, sink func(stream, text string), checks ...func() error) (string, error) {
if parent == nil {
parent = context.Background()
}
@@ -168,10 +247,18 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string,
check = checks[0]
}
spec := ExecSpec{
Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnv(),
Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnvFor(convID),
SkillsDir: skillsRoot, DepsDir: execDepsDir, Timeout: time.Duration(timeoutS) * time.Second,
PythonPrefix: execPythonPrefix, ShimDir: execShimDir,
CheckExternalEffect: check,
}
if dirs, isolated := pythonPathFor(convID); isolated {
spec.DepsDir = ""
spec.LayerDirs = dirs
} else if len(dirs) > 1 || (len(dirs) == 1 && dirs[0] != execDepsDir) {
spec.LayerDirs = dirs
spec.DepsDir = ""
}
pr, pw := io.Pipe()
var buf bytes.Buffer
readDone := make(chan struct{})
@@ -256,7 +343,7 @@ func execShell(rc ToolExecCtx, args json.RawMessage) (string, error) {
if err != nil {
return "", err
}
return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) })
return runInterpreter(execParentCtx(rc), "bash", p, sandbox, rc.ConversationID(), firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) })
}
// ScriptCommand 准备一条以当前用户身份、工作目录为会话沙箱的 python3 命令。
@@ -271,7 +358,7 @@ func ScriptCommand(ctx context.Context, sandbox, path string) (*exec.Cmd, error)
}
cmd := exec.CommandContext(ctx, bin, path)
cmd.Dir = sandbox
cmd.Env = execEnv()
cmd.Env = execEnvFor("")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.WaitDelay = 2 * time.Second
cmd.Cancel = func() error {
@@ -312,7 +399,7 @@ func execPython(rc ToolExecCtx, args json.RawMessage) (string, error) {
if err != nil {
return "", err
}
return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) })
return runInterpreter(execParentCtx(rc), "python3", p, sandbox, rc.ConversationID(), firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) })
}
// toolOutputSink 在执行上下文实现了 EmitToolOutput 时,把 stdout 增量交出去。
@@ -343,7 +430,7 @@ func InterpreterExecConfig() *agenttools.ExecConfig {
if ExecDisabled() {
return nil
}
cfg := &agenttools.ExecConfig{Env: execEnv()}
cfg := &agenttools.ExecConfig{Env: execEnvFor("")}
if p, err := exec.LookPath("bash"); err == nil {
cfg.Bash = p
}
+1 -1
View File
@@ -227,7 +227,7 @@ func TestInterpreterExecConfigForKernel(t *testing.T) {
for _, kv := range cfg.Env {
if strings.HasPrefix(kv, "PATH=") || strings.HasPrefix(kv, "HOME=") || strings.HasPrefix(kv, "LANG=") ||
strings.HasPrefix(kv, "LC_") || strings.HasPrefix(kv, "TZ=") || strings.HasPrefix(kv, "TERM=") ||
kv == "PYTHONDONTWRITEBYTECODE=1" || strings.HasPrefix(kv, "AGENT_SKILLS_DIR=") || strings.HasPrefix(kv, "PYTHONPATH=") {
kv == "PYTHONDONTWRITEBYTECODE=1" || kv == "PIP_NO_INDEX=1" || strings.HasPrefix(kv, "AGENT_SKILLS_DIR=") || strings.HasPrefix(kv, "PYTHONPATH=") {
continue
}
t.Fatalf("env whitelist violated: %q", kv)
+2 -1
View File
@@ -378,7 +378,8 @@ func ooxmlDeclaredNamespaces(f *zip.File) (map[string]bool, error) {
return nil, fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
}
defer rc.Close()
out := map[string]bool{"xml": true, "xmlns": true}
// xml 前缀由 XML 规范预绑定。Go 解码器把它展开成 URI,而不是留下前缀名。
out := map[string]bool{"xml": true, "xmlns": true, "http://www.w3.org/XML/1998/namespace": true}
dec := xml.NewDecoder(rc)
for {
tok, err := dec.Token()