Merge current backend main into human decisions

Why: The W4b verifier branch must include the current compiled-plugin host option surface before review.

Evidence: Focused extension, app, server, and command-bus tests pass after preserving both public API additions.

Impact: The public surface advances to 2.23.0-experimental and exposes both human-decision and compiled-plugin capabilities.

Refs: W4b human decision verifier
This commit is contained in:
weichen
2026-10-15 06:03:03 +00:00
9 changed files with 792 additions and 85 deletions
+39 -1
View File
@@ -15,6 +15,7 @@ import (
"agent-system-backend/internal/app"
"agent-system-backend/internal/config"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
"agent-system-backend/pkg/identity"
)
@@ -73,7 +74,19 @@ func main() {
pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)")
pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)")
pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)")
extension.RegisterCLIFlags(fs)
fs.Parse(os.Args[1:])
handled, hookErr := extension.RunCLIHooks(extension.Options{
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
StorageDSN: *dsn, AuthMode: *auth, DisableExecTools: *noExec, ExecBackend: *execBackend,
})
if hookErr != nil {
fmt.Fprintln(os.Stderr, "agent-system:", hookErr)
os.Exit(1)
}
if handled {
return
}
cliSet := visited(fs)
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
@@ -94,11 +107,36 @@ func main() {
}
os.Exit(1)
}
pluginDefaults, merr := extension.MergeHostOptions(extension.Options{})
if merr != nil {
fmt.Fprintln(os.Stderr, "agent-system:", merr)
os.Exit(1)
}
disableExec := resolved.DisableExecTools
if !cliSet["disableExecTools"] {
disableExec = disableExec || pluginDefaults.DisableExecTools
}
execBackendResolved := resolved.ExecBackend
if !cliSet["execBackend"] && execBackendResolved == "" {
execBackendResolved = pluginDefaults.ExecBackend
}
extOpt := extension.Options{
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir,
DisableUploadIndexing: pluginDefaults.DisableUploadIndexing,
DisableConvCardMirror: pluginDefaults.DisableConvCardMirror,
DisableExecTools: disableExec, ExecBackend: execBackendResolved,
}
if err := extension.PrepareCompiled(&extOpt); err != nil {
fmt.Fprintln(os.Stderr, "agent-system:", err)
os.Exit(1)
}
if err := app.Run(app.Options{
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
DisableUploadIndexing: extOpt.DisableUploadIndexing,
DisableConvCardMirror: extOpt.DisableConvCardMirror,
DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir,
ExecWorkspace: resolved.ExecWorkspace,
Tier: resolved.Tier,
PGPool: store.PoolOptions{
+2 -2
View File
@@ -1,8 +1,8 @@
---
id: tool.run_python
layer: tool
version: 2
version: 3
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`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。报错先读退出码与 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 构件。缺库先 `pip install --user python-pptx python-docx openpyxl`,装不上就如实告知用户当前环境无法生成本类交付物,不要退回手拼。kit 路线执行失败(报错、超时、后台任务溢出)时,修复后仍走 kit 路线重试——任何失败都不构成退回手工拼 OOXML 的理由。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排版的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。
+18 -2
View File
@@ -70,8 +70,24 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) {
t.Fatalf("child page: %d %v", code, childPage)
}
childRows := childPage["events"].([]any)
if len(childRows) != 2 || childRows[0].(map[string]any)["type"] != string(core.EvAgentProgress) || childRows[1].(map[string]any)["type"] != string(core.EvAgentResult) {
t.Fatalf("child progress/result not retained or raw child output leaked: %v", childRows)
var childTypes []string
for _, row := range childRows {
ev := row.(map[string]any)
typ, _ := ev["type"].(string)
if typ == string(core.EvToolOutput) {
payload, _ := ev["payload"].(map[string]any)
if payload["text"] == "child raw output" {
t.Fatalf("raw child output leaked: %v", childRows)
}
}
if typ == string(core.EvAgentProgress) || typ == string(core.EvAgentResult) {
childTypes = append(childTypes, typ)
}
}
// 父运行的终态事件可能在 waitRunTerminal 之后才落库,序号会落在这次追加的
// 工具输出和子运行事件之间。这里只要求子运行进度与结果按序保留。
if len(childTypes) != 2 || childTypes[0] != string(core.EvAgentProgress) || childTypes[1] != string(core.EvAgentResult) {
t.Fatalf("child progress/result not retained: types=%v rows=%v", childTypes, childRows)
}
code, _ = doJSON(t, h, http.MethodGet, "/api/conversations/"+otherConvID+"/runs/"+runID+"/events", nil)
if code != http.StatusNotFound {
+8 -3
View File
@@ -2055,10 +2055,15 @@ func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) {
// registry 统一注册表视图(来源标记 + 按来源启停入口)。
func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
compiledIDs := make([]string, 0, len(s.CompiledPlugins))
for _, cp := range s.CompiledPlugins {
compiledIDs = append(compiledIDs, cp.ID)
}
reg := map[string]any{
"tools": s.visibleTools(s.owner(r)),
"parsers": ingest.ParsersDescription(),
"panels": flattenPanels(s.plugins.List()),
"tools": s.visibleTools(s.owner(r)),
"parsers": ingest.ParsersDescription(),
"panels": flattenPanels(s.plugins.List()),
"compiled": compiledIDs,
}
writeJSON(w, http.StatusOK, reg)
}
+343 -33
View File
@@ -1,8 +1,10 @@
// OOXML 交付物结构自检(登记口 L1):纯 Go 静态校验,零外部依赖。
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出缺母版/主题/文档
// 属性的残缺包——正文抽取照常成功,直到 Office 按 OPC 完整性校验打开时才报
// 损坏。这里按必备部件与 slide→layout→master→theme 引用链在登记前拦截,
// 失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出 Office 拒开的
// 残缺包。已知的两类事故形态:①部件整体缺失(缺母版/主题/docProps);②部件
// 齐全但引用断裂(根元素漏声明 xmlns:r、sldIdLst 与关系表不一致、
// [Content_Types] 缺 Override)——②能骗过"部件存在性"检查,Office 依旧打不开。
// 这里按「容器合法 + 必备部件 + 引用链完整 + 引用一致 + 内容类型覆盖」在登记前
// 拦截,失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁)
// 不在本层:本层只保证「Office 打得开」这条底线。
package tools
@@ -17,6 +19,16 @@ import (
"strings"
)
const (
nsPresentationML = "http://schemas.openxmlformats.org/presentationml/2006/main"
nsOfficeDocRel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
ctPresentation = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"
ctSlide = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
ctSlideLayout = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"
ctSlideMaster = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"
ctTheme = "application/vnd.openxmlformats-officedocument.theme+xml"
)
// ooxmlStructuralCheck 校验 name 指向的 Office 交付物是否具备可被 Office 打开
// 的最小 OPC 结构。不在管控扩展名内的文件直接放行。
func ooxmlStructuralCheck(name string, content []byte) error {
@@ -32,7 +44,8 @@ func ooxmlStructuralCheck(name string, content []byte) error {
}
}
// ooxmlCheckParts 校验容器与必备正文部件存在且为合法 XML(docx/xlsx 底线)。
// ooxmlCheckParts 校验容器与必备正文部件存在、XML 合法、命名空间前缀齐备
// (docx/xlsx 底线)。
func ooxmlCheckParts(content []byte, parts ...string) error {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
@@ -46,11 +59,11 @@ func ooxmlCheckParts(content []byte, parts ...string) error {
return err
}
}
return nil
return ooxmlNamespaceStrict(zr)
}
// ooxmlCheckPptx 校验演示稿的完整引用链:presentation 声明母版列表,
// 每张 slide 可达 slideLayout,版式可达 slideMaster,母版可达 theme。
// ooxmlCheckPptx 校验演示稿:引用链完整(slide→layout→master→theme)、
// sldIdLst/sldMasterIdLst 与关系表一致、[Content_Types] 覆盖全部结构部件。
func ooxmlCheckPptx(content []byte) error {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
@@ -64,32 +77,58 @@ func ooxmlCheckPptx(content []byte) error {
if err := ooxmlRequireElement(zr, "ppt/presentation.xml", "sldMasterIdLst"); err != nil {
return fmt.Errorf("presentation.xml 缺少 <sldMasterIdLst>(幻灯片母版列表)——母版与主题是必备结构")
}
// 严格命名空间先行:后续的 r:id 属性解析依赖前缀正确声明。
if err := ooxmlNamespaceStrict(zr); err != nil {
return err
}
presRels, err := ooxmlRels(zr, "ppt/presentation.xml")
if err != nil {
return err
}
var slides, masters []string
for _, rel := range presRels {
switch {
case strings.HasSuffix(rel.typ, "/slide"):
slides = append(slides, ooxmlResolve("ppt/presentation.xml", rel.target))
case strings.HasSuffix(rel.typ, "/slideMaster"):
masters = append(masters, ooxmlResolve("ppt/presentation.xml", rel.target))
}
// sldIdLst / sldMasterIdLst 是幻灯片与母版的权威清单,必须与关系表一致。
masterIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldMasterIdLst", "sldMasterId")
if err != nil {
return err
}
if len(masters) == 0 {
slideIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldIdLst", "sldId")
if err != nil {
return err
}
relsByType := func(suffix string) map[string]string {
out := map[string]string{}
for _, rel := range presRels {
if strings.HasSuffix(rel.typ, suffix) {
out[rel.id] = ooxmlResolve("ppt/presentation.xml", rel.target)
}
}
return out
}
masterTargets, err := ooxmlConsistent(masterIDs, relsByType("/slideMaster"), "sldMasterIdLst", "slideMaster")
if err != nil {
return err
}
slideTargets, err := ooxmlConsistent(slideIDs, relsByType("/slide"), "sldIdLst", "slide")
if err != nil {
return err
}
if len(masterTargets) == 0 {
return fmt.Errorf("presentation 未引用任何幻灯片母版(slideMaster)——母版与主题是必备结构")
}
for _, master := range masters {
if err := ooxmlMasterChain(zr, master); err != nil {
if len(slideTargets) == 0 {
return fmt.Errorf("presentation 未引用任何幻灯片")
}
themes := map[string]bool{}
for _, master := range masterTargets {
if err := ooxmlMasterChain(zr, master, themes); err != nil {
return err
}
}
if len(slides) == 0 {
return fmt.Errorf("presentation 未引用任何幻灯片")
masterSet := map[string]bool{}
for _, m := range masterTargets {
masterSet[m] = true
}
seenLayouts := map[string]bool{}
for _, slide := range slides {
layouts := map[string]bool{}
for _, slide := range slideTargets {
if err := ooxmlWellFormed(zr, slide); err != nil {
return err
}
@@ -107,22 +146,50 @@ func ooxmlCheckPptx(content []byte) error {
if layout == "" {
return fmt.Errorf("幻灯片 %s 没有指向 slideLayout 的关系——每张 slide 必须经关系表引用一个版式", slide)
}
if seenLayouts[layout] {
if layouts[layout] {
continue
}
seenLayouts[layout] = true
layouts[layout] = true
if err := ooxmlWellFormed(zr, layout); err != nil {
return err
}
if err := ooxmlLayoutMaster(zr, layout); err != nil {
if err := ooxmlLayoutMaster(zr, layout, masterSet); err != nil {
return err
}
}
// [Content_Types].xml 必须对每个结构部件给出 Override;缺了会被 Office
// 当成未知类型拒开(真实事故:只写了 slide1 的 Override)。
overrides, err := ooxmlCTOverrides(zr)
if err != nil {
return err
}
want := map[string]string{"ppt/presentation.xml": ctPresentation}
for _, t := range masterTargets {
want[t] = ctSlideMaster
}
for _, t := range slideTargets {
want[t] = ctSlide
}
for t := range layouts {
want[t] = ctSlideLayout
}
for t := range themes {
want[t] = ctTheme
}
for part, ct := range want {
got, ok := overrides["/"+part]
if !ok {
return fmt.Errorf("[Content_Types].xml 缺少 %s 的 Override(%s)——手拼包常见缺陷,请用 housestyle kit 重建", part, ct)
}
if got != ct {
return fmt.Errorf("[Content_Types].xml 中 %s 的内容类型不符:got %s want %s", part, got, ct)
}
}
return nil
}
// ooxmlMasterChain 校验母版存在且其关系表引用了存在的 theme。
func ooxmlMasterChain(zr *zip.Reader, master string) error {
// ooxmlMasterChain 校验母版存在、声明版式列表且与关系表一致、引用存在的 theme。
func ooxmlMasterChain(zr *zip.Reader, master string, themes map[string]bool) error {
if err := ooxmlWellFormed(zr, master); err != nil {
return err
}
@@ -130,28 +197,258 @@ func ooxmlMasterChain(zr *zip.Reader, master string) error {
if err != nil {
return err
}
if err := ooxmlRequireElement(zr, master, "sldLayoutIdLst"); err != nil {
return fmt.Errorf("母版 %s 缺少 <sldLayoutIdLst>(版式列表)——母版必须声明至少一个版式", master)
}
layoutIDs, err := ooxmlIdListOf(zr, master, "sldLayoutIdLst", "sldLayoutId")
if err != nil {
return err
}
layoutTargets := map[string]string{}
for _, rel := range rels {
if strings.HasSuffix(rel.typ, "/slideLayout") {
layoutTargets[rel.id] = ooxmlResolve(master, rel.target)
}
}
resolved, err := ooxmlConsistent(layoutIDs, layoutTargets, "sldLayoutIdLst", "slideLayout")
if err != nil {
return err
}
for _, target := range resolved {
if err := ooxmlWellFormed(zr, target); err != nil {
return err
}
}
for _, rel := range rels {
if strings.HasSuffix(rel.typ, "/theme") {
return ooxmlWellFormed(zr, ooxmlResolve(master, rel.target))
theme := ooxmlResolve(master, rel.target)
if err := ooxmlWellFormed(zr, theme); err != nil {
return err
}
themes[theme] = true
return nil
}
}
return fmt.Errorf("母版 %s 没有指向 theme 的关系", master)
}
// ooxmlLayoutMaster 校验版式的关系表引用了存在的 slideMaster。
func ooxmlLayoutMaster(zr *zip.Reader, layout string) error {
// ooxmlLayoutMaster 校验版式的关系表引用了存在的、且被 presentation 列出的
// slideMaster。
func ooxmlLayoutMaster(zr *zip.Reader, layout string, masters map[string]bool) error {
rels, err := ooxmlRels(zr, layout)
if err != nil {
return err
}
for _, rel := range rels {
if strings.HasSuffix(rel.typ, "/slideMaster") {
return ooxmlWellFormed(zr, ooxmlResolve(layout, rel.target))
master := ooxmlResolve(layout, rel.target)
if !masters[master] {
return fmt.Errorf("版式 %s 指向的母版 %s 未在 presentation 的 sldMasterIdLst 中声明", layout, master)
}
return nil
}
}
return fmt.Errorf("版式 %s 没有指向 slideMaster 的关系", layout)
}
// ooxmlIdListOf 提取 part 中 <listLocal> 列表内每个 <itemLocal> 的 r:id 属性。
func ooxmlIdListOf(zr *zip.Reader, part, listLocal, itemLocal string) ([]string, error) {
f, err := ooxmlPart(zr, part)
if err != nil {
return nil, err
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("部件 %s 无法读取(%v)", part, err)
}
defer rc.Close()
dec := xml.NewDecoder(rc)
var ids []string
inList := false
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err)
}
switch se := tok.(type) {
case xml.StartElement:
if se.Name.Space == nsPresentationML && se.Name.Local == listLocal {
inList = true
continue
}
if inList && se.Name.Space == nsPresentationML && se.Name.Local == itemLocal {
if rid, ok := ooxmlAttrNS(se, nsOfficeDocRel, "id"); ok {
ids = append(ids, rid)
} else {
return nil, fmt.Errorf("%s 的 <%s> 缺少 r:id 属性——引用链断裂,请用 housestyle kit 重建", listLocal, itemLocal)
}
}
case xml.EndElement:
if inList && se.Name.Space == nsPresentationML && se.Name.Local == listLocal {
inList = false
}
}
}
return ids, nil
}
// ooxmlConsistent 校验 ID 清单与关系表双向一致:清单里每个 r:id 都要命中
// 同类型关系,关系表也不得多出或缺失。返回按清单顺序解析出的目标部件。
func ooxmlConsistent(ids []string, targets map[string]string, listName, what string) ([]string, error) {
seen := map[string]bool{}
var out []string
for _, id := range ids {
if seen[id] {
return nil, fmt.Errorf("%s 重复引用 %s", listName, id)
}
seen[id] = true
target, ok := targets[id]
if !ok {
return nil, fmt.Errorf("%s 引用的 %s 在关系表中不存在(rId=%s 断链)——请用 housestyle kit 重建", listName, what, id)
}
out = append(out, target)
}
if len(targets) != len(ids) {
return nil, fmt.Errorf("%s 与关系表数量不一致(清单 %d 条,关系表 %d 条)——存在未引用或缺失的 %s 关系,请用 housestyle kit 重建", listName, len(ids), len(targets), what)
}
return out, nil
}
// ooxmlNamespaceStrict 扫描包内全部 XML 部件:元素或属性使用了未声明的
// 命名空间前缀即报错。判定基于声明集合而非 URI 形态(Go 解码器对未声明
// 前缀保留原始前缀名作为 Space;已声明则解析为完整 URI,urn: 等 scheme
// 同样正确解析),避免对非 http URI 的误伤。
func ooxmlNamespaceStrict(zr *zip.Reader) error {
for _, f := range zr.File {
if !strings.HasSuffix(f.Name, ".xml") && !strings.HasSuffix(f.Name, ".rels") {
continue
}
declared, err := ooxmlDeclaredNamespaces(f)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
}
dec := xml.NewDecoder(rc)
var scanErr error
scan:
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
scanErr = fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err)
break
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
if sp := ooxmlUnboundPrefix(se.Name.Space, declared); sp != "" {
scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(元素 <%s:%s>)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, se.Name.Local, sp)
break
}
for _, a := range se.Attr {
if sp := ooxmlUnboundPrefix(a.Name.Space, declared); sp != "" {
scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(属性 %s:%s)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, a.Name.Local, sp)
break scan
}
}
}
rc.Close()
if scanErr != nil {
return scanErr
}
}
return nil
}
// ooxmlDeclaredNamespaces 收集部件内全部 xmlns 声明(含默认命名空间与任意
// 位置的限定声明)。前缀 xmlns 为保留名:Go 把 xmlns:r 声明报为
// Space="xmlns"、Local="r";默认声明 xmlns="…" 报为 Space=""、Local="xmlns"。
func ooxmlDeclaredNamespaces(f *zip.File) (map[string]bool, error) {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err)
}
defer rc.Close()
out := map[string]bool{"xml": true, "xmlns": true}
dec := xml.NewDecoder(rc)
for {
tok, err := dec.Token()
if err == io.EOF {
return out, nil
}
if err != nil {
return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err)
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
for _, a := range se.Attr {
switch {
case a.Name.Space == "xmlns":
out[a.Value] = true
case a.Name.Space == "" && a.Name.Local == "xmlns":
out[a.Value] = true
}
}
}
}
// ooxmlUnboundPrefix 返回未绑定的前缀名;已声明、无前缀与内建 xml 前缀返回空串。
func ooxmlUnboundPrefix(space string, declared map[string]bool) string {
if space == "" || declared[space] {
return ""
}
return space
}
// ooxmlCTOverrides 解析 [Content_Types].xml 的 Override 清单(部件路径 → 内容类型)。
func ooxmlCTOverrides(zr *zip.Reader) (map[string]string, error) {
f, err := ooxmlPart(zr, "[Content_Types].xml")
if err != nil {
return nil, err
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("[Content_Types].xml 无法读取(%v)", err)
}
defer rc.Close()
out := map[string]string{}
dec := xml.NewDecoder(rc)
for {
tok, err := dec.Token()
if err == io.EOF {
return out, nil
}
if err != nil {
return nil, fmt.Errorf("[Content_Types].xml 不是合法 XML(%v)", err)
}
se, ok := tok.(xml.StartElement)
if !ok || se.Name.Local != "Override" {
continue
}
partName, contentType := "", ""
for _, a := range se.Attr {
switch a.Name.Local {
case "PartName":
partName = a.Value
case "ContentType":
contentType = a.Value
}
}
out[partName] = contentType
}
}
// ooxmlWellFormed 确认部件存在且可被 XML 解析器完整读取。
func ooxmlWellFormed(zr *zip.Reader, part string) error {
f, err := ooxmlPart(zr, part)
@@ -202,6 +499,16 @@ func ooxmlRequireElement(zr *zip.Reader, part, local string) error {
return fmt.Errorf("部件 %s 缺少 <%s> 声明", part, local)
}
// ooxmlAttrNS 取指定命名空间下的属性值。
func ooxmlAttrNS(se xml.StartElement, space, local string) (string, bool) {
for _, a := range se.Attr {
if a.Name.Space == space && a.Name.Local == local {
return a.Value, true
}
}
return "", false
}
// ooxmlPart 按部件路径精确查找(ZIP 内无目录条目,直接比对名字)。
func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) {
for _, f := range zr.File {
@@ -212,8 +519,9 @@ func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) {
return nil, fmt.Errorf("缺少必备部件 %s", name)
}
// ooxmlRel 是 rels 部件里的一条关系:类型 URI 与目标(原样保留相对形式)。
// ooxmlRel 是 rels 部件里的一条关系:Id、类型 URI 与目标(原样保留相对形式)。
type ooxmlRel struct {
id string
typ string
target string
}
@@ -248,6 +556,8 @@ func ooxmlRels(zr *zip.Reader, part string) ([]ooxmlRel, error) {
var rel ooxmlRel
for _, attr := range se.Attr {
switch attr.Name.Local {
case "Id":
rel.id = attr.Value
case "Type":
rel.typ = attr.Value
case "Target":
+173 -43
View File
@@ -30,11 +30,10 @@ func zipOf(t *testing.T, files map[string]string) []byte {
}
const (
gateCTXML = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>`
gatePres = `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldMasterIdLst><p:sldMasterId id="1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`
gateSlide = `<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
gateLayout = `<?xml version="1.0"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
gateMaster = `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"/>`
gateCTXML = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>`
gateSlide = `<?xml version="1.0"?><p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree/></p:cSld></p:sld>`
gateLayout = `<?xml version="1.0"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree/></p:cSld></p:sldLayout>`
gateMaster = `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:cSld><p:spTree/></p:cSld><p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`
gateTheme = `<?xml version="1.0"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/>`
)
@@ -44,42 +43,96 @@ func relXML(rels ...string) string {
}
const (
relSlide = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`
relMaster = `<Relationship Id="r2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`
relLayout = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>`
relToMaster = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>`
relTheme = `<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>`
relSlide = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`
relMaster = `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`
relLayout = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>`
relToMaster = `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>`
relTheme = `<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>`
)
// gateValidPptx 是结构合法的最小演示稿:引用链 slide→layout→master→theme 齐备。
// gateCT 内容类型表:传入要 Override 的结构部件(缺了的用例自己构造残缺形态)。
func gateCT(parts ...string) string {
cts := map[string]string{
"ppt/presentation.xml": ctPresentation,
"ppt/slides/slide1.xml": ctSlide,
"ppt/slides/slide2.xml": ctSlide,
"ppt/slides/slide3.xml": ctSlide,
"ppt/slideLayouts/slideLayout1.xml": ctSlideLayout,
"ppt/slideMasters/slideMaster1.xml": ctSlideMaster,
"ppt/theme/theme1.xml": ctTheme,
}
out := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>`
for _, p := range parts {
out += `<Override PartName="/` + p + `" ContentType="` + cts[p] + `"/>`
}
return out + `</Types>`
}
var gateAllParts = []string{
"ppt/presentation.xml", "ppt/slides/slide1.xml",
"ppt/slideLayouts/slideLayout1.xml", "ppt/slideMasters/slideMaster1.xml", "ppt/theme/theme1.xml",
}
// gateValidPptx 是结构合法的最小演示稿:r:id 引用、ID 清单与关系表一致、
// 内容类型全量覆盖——与 python-pptx 产物的关键形态一致。
func gateValidPptx(t *testing.T) []byte {
t.Helper()
return zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"[Content_Types].xml": gateCT(gateAllParts...),
"_rels/.rels": relXML(),
"ppt/presentation.xml": gatePres,
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId2"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster),
"ppt/slides/slide1.xml": gateSlide,
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
"ppt/slideMasters/slideMaster1.xml": gateMaster,
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relTheme),
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relLayout, relTheme),
"ppt/theme/theme1.xml": gateTheme,
})
}
// gateBrokenPptx 复刻事故包:只有 presentation + slides + 幽灵 layout,
// 无母版/主题/docProps,presentation 缺 sldMasterIdLst,slide 无关系表。
func gateBrokenPptx(t *testing.T) []byte {
// gateIncident2Pptx 复刻第二起事故(部件齐全但引用断裂,上版门禁放行的形态):
// presentation/母版根元素漏 xmlns:r;sldIdLst 声明 3 页但关系表只有 rId3→slide1;
// [Content_Types] 缺 slide2/3 的 Override。三个 fix 开关逐项修复以隔离断言。
func gateIncident2Pptx(t *testing.T, fixNS, fixRels, fixCT bool) []byte {
t.Helper()
nsDecl := ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`
pns, mns := "", ""
if fixNS {
pns, mns = nsDecl, nsDecl
}
pres := `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"` + pns + `><p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId3"/><p:sldId id="257" r:id="rId4"/><p:sldId id="258" r:id="rId5"/></p:sldIdLst></p:presentation>`
master := `<?xml version="1.0"?><p:sldMaster xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"` + mns + `><p:cSld><p:spTree/></p:cSld><p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst></p:sldMaster>`
rels := `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/></Relationships>`
if fixRels {
rels = relXML(
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`,
`<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>`,
`<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/>`,
`<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide3.xml"/>`)
}
ctParts := []string{"ppt/presentation.xml", "ppt/slideMasters/slideMaster1.xml",
"ppt/slideLayouts/slideLayout1.xml", "ppt/theme/theme1.xml", "ppt/slides/slide1.xml"}
if fixCT {
ctParts = append(ctParts, "ppt/slides/slide2.xml", "ppt/slides/slide3.xml")
}
return zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"_rels/.rels": relXML(),
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels": relXML(relSlide),
"ppt/slides/slide1.xml": gateSlide,
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
"[Content_Types].xml": gateCT(ctParts...),
"_rels/.rels": relXML(),
"ppt/presentation.xml": pres,
"ppt/_rels/presentation.xml.rels": rels,
"ppt/slides/slide1.xml": gateSlide,
"ppt/slides/slide2.xml": gateSlide,
"ppt/slides/slide3.xml": gateSlide,
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
"ppt/slides/_rels/slide2.xml.rels": relXML(relLayout),
"ppt/slides/_rels/slide3.xml.rels": relXML(relLayout),
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
"ppt/slideMasters/slideMaster1.xml": master,
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relLayout, relTheme),
"ppt/theme/theme1.xml": gateTheme,
})
}
@@ -89,8 +142,17 @@ func TestOoxmlGateValidPptx(t *testing.T) {
}
}
func TestOoxmlGateBrokenPptxIncident(t *testing.T) {
err := ooxmlStructuralCheck("机械臂腹部超声设计方案.pptx", gateBrokenPptx(t))
func TestOoxmlGateIncident1PartsMissing(t *testing.T) {
// 第一类事故:部件整体缺失(无母版/主题/docProps、slide 无关系表)。
broken := zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"_rels/.rels": relXML(),
"ppt/presentation.xml": `<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:sldIdLst><p:sldId id="2"/></p:sldIdLst></p:presentation>`,
"ppt/_rels/presentation.xml.rels": relXML(relSlide),
"ppt/slides/slide1.xml": gateSlide,
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
})
err := ooxmlStructuralCheck("事故一.pptx", broken)
if err == nil {
t.Fatal("incident-shaped pptx must be rejected")
}
@@ -101,22 +163,49 @@ func TestOoxmlGateBrokenPptxIncident(t *testing.T) {
}
}
// 第二类事故:部件齐全但引用断裂——上一版门禁放行、Office 拒开的形态。
func TestOoxmlGateIncident2ReferenceBreaks(t *testing.T) {
cases := []struct {
name string
fixNS, fixRels, fixCT bool
want string
}{
{"undeclared-ns", false, true, true, "未声明的命名空间前缀 xmlns:r"},
{"sldid-dangling-rid", true, false, true, "sldIdLst 引用的 slide 在关系表中不存在"},
{"ct-missing-slides", true, true, false, "Content_Types"},
}
for _, tc := range cases {
err := ooxmlStructuralCheck("事故二.pptx", gateIncident2Pptx(t, tc.fixNS, tc.fixRels, tc.fixCT))
if err == nil {
t.Fatalf("%s: must be rejected", tc.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: error %q must mention %q", tc.name, err, tc.want)
}
}
// 三处缺陷全部修复后必须放行(对应 P4 修复产物的形态)。
if err := ooxmlStructuralCheck("修复后.pptx", gateIncident2Pptx(t, true, true, true)); err != nil {
t.Fatalf("fully repaired incident-2 package must pass: %v", err)
}
}
func TestOoxmlGatePptxChainBreaks(t *testing.T) {
base := func(t *testing.T) map[string]string {
t.Helper()
return map[string]string{
"[Content_Types].xml": gateCTXML,
"_rels/.rels": relXML(),
"ppt/presentation.xml": gatePres,
"ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster),
"ppt/slides/slide1.xml": gateSlide,
"ppt/slides/_rels/slide1.xml.rels": relXML(relLayout),
"ppt/slideLayouts/slideLayout1.xml": gateLayout,
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster),
"ppt/slideMasters/slideMaster1.xml": gateMaster,
"ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relTheme),
"ppt/theme/theme1.xml": gateTheme,
valid := gateValidPptx(t)
zr, err := zip.NewReader(bytes.NewReader(valid), int64(len(valid)))
if err != nil {
t.Fatal(err)
}
m := map[string]string{}
for _, f := range zr.File {
b, err := readZipFile(f)
if err != nil {
t.Fatal(err)
}
m[f.Name] = string(b)
}
return m
}
cases := []struct {
name string
@@ -135,21 +224,40 @@ func TestOoxmlGatePptxChainBreaks(t *testing.T) {
{"layout-no-master-rel", func(m map[string]string) {
m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML()
}, "没有指向 slideMaster"},
{"layout-master-not-listed", func(m map[string]string) {
m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML(`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster2.xml"/>`)
m["ppt/slideMasters/slideMaster2.xml"] = gateMaster
m["ppt/slideMasters/_rels/slideMaster2.xml.rels"] = relXML(relLayout, relTheme)
}, "未在 presentation 的 sldMasterIdLst 中声明"},
{"master-no-theme-rel", func(m map[string]string) {
m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML()
m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML(relLayout)
}, "没有指向 theme"},
{"theme-missing", func(m map[string]string) {
delete(m, "ppt/theme/theme1.xml")
}, "缺少必备部件"},
{"master-no-layout-idlst", func(m map[string]string) {
m["ppt/slideMasters/slideMaster1.xml"] = strings.Replace(gateMaster, `<p:sldLayoutIdLst><p:sldLayoutId id="2147483647" r:id="rId1"/></p:sldLayoutIdLst>`, "", 1)
}, "缺少 <sldLayoutIdLst>"},
{"pres-no-master-idlst", func(m map[string]string) {
m["ppt/presentation.xml"] = strings.Replace(gatePres, "<p:sldMasterIdLst><p:sldMasterId id=\"1\"/></p:sldMasterIdLst>", "", 1)
m["ppt/presentation.xml"] = strings.Replace(
m["ppt/presentation.xml"],
`<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId2"/></p:sldMasterIdLst>`, "", 1)
}, "sldMasterIdLst"},
{"pres-no-slide-rels", func(m map[string]string) {
{"pres-slide-rel-dangling", func(m map[string]string) {
m["ppt/_rels/presentation.xml.rels"] = relXML(relMaster)
}, "未引用任何幻灯片"},
}, "sldIdLst 引用的 slide 在关系表中不存在"},
{"pres-extra-slide-rel", func(m map[string]string) {
m["ppt/_rels/presentation.xml.rels"] = relXML(relSlide, relMaster,
`<Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/>`)
m["ppt/slides/slide2.xml"] = gateSlide
m["ppt/slides/_rels/slide2.xml.rels"] = relXML(relLayout)
}, "数量不一致"},
{"xml-malformed", func(m map[string]string) {
m["ppt/slides/slide1.xml"] = "<p:sld>"
m["ppt/slides/slide1.xml"] = `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">`
}, "不是合法 XML"},
{"ct-slide-mismatch", func(m map[string]string) {
m["[Content_Types].xml"] = strings.Replace(gateCT(gateAllParts...), ctSlide, "application/xml", 1)
}, "内容类型不符"},
}
for _, tc := range cases {
m := base(t)
@@ -164,6 +272,19 @@ func TestOoxmlGatePptxChainBreaks(t *testing.T) {
}
}
func readZipFile(f *zip.File) ([]byte, error) {
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
var buf bytes.Buffer
if _, err := buf.ReadFrom(rc); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) {
validDocx := zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
@@ -175,6 +296,15 @@ func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) {
if err := ooxmlStructuralCheck("方案.docx", zipOf(t, map[string]string{"[Content_Types].xml": gateCTXML})); err == nil {
t.Fatal("docx without document.xml must be rejected")
}
// 命名空间严格化同样覆盖 docx:正文用了未声明前缀要拦。
badDocx := zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"word/document.xml": `<?xml version="1.0"?><w:document><w:p r:id="r1"/></w:document>`,
})
err := ooxmlStructuralCheck("方案.docx", badDocx)
if err == nil || !strings.Contains(err.Error(), "未声明") {
t.Fatalf("docx with undeclared prefix must be rejected: %v", err)
}
validXlsx := zipOf(t, map[string]string{
"[Content_Types].xml": gateCTXML,
"xl/workbook.xml": `<?xml version="1.0"?><workbook xmlns="urn:x"/>`,
@@ -203,7 +333,7 @@ func TestRegisterArtifactStructuralGate(t *testing.T) {
t.Fatal(err)
}
broken := filepath.Join(sandbox, "坏.pptx")
if err := os.WriteFile(broken, gateBrokenPptx(t), 0o644); err != nil {
if err := os.WriteFile(broken, gateIncident2Pptx(t, false, false, false), 0o644); err != nil {
t.Fatal(err)
}
res := interpCall(t, r, rc, "register_artifact", `{"path":"坏.pptx"}`)
+93 -1
View File
@@ -6,6 +6,8 @@ import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"sync"
core "agent-system-core"
@@ -55,7 +57,9 @@ import (
// 2.22.0-experimental: optional HumanDecisionVerifier. The host proves that
// this dispatch is an authenticated human response to one exact pending card.
// It does not consume the proof; the caller does that in its own transaction.
const SurfaceVersion = "2.22.0-experimental"
// 2.23.0-experimental: InProc.HostOptions / CLIHook, plus MergeHostOptions,
// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。
const SurfaceVersion = "2.23.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
@@ -183,10 +187,24 @@ type IntentSpec struct {
Description string `json:"description,omitempty"`
}
// CLIHook 让编译插件在基座 serve 旗标集上登记自己的旗标,并在解析后决定是否接管本次进程。
// 插件只登记自己的旗标,不重复基座已有旗标。基座在 flag.Parse 前调用 Register,解析后调用 AfterParse。
type CLIHook struct {
Register func(fs *flag.FlagSet)
AfterParse func(opt Options) (handled bool, err error)
}
// InProc 是随下游二进制编译进来的扩展。
type InProc struct {
ID string
Register func(reg Registry) error
// HostOptions 可选。组合二进制把它当作启动默认值:布尔开关按“任一为 true 则为 true”
// 合并,且不覆盖调用方显式传入的参数。未设置则无产品默认。
HostOptions func() Options
// CLI 可选。组合二进制在基座 serve 旗标上挂接插件专属旗标(如 bootstrap-admin)。
CLI *CLIHook
// Prepare 可选。基座合并 HostOptions 之后、进入服务之前调用,可改写 opt 或拒绝启动。
Prepare func(opt *Options) error
}
// Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。
@@ -225,6 +243,80 @@ var (
runner func(Options) error
)
// MergeHostOptions 把已登记插件的 HostOptions 合并进 base。
// 布尔开关按“任一为 true 则为 true”。ExecBackend 取首个非空值,彼此冲突则返回错误。
// Addr、DataDir、PluginsDir、StorageDSN、AuthMode 等运行参数不从插件合并,避免覆盖调用方。
func MergeHostOptions(base Options) (Options, error) {
for _, e := range Compiled() {
if e.HostOptions == nil {
continue
}
opt := e.HostOptions()
if opt.DisableUploadIndexing {
base.DisableUploadIndexing = true
}
if opt.DisableConvCardMirror {
base.DisableConvCardMirror = true
}
if opt.DisableExecTools {
base.DisableExecTools = true
}
if opt.ExecBackend == "" {
continue
}
if base.ExecBackend != "" && base.ExecBackend != opt.ExecBackend {
return base, fmt.Errorf("extension: plugin %s exec backend %q conflicts with %q", e.ID, opt.ExecBackend, base.ExecBackend)
}
if base.ExecBackend == "" {
base.ExecBackend = opt.ExecBackend
}
}
return base, nil
}
// RegisterCLIFlags 让每个已登记插件往 fs 登记自己的旗标。须在 Parse 之前调用。
func RegisterCLIFlags(fs *flag.FlagSet) {
if fs == nil {
return
}
for _, e := range Compiled() {
if e.CLI != nil && e.CLI.Register != nil {
e.CLI.Register(fs)
}
}
}
// RunCLIHooks 在旗标解析之后依次调用插件 AfterParse。
// 第一个 handled 的插件接管本次进程,基座不应再进入服务循环。
func RunCLIHooks(opt Options) (bool, error) {
for _, e := range Compiled() {
if e.CLI == nil || e.CLI.AfterParse == nil {
continue
}
handled, err := e.CLI.AfterParse(opt)
if err != nil || handled {
return handled, err
}
}
return false, nil
}
// PrepareCompiled 在 HostOptions 合并进 opt 之后调用各插件 Prepare。任一错误拒绝启动。
func PrepareCompiled(opt *Options) error {
if opt == nil {
return errors.New("extension: nil options")
}
for _, e := range Compiled() {
if e.Prepare == nil {
continue
}
if err := e.Prepare(opt); err != nil {
return fmt.Errorf("extension: %s: %w", e.ID, err)
}
}
return nil
}
// RegisterCompiled 在 init 中登记进程内扩展。
func RegisterCompiled(e InProc) {
mu.Lock()
+111
View File
@@ -0,0 +1,111 @@
package extension
import (
"errors"
"flag"
"testing"
)
func TestMergeHostOptionsORAndExecConflict(t *testing.T) {
ResetCompiled()
t.Cleanup(ResetCompiled)
RegisterCompiled(InProc{
ID: "a",
HostOptions: func() Options {
return Options{DisableUploadIndexing: true, ExecBackend: "sandbox"}
},
})
RegisterCompiled(InProc{
ID: "b",
HostOptions: func() Options {
return Options{DisableExecTools: true, DisableConvCardMirror: true}
},
})
got, err := MergeHostOptions(Options{Addr: "127.0.0.1:1"})
if err != nil {
t.Fatal(err)
}
if !got.DisableUploadIndexing || !got.DisableExecTools || !got.DisableConvCardMirror {
t.Fatalf("bool merge: %+v", got)
}
if got.ExecBackend != "sandbox" || got.Addr != "127.0.0.1:1" {
t.Fatalf("kept caller addr and first exec backend: %+v", got)
}
RegisterCompiled(InProc{
ID: "c",
HostOptions: func() Options { return Options{ExecBackend: "direct"} },
})
if _, err := MergeHostOptions(Options{}); err == nil {
t.Fatal("expected exec backend conflict")
}
}
func TestPrepareCompiledCanRejectStartup(t *testing.T) {
ResetCompiled()
t.Cleanup(ResetCompiled)
RegisterCompiled(InProc{
ID: "rdm",
Prepare: func(opt *Options) error {
if opt.ExecBackend == "sandbox" {
return errors.New("no bwrap")
}
opt.DisableExecTools = true
return nil
},
})
opt := Options{ExecBackend: "sandbox"}
if err := PrepareCompiled(&opt); err == nil {
t.Fatal("expected prepare error")
}
opt.ExecBackend = "direct"
if err := PrepareCompiled(&opt); err != nil {
t.Fatal(err)
}
if !opt.DisableExecTools {
t.Fatal("prepare should be able to rewrite options")
}
}
func TestCLIHookHandlesBeforeServe(t *testing.T) {
ResetCompiled()
t.Cleanup(ResetCompiled)
var admin *string
RegisterCompiled(InProc{
ID: "rdm",
CLI: &CLIHook{
Register: func(fs *flag.FlagSet) {
admin = fs.String("bootstrap-admin", "", "owner")
},
AfterParse: func(opt Options) (bool, error) {
if admin == nil || *admin == "" {
return false, nil
}
if opt.DataDir == "" {
return true, errors.New("missing data")
}
return true, nil
},
},
})
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
RegisterCLIFlags(fs)
if err := fs.Parse([]string{"-bootstrap-admin", "owner"}); err != nil {
t.Fatal(err)
}
if fs.Lookup("data") != nil {
t.Fatal("plugin must not redefine base flags")
}
handled, err := RunCLIHooks(Options{DataDir: "/tmp/data"})
if err != nil || !handled {
t.Fatalf("handled=%v err=%v", handled, err)
}
fs2 := flag.NewFlagSet("serve", flag.ContinueOnError)
RegisterCLIFlags(fs2)
if err := fs2.Parse(nil); err != nil {
t.Fatal(err)
}
handled, err = RunCLIHooks(Options{DataDir: "/tmp/data"})
if err != nil || handled {
t.Fatalf("empty flag should not handle: handled=%v err=%v", handled, err)
}
}
+5
View File
@@ -25,6 +25,7 @@ AccountSearcher.SearchAccounts
AdminProvider
AdminProvider.IsAdmin
AdminZoneSpec
CLIHook
Compiled
DelegatedProjectProjectionHost
DelegatedProjectProjectionHost.ProjectProjectionForCreator
@@ -63,9 +64,11 @@ HumanDecisionVerifier.VerifyHumanDecision
InProc
IntentSpec
Main
MergeHostOptions
NewHostClient
NewScopedHost
Options
PrepareCompiled
ProjectAuditAuthorizer
ProjectAuditCoverage
ProjectAuditDisclosure
@@ -133,6 +136,7 @@ ProjectWorkObserver
ProjectWorkPending
ProjectWorkProceed
ProjectWorkSource
RegisterCLIFlags
RegisterCompiled
Registry
Registry.AddAdminZone
@@ -148,6 +152,7 @@ ResetCompiled
ResourceAuthorizer
ResourceAuthorizerV2
ResourceAuthzQuery
RunCLIHooks
SetRunner
SetScopedHostFactory
SurfaceHost