diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index feb602a..079d10e 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -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{ diff --git a/internal/prompts/library/tool-run-python.md b/internal/prompts/library/tool-run-python.md index e45b563..bbf465b 100644 --- a/internal/prompts/library/tool-run-python.md +++ b/internal/prompts/library/tool-run-python.md @@ -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`。 diff --git a/internal/server/run_process_events_test.go b/internal/server/run_process_events_test.go index c879d6d..d65527a 100644 --- a/internal/server/run_process_events_test.go +++ b/internal/server/run_process_events_test.go @@ -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 { diff --git a/internal/server/server.go b/internal/server/server.go index f816145..05ef3da 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2066,10 +2066,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) } diff --git a/internal/tools/ooxmlgate.go b/internal/tools/ooxmlgate.go index d7f5f1c..9bfd839 100644 --- a/internal/tools/ooxmlgate.go +++ b/internal/tools/ooxmlgate.go @@ -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 缺少 (幻灯片母版列表)——母版与主题是必备结构") } + // 严格命名空间先行:后续的 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 缺少 (版式列表)——母版必须声明至少一个版式", 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 中 列表内每个 的 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": diff --git a/internal/tools/ooxmlgate_test.go b/internal/tools/ooxmlgate_test.go index 9ee2957..b6da748 100644 --- a/internal/tools/ooxmlgate_test.go +++ b/internal/tools/ooxmlgate_test.go @@ -30,11 +30,10 @@ func zipOf(t *testing.T, files map[string]string) []byte { } const ( - gateCTXML = `` - gatePres = `` - gateSlide = `` - gateLayout = `` - gateMaster = `` + gateCTXML = `` + gateSlide = `` + gateLayout = `` + gateMaster = `` gateTheme = `` ) @@ -44,42 +43,96 @@ func relXML(rels ...string) string { } const ( - relSlide = `` - relMaster = `` - relLayout = `` - relToMaster = `` - relTheme = `` + relSlide = `` + relMaster = `` + relLayout = `` + relToMaster = `` + relTheme = `` ) -// 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 := `` + for _, p := range parts { + out += `` + } + return out + `` +} + +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": ``, "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 := `` + master := `` + rels := `` + if fixRels { + rels = relXML( + ``, + ``, + ``, + ``) + } + 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": ``, - "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": ``, + "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(``) + 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, ``, "", 1) + }, "缺少 "}, {"pres-no-master-idlst", func(m map[string]string) { - m["ppt/presentation.xml"] = strings.Replace(gatePres, "", "", 1) + m["ppt/presentation.xml"] = strings.Replace( + m["ppt/presentation.xml"], + ``, "", 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, + ``) + 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"] = "" + m["ppt/slides/slide1.xml"] = `` }, "不是合法 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": ``, + }) + 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": ``, @@ -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"}`) diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index feac709..cea9b0f 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -6,6 +6,8 @@ import ( "context" "encoding/json" "errors" + "flag" + "fmt" "sync" core "agent-system-core" @@ -37,7 +39,8 @@ import ( // 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process // AccountDirectoryRegistrar for scoped, current account candidate decisions. // 2.14.0-experimental: optional RolePolicyProvider for the Backend-owned -// single-effective-role policy consumed by trusted in-process domains. +// single-effective-role policy, plus InProc.HostOptions / CLIHook and their +// merge, flag-registration, and startup helpers for composed binaries. // 2.15.0-experimental: resource queries carry the current authority epoch so // downstream caches can reject stale role snapshots. const SurfaceVersion = "2.15.0-experimental" @@ -174,10 +177,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 对齐。 @@ -213,6 +230,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() diff --git a/pkg/extension/hostoptions_test.go b/pkg/extension/hostoptions_test.go new file mode 100644 index 0000000..cabbd02 --- /dev/null +++ b/pkg/extension/hostoptions_test.go @@ -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) + } +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index d7b8813..812e163 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -25,6 +25,7 @@ AccountSearcher.SearchAccounts AdminProvider AdminProvider.IsAdmin AdminZoneSpec +CLIHook Compiled DelegatedProjectProjectionHost DelegatedProjectProjectionHost.ProjectProjectionForCreator @@ -50,9 +51,11 @@ HostClient InProc IntentSpec Main +MergeHostOptions NewHostClient NewScopedHost Options +PrepareCompiled ProjectHost ProjectHost.EnsureProject ProjectHost.OwnedProject @@ -64,6 +67,7 @@ ProjectHost.RenameOwnedProject ProjectLifecycle ProjectLifecycleRegistry ProjectLifecycleRegistry.RegisterProjectLifecycle +RegisterCLIFlags RegisterCompiled Registry Registry.AddAdminZone @@ -103,6 +107,7 @@ RolePolicyProvider.RolePolicyForOwner RolePolicyProvider.RolePolicyForUser RolePolicySchemaVersion RolePolicySnapshot +RunCLIHooks SetRunner SetScopedHostFactory SurfaceHost