Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2da3d76ce1 | ||
|
|
ea497ed149 | ||
|
|
e853260bc7 | ||
|
|
1f952a7ef7 | ||
|
|
aee1d9bcf2 | ||
|
|
503ae000e0 | ||
|
|
392bb68506 | ||
|
|
30e18af70e | ||
|
|
7bcbbdb376 | ||
|
|
8cd4dd0ab7 | ||
|
|
fd69932c7d | ||
|
|
443e7af1b8 | ||
|
|
029917e843 | ||
|
|
9b40595728 | ||
|
|
7b8a34d1aa | ||
|
|
2424f8e3f7 | ||
|
|
b704ba9897 | ||
|
|
b4290c03bd | ||
|
|
03f7f39a9b | ||
|
|
2fc6e7e0a7 | ||
|
|
fc0a0505b3 | ||
|
|
92a4118381 | ||
|
|
97f60c1df6 | ||
|
|
f8e9e9f2d2 | ||
|
|
275b1e234d | ||
|
|
f3f70a7978 | ||
|
|
4c2db4c89b | ||
|
|
613c068c79 | ||
|
|
3b4a7af4b4 | ||
|
|
b6141800cc | ||
|
|
1f61131e72 | ||
|
|
f8a190801c | ||
|
|
49c1259e00 | ||
|
|
4030449f4b | ||
|
|
e8d0c9bd2f | ||
|
|
0e55875329 | ||
|
|
406cffdc37 | ||
|
|
80faf3edbd |
@@ -24,7 +24,7 @@ jobs:
|
||||
echo "SUBMODULE_TOKEN is not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@gitea.naitangbaobao.cn/".insteadOf "ssh://git@gitea.naitangbaobao.cn:52222/"
|
||||
git -c fetch.recurseSubmodules=false submodule update --init --recursive
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
parsed_server = urlsplit(SERVER)
|
||||
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
|
||||
sys.exit("Checkout server must be the platform-injected http(s) URL")
|
||||
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
|
||||
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
|
||||
REPOSITORIES = {
|
||||
"deps/development-guidelines": "Development-Guidelines",
|
||||
|
||||
+10
-10
@@ -1,30 +1,30 @@
|
||||
[submodule "deps/agent-system-core"]
|
||||
path = deps/agent-system-core
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-System-Core.git
|
||||
[submodule "deps/agent-native"]
|
||||
path = deps/agent-native
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git
|
||||
[submodule "deps/agent-intent"]
|
||||
path = deps/agent-intent
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git
|
||||
[submodule "deps/logging-system"]
|
||||
path = deps/logging-system
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Logging-System.git
|
||||
[submodule "deps/error-code-system"]
|
||||
path = deps/error-code-system
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Error-Code-System.git
|
||||
[submodule "deps/agent-squad"]
|
||||
path = deps/agent-squad
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Squad.git
|
||||
[submodule "deps/agent-command"]
|
||||
path = deps/agent-command
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Command.git
|
||||
[submodule "deps/agent-document-renderer"]
|
||||
path = deps/agent-document-renderer
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Document-Renderer.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Document-Renderer.git
|
||||
[submodule "deps/development-guidelines"]
|
||||
path = deps/development-guidelines
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
|
||||
[submodule "deps/development-manager"]
|
||||
path = deps/development-manager
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
|
||||
见到一律按过期处理,不要再试。当前权威通道:
|
||||
|
||||
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
|
||||
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
|
||||
(各仓 origin 与 gitlink 均为此格式)。
|
||||
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||
@@ -33,7 +33,7 @@
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
|
||||
|
||||
## Agent 执行与新项目准入(强制)
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-command updated: 4f583ca3ee...b0b4f39959
Vendored
+1
-1
Submodule deps/agent-document-renderer updated: 3f259990b5...40e5c671d1
Vendored
+1
-1
Submodule deps/agent-intent updated: 651a2e1e03...70b39a9f70
Vendored
+1
-1
Submodule deps/agent-native updated: 2fc66be652...6cc0a86615
Vendored
+1
-1
Submodule deps/agent-squad updated: c7121bda2a...9bb77c1ab7
Vendored
+1
-1
Submodule deps/agent-system-core updated: 0cf03446c4...614dcbff4b
Vendored
+1
-1
Submodule deps/development-guidelines updated: 2688fd2be6...3ed982138c
Vendored
+1
-1
Submodule deps/development-manager updated: 67784f43eb...24e8a5c8ba
Vendored
+1
-1
Submodule deps/error-code-system updated: a3ee67bba2...00c03035b5
Vendored
+1
-1
Submodule deps/logging-system updated: 49b5d8be60...7005493077
+50
-92
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -24,13 +25,10 @@ import (
|
||||
"logging-system/logsys"
|
||||
"logging-system/sink"
|
||||
|
||||
"agent-native/runtime/checkpoint"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentmcp "agent-native/runtime/mcp"
|
||||
"agent-native/runtime/policy"
|
||||
"agent-native/runtime/telemetry"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
@@ -332,98 +330,20 @@ func Run(opt Options) error {
|
||||
}
|
||||
}
|
||||
tools.SetSkillsRoot(skillsRoot)
|
||||
// 小队的 agent-native 内核工厂(组合根装配,逐次运行时从 settings 解析):
|
||||
// 模型走 settings.models.chat 同一路由;搜索/解释器/MCP/技能根复用产品配置;
|
||||
// 委派桥接产品 SpawnSubagent。主模型未配置时返回未启用(不阻塞产品)。
|
||||
// 小队的 agent-native 内核工厂(组合根装配,逐次运行时按当前运行的用户/项目
|
||||
// 解析):模型走 settings.models.chat 同一路由与同一 per-route 规则;
|
||||
// 搜索/解释器/MCP/技能根复用产品配置;委派桥接产品 SpawnSubagent。
|
||||
// 主模型未配置时返回未启用(不阻塞产品)。装配见 squad_native.go。
|
||||
var pluginHost *plugins.Host
|
||||
squadadapter.AgentNativeOptions = func(rc squadadapter.Ctx) (nativeadp.Config, bool) {
|
||||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||||
route := runtime.ResolveChatModel(core.AgentProfile{}, effNow)
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return nativeadp.Config{}, false
|
||||
}
|
||||
mode := "default"
|
||||
if src, ok := rc.(interface{ PermissionMode() string }); ok {
|
||||
mode = src.PermissionMode()
|
||||
}
|
||||
cfg := nativeadp.Config{
|
||||
Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key, HTTPClient: &http.Client{}},
|
||||
DefaultModel: route.Model,
|
||||
MaxTurns: 0,
|
||||
Timeout: 0,
|
||||
Subagent: squadadapter.SubagentFactory(rc),
|
||||
RepeatGuard: true,
|
||||
BeforeTool: func(call agentllm.ToolCall) error {
|
||||
effect := policy.Decide(policy.Request{
|
||||
Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments,
|
||||
Traits: policy.TraitsFor(call.Name),
|
||||
}, nil)
|
||||
if effect == policy.EffectDeny {
|
||||
return fmt.Errorf("计划模式拒绝了工具 %s", call.Name)
|
||||
}
|
||||
squadadapter.AgentNativeOptions = squadAgentNativeFactory(squadNativeDeps{
|
||||
set: setSvc, secrets: st, skillsRoot: skillsRoot, trace: traceFn, mcp: mcpManagerFor,
|
||||
pluginMCP: func() []core.PluginMCPServer {
|
||||
if pluginHost == nil {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream)
|
||||
if keys := tavilyKeyPool(st, effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 {
|
||||
cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
|
||||
}
|
||||
if effNow.GetBool("runtime.exec", true) {
|
||||
cfg.Exec = tools.InterpreterExecConfig()
|
||||
}
|
||||
if st2, err := os.Stat(skillsRoot); err == nil && st2.IsDir() {
|
||||
cfg.SkillsRoots = []string{skillsRoot}
|
||||
}
|
||||
rawMCP := strings.TrimSpace(effNow.GetString("tools.mcp.servers", ""))
|
||||
if pluginHost != nil {
|
||||
rawMCP = mergeMCPServers(rawMCP, pluginHost.MCPServers())
|
||||
}
|
||||
if mgr := mcpManagerFor(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil {
|
||||
cfg.MCP = mgr
|
||||
}
|
||||
// Native squad tools do not yet expose a fail-closed result hook to the
|
||||
// project observation outbox. Project runs therefore use the audited
|
||||
// runCtx retrieval/tool path only; enabling these direct providers would
|
||||
// let their results reach the next model turn without source analysis.
|
||||
if rc.Run().ProjectID != "" {
|
||||
cfg.Search = nil
|
||||
cfg.Exec = nil
|
||||
cfg.Fetch = nil
|
||||
cfg.MCP = nil
|
||||
}
|
||||
cfg.CodeOrchestration = rc.Run().ProjectID == "" && effNow.GetBool("runtime.codeOrchestration", false)
|
||||
if ask, ok := rc.(interface {
|
||||
AskUser(card core.HITLCard) (core.HITLResponse, error)
|
||||
}); ok {
|
||||
cfg.Ask = func(_ context.Context, question string) (string, error) {
|
||||
resp, err := ask.AskUser(core.HITLCard{
|
||||
Question: question, Kind: core.HITLKindInput, AllowFreeInput: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(resp.FreeText) != "" {
|
||||
return strings.TrimSpace(resp.FreeText), nil
|
||||
}
|
||||
return resp.ChoiceID, nil
|
||||
}
|
||||
}
|
||||
cfg.Trace = traceFn
|
||||
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
|
||||
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
|
||||
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
|
||||
if host, ok := rc.(interface {
|
||||
OpenCheckpoints() (*checkpoint.Log, error)
|
||||
CheckpointAnchor() string
|
||||
}); ok {
|
||||
if lg, err := host.OpenCheckpoints(); err == nil {
|
||||
cfg.Checkpoints = lg
|
||||
anchor := host.CheckpointAnchor()
|
||||
cfg.CheckpointAnchor = func() string { return anchor }
|
||||
}
|
||||
}
|
||||
return cfg, true
|
||||
}
|
||||
return pluginHost.MCPServers()
|
||||
},
|
||||
})
|
||||
profSvc := profiles.New(st)
|
||||
if err := profSvc.EnsureDefault(); err != nil {
|
||||
return fmt.Errorf("default profile: %w", err)
|
||||
@@ -623,10 +543,16 @@ func Run(opt Options) error {
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error {
|
||||
return scratchCommandHostGuard(st, req)
|
||||
})
|
||||
bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error {
|
||||
if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" {
|
||||
return fmt.Errorf("formal project command requires an authenticated conversation source")
|
||||
}
|
||||
if err := rejectScratchCommandHost(st, req); err != nil {
|
||||
return err
|
||||
}
|
||||
conv, err := st.GetConversation(req.Owner, req.ConversationID)
|
||||
if err != nil || string(conv.ProjectID) != req.HostProjectID {
|
||||
return fmt.Errorf("formal project command conversation binding changed")
|
||||
@@ -1123,6 +1049,38 @@ func splitKeyPool(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ErrScratchCommandHost 是命令分发的临时会话门禁:临时会话没有 RDM 项目域,
|
||||
// 任何以基座项目为前提的命令(rdm.* 与正式项目命令)在里面一律不可用。
|
||||
var ErrScratchCommandHost = errors.New("not available in scratch sessions")
|
||||
|
||||
// rejectScratchCommandHost 在命令总线内部分发(宿主与插件处理器共用的分发面)
|
||||
// 上执行同一个门禁。基座项目是临时会话时拒绝;不是临时会话(包括项目读不到)
|
||||
// 时交回原路径,不改变任何现有判定。
|
||||
func rejectScratchCommandHost(st *store.Store, req cmdbus.Request) error {
|
||||
if st == nil || req.HostProjectID == "" {
|
||||
return nil
|
||||
}
|
||||
handled, err := st.ScratchProjectDecision(req.HostProjectID, req.Owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled {
|
||||
return ErrScratchCommandHost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scratchCommandHostGuard 是命令总线上的基座项目门禁(组合根装配):
|
||||
// 项目域命令(rdm.* 全族与正式项目命令 cmdbus.RequiresProjectWorkDecision)在
|
||||
// 临时会话宿主上一律拒绝,其它命令与普通项目宿主原样通过。它挂在分发面本身,
|
||||
// 所以聊天意图路由与 system.intent 再分发不再绕过 HTTP 403 与正式命令预检。
|
||||
func scratchCommandHostGuard(st *store.Store, req cmdbus.Request) error {
|
||||
if !strings.HasPrefix(req.ID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(req.ID) {
|
||||
return nil
|
||||
}
|
||||
return rejectScratchCommandHost(st, req)
|
||||
}
|
||||
|
||||
// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口
|
||||
// (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。
|
||||
type busAdapter struct{ bus *cmdbus.Bus }
|
||||
|
||||
@@ -247,6 +247,12 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
||||
if h.actor == "" || h.actor != h.owner || q.ProjectID == "" {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
// An export is not a page. This slice has no whole-history export capability,
|
||||
// so the request is refused outright rather than answered with a first page a
|
||||
// consumer could mistake for the complete record.
|
||||
if q.Export {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
if h.caps.projectAudit == nil || h.caps.st == nil {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
@@ -267,9 +273,16 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.ProjectAuditPage{}, err
|
||||
}
|
||||
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 {
|
||||
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 ||
|
||||
q.LinkedResourceKind != "" || q.LinkedResourceID != "" || q.LinkedRunID != "" {
|
||||
return h.queryLinkedProjectAudit(ctx, q, slot)
|
||||
}
|
||||
// The actor-owned page is not bound to one verified object, so no per-object
|
||||
// body right can be decided for it. Refusing keeps Coverage.ResourceBodies
|
||||
// honest instead of answering a page trimmed by an unasked decision.
|
||||
if q.IncludeBody {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
|
||||
if err != nil {
|
||||
return extension.ProjectAuditPage{}, err
|
||||
@@ -279,7 +292,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
||||
NextOffset: next, CutoffOffset: cutoff,
|
||||
Coverage: extension.ProjectAuditCoverage{
|
||||
CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
|
||||
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported",
|
||||
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported",
|
||||
},
|
||||
}
|
||||
for _, row := range rows {
|
||||
@@ -298,7 +311,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
||||
}
|
||||
|
||||
func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) {
|
||||
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 {
|
||||
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 || q.Export {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||
}
|
||||
slot.mu.RLock()
|
||||
@@ -311,9 +324,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
// The host row was edited after publication: the receipt no longer matches
|
||||
// the binding the domain verified. Fail closed instead of re-projecting it.
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
if err != nil {
|
||||
return extension.ProjectAuditPage{}, err
|
||||
}
|
||||
// Caller locators only narrow the exact binding. Disagreement with stored host
|
||||
// state denies; it never selects a different object.
|
||||
if (q.LinkedResourceKind != "" && q.LinkedResourceKind != link.ResourceKind) ||
|
||||
(q.LinkedResourceID != "" && q.LinkedResourceID != link.ResourceID) ||
|
||||
(q.LinkedRunID != "" && q.LinkedRunID != link.RunID) {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
// A run relation was verified at publication; re-check it against current
|
||||
// storage so a deleted or moved run stops serving its receipt.
|
||||
if link.RunID != "" {
|
||||
bound, err := h.caps.st.RunBoundToAudit(link.Owner, link.ProjectID, row.Envelope.ConversationID, link.RunID)
|
||||
if err != nil {
|
||||
return extension.ProjectAuditPage{}, err
|
||||
}
|
||||
if !bound {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
}
|
||||
resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID,
|
||||
OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass}
|
||||
allowed, err := disclose(ctx, h.actor, resource)
|
||||
@@ -323,14 +359,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro
|
||||
if !allowed.Metadata {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
// audit.read and the per-object body right are separate authorities: a body
|
||||
// request without that right is refused, never trimmed into a partial page.
|
||||
if q.IncludeBody && !allowed.Body {
|
||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return extension.ProjectAuditPage{}, err
|
||||
}
|
||||
nameStatus := "not-recorded"
|
||||
if row.ActorName != "" {
|
||||
nameStatus = "recorded"
|
||||
}
|
||||
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{{SourceOffset: row.Offset, Time: row.Envelope.Time,
|
||||
receipt := extension.HostAuditReceipt{SourceOffset: row.Offset, Time: row.Envelope.Time,
|
||||
ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID,
|
||||
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}},
|
||||
Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
|
||||
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
|
||||
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil
|
||||
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}
|
||||
coverage := extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
|
||||
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
|
||||
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported"}
|
||||
if link.RunID != "" {
|
||||
coverage.RunRelations = "verified-exact-link-run"
|
||||
}
|
||||
if q.IncludeBody {
|
||||
// Body-permitted projection of the verified binding only: the object identity
|
||||
// the domain attributed this command to, never resource content.
|
||||
receipt.Resource = &extension.ProjectAuditReceiptResource{Kind: link.ResourceKind, ID: link.ResourceID, RunID: link.RunID}
|
||||
coverage.ResourceBodies = "verified-exact-link-body"
|
||||
}
|
||||
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{receipt}, Coverage: coverage}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
package app
|
||||
|
||||
// W9 X2 出口矩阵:可选 ProjectAuditHost 的读取面按【受信项目、actor、resource、
|
||||
// run、source offset 与截止】读取,并结合 RDM audit.read 回调与每对象正文权做
|
||||
// 服务端裁剪。本文件覆盖批准需求第(4)条列出的九个出口场景,以及第(3)条
|
||||
// “缺基座能力返回 unsupported 覆盖范围”的语义。
|
||||
//
|
||||
// RDM 消费端(audit.read / doc.read 回调的领域实现)不在本仓:这里只验证宿主
|
||||
// 面必须向注册方询问、按对象裁剪、缺能力即 unsupported,不在宿主侧复制策略。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// auditMatrix 是出口矩阵的共用基座:alice 拥有 A/B 两个项目并在其中各有一份
|
||||
// 会话,bob 是 A 的第二成员(会话建在 A 下),carol 只存在于账号目录、不参与
|
||||
// 任何审计行。alice 的 op-a 是一条已发布链接(绑定 document/doc-1 与一次 run)。
|
||||
type auditMatrix struct {
|
||||
t *testing.T
|
||||
st *store.Store
|
||||
caps hostcaps
|
||||
projectA core.ID
|
||||
projectB core.ID
|
||||
foreign core.ID
|
||||
aliceConv core.ID
|
||||
bobConv core.ID
|
||||
runID string
|
||||
link extension.ProjectAuditLinkReceipt
|
||||
|
||||
// 策略旋钮:每个用例按出口场景改写,回调只读它们。
|
||||
auditAll bool
|
||||
auditRead map[core.ID]bool
|
||||
metadata map[string]bool
|
||||
body map[string]bool
|
||||
auditCalls []string
|
||||
discloseCall int
|
||||
}
|
||||
|
||||
func newAuditMatrix(t *testing.T) *auditMatrix {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
m := &auditMatrix{t: t, st: st, auditRead: map[core.ID]bool{}, metadata: map[string]bool{}, body: map[string]bool{}}
|
||||
for _, account := range []struct{ owner, name string }{
|
||||
{"alice", "Alice at commit"}, {"bob", "Bob"}, {"carol", "Carol directory only"},
|
||||
} {
|
||||
if err := st.EnsureUser(account.owner, account.name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
projectA, err := st.CreateProject("alice", "A")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectB, err := st.CreateProject("alice", "B")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign, err := st.CreateProject("carol", "C")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.projectA, m.projectB, m.foreign = projectA.ID, projectB.ID, foreign.ID
|
||||
aliceConv, err := st.CreateConversation("alice", string(projectA.ID), "A work", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherConv, err := st.CreateConversation("alice", string(projectB.ID), "B work", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobConv, err := st.CreateConversationInProject("bob", string(projectA.ID), "A shared", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.aliceConv, m.bobConv = aliceConv.ID, bobConv.ID
|
||||
run := core.Run{ConversationID: aliceConv.ID, ProjectID: projectA.ID, Owner: "alice",
|
||||
Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking}
|
||||
if err := st.CreateRun(&run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.runID = string(run.ID)
|
||||
m.append("alice", string(aliceConv.ID), "alice.submit", "op-a", m.runID)
|
||||
m.append("alice", string(otherConv.ID), "alice.other", "op-b", "")
|
||||
m.append("bob", string(bobConv.ID), "bob.submit", "op-bob", "")
|
||||
|
||||
m.caps = hostcaps{st: st, projectAudit: &projectAuditSlot{}}
|
||||
reg := pluginRegistry{pluginID: "rdm", caps: m.caps}
|
||||
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
|
||||
m.auditCalls = append(m.auditCalls, actor+"@"+string(projectID))
|
||||
return m.auditAll || m.auditRead[projectID], nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) {
|
||||
return proof.Owner == "alice" && proof.ProjectID == m.projectA && proof.SourceKind == "domain-commit" &&
|
||||
proof.SourceID == "commit-a" && proof.OperationID == "op-a" && proof.ResourceKind == "document" &&
|
||||
proof.ResourceID == "doc-1" && proof.RunID == m.runID, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
|
||||
m.discloseCall++
|
||||
return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && m.metadata[resource.ResourceID],
|
||||
Body: actor == "bob" && m.body[resource.ResourceID]}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publisher, err := factory.ForOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{
|
||||
ProjectID: m.projectA, SourceKind: "domain-commit", SourceID: "commit-a", OperationID: "op-a",
|
||||
ResourceKind: "document", ResourceID: "doc-1", RunID: m.runID, DisclosureClass: "metadata", IdempotencyKey: "outbox-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.link = receipt
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *auditMatrix) append(owner, conversationID, commandID, operationID, runID string) {
|
||||
m.t.Helper()
|
||||
if err := m.st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: owner, CommandID: commandID,
|
||||
ConversationID: conversationID, State: agentcommand.StateCommitted, OperationID: operationID, RunID: runID,
|
||||
ParamsDigest: "sensitive-digest"}); err != nil {
|
||||
m.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *auditMatrix) host(owner string) inprocHost {
|
||||
return inprocHost{caps: m.caps, owner: owner, actor: owner, pluginID: "rdm"}
|
||||
}
|
||||
|
||||
func (m *auditMatrix) linkedQuery() extension.ProjectAuditQuery {
|
||||
return extension.ProjectAuditQuery{ProjectID: m.projectA, LinkedSourceKind: "domain-commit",
|
||||
LinkedSourceID: "commit-a", LinkedHostOffset: m.link.HostAuditOffset}
|
||||
}
|
||||
|
||||
func (m *auditMatrix) openBody() {
|
||||
m.metadata["doc-1"], m.body["doc-1"] = true, true
|
||||
}
|
||||
|
||||
// 同 owner 跨项目/两成员:受信项目归属与成员身份都必须在服务端判定。
|
||||
func TestProjectAuditExitMatrixSameOwnerCrossProjectAndTwoMembers(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
m.auditRead[m.projectB] = true
|
||||
m.metadata["doc-1"] = true
|
||||
|
||||
bobPage, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bobPage.Receipts) != 1 || bobPage.Receipts[0].CommandID != "bob.submit" || bobPage.Receipts[0].ActorName != "Bob" {
|
||||
t.Fatalf("second member sees another member's row: %+v", bobPage.Receipts)
|
||||
}
|
||||
alicePage, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(alicePage.Receipts) != 1 || alicePage.Receipts[0].CommandID != "alice.submit" || alicePage.Receipts[0].ActorName != "Alice at commit" {
|
||||
t.Fatalf("owner page left its project scope: %+v", alicePage.Receipts)
|
||||
}
|
||||
otherProject, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(otherProject.Receipts) != 1 || otherProject.Receipts[0].CommandID != "alice.other" {
|
||||
t.Fatalf("project B page: %+v", otherProject.Receipts)
|
||||
}
|
||||
m.auditRead[m.projectB] = false
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("non-member read another project: %v", err)
|
||||
}
|
||||
if got := m.auditCalls[len(m.auditCalls)-1]; got != "bob@"+string(m.projectB) {
|
||||
t.Fatalf("host did not ask the domain about the caller's project: %s", got)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("empty project id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 恶意 projectId:调用方自报的项目标识只是定位符,既不能换出别的项目,也不能
|
||||
// 让“看起来通过”的未知项目变成受信项目流。
|
||||
func TestProjectAuditExitMatrixMaliciousProjectID(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
// 领域回调对所有项目一律放行(配置失误):宿主仍只能按受信存储归集。
|
||||
m.auditAll = true
|
||||
for _, projectID := range []core.ID{m.foreign, "no-such-project"} {
|
||||
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: projectID})
|
||||
if err != nil {
|
||||
t.Fatalf("project %q: %v", projectID, err)
|
||||
}
|
||||
if len(page.Receipts) != 0 {
|
||||
t.Fatalf("project %q returned rows: %+v", projectID, page.Receipts)
|
||||
}
|
||||
if page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.Export != "unsupported" {
|
||||
t.Fatalf("project %q coverage: %+v", projectID, page.Coverage)
|
||||
}
|
||||
}
|
||||
// 领域误放行也不能换出受信任存储之外的对象:每个主体只看得到自己的行。
|
||||
for _, c := range []struct {
|
||||
owner string
|
||||
projectID core.ID
|
||||
want string
|
||||
}{
|
||||
{"alice", m.projectA, "alice.submit"}, {"bob", m.projectA, "bob.submit"},
|
||||
{"alice", m.projectB, "alice.other"}, {"bob", m.projectB, ""},
|
||||
} {
|
||||
page, err := m.host(c.owner).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: c.projectID})
|
||||
if err != nil {
|
||||
t.Fatalf("%s@%s: %v", c.owner, c.projectID, err)
|
||||
}
|
||||
got := []string{}
|
||||
for _, receipt := range page.Receipts {
|
||||
got = append(got, receipt.CommandID)
|
||||
}
|
||||
if strings.Join(got, ",") != c.want {
|
||||
t.Fatalf("%s@%s rows = %v, want [%s]", c.owner, c.projectID, got, c.want)
|
||||
}
|
||||
}
|
||||
// 他人项目下的链接按 projectId 定位失败:不得跨项目取回。
|
||||
crossProject := m.linkedQuery()
|
||||
crossProject.ProjectID = m.foreign
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), crossProject); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("cross-project link locator: %v", err)
|
||||
}
|
||||
// 调用方自报的 resource/run 定位符必须与受信绑定一致,否则拒绝。
|
||||
// 先打开逐对象元数据权,使拒绝只能来自定位符校验本身。
|
||||
m.metadata["doc-1"] = true
|
||||
for _, mutate := range []func(*extension.ProjectAuditQuery){
|
||||
func(q *extension.ProjectAuditQuery) { q.LinkedResourceID = "doc-2" },
|
||||
func(q *extension.ProjectAuditQuery) { q.LinkedResourceKind = "run" },
|
||||
func(q *extension.ProjectAuditQuery) { q.LinkedRunID = "run-other" },
|
||||
} {
|
||||
q := m.linkedQuery()
|
||||
mutate(&q)
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("forged locator was accepted: %+v err=%v", q, err)
|
||||
}
|
||||
}
|
||||
q := m.linkedQuery()
|
||||
q.LinkedResourceKind, q.LinkedResourceID, q.LinkedRunID = "document", "doc-1", m.runID
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); err != nil {
|
||||
t.Fatalf("matching locators were refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// audit.read 无正文:元数据权成立、正文权不成立时只出元数据;请求正文则拒绝,
|
||||
// 不得返回一份“被悄悄裁剪”的页冒充有正文覆盖。
|
||||
func TestProjectAuditExitMatrixAuditReadWithoutBody(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
m.metadata["doc-1"] = true
|
||||
page, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Receipts) != 1 || page.Receipts[0].Resource != nil || page.Receipts[0].ActorName != "Alice at commit" {
|
||||
t.Fatalf("metadata-only page: %+v", page.Receipts)
|
||||
}
|
||||
if page.Coverage.ResourceBodies != "unsupported" || page.Coverage.CommandAudit != "verified-exact-link-only" {
|
||||
t.Fatalf("coverage overstated body coverage: %+v", page.Coverage)
|
||||
}
|
||||
body := m.linkedQuery()
|
||||
body.IncludeBody = true
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("body requested without the body right: %v", err)
|
||||
}
|
||||
// 无受信对象绑定的 actor 自有页没有每对象正文权可言:显式 unsupported。
|
||||
owned := extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), owned); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("actor-owned body read: %v", err)
|
||||
}
|
||||
// 正文权成立时按对象裁剪下发,且不下发正文载荷。
|
||||
m.openBody()
|
||||
// 有权但未请求:不主动多给对象身份。
|
||||
undeclared, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(undeclared.Receipts) != 1 || undeclared.Receipts[0].Resource != nil || undeclared.Coverage.ResourceBodies != "unsupported" {
|
||||
t.Fatalf("body right was exercised without a body request: %+v coverage=%+v", undeclared.Receipts, undeclared.Coverage)
|
||||
}
|
||||
bodyPage, err := m.host("bob").QueryProjectAudit(context.Background(), body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(bodyPage.Receipts) != 1 || bodyPage.Receipts[0].Resource == nil ||
|
||||
bodyPage.Receipts[0].Resource.Kind != "document" || bodyPage.Receipts[0].Resource.ID != "doc-1" ||
|
||||
bodyPage.Receipts[0].Resource.RunID != m.runID || bodyPage.Coverage.ResourceBodies != "verified-exact-link-body" ||
|
||||
bodyPage.Coverage.RunRelations != "verified-exact-link-run" {
|
||||
t.Fatalf("body-permitted page: %+v coverage=%+v", bodyPage.Receipts, bodyPage.Coverage)
|
||||
}
|
||||
raw, err := json.Marshal(bodyPage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") ||
|
||||
strings.Contains(string(raw), string(m.aliceConv)) {
|
||||
t.Fatalf("body-permitted page leaked a payload or private locator: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 正文无 audit.read:对象权先成立也必须先被项目 audit.read 挡住,且不得因为
|
||||
// 逐对象判定而向无 audit.read 的主体泄露“这个对象存在”。
|
||||
func TestProjectAuditExitMatrixBodyWithoutAuditRead(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
m.openBody()
|
||||
body := m.linkedQuery()
|
||||
body.IncludeBody = true
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); err != nil {
|
||||
t.Fatalf("baseline body read: %v", err)
|
||||
}
|
||||
m.auditRead[m.projectA] = false
|
||||
before := m.discloseCall
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("body right survived revoked audit.read: %v", err)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("body read without audit.read: %v", err)
|
||||
}
|
||||
if m.discloseCall != before {
|
||||
t.Fatalf("per-object decision was consulted before audit.read: %d -> %d", before, m.discloseCall)
|
||||
}
|
||||
}
|
||||
|
||||
// 对象已撤权/删改:撤权走逐对象判定;删除与改动让存储侧无法再把该链接解析成
|
||||
// 受信任回执;已删除的 run 关系也不再服务回执。
|
||||
func TestProjectAuditExitMatrixObjectRevokedDeletedOrEdited(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
m.openBody()
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
|
||||
t.Fatalf("baseline link: %v", err)
|
||||
}
|
||||
m.metadata["doc-1"], m.body["doc-1"] = false, false
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("revoked object: %v", err)
|
||||
}
|
||||
m.openBody()
|
||||
|
||||
// run 关系被改(run 移出本项目):链接仍在,但受约束的 run 不再匹配。
|
||||
if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectB), m.runID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("moved run kept serving its receipt: %v", err)
|
||||
}
|
||||
if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectA), m.runID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
|
||||
t.Fatalf("restored run: %v", err)
|
||||
}
|
||||
|
||||
// 宿主回执被改动(载荷里的 operationId 漂移):不再是领域验证过的那一条。
|
||||
var stored string
|
||||
if err := m.st.DB().QueryRow(`SELECT payload FROM command_audit WHERE id=?`, m.link.HostAuditOffset).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice",
|
||||
CommandID: "alice.submit", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted, OperationID: "op-forged"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, string(forged), m.link.HostAuditOffset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("edited receipt still resolved: %v", err)
|
||||
}
|
||||
if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, stored, m.link.HostAuditOffset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
|
||||
t.Fatalf("restored receipt: %v", err)
|
||||
}
|
||||
// 链接被删:关联不再存在,回执不再属于本项目审计。
|
||||
if _, err := m.st.DB().Exec(`DELETE FROM project_audit_links WHERE id=?`, m.link.LinkID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("deleted link still resolved: %v", err)
|
||||
}
|
||||
// 宿主回执的项目归属被改:外键仍在,但它已不是这一条被验证的绑定。
|
||||
if _, err := m.st.DB().Exec(`INSERT INTO project_audit_links(id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, m.link.LinkID, m.link.HostAuditOffset, string(m.projectA), "alice", "domain-commit",
|
||||
"commit-a", "op-a", "document", "doc-1", m.runID, "metadata", "outbox-a", time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.st.DB().Exec(`UPDATE command_audit SET project_id=? WHERE id=?`, string(m.projectB), m.link.HostAuditOffset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||
t.Fatalf("re-attributed receipt still resolved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 历史 cutoff:第一页固定的上限在其后写入的行不得进入本次分页视图。
|
||||
func TestProjectAuditExitMatrixHistoricalCutoff(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
for i := 0; i < 4; i++ {
|
||||
m.append("bob", string(m.bobConv), "bob.bulk-"+string(rune('a'+i)), "op-bulk", "")
|
||||
}
|
||||
first, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.Receipts) != 2 || first.CutoffOffset == 0 || first.NextOffset == 0 {
|
||||
t.Fatalf("first page: %+v", first)
|
||||
}
|
||||
m.append("bob", string(m.bobConv), "bob.after-cutoff", "op-after", "")
|
||||
seen := map[string]bool{}
|
||||
for _, receipt := range first.Receipts {
|
||||
seen[receipt.CommandID] = true
|
||||
}
|
||||
before, cutoff := first.NextOffset, first.CutoffOffset
|
||||
for page := first; page.NextOffset != 0; {
|
||||
if before >= cutoff {
|
||||
t.Fatalf("cursor did not move: before=%d cutoff=%d", before, cutoff)
|
||||
}
|
||||
page, err = m.host("bob").QueryProjectAudit(context.Background(),
|
||||
extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.CutoffOffset != cutoff {
|
||||
t.Fatalf("cutoff moved mid-read: %d -> %d", cutoff, page.CutoffOffset)
|
||||
}
|
||||
before = page.NextOffset
|
||||
for _, receipt := range page.Receipts {
|
||||
if seen[receipt.CommandID] {
|
||||
t.Fatalf("duplicate receipt across pages: %s", receipt.CommandID)
|
||||
}
|
||||
seen[receipt.CommandID] = true
|
||||
}
|
||||
}
|
||||
if len(seen) != 5 || seen["bob.after-cutoff"] {
|
||||
t.Fatalf("historical cutoff read drifted: %v", seen)
|
||||
}
|
||||
// 不带 cutoff 的新读取才能看到其后写入的行。
|
||||
fresh, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fresh.Receipts) != 6 || fresh.CutoffOffset <= cutoff {
|
||||
t.Fatalf("fresh read did not advance: n=%d cutoff=%d", len(fresh.Receipts), fresh.CutoffOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出:审计授权不是导出授权。本切片不提供整段导出,只能分页,且覆盖显式声明。
|
||||
func TestProjectAuditExitMatrixExportIsRefusedNotDegraded(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
m.openBody()
|
||||
for _, q := range []extension.ProjectAuditQuery{
|
||||
{ProjectID: m.projectA, Export: true},
|
||||
{ProjectID: m.projectA, Export: true, Limit: 100000},
|
||||
} {
|
||||
page, err := m.host("bob").QueryProjectAudit(context.Background(), q)
|
||||
if !errors.Is(err, extension.ErrProjectAuditUnsupported) || len(page.Receipts) != 0 {
|
||||
t.Fatalf("export was not refused: page=%+v err=%v", page, err)
|
||||
}
|
||||
}
|
||||
exportLink := m.linkedQuery()
|
||||
exportLink.Export = true
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(), exportLink); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("export through the linked path: %v", err)
|
||||
}
|
||||
linked, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if linked.Coverage.Export != "unsupported" {
|
||||
t.Fatalf("linked page export coverage: %+v", linked.Coverage)
|
||||
}
|
||||
for i := 0; i < 205; i++ {
|
||||
m.append("bob", string(m.bobConv), "bob.flood", "op-flood", "")
|
||||
}
|
||||
huge, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(huge.Receipts) > 200 || huge.NextOffset == 0 || huge.Coverage.Export != "unsupported" {
|
||||
t.Fatalf("unbounded read was not capped and reported: n=%d next=%d coverage=%+v", len(huge.Receipts), huge.NextOffset, huge.Coverage)
|
||||
}
|
||||
}
|
||||
|
||||
// 长分页:多次翻页必须完整、不重不漏,且游标单调收敛。
|
||||
func TestProjectAuditExitMatrixLongPagination(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
const total = 25
|
||||
for i := 0; i < total; i++ {
|
||||
m.append("bob", string(m.bobConv), "bob.page", "op-page", "")
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
before, cutoff := int64(0), int64(0)
|
||||
for pages := 0; ; pages++ {
|
||||
if pages > 10 {
|
||||
t.Fatalf("pagination did not terminate: %d rows", len(seen))
|
||||
}
|
||||
page, err := m.host("bob").QueryProjectAudit(context.Background(),
|
||||
extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 7})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch {
|
||||
case cutoff == 0:
|
||||
cutoff = page.CutoffOffset
|
||||
if cutoff == 0 {
|
||||
t.Fatal("first page must establish a cutoff")
|
||||
}
|
||||
case page.CutoffOffset != cutoff:
|
||||
t.Fatalf("cutoff drifted on page %d: %d != %d", pages, page.CutoffOffset, cutoff)
|
||||
}
|
||||
if len(page.Receipts) > 7 {
|
||||
t.Fatalf("page %d exceeded the requested limit: %d", pages, len(page.Receipts))
|
||||
}
|
||||
for i, receipt := range page.Receipts {
|
||||
if seen[receipt.SourceOffset] {
|
||||
t.Fatalf("offset %d repeated on page %d", receipt.SourceOffset, pages)
|
||||
}
|
||||
if i > 0 && receipt.SourceOffset >= page.Receipts[i-1].SourceOffset {
|
||||
t.Fatalf("page %d is not strictly descending: %+v", pages, page.Receipts)
|
||||
}
|
||||
seen[receipt.SourceOffset] = true
|
||||
}
|
||||
if page.NextOffset == 0 {
|
||||
break
|
||||
}
|
||||
if before != 0 && page.NextOffset >= before {
|
||||
t.Fatalf("cursor did not advance: %d -> %d", before, page.NextOffset)
|
||||
}
|
||||
before = page.NextOffset
|
||||
}
|
||||
if len(seen) != total+1 {
|
||||
t.Fatalf("long pagination lost rows: got %d want %d", len(seen), total+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧记录不补造字段:显示名是动作当时记录的最小快照,旧行保持 not-recorded;
|
||||
// 账号目录也不会随审计页下发。
|
||||
func TestProjectAuditExitMatrixLegacyRowsAreNotBackfilled(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice",
|
||||
CommandID: "alice.legacy", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
|
||||
VALUES (?,?,'legacy','','committed','',?,?,?,'',?)`, time.Now().UTC().Format(time.RFC3339Nano), "alice.legacy",
|
||||
string(m.aliceConv), "alice", string(legacy), string(m.projectA)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var legacyReceipt *extension.HostAuditReceipt
|
||||
for i := range page.Receipts {
|
||||
if page.Receipts[i].CommandID == "alice.legacy" {
|
||||
legacyReceipt = &page.Receipts[i]
|
||||
}
|
||||
}
|
||||
if legacyReceipt == nil || legacyReceipt.ActorName != "" || legacyReceipt.ActorNameStatus != "not-recorded" {
|
||||
t.Fatalf("legacy row fabricated a display name: %+v", page.Receipts)
|
||||
}
|
||||
if _, err := m.st.DB().Exec(`UPDATE users SET name=? WHERE owner=?`, "Alice renamed", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := 0
|
||||
for _, receipt := range after.Receipts {
|
||||
seen++
|
||||
switch receipt.CommandID {
|
||||
case "alice.legacy":
|
||||
if receipt.ActorName != "" || receipt.ActorNameStatus != "not-recorded" {
|
||||
t.Fatalf("rename backfilled an old row: %+v", receipt)
|
||||
}
|
||||
case "alice.submit":
|
||||
if receipt.ActorName != "Alice at commit" || receipt.ActorNameStatus != "recorded" {
|
||||
t.Fatalf("recorded name was not preserved: %+v", receipt)
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Fatalf("unexpected row count after rename: %d", seen)
|
||||
}
|
||||
m.append("alice", string(m.aliceConv), "alice.after-rename", "op-rename", "")
|
||||
fresh, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fresh.Receipts) != 1 || fresh.Receipts[0].CommandID != "alice.after-rename" || fresh.Receipts[0].ActorName != "Alice renamed" {
|
||||
t.Fatalf("new row did not record the current name: %+v", fresh.Receipts)
|
||||
}
|
||||
raw, err := json.Marshal(page)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "Carol directory only") || strings.Contains(string(raw), "Bob") {
|
||||
t.Fatalf("account directory leaked through the audit page: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// owner 全量审计不是项目流:租户级审计与项目审计互不越界;缺基座能力一律
|
||||
// unsupported(而非静默空页或降级读取)。
|
||||
func TestProjectAuditOwnerWideAuditIsNotAProjectFlowAndUnsupportedIsExplicit(t *testing.T) {
|
||||
m := newAuditMatrix(t)
|
||||
m.auditRead[m.projectA] = true
|
||||
m.metadata["doc-1"] = true
|
||||
// 租户级审计(含无项目归属的旧行)不得成为项目流的取数来源。
|
||||
if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload)
|
||||
VALUES (?,'unbound','','','committed','','','alice','{}')`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ownerWide, err := m.st.ListAudits("alice", "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ownerWide) <= len(page.Receipts) {
|
||||
t.Fatalf("owner-wide audit unexpectedly matched the project page: %d vs %d", len(ownerWide), len(page.Receipts))
|
||||
}
|
||||
for _, receipt := range page.Receipts {
|
||||
if receipt.CommandID == "unbound" || receipt.CommandID == "bob.submit" {
|
||||
t.Fatalf("owner-wide or cross-member row reached the project page: %+v", receipt)
|
||||
}
|
||||
}
|
||||
if page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" {
|
||||
t.Fatalf("coverage must state the project-flow boundary: %+v", page.Coverage)
|
||||
}
|
||||
// 缺基座能力:没有槽位、没有注册方、不是注册插件的宿主句柄。
|
||||
if _, err := (inprocHost{caps: hostcaps{st: m.st}, owner: "bob", actor: "bob", pluginID: "rdm"}).
|
||||
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("missing audit slot: %v", err)
|
||||
}
|
||||
if _, err := (inprocHost{caps: hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}, owner: "bob", actor: "bob", pluginID: "rdm"}).
|
||||
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("unregistered authorizer: %v", err)
|
||||
}
|
||||
if _, err := (inprocHost{caps: m.caps, owner: "bob", actor: "bob", pluginID: "other"}).
|
||||
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("another plugin borrowed the policy: %v", err)
|
||||
}
|
||||
// 逐对象判定缺失时,精确链接读取也必须 unsupported。
|
||||
noDisclosure := hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}
|
||||
if err := (pluginRegistry{pluginID: "rdm", caps: noDisclosure}).RegisterProjectAuditAuthorizer(
|
||||
func(string, core.ID) (bool, error) { return true, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := (inprocHost{caps: noDisclosure, owner: "bob", actor: "bob", pluginID: "rdm"}).
|
||||
QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("missing disclosure authorizer: %v", err)
|
||||
}
|
||||
// 声明式扩展与缺少该接口的注册表都明确拒绝注册,不静默忽略。
|
||||
if err := (compiledRegistry{}).RegisterProjectAuditAuthorizer(func(string, core.ID) (bool, error) { return true, nil }); err == nil {
|
||||
t.Fatal("declarative plugin registered an audit authorizer")
|
||||
}
|
||||
if err := (recordRegistry{Registry: pluginRegistry{pluginID: "rdm", caps: hostcaps{}}}).RegisterProjectAuditAuthorizer(
|
||||
func(string, core.ID) (bool, error) { return true, nil }); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("registry without audit capability: %v", err)
|
||||
}
|
||||
// 导出与无对象正文权同样是显式 unsupported。
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(),
|
||||
extension.ProjectAuditQuery{ProjectID: m.projectA, Export: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("export on the actor-owned page: %v", err)
|
||||
}
|
||||
if _, err := m.host("bob").QueryProjectAudit(context.Background(),
|
||||
extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("body on the actor-owned page: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -119,8 +119,19 @@ func (s *projectContextSlot) readEvidence(ctx context.Context, req extension.Pro
|
||||
}
|
||||
|
||||
func (s *projectContextSlot) recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
||||
if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Scope.Goal != "" {
|
||||
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope missing")
|
||||
// Each unusable scope is reported on its own so host-side logs name the
|
||||
// actual defect; every branch still rejects (fail-closed).
|
||||
if s == nil {
|
||||
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project context source recheck slot missing")
|
||||
}
|
||||
if req.Scope.Actor == "" {
|
||||
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no actor")
|
||||
}
|
||||
if req.Scope.ProjectID == "" {
|
||||
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no project")
|
||||
}
|
||||
if req.Scope.Goal != "" {
|
||||
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: source recheck scope carries a generation goal")
|
||||
}
|
||||
s.mu.RLock()
|
||||
p, holder := s.provider, s.holder
|
||||
|
||||
@@ -27,6 +27,45 @@ func registeredRecheckSlot(t *testing.T, recheck func(context.Context, extension
|
||||
return slot
|
||||
}
|
||||
|
||||
func TestProjectSourceRecheckScopeErrorsAreDistinguishable(t *testing.T) {
|
||||
var nilSlot *projectContextSlot
|
||||
slot := registeredRecheckSlot(t, func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
||||
t.Error("unusable recheck scope reached the provider")
|
||||
return extension.ProjectContextSourceRecheckResult{}, nil
|
||||
})
|
||||
cases := map[string]func() (extension.ProjectContextSourceRecheckResult, error){
|
||||
"slot missing": func() (extension.ProjectContextSourceRecheckResult, error) {
|
||||
return nilSlot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}})
|
||||
},
|
||||
"no actor": func() (extension.ProjectContextSourceRecheckResult, error) {
|
||||
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{ProjectID: "p1"}})
|
||||
},
|
||||
"no project": func() (extension.ProjectContextSourceRecheckResult, error) {
|
||||
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice"}})
|
||||
},
|
||||
"carries goal": func() (extension.ProjectContextSourceRecheckResult, error) {
|
||||
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1", Goal: "secret"}})
|
||||
},
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for name, call := range cases {
|
||||
result, err := call()
|
||||
if err == nil {
|
||||
t.Fatalf("%s scope accepted", name)
|
||||
}
|
||||
if result.Bundle.Status != "" || result.Bundle.ProjectID != "" {
|
||||
t.Fatalf("%s scope returned a non-empty bundle: %+v", name, result.Bundle)
|
||||
}
|
||||
if other, dup := seen[err.Error()]; dup {
|
||||
t.Fatalf("%s and %s share one error message: %q", other, name, err)
|
||||
}
|
||||
seen[err.Error()] = name
|
||||
}
|
||||
if len(seen) != len(cases) {
|
||||
t.Fatalf("expected %d distinct errors, got %d: %v", len(cases), len(seen), seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling(t *testing.T) {
|
||||
var remaining time.Duration
|
||||
slot := registeredRecheckSlot(t, func(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package app
|
||||
|
||||
// 临时会话的内部命令分发门禁(U2):组合根在命令总线的项目预检入口上拒绝
|
||||
// “基座项目是临时会话”的正式项目命令,宿主与插件处理器共用这一条分发面。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func newScratchGateStore(t *testing.T) (*store.Store, core.Project, core.Conversation) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch-gate.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st, project, conversation
|
||||
}
|
||||
|
||||
// TestScratchCommandHostGateDecidesOwnership 门禁本身:属主的临时会话拒绝,
|
||||
// 普通项目与无基座项目的分发不受影响;非属主按存储判定(NotFound)。
|
||||
func TestScratchCommandHostGateDecidesOwnership(t *testing.T) {
|
||||
st, project, _ := newScratchGateStore(t)
|
||||
|
||||
if err := rejectScratchCommandHost(st, cmdbus.Request{
|
||||
ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(project.ID),
|
||||
}); !errors.Is(err, ErrScratchCommandHost) {
|
||||
t.Fatalf("scratch host project accepted a formal command: %v", err)
|
||||
}
|
||||
normal, err := st.CreateProject("alice", "普通项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rejectScratchCommandHost(st, cmdbus.Request{
|
||||
ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(normal.ID),
|
||||
}); err != nil {
|
||||
t.Fatalf("normal host project refused: %v", err)
|
||||
}
|
||||
if err := rejectScratchCommandHost(st, cmdbus.Request{ID: "rdm.reqs.add", Owner: "alice"}); err != nil {
|
||||
t.Fatalf("hostless dispatch refused: %v", err)
|
||||
}
|
||||
// 非属主不能把自己的“属主身份”带进临时会话。
|
||||
if err := rejectScratchCommandHost(st, cmdbus.Request{
|
||||
ID: "rdm.reqs.add", Owner: "bob", HostProjectID: string(project.ID),
|
||||
}); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("non-owner scratch dispatch = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchCommandHostGateOnBusPreflight 门禁在真实分发面上生效:正式项目命令
|
||||
// 在临时会话宿主上被拒,普通项目宿主不受影响(失败原因来自后续的会话来源校验,
|
||||
// 不是临时会话门禁)。
|
||||
func TestScratchCommandHostGateOnBusPreflight(t *testing.T) {
|
||||
st, project, conversation := newScratchGateStore(t)
|
||||
normal, err := st.CreateProject("alice", "普通项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalConv, err := st.CreateConversation("alice", string(normal.ID), "会话", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error {
|
||||
if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" {
|
||||
return errors.New("formal project command requires an authenticated conversation source")
|
||||
}
|
||||
if err := rejectScratchCommandHost(st, req); err != nil {
|
||||
return err
|
||||
}
|
||||
conv, err := st.GetConversation(req.Owner, req.ConversationID)
|
||||
if err != nil || string(conv.ProjectID) != req.HostProjectID {
|
||||
return errors.New("formal project command conversation binding changed")
|
||||
}
|
||||
return errors.New("source analysis did not commit a proceed decision")
|
||||
})
|
||||
if err := bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"},
|
||||
func(context.Context, cmdbus.Request) (any, error) { return map[string]any{"ok": true}, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice",
|
||||
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
|
||||
})
|
||||
if scratchResp.Receipt.State != agentcommand.StateRejected ||
|
||||
!strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
|
||||
t.Fatalf("scratch host dispatch = %+v (want %s)", scratchResp.Receipt, ErrScratchCommandHost)
|
||||
}
|
||||
|
||||
normalResp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice",
|
||||
ConversationID: string(normalConv.ID), AnchorMessageID: "msg-1", HostProjectID: string(normal.ID),
|
||||
})
|
||||
if normalResp.Receipt.State != agentcommand.StateRejected ||
|
||||
strings.Contains(normalResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
|
||||
t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v", normalResp.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchCommandHostGateOnBusDispatch 基座项目门禁挂在命令总线上(组合根的
|
||||
// 真实接线):不经 HTTP 403、也不经正式命令预检——聊天意图路由与 system.intent
|
||||
// 再分发走的就是这条面——rdm.* 在临时会话宿主上被拒且处理器不会被调用;普通项目
|
||||
// 宿主与无宿主分发不受这条门禁影响。
|
||||
func TestScratchCommandHostGateOnBusDispatch(t *testing.T) {
|
||||
st, project, conversation := newScratchGateStore(t)
|
||||
normal, err := st.CreateProject("alice", "普通项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
handled, preflights := 0, 0
|
||||
bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error {
|
||||
return scratchCommandHostGuard(st, req)
|
||||
})
|
||||
bus.SetProjectWorkPreflight(func(context.Context, cmdbus.Request) error { preflights++; return nil })
|
||||
for _, spec := range []agentcommand.CommandSpec{
|
||||
{ID: "rdm.reqs.add", Title: "登记需求"},
|
||||
{ID: "demo.ping", Title: "会话命令"},
|
||||
} {
|
||||
id := spec.ID
|
||||
if err := bus.RegisterPluginCommand(spec,
|
||||
func(context.Context, cmdbus.Request) (any, error) { handled++; return map[string]any{"id": id}, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
|
||||
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
|
||||
})
|
||||
if scratchResp.Receipt.State != agentcommand.StateRejected ||
|
||||
!strings.HasPrefix(scratchResp.Receipt.Reason, "host project: ") ||
|
||||
!strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) {
|
||||
t.Fatalf("scratch host dispatch = %+v (want host project: %s)", scratchResp.Receipt, ErrScratchCommandHost)
|
||||
}
|
||||
if handled != 0 || preflights != 0 {
|
||||
t.Fatalf("scratch host reached the command handler %d times / preflight %d times", handled, preflights)
|
||||
}
|
||||
// 同一宿主上的会话类命令照旧可用(临时会话不是禁用一切命令)。
|
||||
if resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice",
|
||||
ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID),
|
||||
}); resp.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt)
|
||||
}
|
||||
// 正面:普通项目宿主不被这条门禁拒绝;无宿主的分发也不走门禁。
|
||||
for _, req := range []cmdbus.Request{
|
||||
{ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID)},
|
||||
{ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice"},
|
||||
} {
|
||||
if err := scratchCommandHostGuard(st, req); err != nil {
|
||||
t.Fatalf("host guard refused %+v: %v", req, err)
|
||||
}
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID),
|
||||
})
|
||||
if resp.Receipt.State != agentcommand.StateCommitted || strings.Contains(resp.Receipt.Reason, ErrScratchCommandHost.Error()) || handled != 2 {
|
||||
t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v (handler calls %d)", resp.Receipt, handled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// 小队内核配置的归属测试(F13 / KTD3):内核用的模型、端点、密钥必须整条来自
|
||||
// 「本次运行」的生效快照,不得按默认账号重新解析——否则一个用户的模型名会配
|
||||
// 上另一个账号的端点/密钥。全部使用合成密钥与本地假服务。
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// squadRunCtx 是适配器窄端口的最小宿主实现:只有配置作用域(无 Profile 端口,
|
||||
// 属主只能来自 run)。
|
||||
type squadRunCtx struct {
|
||||
run core.Run
|
||||
eff core.EffectiveSettings
|
||||
}
|
||||
|
||||
func (c *squadRunCtx) Context() context.Context { return context.Background() }
|
||||
func (c *squadRunCtx) Run() core.Run { return c.run }
|
||||
func (c *squadRunCtx) Settings() core.EffectiveSettings { return c.eff }
|
||||
func (c *squadRunCtx) Emit(core.StreamEventType, any) error { return nil }
|
||||
func (c *squadRunCtx) UpdatePlan(core.PlanSnapshot) error { return nil }
|
||||
func (c *squadRunCtx) SpawnSubagent(string, string, string) (core.AgentResultPayload, error) {
|
||||
return core.AgentResultPayload{}, nil
|
||||
}
|
||||
func (c *squadRunCtx) AskHITL(core.HITLCard) (core.HITLResponse, error) {
|
||||
return core.HITLResponse{}, nil
|
||||
}
|
||||
func (c *squadRunCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) {
|
||||
return core.Artifact{}, nil
|
||||
}
|
||||
|
||||
var _ squadadapter.Ctx = (*squadRunCtx)(nil)
|
||||
|
||||
// squadProfileRunCtx 是带画像端口的宿主(runmgr 的 runCtx 形态):画像的
|
||||
// Owner 与 run.Owner 一致,模型覆盖按直连规则生效。
|
||||
type squadProfileRunCtx struct {
|
||||
*squadRunCtx
|
||||
prof core.AgentProfile
|
||||
}
|
||||
|
||||
func (c *squadProfileRunCtx) Profile() core.AgentProfile { return c.prof }
|
||||
|
||||
// squadSecretStub 是受管密钥来源桩(Tavily 池按 owner 查)。
|
||||
type squadSecretStub map[string]string
|
||||
|
||||
func (s squadSecretStub) ActiveSecret(owner, _ string) (string, bool) {
|
||||
v, ok := s[owner]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func squadStore(t *testing.T) (*store.Store, *settings.Service) {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "squad.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st, settings.NewService(st)
|
||||
}
|
||||
|
||||
// squadLLMCall 是一次上游请求的观测(只记合成字段)。
|
||||
type squadLLMCall struct {
|
||||
Path string
|
||||
Authorization string
|
||||
Model string
|
||||
ReasoningSplit bool
|
||||
}
|
||||
|
||||
func squadLLMServer(t *testing.T) (*httptest.Server, func() []squadLLMCall) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var calls []squadLLMCall
|
||||
frame := func(v any) string {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return "data: " + string(raw) + "\n\n"
|
||||
}
|
||||
body := frame(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": "ok"}}}}) +
|
||||
frame(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}}) +
|
||||
frame(map[string]any{"choices": []any{}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) +
|
||||
"data: [DONE]\n\n"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var decoded struct {
|
||||
Model string `json:"model"`
|
||||
ReasoningSplit bool `json:"reasoning_split"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &decoded)
|
||||
mu.Lock()
|
||||
calls = append(calls, squadLLMCall{
|
||||
Path: r.URL.Path, Authorization: r.Header.Get("Authorization"),
|
||||
Model: decoded.Model, ReasoningSplit: decoded.ReasoningSplit,
|
||||
})
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server, func() []squadLLMCall {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]squadLLMCall(nil), calls...)
|
||||
}
|
||||
}
|
||||
|
||||
// squadCall 驱动装配出的内核流一次,返回终态事件。
|
||||
func squadCall(t *testing.T, stream agentllm.StreamFunc, model string) []agentllm.Event {
|
||||
t.Helper()
|
||||
if stream == nil {
|
||||
t.Fatal("squad config produced no stream")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
var terminal []agentllm.Event
|
||||
for ev := range stream(ctx, model, []agentllm.Message{{Role: agentllm.RoleUser, Content: "hi"}}, nil, agentllm.StreamOptions{}) {
|
||||
if ev.Kind == agentllm.KindMessageEnd || ev.Kind == agentllm.KindError {
|
||||
terminal = append(terminal, ev)
|
||||
}
|
||||
}
|
||||
if len(terminal) != 1 || terminal[0].Kind != agentllm.KindMessageEnd || terminal[0].Err != nil {
|
||||
t.Fatalf("terminal events = %+v, want one clean message_end", terminal)
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
func chatEff(model, base, key string) core.EffectiveSettings {
|
||||
return core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{
|
||||
"model": model, "baseUrl": base, "apiKey": key,
|
||||
}}}
|
||||
}
|
||||
|
||||
// 两位用户的端点/密钥/模型各不相同:装配必须整条取自本次运行的快照。
|
||||
func TestSquadNativeOptionsResolvesRouteFromRunScope(t *testing.T) {
|
||||
aliceSrv, aliceCalls := squadLLMServer(t)
|
||||
bobSrv, bobCalls := squadLLMServer(t)
|
||||
|
||||
// 默认账号:与两位用户都不同;小队路径不得解析到它。
|
||||
_, svc := squadStore(t)
|
||||
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
|
||||
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
|
||||
|
||||
for _, tc := range []struct {
|
||||
owner core.ID
|
||||
model string
|
||||
base string
|
||||
key string
|
||||
calls func() []squadLLMCall
|
||||
foreign []string
|
||||
}{
|
||||
{"alice", "alice-model", aliceSrv.URL + "/v1", "sk-alice-synthetic", aliceCalls,
|
||||
[]string{"bob-model", "sk-bob-synthetic", "default-account-model", "sk-default-synthetic"}},
|
||||
{"bob", "bob-model", bobSrv.URL + "/v1", "sk-bob-synthetic", bobCalls,
|
||||
[]string{"alice-model", "sk-alice-synthetic", "default-account-model", "sk-default-synthetic"}},
|
||||
} {
|
||||
t.Run(string(tc.owner), func(t *testing.T) {
|
||||
cfg, enabled := factory(&squadRunCtx{
|
||||
run: core.Run{ID: "run_" + string(tc.owner), Owner: tc.owner},
|
||||
eff: chatEff(tc.model, tc.base, tc.key),
|
||||
})
|
||||
if !enabled || cfg.Client == nil {
|
||||
t.Fatalf("factory disabled: enabled=%v cfg=%+v", enabled, cfg)
|
||||
}
|
||||
if cfg.DefaultModel != tc.model {
|
||||
t.Fatalf("model = %q, want %q", cfg.DefaultModel, tc.model)
|
||||
}
|
||||
if cfg.Client.BaseURL != tc.base || cfg.Client.APIKey != tc.key {
|
||||
t.Fatalf("route = %q/%q, want %q/%q", cfg.Client.BaseURL, cfg.Client.APIKey, tc.base, tc.key)
|
||||
}
|
||||
for _, foreign := range tc.foreign {
|
||||
if strings.Contains(cfg.DefaultModel+cfg.Client.BaseURL+cfg.Client.APIKey, foreign) {
|
||||
t.Fatalf("squad config leaked another account's value %q: model=%q base=%q key=%q",
|
||||
foreign, cfg.DefaultModel, cfg.Client.BaseURL, cfg.Client.APIKey)
|
||||
}
|
||||
}
|
||||
// 真正发一次请求:模型名、端点与密钥必须成对出现在同一次调用里。
|
||||
squadCall(t, cfg.Stream, cfg.DefaultModel)
|
||||
calls := tc.calls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("upstream calls = %d, want exactly 1", len(calls))
|
||||
}
|
||||
if calls[0].Path != "/v1/chat/completions" {
|
||||
t.Fatalf("request path = %q, want the run's own endpoint", calls[0].Path)
|
||||
}
|
||||
if calls[0].Authorization != "Bearer "+tc.key {
|
||||
t.Fatalf("authorization = %q, want the run's own key", calls[0].Authorization)
|
||||
}
|
||||
if calls[0].Model != tc.model {
|
||||
t.Fatalf("request model = %q, want %q", calls[0].Model, tc.model)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 受管密钥按属主隔离:画像里的 Owner 决定密钥层归属,不得借默认/系统账号的。
|
||||
func TestSquadNativeOptionsNeverBorrowsAnotherOwnerSecret(t *testing.T) {
|
||||
st, svc := squadStore(t)
|
||||
srv, _ := squadLLMServer(t)
|
||||
base := srv.URL + "/v1"
|
||||
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
|
||||
"model": "shared-model", "baseUrl": base,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for owner, value := range map[string]string{
|
||||
settings.OwnerSystemRow: "sk-system-synthetic",
|
||||
"alice": "sk-alice-synthetic",
|
||||
"bob": "sk-bob-synthetic",
|
||||
store.OwnerDefault: "sk-default-synthetic",
|
||||
} {
|
||||
if _, err := st.AddSecret(owner, "models.chat", "test", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 与组合根同一套解析规则:属主 → 系统行 → 无。
|
||||
saved := runtime.SecretLookup
|
||||
runtime.SecretLookup = func(owner, service string) (string, string, bool) {
|
||||
if owner == "" {
|
||||
owner = store.OwnerDefault
|
||||
}
|
||||
if v, ok := st.ActiveSecret(owner, service); ok {
|
||||
return v, "owner", true
|
||||
}
|
||||
if v, ok := st.ActiveSecret(settings.OwnerSystemRow, service); ok {
|
||||
return v, "system", true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
t.Cleanup(func() { runtime.SecretLookup = saved })
|
||||
|
||||
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
|
||||
for _, tc := range []struct {
|
||||
owner, model, key string
|
||||
}{
|
||||
{"alice", "alice-profile-model", "sk-alice-synthetic"},
|
||||
{"bob", "bob-profile-model", "sk-bob-synthetic"},
|
||||
// 无自有密钥的租户仍按既有规则回落系统行受管密钥(不是默认账号的)。
|
||||
{"carol", "shared-model", "sk-system-synthetic"},
|
||||
} {
|
||||
t.Run(tc.owner, func(t *testing.T) {
|
||||
prof := core.AgentProfile{Owner: tc.owner}
|
||||
if tc.model != "shared-model" {
|
||||
prof.Model = core.ModelRoute{Chat: tc.model}
|
||||
}
|
||||
cfg, enabled := factory(&squadProfileRunCtx{
|
||||
squadRunCtx: &squadRunCtx{
|
||||
run: core.Run{ID: "run_" + tc.owner, Owner: tc.owner},
|
||||
eff: svc.Effective(tc.owner, ""),
|
||||
},
|
||||
prof: prof,
|
||||
})
|
||||
if !enabled {
|
||||
t.Fatal("factory disabled for a configured run")
|
||||
}
|
||||
if cfg.DefaultModel != tc.model || cfg.Client.APIKey != tc.key {
|
||||
t.Fatalf("route = %q/%q, want %q/%q", cfg.DefaultModel, cfg.Client.APIKey, tc.model, tc.key)
|
||||
}
|
||||
if cfg.Client.BaseURL != base {
|
||||
t.Fatalf("base = %q, want %q", cfg.Client.BaseURL, base)
|
||||
}
|
||||
// 无 Profile 端口时属主只能来自 run.Owner——密钥层不得退化成默认账号。
|
||||
bare, _ := factory(&squadRunCtx{
|
||||
run: core.Run{ID: "run_" + tc.owner, Owner: tc.owner},
|
||||
eff: svc.Effective(tc.owner, ""),
|
||||
})
|
||||
if bare.Client.APIKey != tc.key || bare.Client.BaseURL != base {
|
||||
t.Fatalf("owner from run = %q/%q, want %q/%q", bare.Client.APIKey, bare.Client.BaseURL, tc.key, base)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 项目运行消费的是「用户层 + 项目层」合并后的快照:项目覆盖的键取项目,
|
||||
// 未覆盖的键仍留用户层,绝不整包换回默认账号。
|
||||
func TestSquadNativeOptionsHonorsProjectLayerSnapshot(t *testing.T) {
|
||||
_, svc := squadStore(t)
|
||||
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
|
||||
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projSrv, projCalls := squadLLMServer(t)
|
||||
userLayer := map[string]any{
|
||||
"models": map[string]any{"chat": map[string]any{
|
||||
"model": "user-model", "baseUrl": "https://user.invalid/v1", "apiKey": "sk-user-synthetic",
|
||||
}},
|
||||
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "tvly-user-synthetic"}},
|
||||
"runtime": map[string]any{"codeOrchestration": true, "exec": true},
|
||||
}
|
||||
projectLayer := map[string]any{"models": map[string]any{"chat": map[string]any{
|
||||
"baseUrl": projSrv.URL + "/v1", "apiKey": "sk-project-synthetic",
|
||||
}}}
|
||||
eff := core.EffectiveSettings(core.MergeSettings(userLayer, projectLayer))
|
||||
|
||||
factory := squadAgentNativeFactory(squadNativeDeps{
|
||||
set: svc, secrets: squadSecretStub{store.OwnerDefault: "tvly-active-synthetic"},
|
||||
})
|
||||
cfg, enabled := factory(&squadProfileRunCtx{
|
||||
squadRunCtx: &squadRunCtx{run: core.Run{ID: "run_proj", Owner: "alice", ProjectID: "proj1"}, eff: eff},
|
||||
prof: core.AgentProfile{Owner: "alice"},
|
||||
})
|
||||
if !enabled {
|
||||
t.Fatal("factory disabled for a configured project run")
|
||||
}
|
||||
if cfg.DefaultModel != "user-model" {
|
||||
t.Fatalf("model = %q, want the user-layer value %q", cfg.DefaultModel, "user-model")
|
||||
}
|
||||
if cfg.Client.BaseURL != projSrv.URL+"/v1" || cfg.Client.APIKey != "sk-project-synthetic" {
|
||||
t.Fatalf("route = %q/%q, want the project-layer endpoint and key", cfg.Client.BaseURL, cfg.Client.APIKey)
|
||||
}
|
||||
// 直接驱动内核客户端(受保护的 cfg.Stream 在无项目上下文端口的桩上按
|
||||
// 设计 fail-closed,不用于本断言):模型名、端点与密钥成对出现。
|
||||
squadCall(t, cfg.Client.Stream, cfg.DefaultModel)
|
||||
calls := projCalls()
|
||||
if len(calls) != 1 || calls[0].Authorization != "Bearer sk-project-synthetic" || calls[0].Model != "user-model" {
|
||||
t.Fatalf("upstream calls = %+v, want the project endpoint with the user-layer model", calls)
|
||||
}
|
||||
// 项目来源的观察/授权限制不得因为改配置解析而放宽。
|
||||
if cfg.Search != nil || cfg.Exec != nil || cfg.Fetch != nil || cfg.MCP != nil {
|
||||
t.Fatalf("project run kept direct providers: search=%v exec=%v fetch=%v mcp=%v", cfg.Search != nil, cfg.Exec != nil, cfg.Fetch != nil, cfg.MCP != nil)
|
||||
}
|
||||
if cfg.CodeOrchestration {
|
||||
t.Fatal("project run kept code orchestration")
|
||||
}
|
||||
}
|
||||
|
||||
// per-route 规则与直连同源:画像模型覆盖后,reasoning split 与受限 EOF 契约
|
||||
// 都按「覆盖后这条路由」判定。
|
||||
func TestSquadNativeOptionsMatchesDirectRouteRules(t *testing.T) {
|
||||
_, svc := squadStore(t)
|
||||
// 契约只认规范化后的 https 端点,所以这里用合成端点(不发请求)。
|
||||
base := "https://model.example.com/v1"
|
||||
if err := svc.MergeSystem(map[string]any{"models": map[string]any{
|
||||
"chat": map[string]any{"model": "MiniMax-M1", "baseUrl": base},
|
||||
"routeContractEOF": map[string]any{
|
||||
"model": "MiniMax-M1",
|
||||
"baseUrl": base,
|
||||
"identitySha256": agentllm.RouteContractIdentity("MiniMax-M1", base),
|
||||
},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
|
||||
eff := svc.Effective("alice", "")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
profile core.AgentProfile
|
||||
model string
|
||||
wantSplit bool
|
||||
wantContract bool
|
||||
}{
|
||||
{"settings route", core.AgentProfile{Owner: "alice"}, "MiniMax-M1", true, true},
|
||||
{"profile keeps reasoning split, drops contract", core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "MiniMax-M2"}}, "MiniMax-M2", true, false},
|
||||
{"profile override to plain model", core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "plain-model"}}, "plain-model", false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
prof := tc.profile
|
||||
cfg, enabled := factory(&squadProfileRunCtx{
|
||||
squadRunCtx: &squadRunCtx{run: core.Run{ID: "run_rules", Owner: "alice"}, eff: eff},
|
||||
prof: prof,
|
||||
})
|
||||
if !enabled {
|
||||
t.Fatal("factory disabled for a configured run")
|
||||
}
|
||||
if cfg.DefaultModel != tc.model {
|
||||
t.Fatalf("model = %q, want %q", cfg.DefaultModel, tc.model)
|
||||
}
|
||||
if cfg.Client.ReasoningSplit != tc.wantSplit {
|
||||
t.Fatalf("reasoning split = %v, want %v", cfg.Client.ReasoningSplit, tc.wantSplit)
|
||||
}
|
||||
if (cfg.Client.RouteContract != nil) != tc.wantContract {
|
||||
t.Fatalf("route contract = %+v, want attached=%v", cfg.Client.RouteContract, tc.wantContract)
|
||||
}
|
||||
// 逐字段等于直连路径同一快照构造出的客户端。
|
||||
direct := runtime.OpenRouteClient(runtime.ResolveChatModel(prof, eff), eff)
|
||||
if cfg.Client.BaseURL != direct.BaseURL || cfg.Client.APIKey != direct.APIKey ||
|
||||
cfg.Client.ReasoningSplit != direct.ReasoningSplit {
|
||||
t.Fatalf("squad client %+v differs from the direct-path client %+v", cfg.Client, direct)
|
||||
}
|
||||
if (cfg.Client.RouteContract == nil) != (direct.RouteContract == nil) {
|
||||
t.Fatalf("contract decision differs from the direct path: squad=%v direct=%v",
|
||||
cfg.Client.RouteContract, direct.RouteContract)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 宿主没有 Settings 端口(旧骨架/测试桩)时仍按默认账号快照装配,行为不变。
|
||||
func TestSquadNativeOptionsFallsBackToDefaultAccountSnapshot(t *testing.T) {
|
||||
_, svc := squadStore(t)
|
||||
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{
|
||||
"model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic",
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}})
|
||||
for name, eff := range map[string]core.EffectiveSettings{
|
||||
"nil snapshot": nil,
|
||||
"empty snapshot": {},
|
||||
"settings without run": {"models": map[string]any{"chat": map[string]any{"model": "snapshot-model", "baseUrl": "https://snapshot.invalid/v1"}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg, enabled := factory(&squadRunCtx{run: core.Run{ID: "run_default", Owner: store.OwnerDefault}, eff: eff})
|
||||
if !enabled {
|
||||
t.Fatal("default-config run stopped working")
|
||||
}
|
||||
want := "snapshot-model"
|
||||
wantBase := "https://snapshot.invalid/v1"
|
||||
if len(eff) == 0 {
|
||||
want, wantBase = "default-account-model", "https://default.invalid/v1"
|
||||
}
|
||||
if cfg.DefaultModel != want || cfg.Client.BaseURL != wantBase {
|
||||
t.Fatalf("route = %q/%q, want %q/%q", cfg.DefaultModel, cfg.Client.BaseURL, want, wantBase)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 没有快照来源也没有默认账号配置时保持未启用(不阻塞产品)。
|
||||
if _, enabled := squadAgentNativeFactory(squadNativeDeps{})(&squadRunCtx{run: core.Run{ID: "run_none"}}); enabled {
|
||||
t.Fatal("factory reported enabled without any configuration source")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// 小队 agent-native 内核工厂的装配(组合根注入 squadadapter.AgentNativeOptions)。
|
||||
//
|
||||
// 配置解析的硬约束(KTD3 / 审计 F13):内核用的模型、端点、密钥必须整条来自
|
||||
// 「本次运行」的生效快照——rc.Settings() 已按 run.Owner + run.ProjectID 合并,
|
||||
// 画像模型覆盖叠加其上,per-route 规则(reasoning split / 受限 EOF 契约)由
|
||||
// runtime.OpenRouteClient 与直连路径同一函数判定。不得在这里按默认账号重新
|
||||
// 解析:那会把一个用户的模型名和另一个账号的端点/密钥拼在一起。
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"agent-native/runtime/checkpoint"
|
||||
agentllm "agent-native/runtime/llm"
|
||||
agentmcp "agent-native/runtime/mcp"
|
||||
"agent-native/runtime/policy"
|
||||
agenttools "agent-native/runtime/tools"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// squadNativeDeps 是内核工厂的依赖。mcp 与 pluginMCP 取函数而非值:插件宿主在
|
||||
// Run 晚些时候才装配,工厂必须按调用时读,不能把启动期的空值固化进去。
|
||||
type squadNativeDeps struct {
|
||||
// set 提供回落快照:宿主没有 Settings 端口时(骨架/旧宿主)才用默认账号。
|
||||
set *settings.Service
|
||||
// secrets 是受管密钥来源(Tavily 池)。
|
||||
secrets activeSecretSource
|
||||
// skillsRoot 是交付配方技能根。
|
||||
skillsRoot string
|
||||
trace func(name string, attrs map[string]string) func()
|
||||
mcp func(raw, overrides string) *agentmcp.Manager
|
||||
pluginMCP func() []core.PluginMCPServer
|
||||
}
|
||||
|
||||
// squadAgentNativeFactory 返回注入 squadadapter.AgentNativeOptions 的工厂。
|
||||
// 主模型未配置时返回未启用(不阻塞产品);其余装配对每次运行重新解析。
|
||||
func squadAgentNativeFactory(deps squadNativeDeps) func(squadadapter.Ctx) (nativeadp.Config, bool) {
|
||||
return func(rc squadadapter.Ctx) (nativeadp.Config, bool) {
|
||||
// 当前运行的用户/项目快照;宿主无 Settings 端口时才回落默认账号。
|
||||
effNow, ok := runtime.RunEffectiveSettings(rc)
|
||||
if !ok {
|
||||
if deps.set == nil {
|
||||
return nativeadp.Config{}, false
|
||||
}
|
||||
effNow = deps.set.Effective(store.OwnerDefault, "")
|
||||
}
|
||||
// 画像模型覆盖走同一条解析规则(Owner 决定密钥层归属)。
|
||||
route := runtime.ResolveChatModel(runtime.RunProfile(rc), effNow)
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return nativeadp.Config{}, false
|
||||
}
|
||||
mode := "default"
|
||||
if src, ok := rc.(interface{ PermissionMode() string }); ok {
|
||||
mode = src.PermissionMode()
|
||||
}
|
||||
cfg := nativeadp.Config{
|
||||
Client: runtime.OpenRouteClient(route, effNow),
|
||||
DefaultModel: route.Model,
|
||||
MaxTurns: 0,
|
||||
Timeout: 0,
|
||||
Subagent: squadadapter.SubagentFactory(rc),
|
||||
RepeatGuard: true,
|
||||
BeforeTool: func(call agentllm.ToolCall) error {
|
||||
effect := policy.Decide(policy.Request{
|
||||
Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments,
|
||||
Traits: policy.TraitsFor(call.Name),
|
||||
}, nil)
|
||||
if effect == policy.EffectDeny {
|
||||
return fmt.Errorf("计划模式拒绝了工具 %s", call.Name)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream)
|
||||
var keys []string
|
||||
if deps.secrets != nil {
|
||||
keys = tavilyKeyPool(deps.secrets, effNow.GetString("tools.tavily.apiKeys", ""))
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys)
|
||||
}
|
||||
if effNow.GetBool("runtime.exec", true) {
|
||||
cfg.Exec = tools.InterpreterExecConfig()
|
||||
}
|
||||
if st2, err := os.Stat(deps.skillsRoot); err == nil && st2.IsDir() {
|
||||
cfg.SkillsRoots = []string{deps.skillsRoot}
|
||||
}
|
||||
rawMCP := strings.TrimSpace(effNow.GetString("tools.mcp.servers", ""))
|
||||
if deps.pluginMCP != nil {
|
||||
rawMCP = mergeMCPServers(rawMCP, deps.pluginMCP())
|
||||
}
|
||||
if deps.mcp != nil {
|
||||
if mgr := deps.mcp(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil {
|
||||
cfg.MCP = mgr
|
||||
}
|
||||
}
|
||||
// Native squad tools do not yet expose a fail-closed result hook to the
|
||||
// project observation outbox. Project runs therefore use the audited
|
||||
// runCtx retrieval/tool path only; enabling these direct providers would
|
||||
// let their results reach the next model turn without source analysis.
|
||||
if rc.Run().ProjectID != "" {
|
||||
cfg.Search = nil
|
||||
cfg.Exec = nil
|
||||
cfg.Fetch = nil
|
||||
cfg.MCP = nil
|
||||
}
|
||||
cfg.CodeOrchestration = rc.Run().ProjectID == "" && effNow.GetBool("runtime.codeOrchestration", false)
|
||||
if ask, ok := rc.(interface {
|
||||
AskUser(card core.HITLCard) (core.HITLResponse, error)
|
||||
}); ok {
|
||||
cfg.Ask = func(_ context.Context, question string) (string, error) {
|
||||
resp, err := ask.AskUser(core.HITLCard{
|
||||
Question: question, Kind: core.HITLKindInput, AllowFreeInput: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(resp.FreeText) != "" {
|
||||
return strings.TrimSpace(resp.FreeText), nil
|
||||
}
|
||||
return resp.ChoiceID, nil
|
||||
}
|
||||
}
|
||||
cfg.Trace = deps.trace
|
||||
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
|
||||
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
|
||||
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
|
||||
if host, ok := rc.(interface {
|
||||
OpenCheckpoints() (*checkpoint.Log, error)
|
||||
CheckpointAnchor() string
|
||||
}); ok {
|
||||
if lg, err := host.OpenCheckpoints(); err == nil {
|
||||
cfg.Checkpoints = lg
|
||||
anchor := host.CheckpointAnchor()
|
||||
cfg.CheckpointAnchor = func() string { return anchor }
|
||||
}
|
||||
}
|
||||
return cfg, true
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package cmdbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
@@ -16,3 +19,46 @@ func TestStrictAccountWritesRejectLegacyBusPorts(t *testing.T) {
|
||||
t.Fatalf("legacy port wrote under strict mode: %+v, cards=%d audit=%d pending=%d", resp.Receipt, len(cards.created), len(audit.logs), len(b.pending))
|
||||
}
|
||||
}
|
||||
|
||||
// fencedAuditStore 是"账号围栏内审计落盘失败"的替身:围栏写路径已经跑完,
|
||||
// 只有审计行写不进去(围栏内审计失败=结果不确定,不是"什么都没发生")。
|
||||
type fencedAuditStore struct{ appends int }
|
||||
|
||||
func (f *fencedAuditStore) AppendAudit(string, agentcommand.AuditEnvelope) error {
|
||||
f.appends++
|
||||
return errors.New("audit fenced")
|
||||
}
|
||||
|
||||
func (f *fencedAuditStore) AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error {
|
||||
f.appends++
|
||||
return errors.New("audit fenced")
|
||||
}
|
||||
|
||||
// TestStrictAccountWriteAuditLossKeepsOperationID:围栏档下 handler 已提交、
|
||||
// 审计写失败时,回执仍是 unknown 且带上 handler 拿到的 operationID,
|
||||
// 调用方才能在围栏外按记录对账(F10 Bug 2 在围栏路径上的同一语义)。
|
||||
func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) {
|
||||
audit := &fencedAuditStore{}
|
||||
b := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
b.RequireAccountCheckedWrites(true)
|
||||
var seen string
|
||||
if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.fenced", Title: "围栏写入"}, "", func(_ context.Context, req Request) (any, error) {
|
||||
seen = req.OperationID
|
||||
return "written", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := b.Dispatch(context.Background(), Request{ID: "demo.fenced", Owner: "alice", Actor: "alice"})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
|
||||
t.Fatalf("fenced audit loss must stay unknown: %+v", resp.Receipt)
|
||||
}
|
||||
if seen == "" || resp.Receipt.OperationID != seen {
|
||||
t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("fenced receipt must declare possible side effects and validate: %+v", resp.Receipt)
|
||||
}
|
||||
if audit.appends != 1 {
|
||||
t.Fatalf("account-checked audit appends = %d, want 1", audit.appends)
|
||||
}
|
||||
}
|
||||
|
||||
+190
-14
@@ -26,6 +26,43 @@ type AuditStore interface {
|
||||
AppendAudit(owner string, env agentcommand.AuditEnvelope) error
|
||||
}
|
||||
|
||||
// OperationRecord 是"已记录操作"的最小对账投影(R7/KTD6)。它回答的是
|
||||
// “这个 operationID 在本 owner/project 名下到底是什么结果”,而不是重放入口。
|
||||
// 刻意保持最小:审计信封只存状态与标识,不存理由文本,所以这里没有 Reason——
|
||||
// 不为不存在的字段留位置。
|
||||
type OperationRecord struct {
|
||||
OperationID string
|
||||
CommandID string
|
||||
State agentcommand.ResultState
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// OperationQuery 是 operationID 的对账查询端口(组合根装配;nil = 不提供)。
|
||||
//
|
||||
// 授权口径:owner 与 projectID 都是可信过滤条件,实现必须把它们放进查询谓词
|
||||
// (SQL 的 owner/project 等值条件),不得由调用方参数扩大可见范围;查不到就
|
||||
// 返回 not found,不得回退为“未执行”。
|
||||
//
|
||||
// 诚实边界:本端口只解析“已经落成记录”的 operation。宿主侧的命令回执记录就是
|
||||
// 命令审计信封(command_audit 按 owner+project 归属);handler 自己的领域操作表
|
||||
// (如文档投递 document_render_deliveries)由各自领域查询负责,不经本端口。某些
|
||||
// 命令根本不持久化 req.OperationID(内置的 builtin.help、runtime.mock、
|
||||
// models.set、conversation.profile、conversation.create 都是无操作表的即时用例),
|
||||
// 对它们的 operationID 查询恒为 not found——这类命令只能按领域资源(会话、画像、
|
||||
// 设置项)对账,不得声称可按 operationID 查询。
|
||||
type OperationQuery interface {
|
||||
LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrOperationScope 缺少可信 owner/project/operation 作用域:fail-closed,无作用域即无可见性。
|
||||
ErrOperationScope = errors.New("cmdbus: operation lookup requires owner, project and operation scope")
|
||||
// ErrOperationNotRecorded 该作用域内没有此 operation 的记录(未记录 ≠ 未执行)。
|
||||
ErrOperationNotRecorded = errors.New("cmdbus: no operation record in this owner and project scope")
|
||||
// ErrOperationQueryUnavailable 组合根未装配对账查询端口。
|
||||
ErrOperationQueryUnavailable = errors.New("cmdbus: operation query unavailable")
|
||||
)
|
||||
|
||||
// CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。
|
||||
type CardPort interface {
|
||||
Create(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error)
|
||||
@@ -35,6 +72,39 @@ type CardPort interface {
|
||||
// Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。
|
||||
type Handler func(ctx context.Context, req Request) (any, error)
|
||||
|
||||
// ErrNotExecuted 标记"失败确定发生在任何副作用之前"的 handler 错误(F10/U7b)。
|
||||
// 语义口径:只有带此标记的错误才可归 rejected(确定未执行、无副作用、回执不得
|
||||
// 携带 operationID);未标记的 handler 错误一律按"已执行随后不确定"归 unknown,
|
||||
// 并带上本次分发已签发的 trusted operation ID 交由对账收敛。宿主不得据
|
||||
// "处理方报了错"臆断未执行——那会把已写库的操作诱使成不安全重试。
|
||||
//
|
||||
// 进程内插件(pkg/extension.Handler)当前还没有等价的声明位:插件命令的
|
||||
// handler 错误一律按 unknown 对账,直到扩展面补上对应标记为止。
|
||||
var ErrNotExecuted = errors.New("cmdbus: not executed")
|
||||
|
||||
// NotExecutedError 是带"确定未执行"声明的错误包装。Error() 保留原始文案
|
||||
// (回执 reason 不因标记而变形),Unwrap/Is 让 errors.Is 同时匹配
|
||||
// ErrNotExecuted 与被包装的原始错误。
|
||||
type NotExecutedError struct{ Err error }
|
||||
|
||||
func (e *NotExecutedError) Error() string { return e.Err.Error() }
|
||||
func (e *NotExecutedError) Unwrap() error { return e.Err }
|
||||
func (e *NotExecutedError) Is(target error) bool {
|
||||
return target == ErrNotExecuted
|
||||
}
|
||||
|
||||
// NotExecuted 声明该失败确定未执行(校验/权限/确认缺失/领域事务回滚等任何
|
||||
// 副作用之前的拒绝)。nil 原样返回;重复标记幂等。
|
||||
func NotExecuted(err error) error {
|
||||
if err == nil || IsNotExecuted(err) {
|
||||
return err
|
||||
}
|
||||
return &NotExecutedError{Err: err}
|
||||
}
|
||||
|
||||
// IsNotExecuted 报告该错误是否声明了"确定未执行"。
|
||||
func IsNotExecuted(err error) bool { return errors.Is(err, ErrNotExecuted) }
|
||||
|
||||
// CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。
|
||||
// Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。
|
||||
type CommandTool struct {
|
||||
@@ -171,12 +241,24 @@ type Bus struct {
|
||||
strictAccountWrites bool
|
||||
allowImplicitLocal bool
|
||||
projectWorkPreflight func(context.Context, Request) error
|
||||
hostProjectGuard func(context.Context, Request) error
|
||||
// operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。
|
||||
operationQuery OperationQuery
|
||||
}
|
||||
|
||||
func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) {
|
||||
b.projectWorkPreflight = fn
|
||||
}
|
||||
|
||||
// SetHostProjectGuard installs the composition-root check applied to every
|
||||
// dispatch that carries a base project, before the project-work preflight and
|
||||
// before any handler runs. It is the single bus-level place where host-project
|
||||
// capability gates live, so every entrance (slash, exec, chat intent routing,
|
||||
// system.intent re-dispatch, agent tool) is covered by one decision.
|
||||
func (b *Bus) SetHostProjectGuard(fn func(context.Context, Request) error) {
|
||||
b.hostProjectGuard = fn
|
||||
}
|
||||
|
||||
// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。
|
||||
type PendingStore interface {
|
||||
SavePendingConfirm(cardID, owner, payload string) error
|
||||
@@ -200,6 +282,33 @@ func (b *Bus) Registry() *agentcommand.Registry { return b.reg }
|
||||
// SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。
|
||||
func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st }
|
||||
|
||||
// SetOperationQuery 装配 operationID 对账查询端口(组合根;启动期一次)。
|
||||
func (b *Bus) SetOperationQuery(q OperationQuery) { b.operationQuery = q }
|
||||
|
||||
// LookupOperation 按 owner/project 作用域查一个已记录 operation 的当前状态
|
||||
// (R7/KTD6)。这是 unknown 回执的对账入口:拿到 operationID 的调用方先查
|
||||
// 记录,再决定下一步,而不是重发命令。
|
||||
//
|
||||
// 三重 fail-closed:作用域任一为空即拒(不猜、不按 owner=“”放行)、未装配端口
|
||||
// 即拒、查不到即 ErrOperationNotRecorded(不推断“未执行”)。跨 owner 查询由
|
||||
// 端口的 owner 等值谓词拒绝——本层只把 owner 当可信条件往下传。
|
||||
func (b *Bus) LookupOperation(owner, projectID, operationID string) (OperationRecord, error) {
|
||||
if owner == "" || projectID == "" || operationID == "" {
|
||||
return OperationRecord{}, ErrOperationScope
|
||||
}
|
||||
if b.operationQuery == nil {
|
||||
return OperationRecord{}, ErrOperationQueryUnavailable
|
||||
}
|
||||
record, found, err := b.operationQuery.LookupOperation(owner, projectID, operationID)
|
||||
if err != nil {
|
||||
return OperationRecord{}, err
|
||||
}
|
||||
if !found || record.OperationID == "" {
|
||||
return OperationRecord{}, ErrOperationNotRecorded
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// SetEffectiveAccountGuard installs the composition-root check at startup.
|
||||
// Handler Store writes still need their own final transaction fence.
|
||||
func (b *Bus) SetEffectiveAccountGuard(g EffectiveAccountGuard) { b.accountGuard = g }
|
||||
@@ -382,6 +491,13 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
if err := validateParams(spec.ParamsSchema, req.Params); err != nil {
|
||||
return receipt(agentcommand.StateRejected, "", "params: "+err.Error())
|
||||
}
|
||||
// 基座项目门禁:所有带 HostProjectID 的分发都在这里过一道,命令种类与宿主
|
||||
// 能力的匹配面由组合根决定(nil 时保持原行为)。
|
||||
if req.HostProjectID != "" && b.hostProjectGuard != nil {
|
||||
if err := b.hostProjectGuard(ctx, req); err != nil {
|
||||
return receipt(agentcommand.StateRejected, "", "host project: "+err.Error())
|
||||
}
|
||||
}
|
||||
if RequiresProjectWorkDecision(req.ID) {
|
||||
if b.projectWorkPreflight == nil {
|
||||
return receipt(agentcommand.StateRejected, "", "project source preflight unavailable")
|
||||
@@ -405,10 +521,12 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
if b.pendingStore != nil {
|
||||
raw, _ := json.Marshal(req)
|
||||
if err := b.savePending(card.ID, accountActor(req), string(raw)); err != nil {
|
||||
return receipt(agentcommand.StateUnknown, "", "confirm pending: "+err.Error())
|
||||
// 确认卡已创建(副作用已发生),挂起态却没落库:结果不确定,且
|
||||
// 卡 ID 就是本次受理的操作标识——随 unknown 一并给出,供对账。
|
||||
return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: "+err.Error())
|
||||
}
|
||||
} else if b.strictAccountWrites {
|
||||
return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required")
|
||||
return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: account-checked store required")
|
||||
}
|
||||
b.pendMu.Lock()
|
||||
b.pending[card.ID] = pending{req: req, cardID: card.ID}
|
||||
@@ -425,7 +543,9 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
}
|
||||
// The domain handler can persist this trusted ID with its transaction/outbox.
|
||||
// Entropy, not the clock or caller Params, distinguishes concurrent operations.
|
||||
// A handler failure still writes only a rejected audit receipt without an ID.
|
||||
// A handler error keeps the ID only on the "executed then uncertain" path
|
||||
// (unknown): rejected must never carry one, and unknown without a queryable
|
||||
// handle would be a dead end for reconciliation.
|
||||
var opBytes [24]byte
|
||||
if _, err := rand.Read(opBytes[:]); err != nil {
|
||||
return receipt(agentcommand.StateUnknown, "", "operation ID unavailable")
|
||||
@@ -433,11 +553,26 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response {
|
||||
req.OperationID = "op_" + hex.EncodeToString(opBytes[:])
|
||||
payload, err := h(ctx, req)
|
||||
if err != nil {
|
||||
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
|
||||
if IsNotExecuted(err) {
|
||||
// handler 声明失败发生在任何副作用之前:确定未执行,可安全归
|
||||
// rejected(回执契约禁止 rejected 携带 operationID)。
|
||||
return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error())
|
||||
}
|
||||
// handler 已开始执行才报错:宿主无法断言"无副作用"(F10)。按"已执行
|
||||
// 随后不确定"归 unknown,并带上本次分发已签发的 trusted operation ID,
|
||||
// 调用方据此按记录对账,而不是盲目重试。本路径不做任何自动重放。
|
||||
return b.unknownAfterExecution(req, "handler failed after execution began: "+err.Error(), start)
|
||||
}
|
||||
return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload}
|
||||
}
|
||||
|
||||
// unknownAfterExecution 记录并返回"已执行随后不确定"的回执(F10/U7b):审计
|
||||
// 信封带 state=unknown 与已签发的 operationID(可查凭证),回执走库内受信
|
||||
// 构造器 NewUnknownAfterExecution,保证 Validate() 通过且对账句柄不丢。
|
||||
func (b *Bus) unknownAfterExecution(req Request, reason string, start time.Time) Response {
|
||||
return Response{Receipt: b.auditReceipt(req, agentcommand.StateUnknown, req.OperationID, reason, start)}
|
||||
}
|
||||
|
||||
// Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。
|
||||
func (b *Bus) Confirm(owner, cardID, actionID string) Response {
|
||||
return b.ConfirmContext(context.Background(), owner, cardID, actionID)
|
||||
@@ -456,7 +591,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
|
||||
p, ok := b.pending[cardID]
|
||||
b.pendMu.Unlock()
|
||||
if ok && p.req.Owner != "" && p.req.Owner != owner {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
|
||||
}
|
||||
if b.pendingStore != nil {
|
||||
storedOwner, payload, found, err := b.takePending(cardID, owner)
|
||||
@@ -466,7 +601,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
|
||||
if found {
|
||||
var req Request
|
||||
if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}}
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
|
||||
}
|
||||
p = pending{req: req, cardID: cardID}
|
||||
ok = true
|
||||
@@ -477,11 +612,11 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}}
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
|
||||
}
|
||||
req := p.req
|
||||
if req.Owner != "" && req.Owner != owner {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}}
|
||||
return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}}
|
||||
}
|
||||
b.pendMu.Lock()
|
||||
delete(b.pending, cardID)
|
||||
@@ -497,15 +632,29 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string
|
||||
}
|
||||
resp := b.Dispatch(ctx, req)
|
||||
if b.cards != nil {
|
||||
if resp.Receipt.State == agentcommand.StateCommitted {
|
||||
switch resp.Receipt.State {
|
||||
case agentcommand.StateCommitted:
|
||||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed")
|
||||
} else {
|
||||
case agentcommand.StateUnknown:
|
||||
// 已执行随后不确定:卡片必须落到终态(悬在 action_required 会诱导
|
||||
// 二次确认,而挂起项已被消费,重放只会得到"已解决"),但不得断言
|
||||
// 失败——tone 用 warn,note 写明不确定原因与对账用 operationID。
|
||||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "warn", uncertainNote(resp.Receipt))
|
||||
default:
|
||||
_, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason)
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// uncertainNote 组成"结果未知"卡片的说明:不确定原因 + 对账句柄。
|
||||
func uncertainNote(r agentcommand.ExecReceipt) string {
|
||||
if r.OperationID == "" {
|
||||
return r.Reason
|
||||
}
|
||||
return r.Reason + " (operation " + r.OperationID + ")"
|
||||
}
|
||||
|
||||
func accountActor(req Request) string {
|
||||
// Plugin and delegated commands use Actor for an audit label such as a
|
||||
// plugin ID; Owner is the effective account whose state controls effects.
|
||||
@@ -528,7 +677,7 @@ func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error {
|
||||
func (b *Bus) accountRejected(commandID string, start time.Time) Response {
|
||||
return Response{Receipt: agentcommand.ExecReceipt{
|
||||
CommandID: commandID, State: agentcommand.StateRejected,
|
||||
Reason: "effective account unavailable", Time: start.UTC(),
|
||||
Reason: "effective account unavailable", SideEffects: agentcommand.SideEffectsNone, Time: start.UTC(),
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -555,10 +704,37 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas
|
||||
err = b.audit.AppendAudit(req.Owner, env)
|
||||
}
|
||||
if err != nil {
|
||||
// 审计不可用:结果标记未知,不臆断成功(四态语义)。
|
||||
return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()}
|
||||
// 审计不可用:结果标记未知,不臆断成功(四态语义)。已经签发过操作标识的
|
||||
// 路径(committed/accepted/已执行后不确定)连同该标识一并返回——信封丢失
|
||||
// 不等于操作没发生,调用方仍需按 operationID 对账;确定未执行的路径不带
|
||||
// 标识(rejected 契约禁止带 ID,unknown 无 ID 时也无需对账)。
|
||||
return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC())
|
||||
}
|
||||
return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, Time: b.now().UTC()}
|
||||
if state == agentcommand.StateUnknown && op != "" {
|
||||
// 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。
|
||||
return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC())
|
||||
}
|
||||
return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason,
|
||||
SideEffects: sideEffectsFor(state), Time: b.now().UTC()}
|
||||
}
|
||||
|
||||
// unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识
|
||||
// (供对账),否则返回不带标识的 unknown。state=rejected 恒不带标识。
|
||||
func unknownReceipt(commandID string, state agentcommand.ResultState, op, reason string, at time.Time) agentcommand.ExecReceipt {
|
||||
if op != "" && state != agentcommand.StateRejected {
|
||||
return agentcommand.NewUnknownAfterExecution(commandID, op, reason, at)
|
||||
}
|
||||
return agentcommand.ExecReceipt{CommandID: commandID, State: agentcommand.StateUnknown, Reason: reason, Time: at}
|
||||
}
|
||||
|
||||
// sideEffectsFor 回执的副作用声明:总线自身发起的 rejected 均为任何副作用之前
|
||||
// 的拒绝(未知命令/参数校验/项目前置/账号预检/无处理器/确认取消/handler 声明的
|
||||
// not-executed),故断言 none;committed/accepted 维持今日语义(未声明)。
|
||||
func sideEffectsFor(state agentcommand.ResultState) agentcommand.SideEffects {
|
||||
if state == agentcommand.StateRejected {
|
||||
return agentcommand.SideEffectsNone
|
||||
}
|
||||
return agentcommand.SideEffectsUnset
|
||||
}
|
||||
|
||||
func digest(params map[string]any) string {
|
||||
|
||||
@@ -52,7 +52,8 @@ func newTestBus(a AuditStore, c CardPort) *Bus {
|
||||
}, "/risky", func(ctx context.Context, req Request) (any, error) {
|
||||
t, _ := req.Params["target"].(string)
|
||||
if t == "boom" {
|
||||
return nil, errors.New("handler rejected")
|
||||
// 参数级拒绝发生在任何副作用之前:显式声明 not-executed(F10)。
|
||||
return nil, NotExecuted(errors.New("handler rejected"))
|
||||
}
|
||||
return map[string]any{"did": t}, nil
|
||||
})
|
||||
@@ -106,13 +107,20 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) {
|
||||
if req.OperationID == "" {
|
||||
t.Fatal("handler must receive a tentative host operation ID")
|
||||
}
|
||||
return nil, errors.New("domain transaction rejected")
|
||||
// 领域事务回滚 = 确定未执行:声明 not-executed 才是 rejected 的依据。
|
||||
return nil, NotExecuted(errors.New("domain transaction rejected"))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failed := bus.Dispatch(context.Background(), Request{ID: "rdm.fail", Actor: "alice", Owner: "alice"})
|
||||
if failed.Receipt.State != agentcommand.StateRejected || failed.Receipt.OperationID != "" || len(audit.logs) != 2 || audit.logs[1].OperationID != "" {
|
||||
t.Fatalf("failed handler was marked committed: %+v audits=%+v", failed.Receipt, audit.logs)
|
||||
t.Fatalf("not-executed handler was not rejected: %+v audits=%+v", failed.Receipt, audit.logs)
|
||||
}
|
||||
if err := failed.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("rejected receipt must satisfy the library contract: %v", err)
|
||||
}
|
||||
if failed.Receipt.SideEffects != agentcommand.SideEffectsNone {
|
||||
t.Fatalf("rejected receipt must declare no side effects, got %q", failed.Receipt.SideEffects)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,3 +350,248 @@ func TestHighDangerPluginCommandConfirmsFirst(t *testing.T) {
|
||||
t.Fatalf("confirm: %+v executed=%d", resp2.Receipt, executed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F10/U7b:确定未执行 vs 已执行后不确定 + operationID 对账 ----
|
||||
|
||||
// memOperationQuery 是按 owner/project 等值谓词过滤的操作记录替身(模拟 SQL
|
||||
// 作用域):跨 owner 或跨 project 的 operationID 一律查不到。
|
||||
type memOperationQuery struct {
|
||||
records map[string]OperationRecord // key: owner|projectID|operationID
|
||||
}
|
||||
|
||||
func operationKey(owner, projectID, operationID string) string {
|
||||
return owner + "|" + projectID + "|" + operationID
|
||||
}
|
||||
|
||||
func (q *memOperationQuery) LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error) {
|
||||
rec, ok := q.records[operationKey(owner, projectID, operationID)]
|
||||
return rec, ok, nil
|
||||
}
|
||||
|
||||
// recordAudit 把总线真实落下的审计信封登记为可查操作记录(对齐"审计信封就是
|
||||
// 宿主侧操作记录"的口径),用于验证 unknown 回执给出的对账句柄真的查得到。
|
||||
func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcommand.AuditEnvelope) {
|
||||
if env.OperationID == "" {
|
||||
return
|
||||
}
|
||||
q.records[operationKey(owner, projectID, env.OperationID)] = OperationRecord{
|
||||
OperationID: env.OperationID, CommandID: env.CommandID, State: env.State, Time: env.Time,
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归:
|
||||
// handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的
|
||||
// operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。
|
||||
func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) {
|
||||
audit := &memAudit{}
|
||||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
var writes []string
|
||||
var seen string
|
||||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(_ context.Context, req Request) (any, error) {
|
||||
seen = req.OperationID
|
||||
writes = append(writes, req.OperationID) // 副作用已发生
|
||||
return nil, errors.New("commit acknowledgement lost")
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown {
|
||||
t.Fatalf("post-execution failure must not be reported as rejected: %+v", resp.Receipt)
|
||||
}
|
||||
if seen == "" || resp.Receipt.OperationID != seen {
|
||||
t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||||
}
|
||||
if !strings.Contains(resp.Receipt.Reason, "after execution began") {
|
||||
t.Fatalf("reason must name post-execution uncertainty: %q", resp.Receipt.Reason)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible {
|
||||
t.Fatalf("side effects = %q, want %q", resp.Receipt.SideEffects, agentcommand.SideEffectsPossible)
|
||||
}
|
||||
if err := resp.Receipt.Validate(); err != nil {
|
||||
t.Fatalf("trusted unknown receipt must satisfy the library contract: %v", err)
|
||||
}
|
||||
if len(writes) != 1 {
|
||||
t.Fatalf("handler ran %d times, want 1", len(writes))
|
||||
}
|
||||
// 对账凭证:审计信封落 unknown + 同一个 operationID。
|
||||
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != seen {
|
||||
t.Fatalf("audit envelope is not reconcilable: %+v", audit.logs)
|
||||
}
|
||||
// 宿主不得自动重放:再次分发是一次新操作(新的 operationID),旧的不被复用。
|
||||
again := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"})
|
||||
if again.Receipt.State != agentcommand.StateUnknown || again.Receipt.OperationID == resp.Receipt.OperationID || len(writes) != 2 {
|
||||
t.Fatalf("re-dispatch must be a new operation, not a replay: %+v writes=%d", again.Receipt, len(writes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerNotExecutedErrorStaysRejected:handler 显式声明未执行才是
|
||||
// rejected 的依据;回执不得携带 operationID(库契约)。
|
||||
func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) {
|
||||
audit := &memAudit{}
|
||||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
calls := 0
|
||||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.guard", Title: "守卫"}, "", func(context.Context, Request) (any, error) {
|
||||
calls++
|
||||
boom := errors.New("permission denied")
|
||||
return nil, NotExecuted(boom)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.guard", Owner: "alice", Actor: "alice"})
|
||||
if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" {
|
||||
t.Fatalf("not-executed failure must stay rejected without an ID: %+v", resp.Receipt)
|
||||
}
|
||||
if resp.Receipt.Reason != "rejected: permission denied" {
|
||||
t.Fatalf("not-executed marking must not distort the reason: %q", resp.Receipt.Reason)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("rejected receipt must declare no side effects and validate: %+v", resp.Receipt)
|
||||
}
|
||||
if calls != 1 || len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
|
||||
t.Fatalf("unexpected effects: calls=%d audits=%+v", calls, audit.logs)
|
||||
}
|
||||
// 标记语义:errors.Is 同时命中 ErrNotExecuted 与原始错误;nil 幂等。
|
||||
wrapped := NotExecuted(errors.New("boom"))
|
||||
if !IsNotExecuted(wrapped) || !errors.Is(wrapped, ErrNotExecuted) || wrapped.Error() != "boom" {
|
||||
t.Fatalf("not-executed wrapper semantics: %v / %v", wrapped, IsNotExecuted(wrapped))
|
||||
}
|
||||
if NotExecuted(nil) != nil || !IsNotExecuted(NotExecuted(wrapped)) {
|
||||
t.Fatal("not-executed marking must be nil-safe and idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommittedWithAuditFailureKeepsOperationID 是 F10 Bug 2 的回归:审计
|
||||
// 落盘失败时回执仍是 unknown,但必须带着已交给 handler 的 operationID,
|
||||
// 理由点名审计不可用,调用方才能按记录对账而不是重发。
|
||||
func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) {
|
||||
audit := &memAudit{fail: true}
|
||||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
var seen string
|
||||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "", func(_ context.Context, req Request) (any, error) {
|
||||
seen = req.OperationID
|
||||
return map[string]any{"pong": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Owner: "alice", Actor: "alice"})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
|
||||
t.Fatalf("audit loss must stay unknown and name the audit: %+v", resp.Receipt)
|
||||
}
|
||||
if seen == "" || resp.Receipt.OperationID != seen {
|
||||
t.Fatalf("audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("audit-loss receipt must declare possible side effects and validate: %+v", resp.Receipt)
|
||||
}
|
||||
if len(audit.logs) != 0 {
|
||||
t.Fatalf("audit rows written while failing: %+v", audit.logs)
|
||||
}
|
||||
// 校验:未执行路径(rejected)在审计不可用时不得凭空出现 operationID。
|
||||
rejected := bus.Dispatch(context.Background(), Request{ID: "demo.absent"})
|
||||
if rejected.Receipt.OperationID != "" || rejected.Receipt.State != agentcommand.StateUnknown {
|
||||
t.Fatalf("audit loss must not invent an operation ID: %+v", rejected.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationRejectionNeverRunsHandler:显式校验拒绝仍是 rejected、无 ID、
|
||||
// 无副作用,且 handler 一次都不许跑。
|
||||
func TestValidationRejectionNeverRunsHandler(t *testing.T) {
|
||||
audit := &memAudit{}
|
||||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
calls := 0
|
||||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{
|
||||
ID: "demo.needs", Title: "需要参数",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`),
|
||||
}, "", func(context.Context, Request) (any, error) {
|
||||
calls++
|
||||
return "ran", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.needs", Owner: "alice", Params: map[string]any{}})
|
||||
if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("validation rejection: %+v", resp.Receipt)
|
||||
}
|
||||
if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || calls != 0 {
|
||||
t.Fatalf("validation rejection ran the handler or claimed effects: calls=%d %+v", calls, resp.Receipt)
|
||||
}
|
||||
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" {
|
||||
t.Fatalf("validation audit row: %+v", audit.logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepeatedConfirmExecutesOnce:确认卡重复确认不得二次执行(F10 分类改动
|
||||
// 不得放宽既有收口语义);unknown 卡面也不得悬在待确认。
|
||||
func TestRepeatedConfirmExecutesOnce(t *testing.T) {
|
||||
audit, cards := &memAudit{}, &memCards{}
|
||||
bus := New(audit, cards, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
executed := 0
|
||||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{
|
||||
ID: "demo.once", Title: "只执行一次", Danger: agentcommand.DangerHigh,
|
||||
}, "/once", func(context.Context, Request) (any, error) {
|
||||
executed++
|
||||
return "done", nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := bus.Dispatch(context.Background(), Request{ID: "demo.once", Owner: "alice", ConversationID: "cnv_1"}); r.Receipt.State != agentcommand.StateAccepted {
|
||||
t.Fatalf("pre-confirm: %+v", r.Receipt)
|
||||
}
|
||||
first := bus.Confirm("alice", "card_x1", "confirm")
|
||||
second := bus.Confirm("alice", "card_x1", "confirm")
|
||||
if first.Receipt.State != agentcommand.StateCommitted || executed != 1 {
|
||||
t.Fatalf("confirm: %+v executed=%d", first.Receipt, executed)
|
||||
}
|
||||
if second.Receipt.State != agentcommand.StateRejected || second.Receipt.OperationID != "" || executed != 1 {
|
||||
t.Fatalf("re-confirm executed twice: %+v executed=%d", second.Receipt, executed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupOperationIsOwnerProjectScoped(R7/KTD6):unknown 回执的对账查询
|
||||
// 复用 owner/project 等值作用域;跨 owner、跨 project、缺作用域、未装配端口
|
||||
// 一律 fail-closed,查不到绝不推断"未执行"。
|
||||
func TestLookupOperationIsOwnerProjectScoped(t *testing.T) {
|
||||
audit := &memAudit{}
|
||||
query := &memOperationQuery{records: map[string]OperationRecord{}}
|
||||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
if _, err := bus.LookupOperation("alice", "proj_1", "op_1"); !errors.Is(err, ErrOperationQueryUnavailable) {
|
||||
t.Fatalf("unwired query must fail closed: %v", err)
|
||||
}
|
||||
bus.SetOperationQuery(query)
|
||||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(context.Context, Request) (any, error) {
|
||||
// 副作用之后失败 → unknown + operationID(对账句柄)。
|
||||
return nil, errors.New("ack lost")
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Owner: "alice", Actor: "alice"})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" {
|
||||
t.Fatalf("setup dispatch: %+v", resp.Receipt)
|
||||
}
|
||||
// 对账记录=总线真实落下的审计信封(同一 owner/project 快照)。
|
||||
for _, env := range audit.logs {
|
||||
query.recordAudit("alice", "proj_1", env)
|
||||
}
|
||||
op := resp.Receipt.OperationID
|
||||
record, err := bus.LookupOperation("alice", "proj_1", op)
|
||||
if err != nil {
|
||||
t.Fatalf("owner-scoped lookup: %v", err)
|
||||
}
|
||||
if record.OperationID != op || record.CommandID != "demo.write" || record.State != agentcommand.StateUnknown {
|
||||
t.Fatalf("lookup returned the wrong record: %+v", record)
|
||||
}
|
||||
if _, err := bus.LookupOperation("bob", "proj_1", op); !errors.Is(err, ErrOperationNotRecorded) {
|
||||
t.Fatalf("cross-owner lookup must not resolve: %v", err)
|
||||
}
|
||||
if _, err := bus.LookupOperation("alice", "proj_other", op); !errors.Is(err, ErrOperationNotRecorded) {
|
||||
t.Fatalf("cross-project lookup must not resolve: %v", err)
|
||||
}
|
||||
for _, scope := range [][3]string{{"", "proj_1", op}, {"alice", "", op}, {"alice", "proj_1", ""}} {
|
||||
if _, err := bus.LookupOperation(scope[0], scope[1], scope[2]); !errors.Is(err, ErrOperationScope) {
|
||||
t.Fatalf("scope %v must fail closed: %v", scope, err)
|
||||
}
|
||||
}
|
||||
if _, err := bus.LookupOperation("bob", "proj_1", "op_absent"); !errors.Is(err, ErrOperationNotRecorded) {
|
||||
t.Fatalf("unknown operation must not be reported as executed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package cmdbus
|
||||
|
||||
// 基座项目门禁在命令总线上的行为(临时会话隔离,U2):SetHostProjectGuard 装
|
||||
// 的门禁对所有带 HostProjectID 的分发生效,且在 handler 与项目工作预检之前。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
)
|
||||
|
||||
// TestHostProjectGuardRefusesBeforeHandler 基座项目门禁:带宿主的请求先过门禁,
|
||||
// 拒绝时 handler 与预检都不该被调用;同一个命令换一个普通宿主项目照旧执行。
|
||||
func TestHostProjectGuardRefusesBeforeHandler(t *testing.T) {
|
||||
audit := &memAudit{}
|
||||
bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
handled, preflights := 0, 0
|
||||
bus.SetProjectWorkPreflight(func(context.Context, Request) error { preflights++; return nil })
|
||||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, "",
|
||||
func(context.Context, Request) (any, error) { handled++; return map[string]any{"ok": true}, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bus.SetHostProjectGuard(func(_ context.Context, req Request) error {
|
||||
if strings.HasPrefix(req.ID, "rdm.") || RequiresProjectWorkDecision(req.ID) {
|
||||
if req.HostProjectID == "prj_scratch" {
|
||||
return errors.New("not available in scratch sessions")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
resp := bus.Dispatch(context.Background(), Request{
|
||||
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
|
||||
ConversationID: "cnv_scratch", HostProjectID: "prj_scratch",
|
||||
})
|
||||
if resp.Receipt.State != agentcommand.StateRejected {
|
||||
t.Fatalf("scratch host dispatch state = %s, want rejected", resp.Receipt.State)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Receipt.Reason, "host project: ") ||
|
||||
!strings.Contains(resp.Receipt.Reason, "not available in scratch sessions") {
|
||||
t.Fatalf("scratch host rejection reason = %q", resp.Receipt.Reason)
|
||||
}
|
||||
if handled != 0 || preflights != 0 {
|
||||
t.Fatalf("guard ran late: handler=%d preflight=%d", handled, preflights)
|
||||
}
|
||||
|
||||
// 正面:同一命令在普通项目宿主上不被这条门禁拒绝。
|
||||
ok := bus.Dispatch(context.Background(), Request{
|
||||
ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice",
|
||||
ConversationID: "cnv_normal", HostProjectID: "prj_normal",
|
||||
})
|
||||
if ok.Receipt.State != agentcommand.StateCommitted || handled != 1 || preflights != 1 {
|
||||
t.Fatalf("normal host dispatch = %+v handler=%d preflight=%d", ok.Receipt, handled, preflights)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostProjectGuardSkipsHostlessAndUnrelatedCommands 门禁只作用于带宿主的
|
||||
// 请求与非项目命令;会话类命令在同一个临时会话宿主上照旧可用。
|
||||
func TestHostProjectGuardSkipsHostlessAndUnrelatedCommands(t *testing.T) {
|
||||
bus := New(&memAudit{}, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "",
|
||||
func(context.Context, Request) (any, error) { return map[string]any{"pong": true}, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guarded := 0
|
||||
bus.SetHostProjectGuard(func(_ context.Context, req Request) error {
|
||||
guarded++
|
||||
if req.HostProjectID == "prj_scratch" && strings.HasPrefix(req.ID, "rdm.") {
|
||||
return errors.New("not available in scratch sessions")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
resp := bus.Dispatch(context.Background(), Request{
|
||||
ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: "prj_scratch",
|
||||
})
|
||||
if resp.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt)
|
||||
}
|
||||
if guarded != 1 {
|
||||
t.Fatalf("host guard calls = %d, want 1", guarded)
|
||||
}
|
||||
hostless := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: "api", Actor: "alice", Owner: "alice"})
|
||||
if hostless.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("hostless dispatch = %+v", hostless.Receipt)
|
||||
}
|
||||
if guarded != 1 {
|
||||
t.Fatalf("host guard ran for a hostless dispatch: %d", guarded)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package cmdbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
@@ -31,3 +33,35 @@ func TestPendingConfirmRestoredAfterRestart(t *testing.T) {
|
||||
t.Fatalf("restored confirm: %+v", again.Receipt)
|
||||
}
|
||||
}
|
||||
|
||||
// failingPendingStore 是"确认卡已创建、挂起态写不进去"的替身:受理已经发生
|
||||
// (卡在收件箱里),只是结果不可知。
|
||||
type failingPendingStore struct{ err error }
|
||||
|
||||
func (p *failingPendingStore) SavePendingConfirm(string, string, string) error { return p.err }
|
||||
func (p *failingPendingStore) TakePendingConfirm(string) (string, string, bool, error) {
|
||||
return "", "", false, p.err
|
||||
}
|
||||
|
||||
// TestConfirmPendingWriteFailureKeepsCardOperationID:挂起态落盘失败不得吞掉操作
|
||||
// 标识——卡 ID 就是本次受理的句柄,unknown 必须带出去(否则受理事实无凭证)。
|
||||
func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) {
|
||||
audit, cards := &memAudit{}, &memCards{}
|
||||
b := newTestBus(audit, cards)
|
||||
b.SetPendingStore(&failingPendingStore{err: errors.New("pending store down")})
|
||||
resp := b.Dispatch(context.Background(), Request{
|
||||
ID: "demo.risky", Params: map[string]any{"target": "prod"}, ConversationID: "cnv_1", Owner: "alice",
|
||||
})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "confirm pending") {
|
||||
t.Fatalf("pending write failure: %+v", resp.Receipt)
|
||||
}
|
||||
if resp.Receipt.OperationID != "card_x1" || resp.Receipt.Validate() != nil {
|
||||
t.Fatalf("pending write failure dropped the card operation handle: %+v", resp.Receipt)
|
||||
}
|
||||
if len(cards.created) != 1 || len(b.pending) != 0 {
|
||||
t.Fatalf("card created=%d pending=%d", len(cards.created), len(b.pending))
|
||||
}
|
||||
if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != "card_x1" {
|
||||
t.Fatalf("audit envelope: %+v", audit.logs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
package docgen
|
||||
|
||||
const rendererRevision = "3f259990b5d43ec0d318f39d496816140ab559b5"
|
||||
const rendererRevision = "40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
|
||||
|
||||
const RendererIdentity = "agent-document-renderer@" + rendererRevision
|
||||
|
||||
@@ -367,7 +367,7 @@ func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T)
|
||||
}
|
||||
|
||||
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
|
||||
const want = "agent-document-renderer@3f259990b5d43ec0d318f39d496816140ab559b5"
|
||||
const want = "agent-document-renderer@40e5c671d1ef786ceba62bf784bf3aa53c5da26d"
|
||||
if RendererIdentity != want {
|
||||
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
|
||||
}
|
||||
|
||||
+84
-20
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
@@ -14,8 +16,21 @@ import (
|
||||
|
||||
// SlotQuestion 是命中意图后仍缺的必填槽位,以及建议的提问。
|
||||
type SlotQuestion struct {
|
||||
Key string
|
||||
Question string
|
||||
Key string `json:"key"`
|
||||
Question string `json:"question"`
|
||||
}
|
||||
|
||||
// SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09):
|
||||
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/
|
||||
// 生成路径的未知键),Reason 是库层原因原文;
|
||||
// - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。
|
||||
//
|
||||
// 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。
|
||||
type SlotDefect struct {
|
||||
Key string `json:"key"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Question string `json:"question"`
|
||||
Invalid bool `json:"invalid"`
|
||||
}
|
||||
|
||||
// MatchResult 是一次识别。Branch 与 Match 相同。
|
||||
@@ -24,25 +39,45 @@ type MatchResult struct {
|
||||
ID string
|
||||
Slots map[string]string
|
||||
Missing []SlotQuestion
|
||||
// Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。
|
||||
// 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。
|
||||
Invalid []SlotDefect
|
||||
// Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款):
|
||||
// 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。
|
||||
Risk string
|
||||
RequiresConfirm bool
|
||||
Action *ActionRef
|
||||
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
|
||||
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入,
|
||||
// Router/runmgr 等消费方只依赖本包表面。
|
||||
// Complete 报告槽位是否齐全且全部通过权威校验:false 时业务动作不得派发。
|
||||
func (m MatchResult) Complete() bool { return len(m.Missing) == 0 && len(m.Invalid) == 0 }
|
||||
|
||||
// 命令与目录类型经闸门投影(别名):生态库按边界规则只许本包导入,
|
||||
// Router/runmgr 等消费方只依赖本包表面。CatalogSpec/CatalogSlot 随生成器端口
|
||||
// (SetGenerator 的入参)投影,实现生成器不必越过本包直接引用生态库。
|
||||
type (
|
||||
Command = agentintent.Command
|
||||
ActionRef = agentintent.ActionRef
|
||||
Command = agentintent.Command
|
||||
ActionRef = agentintent.ActionRef
|
||||
CatalogSpec = agentintent.CatalogSpec
|
||||
CatalogSlot = agentintent.CatalogSlot
|
||||
)
|
||||
|
||||
const (
|
||||
CommandStart = agentintent.CommandStart
|
||||
ActionAuto = agentintent.ActionAuto
|
||||
ActionCommand = agentintent.ActionCommand
|
||||
ActionWorkflow = agentintent.ActionWorkflow
|
||||
ActionBlueprint = agentintent.ActionBlueprint
|
||||
ActionHandler = agentintent.ActionHandler
|
||||
CommandStart = agentintent.CommandStart
|
||||
CommandSetSlot = agentintent.CommandSetSlot
|
||||
CommandChangeSlot = agentintent.CommandChangeSlot
|
||||
CommandCancel = agentintent.CommandCancel
|
||||
CommandClarify = agentintent.CommandClarify
|
||||
CommandChitchat = agentintent.CommandChitchat
|
||||
CommandHandoff = agentintent.CommandHandoff
|
||||
ActionAuto = agentintent.ActionAuto
|
||||
ActionCommand = agentintent.ActionCommand
|
||||
ActionWorkflow = agentintent.ActionWorkflow
|
||||
ActionBlueprint = agentintent.ActionBlueprint
|
||||
ActionHandler = agentintent.ActionHandler
|
||||
)
|
||||
|
||||
type Gate struct {
|
||||
@@ -133,7 +168,7 @@ func (g *Gate) Match(ctx context.Context, input string) (string, string) {
|
||||
return res.Branch, res.ID
|
||||
}
|
||||
|
||||
// Recognize 在 Match 的结果上带上槽位和缺失提问。
|
||||
// Recognize 在 Match 的结果上带上槽位、缺失提问、非法原因与风险/确认/动作声明。
|
||||
func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
g.mu.RLock()
|
||||
reg, rec, byID := g.reg, g.rec, g.specsByID
|
||||
@@ -163,16 +198,45 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
out.Slots[k] = v
|
||||
}
|
||||
}
|
||||
// 风险/确认/动作声明:规则路径与生成路径(candidateFromGenerated)同款字段,
|
||||
// 宿主据此决定是否必须走确认,不在本包内裁决。
|
||||
out.Risk = res.Top.Risk
|
||||
out.RequiresConfirm = res.Top.RequiresConfirm
|
||||
if res.Top.Action != nil {
|
||||
action := *res.Top.Action
|
||||
out.Action = &action
|
||||
}
|
||||
spec := byID[out.ID]
|
||||
for _, key := range res.Top.MissingRequiredSlots {
|
||||
q := key
|
||||
for _, sl := range spec.Slots {
|
||||
if sl.Key == key && sl.Question != "" {
|
||||
q = sl.Question
|
||||
}
|
||||
}
|
||||
out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: q})
|
||||
out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: questionOf(spec, key)})
|
||||
}
|
||||
// 非法槽位按键名排序(识别结果里的 map 无序,投影必须确定)。
|
||||
for _, key := range sortedKeys(res.Top.InvalidSlots) {
|
||||
out.Invalid = append(out.Invalid, SlotDefect{
|
||||
Key: key, Reason: res.Top.InvalidSlots[key], Invalid: true,
|
||||
Question: questionOf(spec, key),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// questionOf 取 spec 为该槽位声明的建议提问;没有就回落键名本身。
|
||||
func questionOf(spec agentintent.IntentSpec, key string) string {
|
||||
for _, sl := range spec.Slots {
|
||||
if sl.Key == key && strings.TrimSpace(sl.Question) != "" {
|
||||
return sl.Question
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// sortedKeys 按键名排序,保证同一识别结果的投影顺序确定。
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package intentgate
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
@@ -176,3 +177,149 @@ func TestGateGeneratorProducesCommands(t *testing.T) {
|
||||
t.Fatalf("关闭生成层:branch = %s", branch)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)----
|
||||
|
||||
// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。
|
||||
func modeSpec(t *testing.T) json.RawMessage {
|
||||
t.Helper()
|
||||
return raw(t, map[string]any{
|
||||
"id": "demo.mode", "domain": "demo", "version": 1,
|
||||
"matcher": map[string]any{"includes_any": []string{"切换"}},
|
||||
"risk": "high",
|
||||
"requires_confirm": true,
|
||||
"action": map[string]any{"kind": "command", "id": "demo.mode"},
|
||||
"slots": []map[string]any{
|
||||
{"key": "mode", "required": true, "pattern": `模式[::]\s*(\S+)`, "enum": []string{"摘要", "全文"}, "question": "要哪种模式?"},
|
||||
{"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`, "question": "主题是什么?"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := g.Recognize(context.Background(), "切换 主题:核能 模式:随手写")
|
||||
if bad.Branch != "matched" || bad.ID != "demo.mode" {
|
||||
t.Fatalf("识别分支:%+v", bad)
|
||||
}
|
||||
if bad.Complete() {
|
||||
t.Fatalf("非法枚举值不得算作完整:%+v", bad)
|
||||
}
|
||||
if len(bad.Missing) != 0 {
|
||||
// 必填且非法只进 Invalid,不重复进 Missing。
|
||||
t.Fatalf("非法槽位不应重复计入缺失:%+v", bad.Missing)
|
||||
}
|
||||
if len(bad.Invalid) != 1 {
|
||||
t.Fatalf("非法槽位详情:%+v", bad.Invalid)
|
||||
}
|
||||
d := bad.Invalid[0]
|
||||
if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" {
|
||||
t.Fatalf("非法原因/提问未透传:%+v", d)
|
||||
}
|
||||
if _, leaked := bad.Slots["mode"]; leaked {
|
||||
t.Fatalf("非法值不得进入已校验槽位表:%+v", bad.Slots)
|
||||
}
|
||||
if bad.Slots["topic"] != "核能" {
|
||||
t.Fatalf("合法槽位丢失:%+v", bad.Slots)
|
||||
}
|
||||
// 风险/确认/动作声明一并透传(宿主据此决定确认与派发)。
|
||||
if bad.Risk != "high" || !bad.RequiresConfirm {
|
||||
t.Fatalf("风险元数据未透传:%+v", bad)
|
||||
}
|
||||
if bad.Action == nil || bad.Action.Kind != ActionCommand || bad.Action.ID != "demo.mode" {
|
||||
t.Fatalf("动作声明未透传:%+v", bad.Action)
|
||||
}
|
||||
|
||||
ok := g.Recognize(context.Background(), "切换 主题:核能 模式:摘要")
|
||||
if !ok.Complete() || ok.Slots["mode"] != "摘要" || len(ok.Invalid) != 0 {
|
||||
t.Fatalf("合法槽位识别:%+v", ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。
|
||||
func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
Slots: map[string]string{"mode": "随手写", "topic": "核能"},
|
||||
}}})
|
||||
res := g.Recognize(context.Background(), "跟昨天一样")
|
||||
if res.Branch != "matched" || res.ID != "demo.mode" {
|
||||
t.Fatalf("生成路径识别:%+v", res)
|
||||
}
|
||||
if len(res.Invalid) != 1 || res.Invalid[0].Key != "mode" || res.Invalid[0].Reason != "value not in enum" ||
|
||||
res.Invalid[0].Question != "要哪种模式?" {
|
||||
t.Fatalf("生成路径非法详情:%+v", res.Invalid)
|
||||
}
|
||||
if _, leaked := res.Slots["mode"]; leaked || res.Complete() {
|
||||
t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots)
|
||||
}
|
||||
if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil {
|
||||
t.Fatalf("生成路径风险元数据:%+v", res)
|
||||
}
|
||||
|
||||
// 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
Slots: map[string]string{"mode": "摘要", "topic": "核能"},
|
||||
}}})
|
||||
clean := g.Recognize(context.Background(), "跟昨天一样")
|
||||
if !clean.Complete() || clean.Slots["mode"] != "摘要" {
|
||||
t.Fatalf("生成路径合法槽位:%+v", clean)
|
||||
}
|
||||
}
|
||||
|
||||
// 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。
|
||||
func TestRecognizeDistinguishesMissingFromInvalid(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := g.Recognize(context.Background(), "切换 主题:核能")
|
||||
if len(res.Missing) != 1 || res.Missing[0].Key != "mode" || res.Missing[0].Question != "要哪种模式?" {
|
||||
t.Fatalf("缺失投影:%+v", res.Missing)
|
||||
}
|
||||
if len(res.Invalid) != 0 {
|
||||
t.Fatalf("缺失不得混进非法:%+v", res.Invalid)
|
||||
}
|
||||
if res.Complete() {
|
||||
t.Fatal("缺必填槽位不得算作完整")
|
||||
}
|
||||
}
|
||||
|
||||
// 格式校验失败与枚举失败同款透传(原因原文来自库层,不做改写)。
|
||||
func TestRecognizeKeepsFormatFailureReason(t *testing.T) {
|
||||
g := New()
|
||||
spec := raw(t, map[string]any{
|
||||
"id": "demo.ticket", "domain": "demo", "version": 1,
|
||||
"matcher": map[string]any{"includes_any": []string{"开工单"}},
|
||||
"slots": []map[string]any{{
|
||||
"key": "issue", "required": true, "pattern": `编号[::]\s*(\S+)`,
|
||||
"format": `[A-Z]{2}-\d+`, "question": "编号是什么?",
|
||||
}},
|
||||
})
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := g.Recognize(context.Background(), "开工单 编号:随手写的")
|
||||
if len(bad.Invalid) != 1 || bad.Invalid[0].Key != "issue" || bad.Invalid[0].Question != "编号是什么?" {
|
||||
t.Fatalf("格式失败明细:%+v", bad.Invalid)
|
||||
}
|
||||
if !strings.HasPrefix(bad.Invalid[0].Reason, "format mismatch") {
|
||||
t.Fatalf("格式失败原因:%+v", bad.Invalid[0])
|
||||
}
|
||||
if bad.Complete() || len(bad.Missing) != 0 {
|
||||
t.Fatalf("格式失败不得算作完整或缺失:%+v", bad)
|
||||
}
|
||||
if ok := g.Recognize(context.Background(), "开工单 编号:AB-12"); !ok.Complete() || ok.Slots["issue"] != "AB-12" {
|
||||
t.Fatalf("格式合法:%+v", ok)
|
||||
}
|
||||
}
|
||||
|
||||
+177
-20
@@ -10,15 +10,22 @@
|
||||
// - PromptTrigger(技能触发)是提示词装配、不参与路由决胜;
|
||||
// 意图产出 Start/Clarify 类命令时路由优先。
|
||||
//
|
||||
// 槽位准入(F09):可执行业务动作(命令总线)必须在槽位齐全且全部通过
|
||||
// 权威校验时才派发;缺失或非法一律不派发,转澄清诉求(Clarification)。
|
||||
// 工作流是「边收集边执行」的草稿:按既有规则照常启动,缺失清单随决策透出,
|
||||
// 由流程自身追问——非法值不会进入槽位表(库层只把合法值写进 Slots)。
|
||||
//
|
||||
// 当前版本的已知边界(如实记录,不静默):
|
||||
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
|
||||
// 本版本调用方按普通回合处理并保留 Decision 供审计。
|
||||
// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机
|
||||
// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为
|
||||
// fallback,Start 只来自 matched)。
|
||||
// - SetSlot/ChangeSlot/Cancel/Handoff 由库层产出,但 Backend 尚无会话槽位
|
||||
// 状态机与语义层:它们记入 Unsupported 并回落普通回合,绝不假装已支持
|
||||
// (例如用户说「取消」时不会把同一句话里的 Start 当成业务动作派发)。
|
||||
package route
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -33,6 +40,18 @@ const (
|
||||
KindTurn Kind = "turn"
|
||||
)
|
||||
|
||||
// Clarification 是「业务动作未派发,需要向用户澄清」的诉求。
|
||||
// 槽位缺失与非法都带结构化原因(key + reason + question),
|
||||
// 宿主据此渲染澄清话术或转成回合提示,不再自行猜测。
|
||||
type Clarification struct {
|
||||
Needed bool
|
||||
Question string
|
||||
Missing []intentgate.SlotQuestion
|
||||
Invalid []intentgate.SlotDefect
|
||||
// Command 是被拦下的动作 ID(诊断与审计用,未派发)。
|
||||
Command string
|
||||
}
|
||||
|
||||
// Decision 是一次路由裁决。
|
||||
type Decision struct {
|
||||
Kind Kind
|
||||
@@ -41,8 +60,17 @@ type Decision struct {
|
||||
Blueprint string
|
||||
Slots map[string]string
|
||||
Missing []intentgate.SlotQuestion
|
||||
Reason string // 回落 turn 的原因(显式声明不可解析时)
|
||||
Invalid []intentgate.SlotDefect
|
||||
Risk string // 权威意图声明的风险档(low/medium/high)
|
||||
Reason string // 回落 turn 的原因(显式声明不可解析、准入未过等)
|
||||
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
|
||||
// RequiresConfirm 是意图声明的显式确认要求(与命令自身的 danger 分级正交,
|
||||
// 本包只透传,不替代 cmdbus 的高危确认收口)。
|
||||
RequiresConfirm bool
|
||||
// Clarification 非空时业务动作未派发(Needed=true 时必有槽位原因)。
|
||||
Clarification Clarification
|
||||
// Unsupported 是本次识别里宿主不消费的库层命令种类(如 cancel/set_slot)。
|
||||
Unsupported []string
|
||||
}
|
||||
|
||||
// Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。
|
||||
@@ -52,23 +80,52 @@ type Env struct {
|
||||
SquadReady bool
|
||||
}
|
||||
|
||||
// unconsumedCommandKinds 是宿主 v1 不消费的库层命令种类(无会话槽位状态机)。
|
||||
// 命中即不派发业务动作:把「取消」当成「启动」是比不响更糟的错配。
|
||||
var unconsumedCommandKinds = map[string]bool{
|
||||
string(intentgate.CommandSetSlot): true,
|
||||
string(intentgate.CommandChangeSlot): true,
|
||||
string(intentgate.CommandCancel): true,
|
||||
string(intentgate.CommandHandoff): true,
|
||||
string(intentgate.CommandChitchat): true,
|
||||
}
|
||||
|
||||
// Decide 裁决一次识别结果。规则:
|
||||
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
|
||||
// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。
|
||||
// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流;
|
||||
// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。
|
||||
// 2. 出现宿主不消费的库层命令(cancel/set_slot/…)→ turn,并如实记录
|
||||
// Unsupported 与 Reason(不假装支持,也不派发同句的 Start)。
|
||||
// 3. 找到首个 Start 命令;没有时按 TopID(或显式 action 声明)走 auto
|
||||
// (兼容兜底,与现网一致)。
|
||||
// 4. 按 ActionRef.Kind 解析:command→命令总线(先过槽位准入);
|
||||
// workflow→工作流(草稿,缺槽照常启动);blueprint→squad 决策;
|
||||
// handler/auto→命令总线 > 工作流 > turn。
|
||||
func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing}
|
||||
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing, Invalid: res.Invalid,
|
||||
Risk: res.Risk, RequiresConfirm: res.RequiresConfirm}
|
||||
if res.Branch != "matched" {
|
||||
return d
|
||||
}
|
||||
var start *intentgate.Command
|
||||
d.Unsupported = unconsumedKinds(res.Commands)
|
||||
var start, clarify *intentgate.Command
|
||||
for i := range res.Commands {
|
||||
if res.Commands[i].Kind == intentgate.CommandStart {
|
||||
start = &res.Commands[i]
|
||||
break
|
||||
switch res.Commands[i].Kind {
|
||||
case intentgate.CommandStart:
|
||||
if start == nil {
|
||||
start = &res.Commands[i]
|
||||
}
|
||||
case intentgate.CommandClarify:
|
||||
if clarify == nil {
|
||||
clarify = &res.Commands[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(d.Unsupported) > 0 {
|
||||
d.Reason = "宿主未消费库层命令:" + strings.Join(d.Unsupported, "、")
|
||||
if clarify != nil {
|
||||
d.Clarification = clarificationFrom("", res, clarify)
|
||||
}
|
||||
return d
|
||||
}
|
||||
action := intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: res.ID}
|
||||
if start != nil {
|
||||
d.StartFound = true
|
||||
@@ -76,19 +133,19 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
if len(start.Slots) > 0 {
|
||||
d.Slots = start.Slots
|
||||
}
|
||||
} else if res.Action != nil {
|
||||
action = *res.Action
|
||||
}
|
||||
switch action.Kind {
|
||||
case intentgate.ActionCommand:
|
||||
if env.HasCommand != nil && env.HasCommand(action.ID) {
|
||||
d.Kind, d.CommandID = KindCommand, action.ID
|
||||
return d
|
||||
return d.dispatchCommand(action.ID, res, clarify)
|
||||
}
|
||||
d.Reason = "声明的命令不存在:" + action.ID
|
||||
case intentgate.ActionWorkflow:
|
||||
if env.WorkflowFor != nil {
|
||||
if def, ok := env.WorkflowFor(action.ID); ok {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
return d.startWorkflowDraft(def)
|
||||
}
|
||||
}
|
||||
d.Reason = "声明的工作流未注册:" + action.ID
|
||||
@@ -100,15 +157,115 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
d.Reason = "声明的小队未启用:" + action.ID
|
||||
default: // auto / handler
|
||||
if env.HasCommand != nil && env.HasCommand(action.ID) {
|
||||
d.Kind, d.CommandID = KindCommand, action.ID
|
||||
return d
|
||||
return d.dispatchCommand(action.ID, res, clarify)
|
||||
}
|
||||
if env.WorkflowFor != nil {
|
||||
if def, ok := env.WorkflowFor(action.ID); ok {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
return d.startWorkflowDraft(def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// dispatchCommand 派发可执行业务动作前的最后一道闸:槽位必须齐全且通过权威校验。
|
||||
// 未就绪时不碰业务动作,改为带结构化原因的澄清诉求(Kind 留在 turn)。
|
||||
func (d Decision) dispatchCommand(id string, res intentgate.MatchResult, clarify *intentgate.Command) Decision {
|
||||
if !res.Complete() {
|
||||
d.Clarification = clarificationFrom(id, res, clarify)
|
||||
d.Reason = "槽位未就绪,未派发命令:" + id
|
||||
return d
|
||||
}
|
||||
d.Kind, d.CommandID = KindCommand, id
|
||||
return d
|
||||
}
|
||||
|
||||
// startWorkflowDraft 工作流是收集槽位的草稿:缺失与非法都不阻断启动
|
||||
// (非法值本就进不了 Slots),缺失清单随 Decision 透出,由流程自身追问。
|
||||
func (d Decision) startWorkflowDraft(def core.WorkflowDef) Decision {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
}
|
||||
|
||||
// unconsumedKinds 列出命令列表里宿主不消费的种类(按首次出现序,确定性)。
|
||||
func unconsumedKinds(cmds []intentgate.Command) []string {
|
||||
var out []string
|
||||
for _, c := range cmds {
|
||||
if !unconsumedCommandKinds[string(c.Kind)] {
|
||||
continue
|
||||
}
|
||||
if contains(out, string(c.Kind)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, string(c.Kind))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clarificationFrom 组装澄清诉求:优先用 spec 声明的建议提问,
|
||||
// 缺失时回落库层 clarify 命令里的 Question,再缺就按缺陷键列一句兜底。
|
||||
func clarificationFrom(id string, res intentgate.MatchResult, clarify *intentgate.Command) Clarification {
|
||||
c := Clarification{Needed: true, Command: id,
|
||||
Missing: append([]intentgate.SlotQuestion(nil), res.Missing...),
|
||||
Invalid: append([]intentgate.SlotDefect(nil), res.Invalid...)}
|
||||
if len(c.Missing) > 0 {
|
||||
c.Question = strings.TrimSpace(c.Missing[0].Question)
|
||||
}
|
||||
if c.Question == "" && len(c.Invalid) > 0 {
|
||||
c.Question = strings.TrimSpace(c.Invalid[0].Question)
|
||||
}
|
||||
if clarify != nil {
|
||||
if c.Question == "" {
|
||||
c.Question = strings.TrimSpace(clarify.Question)
|
||||
}
|
||||
// 库层 clarify 的 Missing 是缺失与非法键的合并;补齐宿主侧没有投影的键。
|
||||
for _, k := range clarify.Missing {
|
||||
if defectKeyKnown(c, k) {
|
||||
continue
|
||||
}
|
||||
c.Missing = append(c.Missing, intentgate.SlotQuestion{Key: k, Question: k})
|
||||
}
|
||||
}
|
||||
if c.Question == "" {
|
||||
c.Question = fallbackQuestion(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func defectKeyKnown(c Clarification, key string) bool {
|
||||
for _, m := range c.Missing {
|
||||
if m.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, iv := range c.Invalid {
|
||||
if iv.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fallbackQuestion 没有任何声明提问时的兜底话术:按缺失在前、非法在后列键。
|
||||
func fallbackQuestion(c Clarification) string {
|
||||
var keys []string
|
||||
for _, m := range c.Missing {
|
||||
keys = append(keys, m.Key)
|
||||
}
|
||||
for _, iv := range c.Invalid {
|
||||
keys = append(keys, iv.Key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "请补充:" + strings.Join(keys, "、")
|
||||
}
|
||||
|
||||
@@ -81,3 +81,124 @@ func TestSlotsFromStartCommand(t *testing.T) {
|
||||
t.Fatalf("slots = %v", d.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F09:槽位准入——业务动作必须齐全且合法,否则转澄清 ----
|
||||
|
||||
func matchedCommand(action intentgate.ActionRef) intentgate.MatchResult {
|
||||
return intentgate.MatchResult{Branch: "matched", ID: action.ID,
|
||||
Commands: []intentgate.Command{startCmd(action)}}
|
||||
}
|
||||
|
||||
func hasCommandEnv() Env { return Env{HasCommand: func(string) bool { return true }} }
|
||||
|
||||
func TestMissingSlotsBlockCommandDispatch(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
|
||||
in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" {
|
||||
t.Fatalf("缺必填槽位不得派发命令:%+v", d)
|
||||
}
|
||||
if !d.Clarification.Needed || d.Clarification.Command != "demo.brief" {
|
||||
t.Fatalf("澄清诉求缺失:%+v", d.Clarification)
|
||||
}
|
||||
if d.Clarification.Question != "主题是什么?" || len(d.Clarification.Missing) != 1 {
|
||||
t.Fatalf("澄清提问未透传:%+v", d.Clarification)
|
||||
}
|
||||
if d.Reason == "" || !d.StartFound {
|
||||
t.Fatalf("回落原因或 Start 标记缺失:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSlotsBlockCommandWithReason(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"})
|
||||
in.Invalid = []intentgate.SlotDefect{{Key: "mode", Reason: "value not in enum", Question: "要哪种模式?", Invalid: true}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" {
|
||||
t.Fatalf("非法槽位不得派发命令:%+v", d)
|
||||
}
|
||||
if len(d.Clarification.Invalid) != 1 || d.Clarification.Invalid[0].Reason != "value not in enum" {
|
||||
t.Fatalf("非法原因未透传:%+v", d.Clarification)
|
||||
}
|
||||
if d.Clarification.Question != "要哪种模式?" {
|
||||
t.Fatalf("澄清提问未透传:%+v", d.Clarification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSlotsDispatchCommand(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
|
||||
in.Slots = map[string]string{"topic": "核能"}
|
||||
in.Risk, in.RequiresConfirm = "high", true
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindCommand || d.CommandID != "demo.brief" {
|
||||
t.Fatalf("完整合法槽位应派发:%+v", d)
|
||||
}
|
||||
if d.Risk != "high" || !d.RequiresConfirm || d.Clarification.Needed {
|
||||
t.Fatalf("风险元数据未透传或误报澄清:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 工作流是「边收集边执行」的草稿:缺失清单照常启动,由流程自身追问。
|
||||
func TestWorkflowDraftStartsWhileGatheringSlots(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
|
||||
in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}
|
||||
d := Decide(in, Env{
|
||||
HasCommand: func(string) bool { return false },
|
||||
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
|
||||
})
|
||||
if d.Kind != KindWorkflow || d.Workflow.ID != "wf" {
|
||||
t.Fatalf("工作流草稿应照常启动:%+v", d)
|
||||
}
|
||||
if len(d.Missing) != 1 || d.Clarification.Needed {
|
||||
t.Fatalf("缺失清单应随决策透出:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式的取消/改槽命令不得被静默当成已支持:回落普通回合,如实记录。
|
||||
func TestUnsupportedLibraryCommandsAreNotClaimed(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
|
||||
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}},
|
||||
{Kind: intentgate.CommandCancel, Target: "demo.brief"},
|
||||
}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" {
|
||||
t.Fatalf("未消费的库层命令不得派发业务动作:%+v", d)
|
||||
}
|
||||
if len(d.Unsupported) != 1 || d.Unsupported[0] != string(intentgate.CommandCancel) {
|
||||
t.Fatalf("未消费命令种类未如实记录:%+v", d.Unsupported)
|
||||
}
|
||||
if d.Reason == "" {
|
||||
t.Fatalf("回落原因缺失:%+v", d)
|
||||
}
|
||||
// 只有改槽命令时同样回落,且不得声称支持。
|
||||
only := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
|
||||
{Kind: intentgate.CommandSetSlot, Name: "topic", Value: "核能"},
|
||||
}}
|
||||
got := Decide(only, hasCommandEnv())
|
||||
if got.Kind != KindTurn || got.CommandID != "" || len(got.Unsupported) != 1 {
|
||||
t.Fatalf("set_slot 不得被当成可派发:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 澄清提问优先用 spec 声明,缺失时回落库层 clarify 命令,最后按键名兜底。
|
||||
func TestClarificationQuestionSources(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"})
|
||||
in.Missing = []intentgate.SlotQuestion{{Key: "mode"}}
|
||||
in.Commands = append(in.Commands, intentgate.Command{Kind: intentgate.CommandClarify, Question: "要哪种模式?"})
|
||||
if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "要哪种模式?" {
|
||||
t.Fatalf("库层澄清提问未用上:%q", q)
|
||||
}
|
||||
in.Commands = in.Commands[:1]
|
||||
if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "请补充:mode" {
|
||||
t.Fatalf("兜底提问:%q", q)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧消费方(命令列表为空)仍按 TopID 走 auto,且必须槽位完整。
|
||||
func TestLegacyAutoStillNeedsCompleteSlots(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
|
||||
Missing: []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" || !d.Clarification.Needed {
|
||||
t.Fatalf("旧路径也必须过槽位准入:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/memory"
|
||||
@@ -17,23 +18,38 @@ type storeMemory struct {
|
||||
projectID string
|
||||
checked bool
|
||||
allowImplicitDefault bool
|
||||
// scratch 为真时端口对 user/project 记忆完全关闭:临时会话不读也不写
|
||||
// 跨会话记忆(U2 隔离)。会话内状态不受影响。
|
||||
scratch bool
|
||||
}
|
||||
|
||||
// ErrScratchMemory 临时会话不写记忆:记忆是跨会话的共享层。
|
||||
var ErrScratchMemory = errors.New("memory is not available in scratch sessions")
|
||||
|
||||
// NewStoreMemory 构建端口实现(owner 为空时用默认租户)。
|
||||
func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store {
|
||||
return newStoreMemory(st, owner, projectID, false)
|
||||
}
|
||||
|
||||
func newStoreMemory(st *store.Store, owner, projectID string, checked bool) *storeMemory {
|
||||
if owner == "" {
|
||||
owner = store.OwnerDefault
|
||||
}
|
||||
return &storeMemory{st: st, owner: owner, projectID: projectID}
|
||||
return &storeMemory{st: st, owner: owner, projectID: projectID, checked: checked}
|
||||
}
|
||||
|
||||
func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) memory.Store {
|
||||
return &storeMemory{st: st, owner: owner, projectID: projectID, checked: true, allowImplicitDefault: allowImplicitDefault}
|
||||
func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) *storeMemory {
|
||||
port := newStoreMemory(st, owner, projectID, true)
|
||||
port.allowImplicitDefault = allowImplicitDefault
|
||||
return port
|
||||
}
|
||||
|
||||
// Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义),
|
||||
// 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。
|
||||
func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
|
||||
if m.scratch {
|
||||
return nil, nil
|
||||
}
|
||||
projectID := ""
|
||||
if q.Scope == "project" {
|
||||
projectID = m.projectID
|
||||
@@ -65,14 +81,21 @@ func (c *runCtx) StoreMemory() memory.Store {
|
||||
if c.mgr == nil || c.mgr.st == nil {
|
||||
return nil
|
||||
}
|
||||
var port *storeMemory
|
||||
if c.mgr.accountEffectiveAuthorization != nil {
|
||||
return newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal)
|
||||
port = newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal)
|
||||
} else {
|
||||
port = newStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, false)
|
||||
}
|
||||
return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID)
|
||||
port.scratch = c.scratchProject()
|
||||
return port
|
||||
}
|
||||
|
||||
// Append 写回一条(project scope 用适配器的项目上下文)。
|
||||
func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error {
|
||||
if m.scratch {
|
||||
return ErrScratchMemory
|
||||
}
|
||||
projectID := e.ProjectID
|
||||
if e.Scope == "project" && projectID == "" {
|
||||
projectID = m.projectID
|
||||
|
||||
@@ -31,8 +31,11 @@ func (m *Manager) SetProjectContextSourceRechecker(fn func(context.Context, exte
|
||||
m.projectSourceRechecker = fn
|
||||
}
|
||||
|
||||
// ProjectContextEnabled 报告本回合是否组装项目上下文。临时会话没有 RDM 项目
|
||||
// 上下文(它就是一个通用代理会话),所以即使 provider 已注册也不启用;基座项目
|
||||
// 读不到时按普通项目处理,门禁照旧收紧。
|
||||
func (c *runCtx) ProjectContextEnabled() bool {
|
||||
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil
|
||||
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil && !c.confirmedScratchProject()
|
||||
}
|
||||
|
||||
// ValidateProjectCrossGuard links separate model streams in one persisted run.
|
||||
@@ -127,7 +130,10 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
|
||||
}
|
||||
expectedRefs[ref.ID] = ref
|
||||
}
|
||||
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing) {
|
||||
// Set-wise, not provider-order-wise: a benign reordering of the same
|
||||
// recorded identities is tolerated, while any added, dropped or replaced
|
||||
// entry still fails closed. Order carries no authorization meaning.
|
||||
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !sameMissingSet(current.Missing, m.Missing) {
|
||||
return errors.New("project source recheck returned an incomplete source set")
|
||||
}
|
||||
// Each returned item must account for exactly one recorded input; the hash
|
||||
@@ -180,9 +186,27 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
|
||||
return errors.New("project evidence permission or content changed")
|
||||
}
|
||||
}
|
||||
// Load-bearing fail-closed close: a context cancelled or expired after the
|
||||
// last structural pass above must still reject. Do not relax this to
|
||||
// `return nil`; a late cancellation would otherwise admit machine context
|
||||
// that was never rechecked against live authorization.
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// sameMissingSet compares the rechecked and recorded missing-source lists as
|
||||
// sets. Neither input is mutated; any difference other than order is a
|
||||
// rejection, because a withdrawn or newly missing source changes what the
|
||||
// original model request actually saw.
|
||||
func sameMissingSet(current, recorded []string) bool {
|
||||
if len(current) != len(recorded) {
|
||||
return false
|
||||
}
|
||||
left, right := slices.Clone(current), slices.Clone(recorded)
|
||||
slices.Sort(left)
|
||||
slices.Sort(right)
|
||||
return slices.Equal(left, right)
|
||||
}
|
||||
|
||||
func (c *runCtx) contextRequest() extension.ProjectContextRequest {
|
||||
c.projectContextMu.Lock()
|
||||
defer c.projectContextMu.Unlock()
|
||||
@@ -261,7 +285,10 @@ func (c *runCtx) ProjectEvidence(ctx context.Context, id string) (extension.Proj
|
||||
// ProjectContext reloads identity and project from trusted run and conversation
|
||||
// rows on every model request, including a resumed or background run.
|
||||
func (c *runCtx) ProjectContext(ctx context.Context, goal string, budget int) (extension.ProjectContextBundle, bool, error) {
|
||||
if c.run.ProjectID == "" || c.mgr.projectContextResolver == nil {
|
||||
// ProjectContextEnabled 已经是本回合唯一的项目上下文开关(含临时会话):不在
|
||||
// 这里再调 resolver,否则 llm.go 的 refresh.ProjectContext、GuardProjectStream
|
||||
// 与其它任何调用方都会替临时会话把项目上下文取回来。
|
||||
if c.run.ProjectID == "" || c.mgr.projectContextResolver == nil || !c.ProjectContextEnabled() {
|
||||
return extension.ProjectContextBundle{}, false, nil
|
||||
}
|
||||
req, err := c.trustedProjectContextRequest(ctx, budget)
|
||||
|
||||
@@ -314,6 +314,103 @@ func TestProjectStepOutputRejectsUnsettledAttemptInSameStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectSourceRecheckComparesMissingSourcesAsASet(t *testing.T) {
|
||||
f := newRecheckFixture(t)
|
||||
receipt := f.receipt
|
||||
receipt.Missing = []string{"source-b", "source-a"}
|
||||
// Order carries no authorization meaning, so a reordered list of the same
|
||||
// unresolved sources stays a pass.
|
||||
reordered := f.current
|
||||
reordered.Missing = []string{"source-a", "source-b"}
|
||||
f.answer(reordered)
|
||||
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil {
|
||||
t.Fatalf("reordered missing sources treated as a changed source set: %v", err)
|
||||
}
|
||||
if strings.Join(receipt.Missing, ",") != "source-b,source-a" || strings.Join(reordered.Missing, ",") != "source-a,source-b" {
|
||||
t.Fatalf("missing-source comparison mutated a caller slice: recorded=%q current=%q", receipt.Missing, reordered.Missing)
|
||||
}
|
||||
// Any difference other than order still fails closed.
|
||||
for name, missing := range map[string][]string{
|
||||
"dropped": {"source-a"},
|
||||
"added": {"source-a", "source-b", "source-c"},
|
||||
"replaced": {"source-a", "source-z"},
|
||||
"masked-by-dup": {"source-a", "source-a"},
|
||||
"withdrawn": nil,
|
||||
} {
|
||||
changed := f.current
|
||||
changed.Missing = missing
|
||||
f.answer(changed)
|
||||
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err == nil {
|
||||
t.Fatalf("changed missing-source set %q was accepted", name)
|
||||
}
|
||||
}
|
||||
f.answer(reordered)
|
||||
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil {
|
||||
t.Fatalf("unrelated failure left the authorized baseline broken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectStepOutputRejectsEvidenceRefWithChangedHash(t *testing.T) {
|
||||
st := openRecoveryStore(t)
|
||||
m := newRecoveryManager(t, st)
|
||||
conv := recoveryConversation(t, st)
|
||||
run := recoveryRun(t, st, conv, core.RunPending)
|
||||
m.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
|
||||
ref := extension.ProjectEvidenceRef{ID: "ref-a", SourceID: "doc-a", Revision: "r1", Hash: "hd"}
|
||||
evidenceHash := ref.Hash
|
||||
bundle := func() extension.ProjectContextBundle {
|
||||
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: run.ProjectID, Version: "v1", Watermark: "w1",
|
||||
AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1",
|
||||
Items: []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}},
|
||||
Evidence: []extension.ProjectEvidenceRef{{ID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, Hash: evidenceHash}}}
|
||||
}
|
||||
m.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
|
||||
return bundle(), nil
|
||||
})
|
||||
m.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
||||
return extension.ProjectContextSourceRecheckResult{Bundle: bundle()}, nil
|
||||
})
|
||||
m.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
|
||||
return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil
|
||||
})
|
||||
rc := &runCtx{mgr: m, run: run, ctx: context.Background()}
|
||||
current, _, err := rc.ProjectContext(context.Background(), "draft", 900)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, receipt, err := projectcontext.Assemble(current, 900)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt.InputSHA256, receipt.AttemptID, receipt.WorkflowStepAttemptID = "input-1", "model-1", "engine-run/draft/1"
|
||||
receipt.ReadEvidence = []extension.ProjectContextManifestEntry{{ReferenceID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, SourceHash: ref.Hash,
|
||||
InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte("DETAIL_A")))}}
|
||||
scoped := projectcontext.WithResolveScope(context.Background(), "draft", "")
|
||||
for _, delivery := range []string{"attempted", "responded"} {
|
||||
receipt.Delivery = delivery
|
||||
if err := rc.SaveContextManifest(scoped, receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
output := []byte("AUTHORIZED_A report")
|
||||
if err := rc.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", time.Now().Add(-time.Second), output); err != nil {
|
||||
t.Fatalf("unchanged evidence source rejected at binding: %v", err)
|
||||
}
|
||||
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil {
|
||||
t.Fatalf("unchanged evidence source rejected at the critic boundary: %v", err)
|
||||
}
|
||||
// Same ReferenceID and revision, different source hash: a re-published or
|
||||
// substituted document must not inherit the recorded grant.
|
||||
evidenceHash = "hd2"
|
||||
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil {
|
||||
t.Fatal("same reference id with a changed source hash passed the critic boundary")
|
||||
}
|
||||
evidenceHash = ref.Hash
|
||||
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil {
|
||||
t.Fatalf("restoring the recorded hash did not restore the verdict: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveContextManifestRejectsUnrecheckableModelInput(t *testing.T) {
|
||||
st := openRecoveryStore(t)
|
||||
m := newRecoveryManager(t, st)
|
||||
|
||||
+117
-5
@@ -344,7 +344,7 @@ func (m *Manager) SetProjectWorkGate(preflight projectWorkPreflight, respond pro
|
||||
}
|
||||
|
||||
func (c *runCtx) requireProjectWorkDecision(action string) error {
|
||||
if !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" {
|
||||
if !c.projectWorkObservationApplies() {
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
@@ -408,7 +408,71 @@ func (m *Manager) cancelActiveOwnerRuns(owner string) {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// scratchHostFlag 按存储里的 host 标记判定本次 run 的基座项目是否为临时会话。
|
||||
// ok=false 表示这次没判定成功(读不到项目),此时不缓存答案——run 生命周期内项目
|
||||
// 不变,下次读得到时再判定。两个方向的 fail closed 由调用点各取所需。
|
||||
func (c *runCtx) scratchHostFlag() (host bool, ok bool) {
|
||||
if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" {
|
||||
return false, false
|
||||
}
|
||||
c.scratchMu.Lock()
|
||||
host, known := c.scratchHost, c.scratchKnown
|
||||
c.scratchMu.Unlock()
|
||||
if known {
|
||||
return host, true
|
||||
}
|
||||
host, err := c.mgr.st.IsScratchProjectID(string(c.run.ProjectID))
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
c.scratchMu.Lock()
|
||||
c.scratchHost, c.scratchKnown = host, true
|
||||
c.scratchMu.Unlock()
|
||||
return host, true
|
||||
}
|
||||
|
||||
// scratchProject 报告本次 run 的基座项目是否是临时会话,供隔离门禁使用(记忆、
|
||||
// 上下文条目、检索、跨会话读取、记忆端口)。读不到项目时 fail closed:本次调用按
|
||||
// 临时会话处理且不缓存这个未知答案——这些门禁只收紧,读不到宿主就不能为整个 run
|
||||
// 把它们全部放开。
|
||||
func (c *runCtx) scratchProject() bool {
|
||||
if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" {
|
||||
return false
|
||||
}
|
||||
host, ok := c.scratchHostFlag()
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// confirmedScratchProject 只在存储里读到 host 标记后确认本次 run 是临时会话。
|
||||
// 读不到项目时为假且不缓存——“临时会话”在这个方向上是放宽信号(RDM 观察/预检
|
||||
// 门禁、项目上下文),一次读失败不能为整个 run 把它们放开。
|
||||
func (c *runCtx) confirmedScratchProject() bool {
|
||||
host, ok := c.scratchHostFlag()
|
||||
return ok && host
|
||||
}
|
||||
|
||||
// projectWorkObservationApplies 报告本回合是否走 RDM 观察/预检通道。临时会话
|
||||
// 没有 RDM(provider 从未见过它),观察与预检门都不适用:它就是一次普通聊天。
|
||||
func (c *runCtx) projectWorkObservationApplies() bool {
|
||||
return c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && !c.confirmedScratchProject()
|
||||
}
|
||||
|
||||
// authorizeProjectWork is the run-side project work gate. A scratch project is
|
||||
// never known to the domain provider (RDM would always report false), so the
|
||||
// stored ownership decides: its owner works without the provider, everyone else
|
||||
// is NotFound. Ordinary projects keep the provider decision unchanged.
|
||||
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
|
||||
if m.st != nil {
|
||||
if handled, err := m.st.ScratchProjectDecision(string(id), owner); err != nil {
|
||||
return err
|
||||
} else if handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if m.projectWorkAuthorization == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -531,6 +595,11 @@ type runCtx struct {
|
||||
launch *workflow.Launch
|
||||
files *filestate.Tracker
|
||||
permissionMode string
|
||||
// scratchHost 缓存本次 run 的基座项目是否为临时会话(U2 隔离门禁);
|
||||
// scratchKnown 为 false 表示还没判定成功过(读不到项目),不缓存未知答案。
|
||||
scratchHost bool
|
||||
scratchKnown bool
|
||||
scratchMu sync.Mutex
|
||||
// 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。
|
||||
materialNote string
|
||||
workContext string
|
||||
@@ -807,11 +876,17 @@ func (c *runCtx) Injections() []runtime.ContextInjection {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// 临时会话只带本会话自己的上下文条目:owner 级/项目级条目会把别的会话的
|
||||
// 材料带进来,与“私有会话”相反。
|
||||
scratch := c.scratchProject()
|
||||
out := make([]runtime.ContextInjection, 0, len(list))
|
||||
for _, e := range list {
|
||||
if !e.Enabled {
|
||||
continue
|
||||
}
|
||||
if scratch && e.ConversationID != c.run.ConversationID {
|
||||
continue
|
||||
}
|
||||
out = append(out, runtime.ContextInjection{
|
||||
Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content,
|
||||
Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars,
|
||||
@@ -824,6 +899,11 @@ func (c *runCtx) Memories() []core.MemoryEntry {
|
||||
if c.mgr == nil || c.mgr.st == nil {
|
||||
return nil
|
||||
}
|
||||
// 临时会话不读 user/project 级记忆:记忆是跨会话的,正好是被隔离的那一层。
|
||||
// 会话内状态(SessionNote)不受影响。
|
||||
if c.scratchProject() {
|
||||
return nil
|
||||
}
|
||||
list, err := c.mgr.st.ListMemory(c.run.Owner, "", "")
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -843,7 +923,7 @@ func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
|
||||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) {
|
||||
if c.projectWorkObservationApplies() && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) {
|
||||
// Project reply text is released only through the committed message after
|
||||
// the complete run-source manifest receives a domain risk decision.
|
||||
return nil
|
||||
@@ -1237,8 +1317,13 @@ func indexTextOf(name, mime string, content []byte) string {
|
||||
}
|
||||
|
||||
func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
// 临时会话没有可检索的共享知识:@knowledge 与 knowledge_search 一律空手
|
||||
// 而非报错(会话里的附件仍以材料形式进入本回合)。
|
||||
if c.scratchProject() {
|
||||
return []core.RetrievalHit{}, nil
|
||||
}
|
||||
hits, err := c.retrieveRaw(q)
|
||||
if err != nil || !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" {
|
||||
if err != nil || !c.projectWorkObservationApplies() {
|
||||
return hits, err
|
||||
}
|
||||
for _, hit := range hits {
|
||||
@@ -1364,7 +1449,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if ct.ID != call.Name {
|
||||
continue
|
||||
}
|
||||
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" {
|
||||
if c.projectWorkObservationApplies() {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project command tool awaits trusted source preflight"}
|
||||
}
|
||||
var params map[string]any
|
||||
@@ -1403,7 +1488,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
last := tc.arts[len(tc.arts)-1]
|
||||
res.Artifact = &last
|
||||
}
|
||||
if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" {
|
||||
if c.projectWorkObservationApplies() {
|
||||
if call.CallID == "" {
|
||||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project tool result lacks a stable call ID"}
|
||||
}
|
||||
@@ -1494,7 +1579,11 @@ var (
|
||||
)
|
||||
|
||||
// crossReadSelf 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。
|
||||
// 临时会话直接关闭出站读取:它的材料不流向任何其他会话(U2)。
|
||||
func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
|
||||
if t.rc.scratchProject() {
|
||||
return core.Conversation{}, ErrCrossReadDisabled
|
||||
}
|
||||
if !t.rc.eff.GetBool("crossSession.read", false) {
|
||||
return core.Conversation{}, ErrCrossReadDisabled
|
||||
}
|
||||
@@ -1505,6 +1594,20 @@ func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
|
||||
return self, nil
|
||||
}
|
||||
|
||||
// scratchPeer reports whether a conversation lives in a scratch project. Peers are
|
||||
// grouped by project so the trusted lookup runs once per distinct project.
|
||||
func (t *toolCtx) scratchPeer(c core.Conversation, cache map[core.ID]bool) bool {
|
||||
if seen, ok := cache[c.ProjectID]; ok {
|
||||
return seen
|
||||
}
|
||||
scratch, err := t.rc.mgr.st.IsScratchProjectID(string(c.ProjectID))
|
||||
if err != nil {
|
||||
scratch = false
|
||||
}
|
||||
cache[c.ProjectID] = scratch
|
||||
return scratch
|
||||
}
|
||||
|
||||
func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
|
||||
self, err := t.crossReadSelf()
|
||||
if err != nil {
|
||||
@@ -1517,12 +1620,17 @@ func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scratchProjects := map[core.ID]bool{}
|
||||
out := make([]core.Conversation, 0, len(convs))
|
||||
for _, c := range convs {
|
||||
// 只读他人:排除自身;私有会话不出现(存在性本身是信息)。
|
||||
if c.ID == self.ID || c.CrossPrivate {
|
||||
continue
|
||||
}
|
||||
// 临时会话永不作为同伴出现:它不是共享材料,而是别人的私有会话。
|
||||
if t.scratchPeer(c, scratchProjects) {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
@@ -1548,6 +1656,10 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]
|
||||
if !isSelf && !self.AllowCrossRead {
|
||||
return nil, ErrCrossReadDisabled
|
||||
}
|
||||
// 临时会话的材料不出会话:即使同租户、即使未标私有也拒(自读除外)。
|
||||
if !isSelf && t.scratchPeer(target, map[core.ID]bool{}) {
|
||||
return nil, ErrCrossReadForbidden
|
||||
}
|
||||
if !isSelf && (target.CrossPrivate || target.ProjectID != t.rc.run.ProjectID) {
|
||||
return nil, ErrCrossReadForbidden
|
||||
}
|
||||
|
||||
@@ -217,6 +217,24 @@ func TestResolveHITLSecondRoundKeepsTerminalStatus(t *testing.T) {
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("runner did not reach the second HITL question")
|
||||
}
|
||||
// asked 只保证 runner 已经发出提问,不保证 waiting_hitl 行与卡片都已落库;
|
||||
// 与第 1 轮同一序列:等 run 处于 waiting_hitl 且存在未解决卡片后再读取。
|
||||
waitUntilWithin(t, 10*time.Second, func() bool {
|
||||
got, err := st.GetRunByID(run.ID)
|
||||
if err != nil || got.Status != core.RunWaitingHITL {
|
||||
return false
|
||||
}
|
||||
cards, err := st.ActiveHITLCards(conv.Owner, conv.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, c := range cards {
|
||||
if !c.Resolved {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
second := pendingCard(t, st, conv, 1)
|
||||
if got, err := st.GetRunByID(run.ID); err != nil || got.Status != core.RunWaitingHITL {
|
||||
t.Fatalf("round 2 status=%s err=%v (want waiting_hitl)", got.Status, err)
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/memory"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 临时会话隔离门禁(U2,runmgr 侧)。产品规则:临时会话是“只属于自己的通用代理
|
||||
// 会话”——属主的回合像普通聊天一样跑,但它不读检索、不带 owner/项目级上下文、
|
||||
// 不读不写跨会话记忆、也不与任何会话互相读。
|
||||
|
||||
type scratchRunEnv struct {
|
||||
mgr *Manager
|
||||
st *store.Store
|
||||
svc *settings.Service
|
||||
project core.Project // scratch 宿主项目
|
||||
scratch core.Conversation
|
||||
normal core.Project
|
||||
normalAt core.Conversation // 普通项目的调用方会话
|
||||
peer core.Conversation // 普通项目的同伴会话
|
||||
}
|
||||
|
||||
func newScratchRunEnv(t *testing.T) *scratchRunEnv {
|
||||
t.Helper()
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("bob", "Bob", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &scratchRunEnv{st: st, mgr: &Manager{st: st}, svc: settings.NewService(st)}
|
||||
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.project, env.scratch = project, conversation
|
||||
normal, err := st.CreateProject("alice", "普通项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.normal = normal
|
||||
if env.normalAt, err = st.CreateConversation("alice", normal.ID, "调用方", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.peer, err = st.CreateConversation("alice", normal.ID, "同伴", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := core.Message{ConversationID: env.peer.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "同伴内容"}}}
|
||||
if err := st.AppendMessage(&msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// runCtx 造一次“回合视角”:项目 + 会话 + 属主。
|
||||
func (e *scratchRunEnv) runCtx(projectID, conversationID, owner string) *runCtx {
|
||||
return &runCtx{mgr: e.mgr, eff: e.svc.Effective(owner, projectID),
|
||||
run: core.Run{ID: "run-1", Owner: core.ID(owner), ProjectID: core.ID(projectID), ConversationID: core.ID(conversationID)}}
|
||||
}
|
||||
|
||||
func (e *scratchRunEnv) scratchCtx() *runCtx {
|
||||
return e.runCtx(string(e.project.ID), e.scratch.ID, "alice")
|
||||
}
|
||||
|
||||
func (e *scratchRunEnv) normalCtx() *runCtx {
|
||||
return e.runCtx(string(e.normal.ID), e.normalAt.ID, "alice")
|
||||
}
|
||||
|
||||
// 门禁①:runmgr.authorizeRunWork —— 领域 provider 恒 false 时,scratch 属主照常
|
||||
// 工作(provider 从未见过这个项目),其他人 NotFound;普通项目仍由 provider 决定。
|
||||
func TestScratchRunWorkUsesOwnerInsteadOfDomainProvider(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
asked := 0
|
||||
env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) {
|
||||
asked++
|
||||
return false, nil // RDM 视角:永远没有这个项目
|
||||
})
|
||||
scratchID := core.ID(env.project.ID)
|
||||
|
||||
if err := env.mgr.authorizeRunWork(context.Background(), "alice", scratchID); err != nil {
|
||||
t.Fatalf("scratch owner blocked by the domain provider: %v", err)
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Fatalf("scratch owner work consulted the provider %d times", asked)
|
||||
}
|
||||
if err := env.mgr.authorizeRunWork(context.Background(), "bob", scratchID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("non-owner reached a scratch run: %v", err)
|
||||
}
|
||||
// 负面:普通项目没有被这条门禁放宽。
|
||||
if err := env.mgr.authorizeRunWork(context.Background(), "alice", core.ID(env.normal.ID)); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("normal project bypassed the provider decision: %v", err)
|
||||
}
|
||||
// 正面:provider 放行的普通项目照旧放行。
|
||||
env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
|
||||
if err := env.mgr.authorizeRunWork(context.Background(), "alice", core.ID(env.normal.ID)); err != nil {
|
||||
t.Fatalf("provider-approved normal project blocked: %v", err)
|
||||
}
|
||||
// 无 provider 注册时,非属主仍然进不去 scratch。
|
||||
env.mgr.projectWorkAuthorization = nil
|
||||
if err := env.mgr.authorizeRunWork(context.Background(), "bob", scratchID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("unregistered host opened a scratch run for a non-owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 门禁②:RDM 预检/观察通道不适用于临时会话。
|
||||
func TestScratchRunSkipsDomainPreflightGate(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
preflights := 0
|
||||
env.mgr.SetProjectWorkGate(
|
||||
func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
|
||||
preflights++
|
||||
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "d1"}, nil
|
||||
},
|
||||
func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
|
||||
return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "d1"}, nil
|
||||
},
|
||||
)
|
||||
rc := env.scratchCtx()
|
||||
rc.h = &runHandle{}
|
||||
if err := rc.requireProjectWorkDecision("conversation.run"); err != nil {
|
||||
t.Fatalf("scratch run hit the domain preflight: %v", err)
|
||||
}
|
||||
if preflights != 0 {
|
||||
t.Fatalf("scratch run called the domain preflight %d times", preflights)
|
||||
}
|
||||
if rc.projectWorkObservationApplies() {
|
||||
t.Fatal("scratch run must not enter the RDM observation channel")
|
||||
}
|
||||
}
|
||||
|
||||
// 门禁③:项目上下文(X1)即使 provider 已注册也不装配。
|
||||
func TestScratchProjectContextDisabled(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
resolved := 0
|
||||
env.mgr.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
|
||||
resolved++
|
||||
return extension.ProjectContextBundle{ProjectID: env.project.ID}, nil
|
||||
})
|
||||
if env.scratchCtx().ProjectContextEnabled() {
|
||||
t.Fatal("scratch run assembled project context")
|
||||
}
|
||||
if resolved != 0 {
|
||||
t.Fatalf("scratch run resolved project context %d times", resolved)
|
||||
}
|
||||
// 正面:普通项目照旧启用。
|
||||
if !env.normalCtx().ProjectContextEnabled() {
|
||||
t.Fatal("normal project lost project context")
|
||||
}
|
||||
}
|
||||
|
||||
// 门禁③bis:ProjectContext 本身(llm.go refresh.ProjectContext、GuardProjectStream
|
||||
// 与其它调用方走的同一个入口)在临时会话回合里一次 resolver 都不调;普通项目的
|
||||
// 回合照旧调用。
|
||||
func TestScratchProjectContextNeverReachesResolver(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
|
||||
resolved := 0
|
||||
env.mgr.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
|
||||
resolved++
|
||||
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.ProjectID}, nil
|
||||
})
|
||||
|
||||
bundle, active, err := env.scratchCtx().ProjectContext(context.Background(), "临时会话的目标", 900)
|
||||
if err != nil || active {
|
||||
t.Fatalf("scratch ProjectContext = active:%v err:%v", active, err)
|
||||
}
|
||||
if bundle.Status != "" || bundle.ProjectID != "" || len(bundle.Items) != 0 || len(bundle.Evidence) != 0 {
|
||||
t.Fatalf("scratch ProjectContext returned a bundle: %+v", bundle)
|
||||
}
|
||||
if resolved != 0 {
|
||||
t.Fatalf("scratch run called the project context resolver %d times", resolved)
|
||||
}
|
||||
// ProjectEvidence 同样不得借此拿到材料。
|
||||
if _, err := env.scratchCtx().ProjectEvidence(context.Background(), "ref-a"); err == nil {
|
||||
t.Fatal("scratch run read project evidence")
|
||||
}
|
||||
|
||||
// 正面:普通项目的已持久化 run 照旧装一次上下文。
|
||||
run := core.Run{ConversationID: env.normalAt.ID, ProjectID: env.normal.ID, Owner: env.normalAt.Owner,
|
||||
Kind: core.RunConversation, Status: core.RunPending, Phase: core.PhaseThinking}
|
||||
if err := env.st.CreateRun(&run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalRC := env.runCtx(string(env.normal.ID), string(env.normalAt.ID), string(env.normalAt.Owner))
|
||||
normalRC.run = run
|
||||
if _, active, err := normalRC.ProjectContext(context.Background(), "普通项目的目标", 900); err != nil || !active {
|
||||
t.Fatalf("normal project ProjectContext = active:%v err:%v", active, err)
|
||||
}
|
||||
if resolved != 1 {
|
||||
t.Fatalf("normal run called the project context resolver %d times, want 1", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
// 门禁③ter:基座项目读不到时 fail closed——本次调用按临时会话处理,且不把这个
|
||||
// 未知答案缓存下来(否则一次读失败就会为整个 run 放开记忆、上下文条目与跨会话读取)。
|
||||
func TestScratchProjectFailsClosedOnLookupError(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
rc := env.runCtx("prj-missing", string(env.scratch.ID), "alice")
|
||||
if !rc.scratchProject() {
|
||||
t.Fatal("unreadable host project was treated as a normal project")
|
||||
}
|
||||
// 未缓存未知答案:换成能读到的项目后重新判定(缓存 false 会在这里露馅)。
|
||||
rc.run.ProjectID = env.project.ID
|
||||
if !rc.scratchProject() {
|
||||
t.Fatal("real scratch project was not recognised after a failed lookup")
|
||||
}
|
||||
// 正面:读得到时按存储标记判定,普通项目不受影响。
|
||||
normal := env.runCtx(string(env.normal.ID), string(env.normalAt.ID), "alice")
|
||||
if normal.scratchProject() {
|
||||
t.Fatal("normal project was treated as scratch")
|
||||
}
|
||||
scratch := env.runCtx(string(env.project.ID), string(env.scratch.ID), "alice")
|
||||
if !scratch.scratchProject() {
|
||||
t.Fatal("scratch project was not recognised")
|
||||
}
|
||||
// 无项目 run 不受影响(没有基座项目就没有临时会话可谈)。
|
||||
if env.runCtx("", string(env.scratch.ID), "alice").scratchProject() {
|
||||
t.Fatal("projectless run was treated as scratch")
|
||||
}
|
||||
}
|
||||
|
||||
// 门禁③quater:同一件事(基座项目读不到)在两个方向上都 fail closed。收紧型门禁
|
||||
// (记忆、上下文条目、检索、跨会话读取、记忆端口)按临时会话处理;放宽型门禁(RDM
|
||||
// 观察/预检、项目上下文)按普通项目处理。读到标记之前谁都不能被一次读失败固定下来。
|
||||
func TestScratchGatesFailClosedBothWaysOnLookupError(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
preflights := 0
|
||||
configure := func(mgr *Manager) {
|
||||
mgr.SetProjectWorkGate(
|
||||
func(context.Context, string, string) (extension.ProjectWorkDecision, error) {
|
||||
preflights++
|
||||
return extension.ProjectWorkDecision{}, errors.New("domain preflight unreachable")
|
||||
},
|
||||
func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) {
|
||||
return extension.ProjectWorkDecision{}, errors.New("domain preflight unreachable")
|
||||
},
|
||||
)
|
||||
mgr.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
|
||||
return extension.ProjectContextBundle{}, nil
|
||||
})
|
||||
}
|
||||
configure(env.mgr)
|
||||
|
||||
// 基线:普通项目上,两个方向本来就是收紧(不隔离)/ 放宽门生效(观察+上下文)。
|
||||
before := env.normalCtx()
|
||||
if before.scratchProject() {
|
||||
t.Fatal("normal project was treated as scratch")
|
||||
}
|
||||
if !before.projectWorkObservationApplies() {
|
||||
t.Fatal("normal project lost the RDM observation channel")
|
||||
}
|
||||
if !before.ProjectContextEnabled() {
|
||||
t.Fatal("normal project lost project context")
|
||||
}
|
||||
|
||||
// 关掉存储句柄:项目行没变(仍是普通项目),只是 host 标记读不到了。
|
||||
if err := env.st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := env.normalCtx()
|
||||
if !rc.scratchProject() {
|
||||
t.Fatal("unreadable host project stopped tightening the isolation gates")
|
||||
}
|
||||
if !rc.projectWorkObservationApplies() {
|
||||
t.Fatal("a failed scratch lookup skipped the RDM observation/preflight gate")
|
||||
}
|
||||
if !rc.ProjectContextEnabled() {
|
||||
t.Fatal("a failed scratch lookup disabled project context for a normal project")
|
||||
}
|
||||
// 观察门真的走到领域预检:门没有因为读不到标记而被整体跳过。
|
||||
rc.h = &runHandle{}
|
||||
if err := rc.requireProjectWorkDecision("conversation.run"); err == nil {
|
||||
t.Fatal("project work gate skipped the domain preflight after a failed lookup")
|
||||
}
|
||||
if preflights != 1 {
|
||||
t.Fatalf("domain preflight consulted %d times after a failed lookup, want 1", preflights)
|
||||
}
|
||||
|
||||
// 未缓存未知答案:读失败后在能读到的存储上重新判定,两个方向都只认存储标记。
|
||||
again := newScratchRunEnv(t)
|
||||
configure(again.mgr)
|
||||
probe := again.runCtx("prj-missing", string(again.scratch.ID), "alice")
|
||||
if !probe.scratchProject() || probe.confirmedScratchProject() {
|
||||
t.Fatalf("failed lookup did not split the two directions: isolation=%t relaxing=%t",
|
||||
probe.scratchProject(), probe.confirmedScratchProject())
|
||||
}
|
||||
probe.run.ProjectID = again.project.ID
|
||||
if !probe.scratchProject() || !probe.confirmedScratchProject() {
|
||||
t.Fatal("real scratch project was not recognised after a failed lookup")
|
||||
}
|
||||
normal := again.normalCtx()
|
||||
if normal.scratchProject() || normal.confirmedScratchProject() {
|
||||
t.Fatal("normal project was cached as scratch after a failed lookup")
|
||||
}
|
||||
if !normal.projectWorkObservationApplies() || !normal.ProjectContextEnabled() {
|
||||
t.Fatal("readable normal project lost the observation gate or project context")
|
||||
}
|
||||
// 真实临时会话依旧拿不到这些放宽面。
|
||||
if scratch := again.scratchCtx(); scratch.projectWorkObservationApplies() || scratch.ProjectContextEnabled() {
|
||||
t.Fatal("scratch project gained the RDM observation gate or project context")
|
||||
}
|
||||
}
|
||||
|
||||
// 门禁④:检索为空 —— @knowledge / knowledge_search 在临时会话里拿不到任何东西。
|
||||
func TestScratchRunRetrievalReturnsNoHits(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
docs := []core.KnowledgeDoc{
|
||||
{Owner: "alice", ProjectID: core.ID(env.project.ID), Source: "manual", Title: "临时项目文档", Chunk: "临时会话项目内的文档"},
|
||||
{Owner: "alice", Source: "manual", Title: "属主文档", Chunk: "属主全局文档"},
|
||||
}
|
||||
if err := env.st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := core.RetrievalQuery{Text: "文档", TopK: 10}
|
||||
|
||||
hits, err := env.scratchCtx().Retrieve(query)
|
||||
if err != nil || len(hits) != 0 {
|
||||
t.Fatalf("scratch retrieval must return no hits: %+v %v", hits, err)
|
||||
}
|
||||
// 工具面(toolCtx.Retrieve)同样空手。
|
||||
tc := &toolCtx{rc: env.scratchCtx(), callID: "call-1"}
|
||||
if hits, err := tc.Retrieve(query); err != nil || len(hits) != 0 {
|
||||
t.Fatalf("scratch tool retrieval must return no hits: %+v %v", hits, err)
|
||||
}
|
||||
// 正面:普通项目照旧检索得到(这里是项目文档 + 属主文档)。
|
||||
hits, err = env.normalCtx().Retrieve(query)
|
||||
if err != nil || len(hits) == 0 {
|
||||
t.Fatalf("normal project retrieval lost hits: %+v %v", hits, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 门禁⑤:上下文注入只留本会话条目;记忆不读不写。
|
||||
func TestScratchRunKeepsNoSharedContextAndNoMemory(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
// 三种作用域各一条:属主级、项目级(本临时会话项目)、本会话级。
|
||||
for _, entry := range []store.ContextEntry{
|
||||
{Keys: []string{"owner"}, Content: "属主级上下文", Enabled: true},
|
||||
{Keys: []string{"project"}, ProjectID: env.project.ID, Content: "项目级上下文", Enabled: true},
|
||||
{Keys: []string{"conversation"}, ProjectID: env.project.ID, ConversationID: env.scratch.ID, Content: "本会话上下文", Enabled: true},
|
||||
} {
|
||||
if _, err := env.st.SaveContextEntry("alice", entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got := map[string]string{}
|
||||
for _, inj := range env.scratchCtx().Injections() {
|
||||
for _, key := range inj.Keys {
|
||||
got[key] = inj.Content
|
||||
}
|
||||
}
|
||||
if got["conversation"] != "本会话上下文" {
|
||||
t.Fatalf("scratch run lost its own context entry: %+v", got)
|
||||
}
|
||||
if _, ok := got["owner"]; ok {
|
||||
t.Fatalf("owner-wide context entry leaked into a scratch run: %+v", got)
|
||||
}
|
||||
if _, ok := got["project"]; ok {
|
||||
t.Fatalf("project-wide context entry leaked into a scratch run: %+v", got)
|
||||
}
|
||||
// 正面:普通项目的回合仍然带属主级上下文。
|
||||
normalInj := map[string]bool{}
|
||||
for _, inj := range env.normalCtx().Injections() {
|
||||
for _, key := range inj.Keys {
|
||||
normalInj[key] = true
|
||||
}
|
||||
}
|
||||
if !normalInj["owner"] {
|
||||
t.Fatalf("normal run lost owner-wide context: %+v", normalInj)
|
||||
}
|
||||
|
||||
// 记忆:临时会话既不读也不写 user/project 记忆。
|
||||
if _, err := env.st.PutMemory("alice", core.MemoryEntry{Scope: "user", Type: "note", Content: "属主记忆", Confidence: 0.9}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entries := env.scratchCtx().Memories(); len(entries) != 0 {
|
||||
t.Fatalf("scratch run read user memory: %+v", entries)
|
||||
}
|
||||
if entries := env.normalCtx().Memories(); len(entries) != 1 || entries[0].Content != "属主记忆" {
|
||||
t.Fatalf("normal run lost user memory: %+v", entries)
|
||||
}
|
||||
|
||||
scratchPort := env.scratchCtx().StoreMemory()
|
||||
if entries, err := scratchPort.Recall(context.Background(), memory.RecallQuery{Scope: "user"}); err != nil || len(entries) != 0 {
|
||||
t.Fatalf("scratch memory port recalled: %+v %v", entries, err)
|
||||
}
|
||||
if err := scratchPort.Append(context.Background(), memory.Entry{Scope: "user", Type: "note", Content: "临时记忆"}); !errors.Is(err, ErrScratchMemory) {
|
||||
t.Fatalf("scratch memory port accepted a user-scope write: %v", err)
|
||||
}
|
||||
if err := scratchPort.Append(context.Background(), memory.Entry{Scope: "project", Type: "note", Content: "临时项目记忆"}); !errors.Is(err, ErrScratchMemory) {
|
||||
t.Fatalf("scratch memory port accepted a project-scope write: %v", err)
|
||||
}
|
||||
if entries, err := env.st.ListMemory("alice", "", ""); err != nil || len(entries) != 1 {
|
||||
t.Fatalf("refused writes reached the store: %+v %v", entries, err)
|
||||
}
|
||||
// 正面:普通项目的记忆端口照常读写。
|
||||
normalPort := env.normalCtx().StoreMemory()
|
||||
if err := normalPort.Append(context.Background(), memory.Entry{Scope: "user", Type: "note", Content: "普通记忆"}); err != nil {
|
||||
t.Fatalf("normal memory port write: %v", err)
|
||||
}
|
||||
if entries, err := normalPort.Recall(context.Background(), memory.RecallQuery{Scope: "user"}); err != nil || len(entries) < 2 {
|
||||
t.Fatalf("normal memory port recall: %+v %v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 门禁⑥:跨会话读取 —— 临时会话读不到别人;别人的列表里也没有临时会话。
|
||||
func TestScratchCrossSessionReadIsIsolated(t *testing.T) {
|
||||
env := newScratchRunEnv(t)
|
||||
// 临时会话自己的项目开关:即使显式开闸也不放行。
|
||||
if err := env.svc.MergeProject("alice", string(env.project.ID), map[string]any{"crossSession": map[string]any{"read": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.svc.MergeProject("alice", string(env.normal.ID), map[string]any{"crossSession": map[string]any{"read": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scratchTool := &toolCtx{rc: env.scratchCtx(), callID: "call-1"}
|
||||
if _, err := scratchTool.PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) {
|
||||
t.Fatalf("scratch run listed peers: %v", err)
|
||||
}
|
||||
if _, err := scratchTool.PeerConversationMessages(env.peer.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
|
||||
t.Fatalf("scratch run read a peer: %v", err)
|
||||
}
|
||||
|
||||
normalTool := &toolCtx{rc: env.normalCtx(), callID: "call-2"}
|
||||
convs, err := normalTool.PeerConversations()
|
||||
if err != nil {
|
||||
t.Fatalf("normal peer listing: %v", err)
|
||||
}
|
||||
for _, c := range convs {
|
||||
if c.ID == env.scratch.ID {
|
||||
t.Fatalf("scratch conversation showed up in a normal peer list: %+v", convs)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, c := range convs {
|
||||
if c.ID == env.peer.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("normal peer listing lost the real peer: %+v", convs)
|
||||
}
|
||||
if _, err := normalTool.PeerConversationMessages(env.scratch.ID, 0); !errors.Is(err, ErrCrossReadForbidden) {
|
||||
t.Fatalf("normal run read a scratch conversation: %v", err)
|
||||
}
|
||||
// 正面:普通项目的同伴会话照旧可读。
|
||||
if msgs, err := normalTool.PeerConversationMessages(env.peer.ID, 0); err != nil || len(msgs) != 1 {
|
||||
t.Fatalf("normal peer read: %+v %v", msgs, err)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agent-native/adapters/native"
|
||||
"agent-native/runtime/tools"
|
||||
@@ -25,14 +26,30 @@ type producedSink interface {
|
||||
add(ref *core.ArtifactRef)
|
||||
}
|
||||
|
||||
// artifactList 是本次运行已登记的工件表。宿主工具可能被内核并行调用,
|
||||
// 因此写入与读取都在同一把锁下:并发调用既不丢条目也不读到半成品。
|
||||
type artifactList struct {
|
||||
mu sync.Mutex
|
||||
items []*core.ArtifactRef
|
||||
}
|
||||
|
||||
func (a *artifactList) add(ref *core.ArtifactRef) {
|
||||
if ref != nil {
|
||||
a.items = append(a.items, ref)
|
||||
if ref == nil {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.items = append(a.items, ref)
|
||||
}
|
||||
|
||||
// snapshot 返回当前工件的副本,供终局检查与部件装配使用。
|
||||
func (a *artifactList) snapshot() []*core.ArtifactRef {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return append([]*core.ArtifactRef(nil), a.items...)
|
||||
}
|
||||
|
||||
// bridgeHostTools 把画像允许的产品工具包成内核宿主工具。
|
||||
|
||||
@@ -252,7 +252,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error {
|
||||
if stream == nil {
|
||||
// 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射),
|
||||
// 此处不再显式包装(防双重)。
|
||||
client := openRouteClient(route, eff)
|
||||
client := OpenRouteClient(route, eff)
|
||||
stream = client.Stream
|
||||
}
|
||||
stream = annotateKeyFailure(stream, route)
|
||||
@@ -601,13 +601,6 @@ func openLLMClient(base, key, model string) *agentllm.Client {
|
||||
return c
|
||||
}
|
||||
|
||||
// openRouteClient 按一次解析出的路由构造客户端,并为该路由独立判定 RouteContract。
|
||||
func openRouteClient(route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client {
|
||||
c := openLLMClient(route.Base, route.Key, route.Model)
|
||||
c.RouteContract = routeContractFor(eff, route)
|
||||
return c
|
||||
}
|
||||
|
||||
// routeContractFor 只看本客户端实际解析出的 model 与 base,不看 route.Kind,
|
||||
// 也不读画像:三项系统设置任一为空、模型名不逐字相等、规范化后的端点与配置
|
||||
// 不逐字相等(配置本身须已是规范形态)或身份摘要不符,都返回 nil(关闭)。
|
||||
|
||||
+104
-34
@@ -110,6 +110,13 @@ type toolEvidence struct {
|
||||
VerifyText string // 给验证器的尾部摘要,比界面上的摘要长
|
||||
}
|
||||
|
||||
// toolSlot 是一次在途工具调用的过程状态。
|
||||
//
|
||||
// 并行工具执行下槽位有两个并发访问方:工具 goroutine 回写结果字段,
|
||||
// 内核事件回调(onEvent 的 tool_execution_end)在 toolMu 下读它们装部件。
|
||||
// 字段一律经 directRun.writeSlot 在 toolMu 下访问,直接写即数据竞争。
|
||||
// claimed 只在 toolMu 下访问:并行且无 ctx 注入的调用(宿主 UnknownTool
|
||||
// 直调)按认领取槽位,两个 goroutine 不会写同一个槽位、结果不会互相覆盖。
|
||||
type toolSlot struct {
|
||||
call agentllm.ToolCall
|
||||
start time.Time
|
||||
@@ -117,6 +124,7 @@ type toolSlot struct {
|
||||
verifyText string
|
||||
status string
|
||||
truncated bool
|
||||
claimed bool // 已被某次工具执行认领(无注入路径据此避免撞槽)
|
||||
artifact *core.ArtifactRef
|
||||
}
|
||||
|
||||
@@ -565,9 +573,11 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
|
||||
// 摘要/状态/验证文本在这里补记,保证过程面板与终轮验证器看到同样的证据。
|
||||
func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result) {
|
||||
call, ok := nruntime.ToolCallFromContext(ctx)
|
||||
slot := st.slotFor("")
|
||||
if ok {
|
||||
var slot *toolSlot
|
||||
if ok && call.ID != "" {
|
||||
slot = st.slotFor(call.ID)
|
||||
} else {
|
||||
slot = st.slotFor("")
|
||||
}
|
||||
if slot == nil {
|
||||
return
|
||||
@@ -576,28 +586,74 @@ func (st *directRun) noteKernelResult(ctx context.Context, res agenttools.Result
|
||||
if res.IsError {
|
||||
summary = "error: " + summary
|
||||
}
|
||||
slot.summary = summary
|
||||
slot.status = statusOf(nil)
|
||||
status := statusOf(nil)
|
||||
if res.IsError {
|
||||
slot.status = "error"
|
||||
status = "error"
|
||||
}
|
||||
slot.verifyText = tailRunes(res.Content, 600)
|
||||
verifyText := tailRunes(res.Content, 600)
|
||||
st.writeSlot(slot, func(s *toolSlot) {
|
||||
s.summary = summary
|
||||
s.status = status
|
||||
s.verifyText = verifyText
|
||||
})
|
||||
}
|
||||
|
||||
// slotFor 按调用 ID 取在途槽位;ID 为空且恰有一个槽位时取之(兼容无注入
|
||||
// 的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
|
||||
// writeSlot 在 toolMu 下回写在途槽位:工具 goroutine 写、事件回调读的是同一
|
||||
// 块内存,只有同一把锁才能给出 happens-before。
|
||||
func (st *directRun) writeSlot(slot *toolSlot, apply func(*toolSlot)) {
|
||||
if slot == nil {
|
||||
return
|
||||
}
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
apply(slot)
|
||||
}
|
||||
|
||||
// appendFinalPart 在 toolMu 下追加最终部件:并行工具执行(工件门面卡、卡片
|
||||
// 工具)与事件回调共享同一片 finalParts。
|
||||
func (st *directRun) appendFinalPart(parts ...core.Part) {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
st.finalParts = append(st.finalParts, parts...)
|
||||
}
|
||||
|
||||
// markExplicitCard / cardSuppressed 记「模型已自己发过卡片」:写方是卡片工具
|
||||
// (保序执行,仍与并行工具 goroutine 同时在跑),读方是工件门面卡分支。
|
||||
func (st *directRun) markExplicitCard() {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
st.explicitCard = true
|
||||
}
|
||||
|
||||
func (st *directRun) cardSuppressed() bool {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
return st.explicitCard
|
||||
}
|
||||
|
||||
// slotFor 按调用 ID 取在途槽位;ID 为空时认领一个尚未被认领的槽位(兼容无
|
||||
// 注入的调用路径)。返回的指针仅供本次工具执行内写回,不跨事件持有。
|
||||
func (st *directRun) slotFor(callID string) *toolSlot {
|
||||
st.toolMu.Lock()
|
||||
defer st.toolMu.Unlock()
|
||||
if callID != "" {
|
||||
return st.inflight[callID]
|
||||
slot := st.inflight[callID]
|
||||
if slot != nil {
|
||||
slot.claimed = true
|
||||
}
|
||||
return slot
|
||||
}
|
||||
if len(st.inflight) == 1 {
|
||||
for _, slot := range st.inflight {
|
||||
return slot
|
||||
var free *toolSlot
|
||||
for _, slot := range st.inflight {
|
||||
if !slot.claimed {
|
||||
free = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
if free != nil {
|
||||
free.claimed = true
|
||||
}
|
||||
return free
|
||||
}
|
||||
|
||||
// noteToolDelta 把模型正在编写的工具参数立刻转成会话事件。
|
||||
@@ -725,18 +781,22 @@ func (st *directRun) onEvent(ev nruntime.Event) {
|
||||
st.toolMu.Unlock()
|
||||
return
|
||||
}
|
||||
// 锁内快照结果字段:工具回写(并行工具 goroutine)与本回调分属两个
|
||||
// goroutine,解锁后再读槽位字段即数据竞争,事件只带快照走。
|
||||
call, start := slot.call, slot.start
|
||||
status, summary, truncated := slot.status, slot.summary, slot.truncated
|
||||
end := st.l.now()
|
||||
st.finalParts = append(st.finalParts,
|
||||
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: slot.call.ID, Name: slot.call.Name, Arguments: slot.call.Arguments, Duration: durationMs(slot.start, end)}},
|
||||
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary}},
|
||||
core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: call.ID, Name: call.Name, Arguments: call.Arguments, Duration: durationMs(start, end)}},
|
||||
core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: status, Summary: summary}},
|
||||
)
|
||||
if slot.artifact != nil {
|
||||
st.deliverables = append(st.deliverables, slot.artifact)
|
||||
}
|
||||
st.evidence = append(st.evidence, toolEvidence{Name: slot.call.Name, Status: slot.status, Summary: slot.summary, VerifyText: slot.verifyText})
|
||||
st.evidence = append(st.evidence, toolEvidence{Name: call.Name, Status: status, Summary: summary, VerifyText: slot.verifyText})
|
||||
st.toolMu.Unlock()
|
||||
st.noteFailureOutcome(slot.status)
|
||||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: slot.call.ID, Status: slot.status, Summary: slot.summary, Truncated: slot.truncated, DurationMs: durationMs(slot.start, end)})
|
||||
st.noteFailureOutcome(status)
|
||||
_ = st.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: status, Summary: summary, Truncated: truncated, DurationMs: durationMs(start, end)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,17 +1189,17 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo
|
||||
}
|
||||
}
|
||||
if res.Artifact != nil && slot != nil {
|
||||
slot.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||||
if st.cardflow && res.Truncated && !st.explicitCard {
|
||||
st.writeSlot(slot, func(s *toolSlot) {
|
||||
s.artifact = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
|
||||
})
|
||||
if st.cardflow && res.Truncated && !st.cardSuppressed() {
|
||||
fc, ferr := st.rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil)))
|
||||
if ferr == nil {
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
||||
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}})
|
||||
}
|
||||
}
|
||||
}
|
||||
if slot != nil {
|
||||
slot.truncated = res.Truncated
|
||||
}
|
||||
st.writeSlot(slot, func(s *toolSlot) { s.truncated = res.Truncated })
|
||||
}
|
||||
|
||||
summary := summarize(out, nil)
|
||||
@@ -1148,11 +1208,12 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo
|
||||
summary = "error: " + err.Error()
|
||||
out = summary
|
||||
}
|
||||
if slot != nil {
|
||||
slot.summary = summary
|
||||
slot.status = status
|
||||
slot.verifyText = tailRunes(out, 600)
|
||||
}
|
||||
verifyText := tailRunes(out, 600)
|
||||
st.writeSlot(slot, func(s *toolSlot) {
|
||||
s.summary = summary
|
||||
s.status = status
|
||||
s.verifyText = verifyText
|
||||
})
|
||||
if r := []rune(out); len(r) > 16000 {
|
||||
out = string(r[:16000]) + "…(超长截断)"
|
||||
}
|
||||
@@ -1257,8 +1318,8 @@ func (st *directRun) submitCard(args string) string {
|
||||
if cerr != nil {
|
||||
return "submit_card 失败: " + cerr.Error()
|
||||
}
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||
st.explicitCard = true
|
||||
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||
st.markExplicitCard()
|
||||
where := "对话"
|
||||
if a.Scope == "project" {
|
||||
where = "项目"
|
||||
@@ -1311,18 +1372,21 @@ func (st *directRun) commitCard() string {
|
||||
if cerr != nil {
|
||||
return "card_commit 失败: " + cerr.Error()
|
||||
}
|
||||
st.finalParts = append(st.finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||
st.explicitCard = true
|
||||
st.appendFinalPart(core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}})
|
||||
st.markExplicitCard()
|
||||
return fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID)
|
||||
}
|
||||
|
||||
func (st *directRun) pinSubagentArtifacts(text string) {
|
||||
st.toolMu.Lock()
|
||||
seen := map[string]bool{}
|
||||
for _, ref := range st.deliverables {
|
||||
if ref != nil {
|
||||
seen[ref.ArtifactID] = true
|
||||
}
|
||||
}
|
||||
st.toolMu.Unlock()
|
||||
pinned := make([]*core.ArtifactRef, 0, 4)
|
||||
for _, id := range gates.ArtifactIDPattern.FindAllString(text, -1) {
|
||||
if seen[id] {
|
||||
continue
|
||||
@@ -1332,8 +1396,14 @@ func (st *directRun) pinSubagentArtifacts(text string) {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
st.deliverables = append(st.deliverables, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
|
||||
pinned = append(pinned, &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind})
|
||||
}
|
||||
if len(pinned) == 0 {
|
||||
return
|
||||
}
|
||||
st.toolMu.Lock()
|
||||
st.deliverables = append(st.deliverables, pinned...)
|
||||
st.toolMu.Unlock()
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
|
||||
@@ -27,6 +27,7 @@ type fakeRunCtx struct {
|
||||
cardflow bool
|
||||
events []emittedEvent
|
||||
eventsMu sync.Mutex
|
||||
stateMu sync.Mutex // 并行工具执行下会写回的假状态(计数/卡片/用量/后台)
|
||||
assistant []core.Part
|
||||
cards []core.ActivityCard
|
||||
streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义)
|
||||
@@ -80,8 +81,15 @@ func (f *fakeRunCtx) emittedEvents() []emittedEvent {
|
||||
defer f.eventsMu.Unlock()
|
||||
return append([]emittedEvent(nil), f.events...)
|
||||
}
|
||||
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil }
|
||||
func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
f.assistant = parts
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRunCtx) AddUsage(u core.Usage) {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
f.usageTotal.PromptTokens += u.PromptTokens
|
||||
f.usageTotal.CompletionTokens += u.CompletionTokens
|
||||
f.usageTotal.TotalTokens += u.TotalTokens
|
||||
@@ -113,6 +121,8 @@ func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (cor
|
||||
}
|
||||
func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
|
||||
func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
f.toolRuns++
|
||||
res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated}
|
||||
if f.execStatus != "" {
|
||||
@@ -133,6 +143,8 @@ func (f *fakeRunCtx) History() []core.Message {
|
||||
return []core.Message{{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}}
|
||||
}
|
||||
func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID, error) {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
f.bgCalls++
|
||||
if f.bgErr != nil {
|
||||
return "", f.bgErr
|
||||
@@ -174,6 +186,14 @@ func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return f.attachments
|
||||
func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil }
|
||||
func (f *fakeRunCtx) Subtask() string { return "" }
|
||||
func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
return f.createCardLocked(card)
|
||||
}
|
||||
|
||||
// createCardLocked 建卡(调用方须持有 stateMu);卡片工具与工件门面卡
|
||||
// 可能从并行工具 goroutine 同时进来。
|
||||
func (f *fakeRunCtx) createCardLocked(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
card.ID = core.ID(fmt.Sprintf("card_%d", len(f.cards)+1))
|
||||
f.cards = append(f.cards, card)
|
||||
return card, nil
|
||||
@@ -199,7 +219,9 @@ func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, erro
|
||||
// runmgr/cardsvc 语义,供工具编排层断言。
|
||||
func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||||
card.Status = core.CardActive
|
||||
created, err := f.CreateCard(card)
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
created, err := f.createCardLocked(card)
|
||||
if err != nil {
|
||||
return created, err
|
||||
}
|
||||
@@ -208,6 +230,8 @@ func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard,
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) AppendStreamCard(text string) error {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
if f.streamCardID == "" {
|
||||
return errors.New("card: no active stream card (card_begin first)")
|
||||
}
|
||||
@@ -226,6 +250,8 @@ func (f *fakeRunCtx) AppendStreamCard(text string) error {
|
||||
}
|
||||
|
||||
func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
if f.streamCardID == "" {
|
||||
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
|
||||
}
|
||||
@@ -240,6 +266,8 @@ func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||||
return core.ActivityCard{}, errors.New("card: not found")
|
||||
}
|
||||
func (f *fakeRunCtx) Card(id string) *core.ActivityCard {
|
||||
f.stateMu.Lock()
|
||||
defer f.stateMu.Unlock()
|
||||
for i := range f.cards {
|
||||
if string(f.cards[i].ID) == id {
|
||||
return &f.cards[i]
|
||||
|
||||
@@ -90,3 +90,134 @@ func TestResolveVisionModel(t *testing.T) {
|
||||
t.Fatalf("text fallbacks %#v", fb)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有 Settings 端口的宿主:快照可取,没有就报缺失(调用方自己决定回落)。
|
||||
type settingsOnlyCtx struct{ eff core.EffectiveSettings }
|
||||
|
||||
func (c settingsOnlyCtx) Settings() core.EffectiveSettings { return c.eff }
|
||||
|
||||
// 只有 Run 端口的宿主:属主只能来自 run。
|
||||
type runOnlyCtx struct{ owner string }
|
||||
|
||||
func (c runOnlyCtx) Run() core.Run { return core.Run{ID: "run_t", Owner: core.ID(c.owner)} }
|
||||
|
||||
// 两个端口都有的宿主(runmgr 的 runCtx 形态)。
|
||||
type runProfileCtx struct {
|
||||
settingsOnlyCtx
|
||||
runOnlyCtx
|
||||
prof core.AgentProfile
|
||||
}
|
||||
|
||||
func (c runProfileCtx) Profile() core.AgentProfile { return c.prof }
|
||||
|
||||
func TestRunEffectiveSettingsNeedsHostPort(t *testing.T) {
|
||||
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "m"}}})
|
||||
if got, ok := RunEffectiveSettings(settingsOnlyCtx{eff: eff}); !ok || got.GetString("models.chat.model", "") != "m" {
|
||||
t.Fatalf("run snapshot = %+v ok=%v, want the host snapshot", got, ok)
|
||||
}
|
||||
if _, ok := RunEffectiveSettings(settingsOnlyCtx{}); ok {
|
||||
t.Fatal("empty snapshot must be reported as missing so the caller can fall back")
|
||||
}
|
||||
if _, ok := RunEffectiveSettings(runOnlyCtx{owner: "alice"}); ok {
|
||||
t.Fatal("host without a Settings port must not be reported as having one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProfileNeverFallsBackToDefaultOwner(t *testing.T) {
|
||||
prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "alice-model"}}
|
||||
both := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "alice"}, prof: prof}
|
||||
if got := RunProfile(both); got.Owner != "alice" || got.Model.Chat != "alice-model" {
|
||||
t.Fatalf("profile port must win: %+v", got)
|
||||
}
|
||||
if got := RunProfile(both).Owner; got != "alice" {
|
||||
t.Fatalf("owner = %q, want alice", got)
|
||||
}
|
||||
// 画像端口缺席(或没带 Owner)时取 run.Owner——密钥层归属不能退化成默认账号。
|
||||
bare := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}}
|
||||
if got := RunProfile(bare).Owner; got != "bob" {
|
||||
t.Fatalf("owner = %q, want the run owner bob", got)
|
||||
}
|
||||
if got := RunProfile(runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}, prof: core.AgentProfile{}}).Owner; got != "bob" {
|
||||
t.Fatalf("empty profile owner = %q, want the run owner bob", got)
|
||||
}
|
||||
if got := RunProfile(runOnlyCtx{}).Owner; got != "" {
|
||||
t.Fatalf("owner without any port = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 带自定义快照的运行上下文(其余端口由 fakeRunCtx 满足)。
|
||||
type runSnapCtx struct {
|
||||
*fakeRunCtx
|
||||
eff core.EffectiveSettings
|
||||
}
|
||||
|
||||
func (c *runSnapCtx) Settings() core.EffectiveSettings { return c.eff }
|
||||
|
||||
// 运行内解析主路由:快照 + 画像覆盖 + 属主密钥层,与直连同一条规则。
|
||||
func TestRunChatRouteUsesRunSnapshotAndOwnerKey(t *testing.T) {
|
||||
saved := SecretLookup
|
||||
defer func() { SecretLookup = saved }()
|
||||
secrets := map[string]string{"alice": "sk-alice-synthetic", "bob": "sk-bob-synthetic"}
|
||||
SecretLookup = func(owner, _ string) (string, string, bool) {
|
||||
if owner == "" {
|
||||
owner = "local"
|
||||
}
|
||||
v, ok := secrets[owner]
|
||||
return v, "owner", ok
|
||||
}
|
||||
eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{
|
||||
"model": "settings-model", "baseUrl": "https://shared.example/v1", "apiKey": "sk-settings-synthetic",
|
||||
}}})
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
rc := &runSnapCtx{fakeRunCtx: &fakeRunCtx{prof: core.AgentProfile{Owner: owner, Model: core.ModelRoute{Chat: owner + "-profile-model"}}}, eff: eff}
|
||||
got := RunChatRoute(rc)
|
||||
want := ResolveChatModel(rc.Profile(), rc.Settings())
|
||||
if got != want {
|
||||
t.Fatalf("%s: run route %+v != direct rule %+v", owner, got, want)
|
||||
}
|
||||
if got.Model != owner+"-profile-model" || got.Base != "https://shared.example/v1" {
|
||||
t.Fatalf("%s: route = %+v, want the profile model on the shared endpoint", owner, got)
|
||||
}
|
||||
if got.Key != "sk-"+owner+"-synthetic" || got.KeySource != "owner" {
|
||||
t.Fatalf("%s: key = %q (%s), want that owner's own secret", owner, got.Key, got.KeySource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端构造:reasoning split 与受限 EOF 契约都只看本路由自己的 model+base,
|
||||
// 画像覆盖后的路由重新判定(与直连路径同一个构造器)。
|
||||
func TestOpenRouteClientDecidesPerRoute(t *testing.T) {
|
||||
const base = "https://api.example.com/v1"
|
||||
contract := routeEOFMatching(routeEOFModel, base)
|
||||
eff := routeEOFEff(map[string]any{"model": routeEOFModel, "baseUrl": base, "apiKey": "k"}, contract)
|
||||
|
||||
route := ResolvedModel{Kind: "chat", Model: routeEOFModel, Base: base, Key: "k"}
|
||||
client := OpenRouteClient(route, eff)
|
||||
if client.BaseURL != base || client.APIKey != "k" {
|
||||
t.Fatalf("client route = %q/%q, want %q/k", client.BaseURL, client.APIKey, base)
|
||||
}
|
||||
if client.ReasoningSplit {
|
||||
t.Fatal("non-MiniMax model must not enable reasoning split")
|
||||
}
|
||||
if client.RouteContract == nil {
|
||||
t.Fatal("matching configuration must attach the contract")
|
||||
}
|
||||
// 画像覆盖改的是模型名:分流与契约都按覆盖后的路由重新判定。
|
||||
for _, tc := range []struct {
|
||||
model string
|
||||
wantSplit bool
|
||||
wantEOfRule bool
|
||||
}{
|
||||
{"MiniMax-M2", true, false},
|
||||
{routeEOFModel, false, true},
|
||||
{"other-model", false, false},
|
||||
} {
|
||||
prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: tc.model}}
|
||||
overridden := ResolveChatModel(prof, eff)
|
||||
got := OpenRouteClient(overridden, eff)
|
||||
if got.ReasoningSplit != tc.wantSplit || (got.RouteContract != nil) != tc.wantEOfRule {
|
||||
t.Fatalf("profile model %q: split=%v contract=%v, want split=%v contract=%v",
|
||||
tc.model, got.ReasoningSplit, got.RouteContract != nil, tc.wantSplit, tc.wantEOfRule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func routeEOFMatching(model, base string) map[string]any {
|
||||
|
||||
func TestRouteContractDefaultOff(t *testing.T) {
|
||||
route := ResolvedModel{Kind: "chat", Model: routeEOFModel, Base: "https://api.example.com/v1", Key: "k"}
|
||||
if c := openRouteClient(route, core.EffectiveSettings{}); c.RouteContract != nil {
|
||||
if c := OpenRouteClient(route, core.EffectiveSettings{}); c.RouteContract != nil {
|
||||
t.Fatalf("no settings must leave RouteContract nil, got %+v", c.RouteContract)
|
||||
}
|
||||
full := routeEOFMatching(routeEOFModel, "https://api.example.com/v1")
|
||||
@@ -206,7 +206,7 @@ func routeEOFHTTPClient(t *testing.T, server *httptest.Server) *http.Client {
|
||||
|
||||
func routeEOFClient(t *testing.T, server *httptest.Server, route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client {
|
||||
t.Helper()
|
||||
c := openRouteClient(route, eff)
|
||||
c := OpenRouteClient(route, eff)
|
||||
c.HTTPClient = routeEOFHTTPClient(t, server)
|
||||
return c
|
||||
}
|
||||
@@ -373,3 +373,65 @@ func TestRouteContractEOFReleasesOneToolCallInLoop(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 小队路径的路由来自同一份运行快照:契约对得上的路由与直连一样放行一次
|
||||
// 工具调用;画像把模型名改掉之后,契约不再跟随,放行也一并消失。
|
||||
func TestRouteContractEOFForRunScopedRoute(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := routeEOFServer(t, &requests)
|
||||
base := server.URL + "/v1"
|
||||
models := map[string]any{
|
||||
"chat": map[string]any{"model": routeEOFModel, "baseUrl": base, "apiKey": "k"},
|
||||
"routeContractEOF": routeEOFMatching(routeEOFModel, base),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
decls := []agentllm.ToolDecl{{Name: routeEOFTool, Description: "report", Parameters: routeEOFParams}}
|
||||
|
||||
run := func(t *testing.T, prof core.AgentProfile) (*agentllm.Client, []agentllm.Event) {
|
||||
t.Helper()
|
||||
rc := &runSnapCtx{
|
||||
fakeRunCtx: &fakeRunCtx{ctx: ctx, prof: prof},
|
||||
eff: core.EffectiveSettings{"models": models},
|
||||
}
|
||||
route := RunChatRoute(rc)
|
||||
client := routeEOFClient(t, server, route, rc.Settings())
|
||||
stream := GuardProjectStream(rc, "", annotateKeyFailure(client.Stream, route))
|
||||
var terminal []agentllm.Event
|
||||
for ev := range stream(ctx, route.Model, []agentllm.Message{{Role: agentllm.RoleUser, Content: "hi"}}, decls, agentllm.StreamOptions{}) {
|
||||
if ev.Kind == agentllm.KindMessageEnd || ev.Kind == agentllm.KindError {
|
||||
terminal = append(terminal, ev)
|
||||
}
|
||||
}
|
||||
return client, terminal
|
||||
}
|
||||
|
||||
t.Run("route matching the configuration releases one call", func(t *testing.T) {
|
||||
client, terminal := run(t, core.AgentProfile{Owner: "alice"})
|
||||
if client.RouteContract == nil {
|
||||
t.Fatal("run-scoped route must carry the contract, as on the direct path")
|
||||
}
|
||||
if len(terminal) != 1 || terminal[0].Kind != agentllm.KindMessageEnd || terminal[0].Err != nil {
|
||||
t.Fatalf("terminal events = %+v, want one clean message_end", terminal)
|
||||
}
|
||||
if len(terminal[0].Message.ToolCalls) != 1 || terminal[0].Message.ToolCalls[0].Name != routeEOFTool {
|
||||
t.Fatalf("released calls = %+v, want exactly one %s", terminal[0].Message.ToolCalls, routeEOFTool)
|
||||
}
|
||||
if terminal[0].CompletionBasis != agentllm.CompletionRouteContractEOF {
|
||||
t.Fatalf("completion basis = %q, want %q", terminal[0].CompletionBasis, agentllm.CompletionRouteContractEOF)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profile override drops the contract and the release", func(t *testing.T) {
|
||||
client, terminal := run(t, core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "profile-model"}})
|
||||
if client.RouteContract != nil {
|
||||
t.Fatalf("overridden route kept the contract: %+v", client.RouteContract)
|
||||
}
|
||||
if len(terminal) != 1 || terminal[0].Err == nil || !strings.Contains(terminal[0].Err.Error(), routeEOFRejection) {
|
||||
t.Fatalf("terminal events = %+v, want the %q rejection", terminal, routeEOFRejection)
|
||||
}
|
||||
if len(terminal[0].Message.ToolCalls) != 0 {
|
||||
t.Fatalf("tool call released without the contract: %+v", terminal[0].Message.ToolCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 运行配置解析的窄端口。直连 RunCtx 与小队 squadadapter.Ctx 共用同一组
|
||||
// 可选接口:任一执行面都能从「本次运行」拿到同一份生效快照,绝不按默认账号
|
||||
// 另起一套解析(KTD3)。
|
||||
|
||||
type runSettingsPort interface{ Settings() core.EffectiveSettings }
|
||||
type runProfilePort interface{ Profile() core.AgentProfile }
|
||||
type runScopePort interface{ Run() core.Run }
|
||||
|
||||
// RunEffectiveSettings 返回本次运行生效的配置快照(Settings 端口已由宿主按
|
||||
// run.Owner + run.ProjectID 合并 defaults→system→user→project)。
|
||||
// ok=false 表示宿主没有该端口或快照为空;调用方自行决定回落策略,
|
||||
// 不得在有快照时回落到别的属主。
|
||||
func RunEffectiveSettings(rc any) (core.EffectiveSettings, bool) {
|
||||
port, ok := rc.(runSettingsPort)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
eff := port.Settings()
|
||||
if len(eff) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return eff, true
|
||||
}
|
||||
|
||||
// RunProfile 返回本次运行的画像:宿主实现 Profile 端口时直接取;否则按
|
||||
// run.Owner 合成零值画像——Owner 决定密钥层归属(overlayKey),缺失时
|
||||
// 绝不能退化成默认账号,否则会用别人的密钥。
|
||||
func RunProfile(rc any) core.AgentProfile {
|
||||
var prof core.AgentProfile
|
||||
if port, ok := rc.(runProfilePort); ok {
|
||||
prof = port.Profile()
|
||||
}
|
||||
if strings.TrimSpace(prof.Owner) == "" {
|
||||
if scope, ok := rc.(runScopePort); ok {
|
||||
prof.Owner = scope.Run().Owner
|
||||
}
|
||||
}
|
||||
return prof
|
||||
}
|
||||
|
||||
// RunChatRoute 解析本次运行的主对话路由(画像 ModelRoute > 当前用户/项目设置层)。
|
||||
// SquadRunner 用它给小队入口定模型,使小队与直连共用同一条解析规则。
|
||||
func RunChatRoute(rc RunCtx) ResolvedModel { return ResolveChatModel(rc.Profile(), rc.Settings()) }
|
||||
|
||||
// OpenRouteClient 按一次解析出的路由构造 OpenAI 兼容客户端:端点与密钥取自
|
||||
// 本路由自己的解析结果,MiniMax 系模型的 reasoning split 与受限 EOF route
|
||||
// contract 都只看本路由的 model+base。直连、小队与其他执行面必须共用它,
|
||||
// 避免同一个 run 里出现两套 per-route 规则(分流的思考、EOF 放行会不一致)。
|
||||
func OpenRouteClient(route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client {
|
||||
c := openLLMClient(route.Base, route.Key, route.Model)
|
||||
c.RouteContract = routeContractFor(eff, route)
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agent-squad/squad"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// toolResultSummaryLen 是工具结果摘要的截断长度(与既有展示一致)。
|
||||
const toolResultSummaryLen = 160
|
||||
|
||||
// toolEventID 是宿主侧的工具调用身份:优先用上游 CallID(并行同名工具据此配对,
|
||||
// 而不是按到达顺序);空串 = legacy/未知,退回工具名,保持既有展示不变。
|
||||
func toolEventID(ev squad.ProcessEvent) string {
|
||||
if id := strings.TrimSpace(ev.CallID); id != "" {
|
||||
return id
|
||||
}
|
||||
return ev.Tool
|
||||
}
|
||||
|
||||
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的真实结局。
|
||||
//
|
||||
// 取值域与上游一致(ok | error | denied | canceled),不新增、不改写上游状态:
|
||||
// - Status 非空:原样透传;
|
||||
// - Status 为空且没有失败信号(ErrorReason 也空):legacy/未知,沿用旧的 "ok"
|
||||
// 与 Detail 摘要(字节一致,不臆造结局);
|
||||
// - Status 为空但有 ErrorReason:有失败信号而状态未知,报 error 而不是 ok;
|
||||
// - 非 ok 时摘要取 ErrorReason 原文(截断),上游没给原因才回退 Detail。
|
||||
func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) {
|
||||
status = strings.TrimSpace(ev.Status)
|
||||
if status == "" {
|
||||
if strings.TrimSpace(ev.ErrorReason) == "" {
|
||||
return squad.ToolStatusOK, truncateRun(ev.Detail, toolResultSummaryLen)
|
||||
}
|
||||
status = squad.ToolStatusError
|
||||
}
|
||||
if status == squad.ToolStatusOK {
|
||||
return status, truncateRun(ev.Detail, toolResultSummaryLen)
|
||||
}
|
||||
if strings.TrimSpace(ev.ErrorReason) != "" {
|
||||
return status, truncateRun(ev.ErrorReason, toolResultSummaryLen)
|
||||
}
|
||||
return status, truncateRun(ev.Detail, toolResultSummaryLen)
|
||||
}
|
||||
|
||||
// toolFailure 是一次未被恢复的工具失败(用于终局文本一致性检查,不覆盖工具部件)。
|
||||
type toolFailure struct {
|
||||
Tool string
|
||||
CallID string
|
||||
Status string
|
||||
Summary string
|
||||
}
|
||||
|
||||
// squadEventLog 是小队过程帧在宿主侧的聚合点。
|
||||
//
|
||||
// 并行工具调用会从多个 goroutine 回调进来(内核 executeParallel 逐个派发),
|
||||
// 因此流式文本、工具部件、用量与 pending 配对表全部在同一把锁下更新:
|
||||
// 既不丢条目,也不会把 A 调用的 start 与 B 调用的 end 交错写坏。
|
||||
// 事件发射与部件追加在同一临界区内,保持到达顺序与最终部件顺序一致。
|
||||
type squadEventLog struct {
|
||||
rc RunCtx
|
||||
|
||||
mu sync.Mutex
|
||||
streamed strings.Builder
|
||||
textIdx int
|
||||
parts []core.Part
|
||||
pending map[string]string // callID → 工具名(start/end 配对)
|
||||
failure *toolFailure // 最近一次失败;同名工具后续再起即视为已恢复
|
||||
}
|
||||
|
||||
// newSquadEventLog 绑定一次运行的过程帧回调。
|
||||
func newSquadEventLog(rc RunCtx) *squadEventLog {
|
||||
return &squadEventLog{rc: rc, pending: map[string]string{}}
|
||||
}
|
||||
|
||||
// onEvent 是小队过程帧回调(并发安全)。
|
||||
func (l *squadEventLog) onEvent(ev squad.ProcessEvent) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
switch ev.Type {
|
||||
case "text_delta":
|
||||
if ev.Text == "" {
|
||||
return
|
||||
}
|
||||
l.streamed.WriteString(ev.Text)
|
||||
_ = l.rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: l.textIdx, Text: ev.Text})
|
||||
case "reasoning_delta":
|
||||
if ev.Text != "" {
|
||||
_ = l.rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text})
|
||||
}
|
||||
case "tool_execution_start":
|
||||
id := toolEventID(ev)
|
||||
if id != "" {
|
||||
l.pending[id] = ev.Tool
|
||||
}
|
||||
l.noteRecovered(ev.Tool)
|
||||
_ = l.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: id, Name: ev.Tool, Arguments: ev.Detail})
|
||||
l.parts = append(l.parts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: id, Name: ev.Tool, Arguments: ev.Detail}})
|
||||
case "tool_execution_end":
|
||||
id := toolEventID(ev)
|
||||
if tool, ok := l.pending[id]; ok && ev.Tool == "" {
|
||||
ev.Tool = tool
|
||||
}
|
||||
delete(l.pending, id)
|
||||
status, summary := toolEventOutcome(ev)
|
||||
// 发射与部件同源:前端过程行与落库部件说的是同一件事。
|
||||
_ = l.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: id, Status: status, Summary: summary})
|
||||
l.parts = append(l.parts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: id, Status: status, Summary: summary}})
|
||||
if status == squad.ToolStatusOK {
|
||||
l.noteRecovered(ev.Tool)
|
||||
} else {
|
||||
l.failure = &toolFailure{Tool: ev.Tool, CallID: id, Status: status, Summary: summary}
|
||||
}
|
||||
case "usage":
|
||||
if sink, ok := l.rc.(interface{ AddUsage(core.Usage) }); ok {
|
||||
sink.AddUsage(core.Usage{
|
||||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// noteRecovered 把同名工具的后续进展(再次发起或调用成功)记为已恢复:
|
||||
// 失败部件仍留在过程部件里,只是不再让收尾文本替它宣布成功。
|
||||
func (l *squadEventLog) noteRecovered(tool string) {
|
||||
if l.failure != nil && l.failure.Tool == tool {
|
||||
l.failure = nil
|
||||
}
|
||||
}
|
||||
|
||||
// reply 是本次运行聚合到的模型文本(不含任何宿主补写)。
|
||||
func (l *squadEventLog) reply() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.streamed.String()
|
||||
}
|
||||
|
||||
// parts 返回过程部件的快照(复制,避免把内部切片交给最终部件)。
|
||||
func (l *squadEventLog) toolParts() []core.Part {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]core.Part(nil), l.parts...)
|
||||
}
|
||||
|
||||
// unresolvedFailure 返回终局时仍未恢复的工具失败,没有则 nil。
|
||||
func (l *squadEventLog) unresolvedFailure() *toolFailure {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.failure == nil {
|
||||
return nil
|
||||
}
|
||||
f := *l.failure
|
||||
return &f
|
||||
}
|
||||
|
||||
// finalReply 决定终局文本。模型文本原样保留(不代写、不改写);只有宿主兜底的
|
||||
// 「已完成。」在仍有未恢复的工具失败时会被换成如实说明,避免收尾文本与失败部件
|
||||
// 自相矛盾。legacy 事件(无 CallID/Status)走不到这里,展示与旧行为一致。
|
||||
func (l *squadEventLog) finalReply(modelText, summary, fallback string) string {
|
||||
reply := strings.TrimSpace(modelText)
|
||||
if reply == "" {
|
||||
reply = strings.TrimSpace(summary)
|
||||
}
|
||||
if reply != "" {
|
||||
return reply
|
||||
}
|
||||
if f := l.unresolvedFailure(); f != nil {
|
||||
return failedToolLine(f)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// failedToolLine 如实描述未恢复的工具失败,不声称成功,也不编造原因。
|
||||
func failedToolLine(f *toolFailure) string {
|
||||
name := strings.TrimSpace(f.Tool)
|
||||
if name == "" {
|
||||
name = "工具"
|
||||
}
|
||||
reason := strings.TrimSpace(f.Summary)
|
||||
if reason == "" {
|
||||
return name + " 未完成(" + f.Status + ")"
|
||||
}
|
||||
return name + " 未完成(" + f.Status + "):" + reason
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
agentllm "agent-native/runtime/llm"
|
||||
"agent-squad/squad"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// eventProbe 记录小队过程帧回调发射出去的事件(顺序与内容都要能断言)。
|
||||
// Emit 由 squadEventLog 的临界区内调用,因此这里无需再自锁。
|
||||
type eventProbe struct {
|
||||
*entryFake
|
||||
rec *squadEventLog
|
||||
toolCall []core.ToolCallPayload
|
||||
result []core.ToolResultPayload
|
||||
}
|
||||
|
||||
func newEventProbe() *eventProbe {
|
||||
p := &eventProbe{entryFake: &entryFake{}}
|
||||
p.rec = newSquadEventLog(p)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *eventProbe) Emit(typ core.StreamEventType, payload any) error {
|
||||
switch v := payload.(type) {
|
||||
case core.ToolCallPayload:
|
||||
p.toolCall = append(p.toolCall, v)
|
||||
case core.ToolResultPayload:
|
||||
p.result = append(p.result, v)
|
||||
}
|
||||
return p.entryFake.Emit(typ, payload)
|
||||
}
|
||||
|
||||
func (p *eventProbe) onEvent(ev squad.ProcessEvent) { p.rec.onEvent(ev) }
|
||||
|
||||
func (p *eventProbe) parts() []core.Part { return p.rec.toolParts() }
|
||||
|
||||
func (p *eventProbe) calls() []core.ToolCallPayload {
|
||||
return append([]core.ToolCallPayload(nil), p.toolCall...)
|
||||
}
|
||||
|
||||
func (p *eventProbe) results() []core.ToolResultPayload {
|
||||
return append([]core.ToolResultPayload(nil), p.result...)
|
||||
}
|
||||
|
||||
func toolCallsOf(parts []core.Part) []core.ToolCallPart {
|
||||
var out []core.ToolCallPart
|
||||
for _, p := range parts {
|
||||
if p.ToolCall != nil {
|
||||
out = append(out, *p.ToolCall)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toolResultsOf(parts []core.Part) []core.ToolResultPart {
|
||||
var out []core.ToolResultPart
|
||||
for _, p := range parts {
|
||||
if p.ToolResult != nil {
|
||||
out = append(out, *p.ToolResult)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSquadEventsPairByCallID:两个同名工具的 start/end 必须按 CallID 配对,
|
||||
// 不得因名字相同而互相对调(这是并行同名工具显示错乱的根因)。
|
||||
func TestSquadEventsPairByCallID(t *testing.T) {
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_a", Detail: `{"q":"a"}`})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: "call_b", Detail: `{"q":"b"}`})
|
||||
// 交错到达:B 先结束。
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_b", Status: squad.ToolStatusOK, Detail: "命中 B"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "call_a", Status: squad.ToolStatusError, ErrorReason: "路径不存在"})
|
||||
|
||||
parts := p.parts()
|
||||
calls := toolCallsOf(parts)
|
||||
if len(calls) != 2 || calls[0].ID != "call_a" || calls[1].ID != "call_b" {
|
||||
t.Fatalf("tool call parts = %+v", calls)
|
||||
}
|
||||
emittedCalls := p.calls()
|
||||
if len(emittedCalls) != 2 || emittedCalls[0].CallID != "call_a" || emittedCalls[1].CallID != "call_b" {
|
||||
t.Fatalf("emitted tool calls = %+v", emittedCalls)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("tool result parts = %+v", results)
|
||||
}
|
||||
if results[0].CallID != "call_b" || results[0].Status != squad.ToolStatusOK || results[0].Summary != "命中 B" {
|
||||
t.Fatalf("result B = %+v", results[0])
|
||||
}
|
||||
if results[1].CallID != "call_a" || results[1].Status != squad.ToolStatusError || results[1].Summary != "路径不存在" {
|
||||
t.Fatalf("result A = %+v", results[1])
|
||||
}
|
||||
if p.rec.unresolvedFailure() == nil || p.rec.unresolvedFailure().CallID != "call_a" {
|
||||
t.Fatalf("unresolved failure = %+v", p.rec.unresolvedFailure())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsStatusIsTruthful:失败/取消/拒绝必须原样透传,不得一律报 ok,
|
||||
// 摘要取真实原因(截断),部件与发射载荷完全一致。
|
||||
func TestSquadEventsStatusIsTruthful(t *testing.T) {
|
||||
long := strings.Repeat("失败原因", 80)
|
||||
cases := []struct {
|
||||
name string
|
||||
ev squad.ProcessEvent
|
||||
status string
|
||||
summary string
|
||||
}{
|
||||
{"error", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c1",
|
||||
Status: squad.ToolStatusError, ErrorReason: "权限不足", Detail: "旧摘要"}, squad.ToolStatusError, "权限不足"},
|
||||
{"denied", squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
|
||||
Status: squad.ToolStatusDenied, ErrorReason: "策略拒绝执行"}, squad.ToolStatusDenied, "策略拒绝执行"},
|
||||
{"canceled", squad.ProcessEvent{Type: "tool_execution_end", Tool: "web_search", CallID: "c3",
|
||||
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"}, squad.ToolStatusCanceled, "用户取消"},
|
||||
{"ok", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c4",
|
||||
Status: squad.ToolStatusOK, Detail: "命中 3 处"}, squad.ToolStatusOK, "命中 3 处"},
|
||||
// 有失败信号但状态未知:不得报 ok。
|
||||
{"status-less error signal", squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: "c5",
|
||||
ErrorReason: "连接被拒绝", Detail: "旧摘要"}, squad.ToolStatusError, "连接被拒绝"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
status, summary := toolEventOutcome(tc.ev)
|
||||
if status != tc.status || summary != tc.summary {
|
||||
t.Fatalf("outcome = %q/%q, want %q/%q", status, summary, tc.status, tc.summary)
|
||||
}
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: tc.ev.Tool, CallID: tc.ev.CallID})
|
||||
p.onEvent(tc.ev)
|
||||
got := toolResultsOf(p.parts())
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("parts = %+v", p.parts())
|
||||
}
|
||||
// 落库部件与发射载荷同源。
|
||||
if got[0].Status != tc.status || got[0].Summary != tc.summary || got[0].CallID != tc.ev.CallID {
|
||||
t.Fatalf("result part = %+v", got[0])
|
||||
}
|
||||
wantPayload := core.ToolResultPayload{CallID: tc.ev.CallID, Status: tc.status, Summary: tc.summary}
|
||||
if gotPayloads := p.results(); len(gotPayloads) != 1 || gotPayloads[0] != wantPayload {
|
||||
t.Fatalf("emitted payload = %+v", gotPayloads)
|
||||
}
|
||||
if gotCalls := p.calls(); len(gotCalls) != 1 || gotCalls[0].CallID != tc.ev.CallID || gotCalls[0].Name != tc.ev.Tool {
|
||||
t.Fatalf("emitted tool call = %+v", gotCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("summary truncated", func(t *testing.T) {
|
||||
status, summary := toolEventOutcome(squad.ProcessEvent{Status: squad.ToolStatusError, ErrorReason: long})
|
||||
if status != squad.ToolStatusError {
|
||||
t.Fatalf("status = %q", status)
|
||||
}
|
||||
if want := truncateRun(long, toolResultSummaryLen); summary != want {
|
||||
t.Fatalf("summary = %q, want truncated %q", summary, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSquadEventsLegacyFramesUnchanged:字段全空的 legacy 事件必须与旧行为字节一致
|
||||
// (按工具名配对、状态恒为 ok、摘要取 Detail)。
|
||||
func TestSquadEventsLegacyFramesUnchanged(t *testing.T) {
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Detail: strings.Repeat("d", 200)})
|
||||
p.onEvent(squad.ProcessEvent{Type: "text_delta", Text: "结论"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "usage", PromptTokens: 3, CompletionTokens: 1, LastPromptTokens: 2, CacheReadTokens: 1})
|
||||
|
||||
parts := p.parts()
|
||||
calls := toolCallsOf(parts)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls = %+v", calls)
|
||||
}
|
||||
wantCall := core.ToolCallPart{ID: "grep", Name: "grep", Arguments: `{"q":"x"}`}
|
||||
if calls[0] != wantCall {
|
||||
t.Fatalf("call part = %+v, want %+v", calls[0], wantCall)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
wantResult := core.ToolResultPart{CallID: "grep", Status: "ok", Summary: truncateRun(strings.Repeat("d", 200), 160)}
|
||||
if len(results) != 1 || results[0] != wantResult {
|
||||
t.Fatalf("result part = %+v, want %+v", results, wantResult)
|
||||
}
|
||||
emitted := p.results()
|
||||
if len(emitted) != 1 || emitted[0].CallID != "grep" || emitted[0].Status != "ok" ||
|
||||
emitted[0].Summary != truncateRun(strings.Repeat("d", 200), 160) {
|
||||
t.Fatalf("emitted = %+v", emitted)
|
||||
}
|
||||
if p.rec.reply() != "结论" {
|
||||
t.Fatalf("reply = %q", p.rec.reply())
|
||||
}
|
||||
if f := p.rec.unresolvedFailure(); f != nil {
|
||||
t.Fatalf("legacy frames must not register a failure: %+v", f)
|
||||
}
|
||||
if p.rec.finalReply("", "", "已完成。") != "已完成。" {
|
||||
t.Fatalf("legacy fallback text changed: %q", p.rec.finalReply("", "", "已完成。"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsConcurrentCallbacksKeepEveryEntry:并行回调(-race)下不得丢条目、
|
||||
// 不得串字段;每次调用自己的 start/end 仍成对。
|
||||
func TestSquadEventsConcurrentCallbacksKeepEveryEntry(t *testing.T) {
|
||||
const calls = 64
|
||||
p := newEventProbe()
|
||||
wantStatus := map[string]string{}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < calls; i++ {
|
||||
id := fmt.Sprintf("call_%02d", i)
|
||||
wantStatus[id] = squad.ToolStatusOK
|
||||
failing := i%8 == 0
|
||||
if failing {
|
||||
wantStatus[id] = squad.ToolStatusError
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "grep", CallID: id, Detail: id})
|
||||
if failing {
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
|
||||
Status: squad.ToolStatusError, ErrorReason: "boom " + id})
|
||||
return
|
||||
}
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "grep", CallID: id,
|
||||
Status: squad.ToolStatusOK, Detail: "ok " + id})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
parts := p.parts()
|
||||
if got := len(toolCallsOf(parts)); got != calls {
|
||||
t.Fatalf("tool call parts = %d, want %d", got, calls)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
if len(results) != calls {
|
||||
t.Fatalf("tool result parts = %d, want %d", len(results), calls)
|
||||
}
|
||||
seenStart := map[string]bool{}
|
||||
for _, c := range toolCallsOf(parts) {
|
||||
if seenStart[c.ID] {
|
||||
t.Fatalf("duplicate start for %s", c.ID)
|
||||
}
|
||||
seenStart[c.ID] = true
|
||||
}
|
||||
emitted := map[string]string{}
|
||||
for _, r := range results {
|
||||
if _, dup := emitted[r.CallID]; dup {
|
||||
t.Fatalf("duplicate end for %s", r.CallID)
|
||||
}
|
||||
emitted[r.CallID] = r.Status
|
||||
if !seenStart[r.CallID] {
|
||||
t.Fatalf("end without start: %s", r.CallID)
|
||||
}
|
||||
}
|
||||
for id, status := range emitted {
|
||||
if status != wantStatus[id] {
|
||||
t.Fatalf("end %s status = %q, want %q", id, status, wantStatus[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsConcurrentToolCallbacksShareArtifactList:宿主工具被并行调用时,
|
||||
// 登记的工件一条都不能丢,snapshot 也不应把内部切片直接交出去。
|
||||
func TestSquadEventsConcurrentToolCallbacksShareArtifactList(t *testing.T) {
|
||||
const n = 128
|
||||
produced := &artifactList{}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
produced.add(&core.ArtifactRef{ArtifactID: core.ID(fmt.Sprintf("art_%03d", i)), Name: "纪要.docx", Kind: "document"})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
got := produced.snapshot()
|
||||
if len(got) != n {
|
||||
t.Fatalf("artifacts = %d, want %d", len(got), n)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, ref := range got {
|
||||
ids[string(ref.ArtifactID)] = true
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if !ids[fmt.Sprintf("art_%03d", i)] {
|
||||
t.Fatalf("missing artifact %d", i)
|
||||
}
|
||||
}
|
||||
got[0] = nil // 快照必须是副本:改动不得回写内部表
|
||||
if produced.snapshot()[0] == nil {
|
||||
t.Fatal("snapshot aliases the internal list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSquadEventsFinalTextMatchesToolOutcome:未恢复的失败不得被收尾文本改写成成功;
|
||||
// 同名工具重试成功后收尾文本恢复旧行为。
|
||||
func TestSquadEventsFinalTextMatchesToolOutcome(t *testing.T) {
|
||||
p := newEventProbe()
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
|
||||
p.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
|
||||
Status: squad.ToolStatusCanceled, ErrorReason: "用户取消"})
|
||||
if got := p.rec.finalReply("", "", "已完成。"); got == "已完成。" || !strings.Contains(got, "run_command") || !strings.Contains(got, "用户取消") {
|
||||
t.Fatalf("fallback text = %q", got)
|
||||
}
|
||||
if got := p.rec.finalReply("已按你的要求处理。", "", "已完成。"); got != "已按你的要求处理。" {
|
||||
t.Fatalf("model text must be preserved: %q", got)
|
||||
}
|
||||
// 失败部件必须留在最终部件里,不被收尾文本覆盖。
|
||||
if results := toolResultsOf(p.parts()); len(results) != 1 || results[0].Status != squad.ToolStatusCanceled {
|
||||
t.Fatalf("failure part dropped: %+v", results)
|
||||
}
|
||||
|
||||
// 同名工具重试成功 → 失败记为已恢复,兜底文本回到旧行为。
|
||||
q := newEventProbe()
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c1"})
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c1",
|
||||
Status: squad.ToolStatusError, ErrorReason: "超时"})
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_start", Tool: "run_command", CallID: "c2"})
|
||||
q.onEvent(squad.ProcessEvent{Type: "tool_execution_end", Tool: "run_command", CallID: "c2",
|
||||
Status: squad.ToolStatusOK, Detail: "done"})
|
||||
if f := q.rec.unresolvedFailure(); f != nil {
|
||||
t.Fatalf("retry should clear the failure: %+v", f)
|
||||
}
|
||||
if got := q.rec.finalReply("", "", "已完成。"); got != "已完成。" {
|
||||
t.Fatalf("recovered run fallback = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// entryProbe 在真实运行桩上记录工具事件载荷,并让指定工具真的失败,
|
||||
// 用来端到端验证「工具结局 → 过程事件 → 落库部件」这条链。
|
||||
type entryProbe struct {
|
||||
*entryFake
|
||||
mu sync.Mutex
|
||||
calls []core.ToolCallPayload
|
||||
results []core.ToolResultPayload
|
||||
}
|
||||
|
||||
func (p *entryProbe) Emit(typ core.StreamEventType, payload any) error {
|
||||
switch v := payload.(type) {
|
||||
case core.ToolCallPayload:
|
||||
p.mu.Lock()
|
||||
p.calls = append(p.calls, v)
|
||||
p.mu.Unlock()
|
||||
case core.ToolResultPayload:
|
||||
p.mu.Lock()
|
||||
p.results = append(p.results, v)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
return p.entryFake.Emit(typ, payload)
|
||||
}
|
||||
|
||||
func (p *entryProbe) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||||
if call.Name == "knowledge_search" {
|
||||
return core.ToolCallResult{Status: "error", Output: "索引不可用"}
|
||||
}
|
||||
return p.entryFake.ExecuteTool(call)
|
||||
}
|
||||
|
||||
func (p *entryProbe) resultPayloads() []core.ToolResultPayload {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]core.ToolResultPayload(nil), p.results...)
|
||||
}
|
||||
|
||||
// TestSquadEntryFailedToolReachesFinalParts:真实跑一次小队入口,同名并行工具
|
||||
// 必须按各自 CallID 落部件,失败必须显示为失败,且不被收尾文本改写成成功。
|
||||
func TestSquadEntryFailedToolReachesFinalParts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := baseEntry(dir)
|
||||
rc := &entryProbe{entryFake: base}
|
||||
rc.tools = []core.ToolDesc{{Name: "knowledge_search", Description: "查资料", Parameters: json.RawMessage(`{"type":"object"}`)}}
|
||||
|
||||
runner := &SquadRunner{
|
||||
Router: fixedRoute{mode: squad.RouteDirect}, Checker: squad.MinimalChecker{},
|
||||
Hitl: squad.HitlPolicy{Mode: squad.HitlDisabled},
|
||||
Stream: scriptedCalls(t, nil, [][]agentllm.Event{
|
||||
{{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
|
||||
ToolCalls: []agentllm.ToolCall{
|
||||
{ID: "call_k1", Name: "knowledge_search", Arguments: `{"q":"a"}`},
|
||||
{ID: "call_k2", Name: "knowledge_search", Arguments: `{"q":"b"}`},
|
||||
}}}},
|
||||
{{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, StopReason: agentllm.StopToolCalls,
|
||||
ToolCalls: []agentllm.ToolCall{{ID: "w", Name: "write_file", Arguments: mustDocArgs()}}}}},
|
||||
{{Kind: agentllm.KindTextDelta, Text: "写完了。"},
|
||||
{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{Role: agentllm.RoleAssistant, Content: "写完了。", StopReason: agentllm.StopEnd}}},
|
||||
}),
|
||||
}
|
||||
if err := runner.Execute(rc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := base.parts
|
||||
calls := toolCallsOf(parts)
|
||||
ids := map[string]bool{}
|
||||
for _, c := range calls {
|
||||
if c.Name != "knowledge_search" {
|
||||
continue
|
||||
}
|
||||
if c.ID == "knowledge_search" || ids[c.ID] {
|
||||
t.Fatalf("tool call part lost its call id: %+v (all %+v)", c, calls)
|
||||
}
|
||||
ids[c.ID] = true
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("distinct call ids = %v", ids)
|
||||
}
|
||||
results := toolResultsOf(parts)
|
||||
search := map[string]core.ToolResultPart{}
|
||||
for _, r := range results {
|
||||
if ids[r.CallID] {
|
||||
search[r.CallID] = r
|
||||
}
|
||||
}
|
||||
if len(search) != 2 {
|
||||
t.Fatalf("tool results for knowledge_search = %+v", results)
|
||||
}
|
||||
for id, r := range search {
|
||||
if r.Status != squad.ToolStatusError {
|
||||
t.Fatalf("%s status = %q, want error", id, r.Status)
|
||||
}
|
||||
if r.Summary != "索引不可用" {
|
||||
t.Fatalf("%s summary = %q", id, r.Summary)
|
||||
}
|
||||
}
|
||||
// 发射载荷与落库部件一致:前端过程行不得说 ok。
|
||||
emitted := map[string]string{}
|
||||
for _, pl := range rc.resultPayloads() {
|
||||
if ids[pl.CallID] {
|
||||
emitted[pl.CallID] = pl.Status
|
||||
}
|
||||
}
|
||||
for id, r := range search {
|
||||
if emitted[id] != r.Status {
|
||||
t.Fatalf("emitted %s status = %q, part status = %q", id, emitted[id], r.Status)
|
||||
}
|
||||
}
|
||||
// 收尾文本保留模型原话,失败部件不被覆盖。
|
||||
if last := parts[len(parts)-1]; last.Type != core.PartText || last.Text != "写完了。" {
|
||||
t.Fatalf("final part = %+v", last)
|
||||
}
|
||||
}
|
||||
@@ -30,37 +30,8 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
prof := rc.Profile()
|
||||
maxTurns, maxTools := loopCaps(prof.Budget)
|
||||
produced := &artifactList{}
|
||||
var streamed strings.Builder
|
||||
var toolParts []core.Part
|
||||
textIdx := 0
|
||||
onEvent := func(ev squad.ProcessEvent) {
|
||||
switch ev.Type {
|
||||
case "text_delta":
|
||||
if ev.Text == "" {
|
||||
return
|
||||
}
|
||||
streamed.WriteString(ev.Text)
|
||||
_ = rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: ev.Text})
|
||||
case "reasoning_delta":
|
||||
if ev.Text != "" {
|
||||
_ = rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text})
|
||||
}
|
||||
case "tool_execution_start":
|
||||
_ = rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail})
|
||||
toolParts = append(toolParts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: ev.Tool, Name: ev.Tool, Arguments: ev.Detail}})
|
||||
case "tool_execution_end":
|
||||
_ = rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)})
|
||||
toolParts = append(toolParts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: ev.Tool, Status: "ok", Summary: truncateRun(ev.Detail, 160)}})
|
||||
case "usage":
|
||||
if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok {
|
||||
sink.AddUsage(core.Usage{
|
||||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 过程帧可能来自并行的工具回调:聚合全部走 squadEventLog(内部互斥)。
|
||||
evlog := newSquadEventLog(rc)
|
||||
router := s.Router
|
||||
if router == nil {
|
||||
router = newModelRouter(rc)
|
||||
@@ -73,14 +44,16 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
DataDir: rc.Settings().GetString("storage.dataDir", "./data"),
|
||||
Goal: entryGoal(rc),
|
||||
HostTools: bridgeHostTools(rc, produced),
|
||||
OnEvent: onEvent,
|
||||
OnEvent: evlog.onEvent,
|
||||
Stream: stream,
|
||||
Router: router,
|
||||
Checker: s.Checker,
|
||||
Hitl: s.Hitl,
|
||||
MaxTurns: maxTurns,
|
||||
MaxToolCalls: maxTools,
|
||||
Model: ResolveChatModel(prof, rc.Settings()).Model,
|
||||
// 模型走本次运行的生效配置(当前用户/项目 + 画像覆盖),与直连同一
|
||||
// 解析规则:内核工厂用同一快照配端点/密钥,绝不与默认账号混搭。
|
||||
Model: RunChatRoute(rc).Model,
|
||||
})
|
||||
if rc.Context().Err() != nil {
|
||||
return rc.Context().Err()
|
||||
@@ -88,14 +61,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply := strings.TrimSpace(streamed.String())
|
||||
if reply == "" {
|
||||
reply = strings.TrimSpace(res.Summary)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = "已完成。"
|
||||
}
|
||||
if claimsFalseDeliverable(reply, produced.items, rc) {
|
||||
// 收尾文本:模型文本原样保留;仍有未恢复的工具失败时不用「已完成。」兜底。
|
||||
reply := evlog.finalReply(evlog.reply(), res.Summary, "已完成。")
|
||||
if claimsFalseDeliverable(reply, produced.snapshot(), rc) {
|
||||
return fmt.Errorf("声称已生成文件,但没有登记对应工件")
|
||||
}
|
||||
if strings.TrimSpace(res.Body) != "" {
|
||||
@@ -103,9 +71,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
parts := append([]core.Part{}, toolParts...)
|
||||
parts := evlog.toolParts()
|
||||
parts = append(parts, core.Part{Type: core.PartText, Text: reply})
|
||||
for _, ref := range produced.items {
|
||||
for _, ref := range produced.snapshot() {
|
||||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: ref})
|
||||
}
|
||||
return rc.SetAssistantParts(parts)
|
||||
@@ -143,7 +111,7 @@ func newModelRouter(rc RunCtx) modelRouter {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return modelRouter{}
|
||||
}
|
||||
client := openRouteClient(route, rc.Settings())
|
||||
client := OpenRouteClient(route, rc.Settings())
|
||||
return modelRouter{stream: GuardProjectStream(rc, "", client.Stream), model: route.Model}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base)
|
||||
}
|
||||
client := openRouteClient(route, rc.Settings())
|
||||
client := OpenRouteClient(route, rc.Settings())
|
||||
stream = client.Stream
|
||||
}
|
||||
stream = GuardProjectStream(rc, subagentUserText(rc), stream)
|
||||
|
||||
@@ -58,7 +58,7 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence,
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return gates.Verdict{}, fmt.Errorf("验证器调用失败")
|
||||
}
|
||||
client := openRouteClient(route, rc.Settings())
|
||||
client := OpenRouteClient(route, rc.Settings())
|
||||
// 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。
|
||||
stream = agentllm.WithFallback(
|
||||
agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil),
|
||||
|
||||
@@ -81,7 +81,7 @@ func judgeStreamFor(rc RunCtx) agentllm.StreamFunc {
|
||||
if route.Model == "" || route.Base == "" {
|
||||
return nil
|
||||
}
|
||||
client := openRouteClient(route, rc.Settings())
|
||||
client := OpenRouteClient(route, rc.Settings())
|
||||
model := route.Model
|
||||
base := func(ctx context.Context, _ string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
|
||||
return client.Stream(ctx, model, msgs, tools, opts)
|
||||
|
||||
@@ -946,7 +946,7 @@ func TestStaticServed(t *testing.T) {
|
||||
_ = io.Discard
|
||||
}
|
||||
|
||||
// TestHITLIntentPivot:装载插件意图后,自由输入命中其他意图 → 转轨(agent-intent 闸门)。
|
||||
// TestHITLIntentPivot:装载插件意图后,自由输入命中其他意图 → 转轨(intentgate 闸门)。
|
||||
func TestHITLIntentPivot(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package server
|
||||
|
||||
// 意图接纳(F09):把「一次识别」落到「一次命令派发」之前,
|
||||
// 按命令 DECLARED schema 完成参数映射与准入判定:
|
||||
// - 只映射命令声明过的槽位键,并按声明类型转换(string/number/integer/boolean);
|
||||
// 未声明的键一律丢弃——不把任意字符串复制进 params。
|
||||
// - 声明的必填参数无法由「原文 + 已识别槽位」满足时不派发:不把拒绝留给
|
||||
// 总线层的 schema 校验(那会变成一条让人困惑的 "params: missing required
|
||||
// param x"),而是转成澄清诉求,与路由层的槽位缺口同一出口。
|
||||
//
|
||||
// 确认语义(高危命令的确认卡)完全由 cmdbus 收口,本文件不参与。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/route"
|
||||
)
|
||||
|
||||
// chatTextParam 是 chat 入口恒定提供的原始文本参数名(既有文本处理器契约)。
|
||||
const chatTextParam = "text"
|
||||
|
||||
// intentAdmission 是一次「意图命中 → 命令派发」的接纳结果。
|
||||
type intentAdmission struct {
|
||||
// Params 是派发参数:恒含 text,其余按声明 schema 映射。
|
||||
Params map[string]any
|
||||
// Blocked 表示准入未过(业务动作不得派发)。
|
||||
Blocked bool
|
||||
// Reason 是准入未过的原因(诊断用;面向用户的说法走澄清诉求)。
|
||||
Reason string
|
||||
// MissingParams 是声明要求但槽位无法满足的参数名(按声明序)。
|
||||
MissingParams []string
|
||||
}
|
||||
|
||||
// admitIntentCommand 按命令声明的 schema 把已识别槽位映射成派发参数。
|
||||
// 声明不可解析(命令不存在/未启用)时按 fail-closed 返回 Blocked。
|
||||
func admitIntentCommand(bus *cmdbus.Bus, commandID string, slots map[string]string, text string) intentAdmission {
|
||||
if bus == nil {
|
||||
return intentAdmission{Blocked: true, Reason: "命令总线不可用:" + commandID}
|
||||
}
|
||||
spec, _, err := bus.Registry().Resolve(commandID)
|
||||
if err != nil {
|
||||
return intentAdmission{Blocked: true, Reason: "命令不可解析:" + commandID}
|
||||
}
|
||||
params, missing := mapSchemaParams(spec.ParamsSchema, slots)
|
||||
params[chatTextParam] = text
|
||||
if len(missing) > 0 {
|
||||
return intentAdmission{Params: params, Blocked: true,
|
||||
Reason: "命令必填参数未满足:" + strings.Join(missing, "、"),
|
||||
MissingParams: missing}
|
||||
}
|
||||
return intentAdmission{Params: params}
|
||||
}
|
||||
|
||||
// mapSchemaParams 把槽位按 JSON Schema(draft-07 子集,与总线侧校验同款)
|
||||
// 投影成参数。只认声明过的键,并按声明类型转换;转换不成的不写参数
|
||||
// (数组/对象等非标量声明也不会收到字符串)。必填参数里无法满足的按键名返回。
|
||||
func mapSchemaParams(schema json.RawMessage, slots map[string]string) (map[string]any, []string) {
|
||||
params := map[string]any{}
|
||||
props, required, ok := objectSchema(schema)
|
||||
if !ok {
|
||||
// 没有可用声明:不复制任何槽位(fail-closed),只保留原文入口参数。
|
||||
return params, nil
|
||||
}
|
||||
keys := make([]string, 0, len(slots))
|
||||
for k := range slots {
|
||||
if _, declared := props[k]; declared {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if v, ok := castParam(props[k], slots[k]); ok {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
var missing []string
|
||||
for _, k := range required {
|
||||
if k == chatTextParam {
|
||||
continue // 原文入口恒供
|
||||
}
|
||||
if _, ok := params[k]; !ok {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
return params, missing
|
||||
}
|
||||
|
||||
// objectSchema 解析命令的 paramsSchema;非对象/非法 schema 返回 ok=false。
|
||||
func objectSchema(schema json.RawMessage) (props map[string]any, required []string, ok bool) {
|
||||
if len(schema) == 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
var sc struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(schema, &sc); err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
if sc.Type != "" && sc.Type != "object" {
|
||||
return nil, nil, false
|
||||
}
|
||||
if sc.Properties == nil {
|
||||
sc.Properties = map[string]any{}
|
||||
}
|
||||
return sc.Properties, sc.Required, true
|
||||
}
|
||||
|
||||
// castParam 按声明类型把槽位字符串转成参数值;类型不支持或转换失败返回 false。
|
||||
func castParam(decl any, raw string) (any, bool) {
|
||||
m, _ := decl.(map[string]any)
|
||||
want, _ := m["type"].(string)
|
||||
switch want {
|
||||
case "", "string":
|
||||
return raw, true
|
||||
case "number":
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return f, true
|
||||
case "integer":
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return n, true
|
||||
case "boolean":
|
||||
b, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
default:
|
||||
// array/object 等非标量声明:拒绝把字符串塞进去。
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// clarifyPayload 是回给调用方的澄清诉求(postMessage 响应里的 clarify 字段)。
|
||||
// 既有端点上的附加字段,不新增 API 面:缺失与非法各自带 key/原因/建议提问。
|
||||
type clarifyPayload struct {
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Question string `json:"question,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Missing []intentgate.SlotQuestion `json:"missing,omitempty"`
|
||||
Invalid []intentgate.SlotDefect `json:"invalid,omitempty"`
|
||||
}
|
||||
|
||||
// newClarifyPayload 把路由裁决与准入结果合成一条澄清诉求;无需澄清时返回 nil。
|
||||
func newClarifyPayload(res intentgate.MatchResult, dec route.Decision, adm *intentAdmission) *clarifyPayload {
|
||||
if !dec.Clarification.Needed && (adm == nil || !adm.Blocked) {
|
||||
return nil
|
||||
}
|
||||
c := dec.Clarification
|
||||
out := &clarifyPayload{
|
||||
Intent: res.ID,
|
||||
Command: firstNonEmpty(c.Command, dec.CommandID),
|
||||
Question: strings.TrimSpace(c.Question),
|
||||
Missing: append([]intentgate.SlotQuestion(nil), c.Missing...),
|
||||
Invalid: append([]intentgate.SlotDefect(nil), c.Invalid...),
|
||||
}
|
||||
if adm != nil && adm.Blocked {
|
||||
out.Reason = adm.Reason
|
||||
names := make([]string, 0, len(adm.MissingParams))
|
||||
for _, name := range adm.MissingParams {
|
||||
if slotQuestionKnown(out.Missing, name) {
|
||||
continue
|
||||
}
|
||||
out.Missing = append(out.Missing, intentgate.SlotQuestion{Key: name, Question: name})
|
||||
names = append(names, name)
|
||||
}
|
||||
if out.Question == "" && len(names) > 0 {
|
||||
out.Question = "请补充:" + strings.Join(names, "、")
|
||||
}
|
||||
}
|
||||
if out.Question == "" {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slotQuestionKnown(list []intentgate.SlotQuestion, key string) bool {
|
||||
for _, q := range list {
|
||||
if q.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
)
|
||||
|
||||
// F09 意图接纳:槽位缺失/非法一律不派发业务动作;合法槽位按命令 DECLARED
|
||||
// schema 映射后进处理器;高危命令的确认语义完全由 cmdbus 收口(此处不动)。
|
||||
|
||||
// reportIntent 声明一个带枚举校验与多个可选槽位的意图(合成数据)。
|
||||
func reportIntent() json.RawMessage {
|
||||
return json.RawMessage(`{"id":"demo.report","domain":"demo","version":1,
|
||||
"matcher":{"includes_any":["生成报告"]},
|
||||
"slots":[
|
||||
{"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"},
|
||||
{"key":"mode","required":true,"pattern":"模式[::]\\s*(\\S+)","enum":["摘要","全文"],"question":"要哪种模式?"},
|
||||
{"key":"count","pattern":"份数[::]\\s*(\\S+)"},
|
||||
{"key":"enabled","pattern":"归档[::]\\s*(\\S+)"},
|
||||
{"key":"note","pattern":"备注[::]\\s*(\\S+)"}]}`)
|
||||
}
|
||||
|
||||
// reportCommandSpec 声明参数 schema:count 是 integer、enabled 是 boolean,
|
||||
// note 未声明——未声明的键不得被塞进 params。
|
||||
func reportCommandSpec() agentcommand.CommandSpec {
|
||||
return agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic","mode"],
|
||||
"properties":{"topic":{"type":"string"},"mode":{"type":"string"},
|
||||
"count":{"type":"integer"},"enabled":{"type":"boolean"}}}`)}
|
||||
}
|
||||
|
||||
// dispatchLog 记录每次真实派发(处理器被调用才算执行)。
|
||||
type dispatchLog struct {
|
||||
mu sync.Mutex
|
||||
calls []cmdbus.Request
|
||||
params []map[string]any
|
||||
}
|
||||
|
||||
func (l *dispatchLog) record(req cmdbus.Request) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
copied := map[string]any{}
|
||||
for k, v := range req.Params {
|
||||
copied[k] = v
|
||||
}
|
||||
l.calls = append(l.calls, req)
|
||||
l.params = append(l.params, copied)
|
||||
}
|
||||
|
||||
func (l *dispatchLog) snapshot() []map[string]any {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]map[string]any(nil), l.params...)
|
||||
}
|
||||
|
||||
func (l *dispatchLog) byOwner() map[string]map[string]any {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
out := map[string]map[string]any{}
|
||||
for _, req := range l.calls {
|
||||
out[req.Owner] = req.Params
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// admissionServer 组装带意图闸门与 demo.report 命令的测试服务器。
|
||||
func admissionServer(t *testing.T) (*Server, *dispatchLog) {
|
||||
t.Helper()
|
||||
return admissionServerWith(t, reportCommandSpec())
|
||||
}
|
||||
|
||||
func admissionServerWith(t *testing.T, spec agentcommand.CommandSpec) (*Server, *dispatchLog) {
|
||||
t.Helper()
|
||||
srv, _ := newTestServer(t)
|
||||
log := &dispatchLog{}
|
||||
if err := srv.Bus.RegisterBuiltin(spec, "", func(_ context.Context, req cmdbus.Request) (any, error) {
|
||||
log.record(req)
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := intentgate.New()
|
||||
if err := gate.Rebuild(context.Background(), []json.RawMessage{reportIntent()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Gate = gate
|
||||
return srv, log
|
||||
}
|
||||
|
||||
func clarifyOf(t *testing.T, out map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
c, ok := out["clarify"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("响应缺少澄清诉求:%v", out)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 缺必填槽位:不执行业务命令,澄清诉求带正确的提问。
|
||||
func TestMissingSlotBlocksCommandAndSurfacesClarification(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "生成报告 模式:摘要"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("缺必填槽位不得执行业务命令:%v", out)
|
||||
}
|
||||
if out["run"] == nil {
|
||||
t.Fatalf("未派发命令时应回落普通回合:%v", out)
|
||||
}
|
||||
c := clarifyOf(t, out)
|
||||
if c["question"] != "主题是什么?" {
|
||||
t.Fatalf("澄清提问:%v", c)
|
||||
}
|
||||
if c["command"] != "demo.report" || c["intent"] != "demo.report" {
|
||||
t.Fatalf("被拦下的动作未标明:%v", c)
|
||||
}
|
||||
missing, _ := c["missing"].([]any)
|
||||
if len(missing) != 1 {
|
||||
t.Fatalf("缺失槽位明细:%v", c)
|
||||
}
|
||||
if m := missing[0].(map[string]any); m["key"] != "topic" || m["question"] != "主题是什么?" {
|
||||
t.Fatalf("缺失槽位键/提问:%v", m)
|
||||
}
|
||||
if _, bad := c["invalid"]; bad {
|
||||
t.Fatalf("缺失不应混进非法:%v", c)
|
||||
}
|
||||
if got := log.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("命令处理器被调用:%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 非法枚举值:不执行业务命令,结构化原因原样透出。
|
||||
func TestInvalidSlotBlocksCommandWithReason(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "生成报告 主题:核能 模式:随手写"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("非法槽位不得执行业务命令:%v", out)
|
||||
}
|
||||
c := clarifyOf(t, out)
|
||||
if c["question"] != "要哪种模式?" {
|
||||
t.Fatalf("澄清提问:%v", c)
|
||||
}
|
||||
invalid, _ := c["invalid"].([]any)
|
||||
if len(invalid) != 1 {
|
||||
t.Fatalf("非法槽位明细:%v", c)
|
||||
}
|
||||
got := invalid[0].(map[string]any)
|
||||
if got["key"] != "mode" || got["reason"] != "value not in enum" || got["question"] != "要哪种模式?" {
|
||||
t.Fatalf("非法原因/提问未透传:%v", got)
|
||||
}
|
||||
if got["invalid"] != true {
|
||||
t.Fatalf("非法标记:%v", got)
|
||||
}
|
||||
if _, missing := c["missing"]; missing {
|
||||
t.Fatalf("非法不应重复计入缺失:%v", c)
|
||||
}
|
||||
if len(log.snapshot()) != 0 {
|
||||
t.Fatalf("命令处理器被调用:%v", log.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// 合法槽位:按声明 schema 映射后到达处理器(类型正确、未声明键不复制)。
|
||||
func TestValidSlotsReachHandlerMappedPerSchema(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
text := "生成报告 主题:核能 模式:摘要 份数:3 归档:true 备注:随便"
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
|
||||
if code != 200 || out["command"] != "demo.report" {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, ok := out["clarify"]; ok {
|
||||
t.Fatalf("完整合法槽位不应带澄清诉求:%v", out)
|
||||
}
|
||||
calls := log.snapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("处理器调用次数:%d", len(calls))
|
||||
}
|
||||
params := calls[0]
|
||||
if params["topic"] != "核能" || params["mode"] != "摘要" || params["text"] != text {
|
||||
t.Fatalf("字符串参数映射:%v", params)
|
||||
}
|
||||
if params["count"] != int64(3) {
|
||||
t.Fatalf("integer 参数映射:%#v", params["count"])
|
||||
}
|
||||
if params["enabled"] != true {
|
||||
t.Fatalf("boolean 参数映射:%#v", params["enabled"])
|
||||
}
|
||||
if _, ok := params["note"]; ok {
|
||||
t.Fatalf("未声明的槽位不得复制进 params:%v", params)
|
||||
}
|
||||
if len(params) != 5 {
|
||||
t.Fatalf("参数量超出声明:%v", params)
|
||||
}
|
||||
}
|
||||
|
||||
// 声明的必填参数无法由原文+槽位满足:不派发,转澄清(不再由总线报 schema 错)。
|
||||
func TestDeclaredRequiredParamBlocksDispatch(t *testing.T) {
|
||||
srv, log := admissionServerWith(t, agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","target"],
|
||||
"properties":{"topic":{"type":"string"},"target":{"type":"string"}}}`)})
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "生成报告 主题:核能 模式:摘要"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("必填参数缺失不得执行业务命令:%v", out)
|
||||
}
|
||||
c := clarifyOf(t, out)
|
||||
missing, _ := c["missing"].([]any)
|
||||
found := false
|
||||
for _, m := range missing {
|
||||
if m.(map[string]any)["key"] == "target" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("缺失必填参数未透出:%v", c)
|
||||
}
|
||||
if c["reason"] == "" {
|
||||
t.Fatalf("准入原因缺失:%v", c)
|
||||
}
|
||||
if len(log.snapshot()) != 0 {
|
||||
t.Fatalf("命令处理器被调用:%v", log.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// 高危命令仍走 cmdbus 的确认卡收口:接纳通过后创建确认卡,处理器不执行。
|
||||
func TestHighRiskCommandStillRequiresConfirmation(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
gate := intentgate.New()
|
||||
if err := gate.Rebuild(context.Background(), []json.RawMessage{
|
||||
reportIntent(),
|
||||
json.RawMessage(`{"id":"demo.purge","domain":"demo","version":1,
|
||||
"matcher":{"includes_any":["清除报告"]},
|
||||
"action":{"kind":"command","id":"demo.purge"},
|
||||
"slots":[{"key":"topic","required":true,"pattern":"对象[::]\\s*(\\S+)","question":"清除哪个对象?"}]}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Gate = gate
|
||||
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.purge", Title: "清除报告",
|
||||
Danger: agentcommand.DangerHigh,
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic"],
|
||||
"properties":{"topic":{"type":"string"}}}`)}, "", func(context.Context, cmdbus.Request) (any, error) {
|
||||
t.Fatalf("高危命令未经确认不得执行处理器")
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 缺必填槽位时连确认卡都不该出现(业务动作根本没到总线)。
|
||||
_, blocked := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+blocked+"/messages",
|
||||
map[string]any{"text": "清除报告"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, hasCard := out["card"]; hasCard {
|
||||
t.Fatalf("槽位未就绪不得创建确认卡:%v", out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("槽位未就绪不得执行业务命令:%v", out)
|
||||
}
|
||||
|
||||
// 槽位齐全:派发到总线,高危收口创建确认卡,处理器仍未执行。
|
||||
_, ready := setupConversation(t, h)
|
||||
code, out = doJSON(t, h, "POST", "/api/conversations/"+ready+"/messages",
|
||||
map[string]any{"text": "清除报告 对象:旧归档"})
|
||||
if code != 200 || out["command"] != "demo.purge" {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if out["card"] == nil {
|
||||
t.Fatalf("高危命令必须要求确认:%v", out)
|
||||
}
|
||||
if len(log.snapshot()) != 0 {
|
||||
t.Fatalf("确认前不得派发其他命令:%v", log.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// 显式取消命令宿主未实现:如实回落普通回合,不派发同一句里的 Start。
|
||||
type cancelGenerator struct{}
|
||||
|
||||
// 生成器只经 intentgate 表面接线:目录与命令类型都由闸门投影,
|
||||
// 宿主实现不必(也不应)直接导入生态库。
|
||||
func (cancelGenerator) Generate(context.Context, string, []intentgate.CatalogSpec) ([]intentgate.Command, error) {
|
||||
return []intentgate.Command{
|
||||
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.report"}},
|
||||
{Kind: intentgate.CommandCancel, Target: "demo.report"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestUnsupportedCancelCommandFallsBackToTurn(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
gate, ok := srv.Gate.(*intentgate.Gate)
|
||||
if !ok {
|
||||
t.Fatalf("闸门类型:%T", srv.Gate)
|
||||
}
|
||||
gate.SetGenerator(cancelGenerator{})
|
||||
_, convID := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "算了吧 生成报告"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("未支持的取消命令不得派发业务动作:%v", out)
|
||||
}
|
||||
if out["run"] == nil {
|
||||
t.Fatalf("未支持的库层命令应回落普通回合:%v", out)
|
||||
}
|
||||
if len(log.snapshot()) != 0 {
|
||||
t.Fatalf("命令处理器被调用:%v", log.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// 两个租户的槽位上下文互不串味:各自只看到自己那句输入映射出的参数。
|
||||
func TestIntentSlotsDoNotLeakAcrossOwners(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
srv.Auth = NewAuthenticator(srv.st, true)
|
||||
convID := map[string]string{}
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := srv.st.CreateAccount(owner, owner, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proj, err := srv.st.CreateProject(owner, owner+" 的项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := srv.st.CreateConversation(owner, proj.ID, owner+" 的会话", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
convID[owner] = conv.ID
|
||||
}
|
||||
h := srv.Handler()
|
||||
send := func(owner, text string) map[string]any {
|
||||
t.Helper()
|
||||
key, err := srv.st.CreateAPIKey(owner, owner+"-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"text": text})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := bearerRequest(h, "POST", "/api/conversations/"+convID[owner]+"/messages", string(body), key)
|
||||
out := map[string]any{}
|
||||
if rec.Body.Len() > 0 {
|
||||
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
}
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("%s 发送 %q: status %d body %s", owner, text, rec.Code, rec.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
if out := send("alice", "生成报告 主题:核能 模式:摘要"); out["command"] != "demo.report" {
|
||||
t.Fatalf("alice 派发:%v", out)
|
||||
}
|
||||
if out := send("bob", "生成报告 主题:水利 模式:全文"); out["command"] != "demo.report" {
|
||||
t.Fatalf("bob 派发:%v", out)
|
||||
}
|
||||
if got := log.byOwner(); len(got) != 2 {
|
||||
t.Fatalf("派发归属:%v", got)
|
||||
}
|
||||
byOwner := log.byOwner()
|
||||
if byOwner["alice"]["topic"] != "核能" || byOwner["alice"]["mode"] != "摘要" {
|
||||
t.Fatalf("alice 收到别人的槽位:%v", byOwner["alice"])
|
||||
}
|
||||
if byOwner["bob"]["topic"] != "水利" || byOwner["bob"]["mode"] != "全文" {
|
||||
t.Fatalf("bob 收到别人的槽位:%v", byOwner["bob"])
|
||||
}
|
||||
if byOwner["alice"]["topic"] == byOwner["bob"]["topic"] {
|
||||
t.Fatalf("两个租户共享了同一份槽位上下文:%v", byOwner)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// commandOperationQuery 把"已记录 operation 的状态查询"落在既有的 owner+project
|
||||
// 作用域审计查询上(store.ListProjectCommandAudits),不新建通用幂等平台:不加表、
|
||||
// 不加索引、不引入新的重试语义。命令回执(审计信封)本就是宿主侧的操作记录,
|
||||
// handler 自己事务/发件箱里的领域操作表(如文档投递 document_render_deliveries)
|
||||
// 仍由各自领域查询负责。
|
||||
//
|
||||
// 诚实边界:
|
||||
// - 只有成功落下审计信封的 operation 查得到。审计不可用时总线回 unknown +
|
||||
// operationID,但那一行没有落库:这里返回"无记录",绝不推断"未执行"。
|
||||
// - 扫描有界(最近若干页):超出窗口的旧操作返回无记录,而不是全表扫描。
|
||||
// - project_id 是追加时按可信会话快照下来的归属;改绑/删除会话不改写历史归属,
|
||||
// 与既有项目审计查询同一口径。
|
||||
type commandOperationQuery struct{ st *store.Store }
|
||||
|
||||
const (
|
||||
operationLookupPageSize = 100
|
||||
operationLookupMaxPages = 20 // 有界扫描:最近 2000 行,超窗即"无记录"
|
||||
)
|
||||
|
||||
func (q commandOperationQuery) LookupOperation(owner, projectID, operationID string) (cmdbus.OperationRecord, bool, error) {
|
||||
if q.st == nil || owner == "" || projectID == "" || operationID == "" {
|
||||
return cmdbus.OperationRecord{}, false, nil
|
||||
}
|
||||
var before, cutoff int64
|
||||
for page := 0; page < operationLookupMaxPages; page++ {
|
||||
rows, resolvedCutoff, next, err := q.st.ListProjectCommandAudits(owner, projectID, before, cutoff, operationLookupPageSize)
|
||||
if err != nil {
|
||||
return cmdbus.OperationRecord{}, false, err
|
||||
}
|
||||
cutoff = resolvedCutoff // 首帧固定游标,后续页在同一提交前缀内翻页
|
||||
for _, row := range rows {
|
||||
if row.Envelope.OperationID != operationID {
|
||||
continue
|
||||
}
|
||||
return cmdbus.OperationRecord{
|
||||
OperationID: row.Envelope.OperationID,
|
||||
CommandID: row.Envelope.CommandID,
|
||||
State: row.Envelope.State,
|
||||
Time: row.Envelope.Time,
|
||||
}, true, nil
|
||||
}
|
||||
if next == 0 {
|
||||
return cmdbus.OperationRecord{}, false, nil
|
||||
}
|
||||
before = next
|
||||
}
|
||||
return cmdbus.OperationRecord{}, false, nil
|
||||
}
|
||||
|
||||
// getCommandOperation 是 unknown 回执的对账入口(R7/KTD6):
|
||||
// GET /api/commands/operations/{operationId}?projectId=…
|
||||
//
|
||||
// 授权复用既有口径:owner 只取自认证上下文(不读请求参数),projectId 仅用于
|
||||
// 收窄到该 owner 名下的项目——与 /api/commands/audit 同源,跨租户天然不可见。
|
||||
// 查不到一律 404:HTTP 层的"没有记录"同样不得被读成"未执行"。
|
||||
func (s *Server) getCommandOperation(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Bus == nil {
|
||||
http.Error(w, "commands disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
record, err := s.Bus.LookupOperation(s.owner(r), r.URL.Query().Get("projectId"), r.PathValue("operationId"))
|
||||
switch {
|
||||
case errors.Is(err, cmdbus.ErrOperationScope):
|
||||
http.Error(w, "projectId and operationId are required", http.StatusBadRequest)
|
||||
return
|
||||
case errors.Is(err, cmdbus.ErrOperationQueryUnavailable):
|
||||
http.Error(w, "operation query unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
case errors.Is(err, cmdbus.ErrOperationNotRecorded):
|
||||
http.Error(w, "no operation record in this scope", http.StatusNotFound)
|
||||
return
|
||||
case err != nil:
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"operation": record})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
)
|
||||
|
||||
// TestCommandOperationLookupIsOwnerProjectScoped 覆盖 U7b/R7 的对账查询:
|
||||
// unknown 回执给出的 operationID 能按认证 owner + project 查到落库记录;
|
||||
// 跨 owner、错 project、缺 projectId 一律查不到或拒(fail-closed),任何
|
||||
// "查不到"都不得被读成"未执行"。
|
||||
func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
srv.Bus = cmdbus.New(st, nil, time.Now)
|
||||
srv.WireBuiltinCommands() // 生产装配点:总线拿到宿主侧对账查询端口
|
||||
h := srv.Handler()
|
||||
|
||||
aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := st.CreateProject("alice", "对账项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := st.CreateConversation("alice", project.ID, "对账会话", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。
|
||||
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"},
|
||||
func(context.Context, cmdbus.Request) (any, error) { return nil, errors.New("ack lost") }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := srv.Bus.Dispatch(context.Background(), cmdbus.Request{
|
||||
ID: "demo.write", Owner: "alice", Actor: "alice", ConversationID: conversation.ID,
|
||||
})
|
||||
if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" {
|
||||
t.Fatalf("post-execution failure must be unknown with an operation ID: %+v", resp.Receipt)
|
||||
}
|
||||
operationID := resp.Receipt.OperationID
|
||||
|
||||
get := func(key, url string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
rec := get(aliceKey, "/api/commands/operations/"+operationID+"?projectId="+project.ID)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("owner lookup: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Operation struct {
|
||||
OperationID string `json:"OperationID"`
|
||||
CommandID string `json:"CommandID"`
|
||||
State string `json:"State"`
|
||||
} `json:"operation"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Operation.OperationID != operationID || out.Operation.CommandID != "demo.write" || out.Operation.State != string(agentcommand.StateUnknown) {
|
||||
t.Fatalf("lookup projection wrong: %+v", out.Operation)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, key, url string
|
||||
want int
|
||||
}{
|
||||
{"cross owner", bobKey, "/api/commands/operations/" + operationID + "?projectId=" + project.ID, http.StatusNotFound},
|
||||
{"wrong project", aliceKey, "/api/commands/operations/" + operationID + "?projectId=prj_other", http.StatusNotFound},
|
||||
{"missing project", aliceKey, "/api/commands/operations/" + operationID, http.StatusBadRequest},
|
||||
{"missing operation", aliceKey, "/api/commands/operations/op_absent?projectId=" + project.ID, http.StatusNotFound},
|
||||
} {
|
||||
if rec := get(tc.key, tc.url); rec.Code != tc.want {
|
||||
t.Fatalf("%s: %d %s (want %d)", tc.name, rec.Code, rec.Body.String(), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,10 +266,13 @@ func newIAMFixture(t *testing.T) *iamFixture {
|
||||
member, _ := req.Params["member"].(string)
|
||||
err := f.provider.addMember(ctx, srv.lifecycleHost(req.Owner), projectID, member)
|
||||
if failure, ok := categorizeWorkFailure(extension.WorkOperationMemberAdd, err); ok {
|
||||
return nil, failure
|
||||
// 假 provider 的每条失败路径都发生在改成员之前(唯一写点在最后一行
|
||||
// 且随后返回 nil),因此显式声明 not-executed:拒绝语义与 4xx 映射
|
||||
// 由此成立,而不是靠“报了错就算没执行”(F10)。
|
||||
return nil, cmdbus.NotExecuted(failure)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, cmdbus.NotExecuted(err)
|
||||
}
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
|
||||
@@ -21,7 +21,24 @@ func (s *Server) lifecycleHost(owner string) extension.Host {
|
||||
}
|
||||
return s.projectHost(owner)
|
||||
}
|
||||
|
||||
// scratchProjectWork 让临时会话的属主在没有 RDM provider 时也能工作。scratch 项目
|
||||
// 从不进入 RDM:provider 只会报 Work=false,属主会被自己的临时会话挡住(U2 核心)。
|
||||
// 所以门禁先走存储侧归属:属主放行且不再问 provider,非属主一律 NotFound(与
|
||||
// 项目工作同一错误面)。handled=false 表示这不是临时会话,调用点照旧。
|
||||
func (s *Server) scratchProjectWork(owner, projectID string) (handled bool, err error) {
|
||||
if s.st == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.st.ScratchProjectDecision(projectID, owner)
|
||||
}
|
||||
|
||||
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
|
||||
if handled, err := s.scratchProjectWork(owner, projectID); err != nil {
|
||||
return err
|
||||
} else if handled {
|
||||
return nil
|
||||
}
|
||||
if s.projectLifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -48,6 +65,12 @@ func (s *Server) projectWork(ctx context.Context, owner, projectID string) error
|
||||
// With no resource authorizer registered (EXT-8b V2 absent) this is exactly
|
||||
// projectWork, so un-extended hosts keep their current denial unchanged.
|
||||
func (s *Server) projectRead(ctx context.Context, owner, projectID string) error {
|
||||
if handled, err := s.scratchProjectWork(owner, projectID); err != nil {
|
||||
return err
|
||||
} else if handled {
|
||||
// 临时会话只对属主可读:共享域回落永远不参与(它不是共享资源)。
|
||||
return nil
|
||||
}
|
||||
if s.projectLifecycle == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -70,7 +93,12 @@ func (s *Server) projectRead(ctx context.Context, owner, projectID string) error
|
||||
|
||||
// createPrivateConversation is shared by the API and /new. Only live work
|
||||
// authorization grants member creation; the resulting conversation stays owned.
|
||||
// A scratch project already holds its one private conversation, so this shared
|
||||
// path refuses a second one instead of handing out a normal session under it.
|
||||
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
|
||||
if err := s.rejectScratchProjectConversation(projectID, owner); err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
if err := s.projectWork(ctx, owner, projectID); err != nil {
|
||||
return core.Conversation{}, err
|
||||
@@ -83,6 +111,24 @@ func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// rejectScratchProjectConversation keeps the scratch 1:1 rule on the shared
|
||||
// conversation-create path (POST /api/projects/{id}/conversations and /new).
|
||||
// The lookup is the internal trusted one, and it is ownership-aware: a non-owner
|
||||
// is not told anything about the session (projectWork answers NotFound), while
|
||||
// the owner still gets the 1:1 refusal. Nothing here is returned to the caller
|
||||
// for an unreadable project, which the store path decides as before.
|
||||
func (s *Server) rejectScratchProjectConversation(projectID, owner string) error {
|
||||
if s.st == nil {
|
||||
return nil
|
||||
}
|
||||
handled, err := s.st.ScratchProjectDecision(projectID, owner)
|
||||
if !handled || err != nil {
|
||||
return nil
|
||||
}
|
||||
return store.ErrScratchSessionSingle
|
||||
}
|
||||
|
||||
func writeProjectErr(w http.ResponseWriter, err error) {
|
||||
var coded interface{ StatusCode() int }
|
||||
if errors.As(err, &coded) {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// 会话响应上的 scratch 标记(U2B):临时会话在任务列表、项目会话列表与详情里都
|
||||
// 带 scratch=true,普通项目的会话没有这个键——前端据此把临时会话归到“无项目对话”,
|
||||
// 不必再从项目列表反推。
|
||||
|
||||
// conversationRow 保留原始 JSON 对象:标记在不在键里都能看见。
|
||||
type conversationRow map[string]any
|
||||
|
||||
func decodeConversationRows(t *testing.T, rec *httptest.ResponseRecorder) map[string]conversationRow {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list conversations: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var rows []conversationRow
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
byID := map[string]conversationRow{}
|
||||
for _, row := range rows {
|
||||
id, _ := row["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("conversation without id: %s", rec.Body.String())
|
||||
}
|
||||
byID[id] = row
|
||||
}
|
||||
return byID
|
||||
}
|
||||
|
||||
// assertScratchFlag 校验一条会话:want=true 必须带 scratch=true;want=false 时
|
||||
// 必须完全没有 scratch 键(普通项目的会话 JSON 与加标记之前同形)。
|
||||
func assertScratchFlag(t *testing.T, where string, row conversationRow, want bool) {
|
||||
t.Helper()
|
||||
if row == nil {
|
||||
t.Fatalf("%s: conversation missing from the response", where)
|
||||
}
|
||||
raw, ok := row["scratch"]
|
||||
if !want {
|
||||
if ok {
|
||||
t.Fatalf("%s: non-scratch conversation carries scratch: %v", where, raw)
|
||||
}
|
||||
return
|
||||
}
|
||||
if flag, _ := raw.(bool); !ok || !flag {
|
||||
t.Fatalf("%s: scratch conversation is not marked: %v", where, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func createdConversationObject(t *testing.T, rec *httptest.ResponseRecorder) conversationRow {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Conversation conversationRow `json:"conversation"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
return body.Conversation
|
||||
}
|
||||
|
||||
// scratchFlagIDs 是 fixture 造出来的两条会话(普通 + 临时)的 id 与项目 id。
|
||||
type scratchFlagIDs struct {
|
||||
normalID, normalProjectID string
|
||||
scratchID, scratchProjectID string
|
||||
}
|
||||
|
||||
// scratchFlagFixture 造一个普通项目的会话和一个临时会话。临时会话走 HTTP 创建,
|
||||
// 因此创建响应上的标记也一并断言。
|
||||
func scratchFlagFixture(t *testing.T, h http.Handler, st *store.Store, key string) scratchFlagIDs {
|
||||
t.Helper()
|
||||
project, err := st.CreateProjectAccountChecked("engineer", "普通项目", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normal, err := st.CreateConversation("engineer", string(project.ID), "普通会话", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
|
||||
if created.Code != http.StatusOK {
|
||||
t.Fatalf("create scratch session: %d %s", created.Code, created.Body.String())
|
||||
}
|
||||
view := decodeScratchSessionHTTP(t, created)
|
||||
if view.Project.ID == "" || view.Conversation.ID == "" {
|
||||
t.Fatalf("incomplete scratch session: %s", created.Body.String())
|
||||
}
|
||||
assertScratchFlag(t, "POST /api/scratch-sessions", createdConversationObject(t, created), true)
|
||||
return scratchFlagIDs{
|
||||
normalID: string(normal.ID), normalProjectID: string(project.ID),
|
||||
scratchID: view.Conversation.ID, scratchProjectID: view.Project.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchFlagOnConversationResponses 同一份 fixture 上验证三条读取路径:
|
||||
// 任务列表、项目会话列表、会话详情——临时会话带 scratch=true,普通项目的会话
|
||||
// 不带这个键。
|
||||
func TestScratchFlagOnConversationResponses(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key := scratchAccountKey(t, st, "engineer")
|
||||
ids := scratchFlagFixture(t, h, st, key)
|
||||
|
||||
tasks := decodeConversationRows(t, bearerRequest(h, "GET", "/api/conversations", "", key))
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("task list must hold both conversations: %d %+v", len(tasks), tasks)
|
||||
}
|
||||
if tasks[ids.scratchID] == nil || tasks[ids.normalID] == nil {
|
||||
t.Fatalf("task list does not cover both conversations: %+v", tasks)
|
||||
}
|
||||
assertScratchFlag(t, "GET /api/conversations (scratch)", tasks[ids.scratchID], true)
|
||||
assertScratchFlag(t, "GET /api/conversations (normal)", tasks[ids.normalID], false)
|
||||
|
||||
scratchUnder := decodeConversationRows(t, bearerRequest(h, "GET", "/api/projects/"+ids.scratchProjectID+"/conversations", "", key))
|
||||
normalUnder := decodeConversationRows(t, bearerRequest(h, "GET", "/api/projects/"+ids.normalProjectID+"/conversations", "", key))
|
||||
assertScratchFlag(t, "GET /api/projects/{scratch}/conversations", scratchUnder[ids.scratchID], true)
|
||||
assertScratchFlag(t, "GET /api/projects/{normal}/conversations", normalUnder[ids.normalID], false)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
id string
|
||||
scratch bool
|
||||
}{
|
||||
{"scratch", ids.scratchID, true},
|
||||
{"normal", ids.normalID, false},
|
||||
} {
|
||||
rec := bearerRequest(h, "GET", "/api/conversations/"+tc.id, "", key)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detail %s: %d %s", tc.name, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Conversation conversationRow `json:"conversation"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode detail %s: %v body=%s", tc.name, err, rec.Body.String())
|
||||
}
|
||||
if body.Conversation["id"] != tc.id {
|
||||
t.Fatalf("detail %s returned %v", tc.name, body.Conversation["id"])
|
||||
}
|
||||
assertScratchFlag(t, "GET /api/conversations/{id} ("+tc.name+")", body.Conversation, tc.scratch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 临时会话隔离门禁(U2)。产品规则:scratch 会话是“只属于自己的通用代理会话”——
|
||||
// 属主能像普通聊天一样用它,但它不共享任何文档/上下文,也不触达项目域能力。
|
||||
// 每个门禁都成对验证:owner 放行、其他人/其它项目照旧。
|
||||
|
||||
// scratchGateFixture 建一个已登录的属主账号并落下一个临时会话。
|
||||
func scratchGateFixture(t *testing.T, srv *Server, st *store.Store, owner string) (key, projectID, conversationID string) {
|
||||
t.Helper()
|
||||
key = scratchAccountKey(t, st, owner)
|
||||
rec := bearerRequest(srv.Handler(), "POST", "/api/scratch-sessions", `{}`, key)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create scratch session: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
view := decodeScratchSessionHTTP(t, rec)
|
||||
return key, view.Project.ID, view.Conversation.ID
|
||||
}
|
||||
|
||||
// TestScratchOwnerWorksWithoutRDMProvider 是 U2 的核心:注册一个 Work 恒为 false
|
||||
// 的 projectLifecycle(RDM 从未听说过 scratch 项目)后,属主仍能在自己的临时
|
||||
// 会话里发消息,而别的账号读不到这个临时会话——即使共享授权适配器对所有人放行。
|
||||
func TestScratchOwnerWorksWithoutRDMProvider(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
aliceKey, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
|
||||
bobKey := scratchAccountKey(t, st, "bob")
|
||||
|
||||
providerCalls := 0
|
||||
srv.SetProjectLifecycle(extension.ProjectLifecycle{
|
||||
Work: func(context.Context, string, core.ID) (bool, error) {
|
||||
providerCalls++
|
||||
return false, nil // RDM 视角:没有这个项目
|
||||
},
|
||||
}, nil)
|
||||
// 共享域适配器对任何人都放行(最宽松的现网配置),临时会话仍不可共享。
|
||||
srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
|
||||
srv.SetResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
|
||||
|
||||
rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"帮我总结这段话"}`, aliceKey)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("scratch owner could not chat without the provider: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
msgs, err := st.ListMessages(conversationID)
|
||||
if err != nil || len(msgs) == 0 {
|
||||
t.Fatalf("owner message not persisted: %+v %v", msgs, err)
|
||||
}
|
||||
if providerCalls != 0 {
|
||||
t.Fatalf("scratch owner work asked the domain provider %d times", providerCalls)
|
||||
}
|
||||
|
||||
// 负面:别的账号读不到这个临时会话(共享授权适配器一律放行也不作数)。
|
||||
if rec := bearerRequest(h, "GET", "/api/projects/"+projectID+"/conversations", "", bobKey); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("non-owner listed a scratch project: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/projects/"+projectID+"/conversations", `{"title":"混入"}`, bobKey); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("non-owner created a conversation under scratch: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := srv.projectRead(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("projectRead opened a scratch project for a non-owner: %v", err)
|
||||
}
|
||||
if err := srv.projectWork(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("projectWork opened a scratch project for a non-owner: %v", err)
|
||||
}
|
||||
// 属主的读面不受共享适配器影响(存储归属即可)。
|
||||
if err := srv.projectRead(context.Background(), "alice", projectID); err != nil {
|
||||
t.Fatalf("scratch owner projectRead: %v", err)
|
||||
}
|
||||
// 普通项目不受影响:provider 说 false 就是 false。
|
||||
normal, err := st.CreateProject("alice", "普通项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.projectWork(context.Background(), "alice", string(normal.ID)); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("normal project bypassed the provider decision: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchHasNoKnowledgeBase 知识库是项目域能力:入库与检索两个面都 400。
|
||||
func TestScratchHasNoKnowledgeBase(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
|
||||
doc := `[{"title":"手动文档","chunk":"知识库内容"}]`
|
||||
|
||||
for name, path := range map[string]string{"scratch": projectID, "normal": normal} {
|
||||
rec := bearerRequest(h, "POST", "/api/projects/"+path+"/knowledge", doc, key)
|
||||
switch name {
|
||||
case "scratch":
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") {
|
||||
t.Fatalf("addKnowledge on scratch = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
case "normal":
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("addKnowledge on a normal project = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
// 被拒的写入没有落库。
|
||||
if docs, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "手动文档", TopK: 10}); err != nil || len(docs) != 0 {
|
||||
t.Fatalf("scratch project kept knowledge rows: %+v %v", docs, err)
|
||||
}
|
||||
|
||||
rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+projectID, "", key)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") {
|
||||
t.Fatalf("searchKnowledge on scratch = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 正面:普通项目仍然检索得到自己刚入库的文档。
|
||||
if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+normal, "", key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("searchKnowledge on a normal project = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 无 project 参数的全局检索不受门禁影响(它本来就不落在临时会话上)。
|
||||
if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库", "", key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("global search = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchUploadStaysOutOfKnowledgeIndex 上传仍可用(对话附件),但临时会话的
|
||||
// 附件不进任何知识索引;普通项目的上传照旧入库。
|
||||
func TestScratchUploadStaysOutOfKnowledgeIndex(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
|
||||
|
||||
upload := func(projectID, name, text string) string {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(text)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+projectID+"/uploads", &buf)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload to %s: %d %s", projectID, rec.Code, rec.Body.String())
|
||||
}
|
||||
var up struct {
|
||||
ArtifactID string `json:"artifactId"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return up.ArtifactID
|
||||
}
|
||||
|
||||
scratchArtifact := upload(projectID, "临时附件.txt", "临时会话的附件正文")
|
||||
if scratchArtifact == "" {
|
||||
t.Fatal("scratch upload returned no artifact")
|
||||
}
|
||||
upload(normal, "普通附件.txt", "普通项目的附件正文")
|
||||
|
||||
scratchHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "附件", TopK: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, hit := range scratchHits {
|
||||
if string(hit.Doc.ProjectID) == projectID || hit.Doc.Ref == scratchArtifact {
|
||||
t.Fatalf("scratch upload was indexed: %+v", hit.Doc)
|
||||
}
|
||||
}
|
||||
normalHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: normal, Text: "附件", TopK: 10})
|
||||
if err != nil || len(normalHits) == 0 {
|
||||
t.Fatalf("normal project upload lost indexing: %+v %v", normalHits, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchRejectsDomainCommands 临时会话里 rdm.* 命令 403;不碰项目域的内置
|
||||
// 命令照旧可用(它就是普通聊天能力)。
|
||||
func TestScratchRejectsDomainCommands(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
|
||||
normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key))
|
||||
|
||||
srv.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
|
||||
srv.WireBuiltinCommands()
|
||||
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.list", Title: "需求列表"}, func(ctx context.Context, req cmdbus.Request) (any, error) {
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// API 入口(POST /api/commands/{id}):临时会话宿主一律 403。
|
||||
for _, id := range []string{"rdm.reqs.add", "rdm.reqs.list"} {
|
||||
rec := bearerRequest(h, "POST", "/api/commands/"+id, `{"conversationId":"`+conversationID+`"}`, key)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "not available in scratch sessions") {
|
||||
t.Fatalf("%s on a scratch host = %d %s", id, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// 正面:读侧命令在普通项目宿主上照旧分发。
|
||||
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{"conversationId":"`+normalConv+`"}`, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("rdm.reqs.list on a normal host = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 没有会话就没有基座项目:门禁不适用,命令走原路径(正式项目命令仍由总线预检拒绝)。
|
||||
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{}`, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("command without a host project = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.add", `{}`, key); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("hostless formal command must keep the bus preflight answer: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 内置会话命令在临时会话里照旧可用(它不碰项目域)。
|
||||
rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"/help"}`, key)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "command") {
|
||||
t.Fatalf("builtin host command in a scratch session = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) {
|
||||
t.Fatalf("scratch marker lost during command gating: %s", projectID)
|
||||
}
|
||||
}
|
||||
|
||||
func mustScratchSettings(t *testing.T, st *store.Store, projectID string) map[string]any {
|
||||
t.Helper()
|
||||
p, err := st.ProjectMetadata(projectID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p.Settings
|
||||
}
|
||||
|
||||
// TestScratchProjectPatchAllowsNameOnly 临时会话只允许改名;任何设置写入 400。
|
||||
func TestScratchProjectPatchAllowsNameOnly(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, projectID, _ := scratchGateFixture(t, srv, st, "alice")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
|
||||
if rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, `{"name":"改个名字"}`, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("rename scratch project = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if p, err := st.ProjectMetadata(projectID); err != nil || p.Name != "改个名字" {
|
||||
t.Fatalf("scratch project not renamed: %+v %v", p, err)
|
||||
}
|
||||
for _, body := range []string{`{"settings":{"note":"任何设置"}}`, `{"settings":{"crossSession":{"read":true}}}`, `{"settings":{"host":{"kind":"normal"}}}`} {
|
||||
rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, body, key)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no project settings") {
|
||||
t.Fatalf("scratch settings patch %s = %d %s", body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) {
|
||||
t.Fatal("refused patch changed the scratch marker")
|
||||
}
|
||||
// 正面:普通项目的设置写入照旧放行。
|
||||
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
|
||||
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normal, `{"settings":{"note":"普通"}}`, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("normal settings patch = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeletingScratchConversationRemovesScratchProject 删会话即删临时会话外壳;
|
||||
// 普通项目删会话仍保留项目。
|
||||
func TestDeletingScratchConversationRemovesScratchProject(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key))
|
||||
normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key))
|
||||
|
||||
bobKey := scratchAccountKey(t, st, "bob")
|
||||
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", bobKey); rec.Code == http.StatusOK {
|
||||
t.Fatalf("non-owner deleted a scratch conversation: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete scratch conversation = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := st.ProjectMetadata(projectID); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("scratch project survived its conversation: %v", err)
|
||||
}
|
||||
if rec := bearerRequest(h, "DELETE", "/api/conversations/"+normalConv, "", key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete normal conversation = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := st.ProjectMetadata(normal); err != nil {
|
||||
t.Fatalf("normal project removed with its conversation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 临时会话入口(U1):POST /api/scratch-sessions。
|
||||
//
|
||||
// 能力是内生的——任何已启用账号都能建,不需要 project.create,也不在权限目录
|
||||
// /replaceRolePermissions 里出现为可配置项。项目与会话由 store 在一个事务里
|
||||
// 写出(scratch 标记 + cross_private 会话),不经过 RDM/projectLifecycle。
|
||||
|
||||
// scratchSessionView 是创建结果:调用方同时拿到 scratch 项目与它唯一的会话。
|
||||
// 会话走 server 本地的 conversationView:刚建出来的会话必然属于 scratch 项目,
|
||||
// 标记直接置真,不必回查。
|
||||
type scratchSessionView struct {
|
||||
Project core.Project `json:"project"`
|
||||
Conversation conversationView `json:"conversation"`
|
||||
}
|
||||
|
||||
func (s *Server) createScratchSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
ProfileID string `json:"profileId"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
}
|
||||
// 请求体可省略:空体即默认名临时会话。非空体必须是单一 JSON 对象。
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
http.Error(w, "invalid scratch session request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 标记归服务端所有:客户端不能带 settings 上来(哪怕是别的键),
|
||||
// 否则“scratch 标记”会变成一个客户端可写的项目字段。
|
||||
if req.Settings != nil {
|
||||
http.Error(w, "scratch host marker is server-owned", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = store.ScratchDefaultProjectName
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" {
|
||||
title = "新会话"
|
||||
}
|
||||
// 与其它 create 路径一致:认证关闭(嵌入式档)走默认租户,认证开启时由
|
||||
// store 的账号有效性边界拒绝停用账号——scratch 不是绕过账号状态的旁路。
|
||||
project, conversation, err := s.st.CreateScratchSessionAccountChecked(
|
||||
s.owner(r), name, title, strings.TrimSpace(req.ProfileID), s.Auth == nil || !s.Auth.Enabled)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
// 状态码随同类创建入口(POST /api/projects、/api/projects/{id}/conversations):
|
||||
// 200 配 writeJSON,而不是 201。
|
||||
writeJSON(w, http.StatusOK, scratchSessionView{
|
||||
Project: project,
|
||||
Conversation: conversationView{Conversation: conversation, Scratch: true},
|
||||
})
|
||||
}
|
||||
|
||||
// ---- U2:隔离门禁 ----
|
||||
//
|
||||
// 临时会话是“只属于自己的通用代理会话”:没有知识库、没有项目上下文、不与任何
|
||||
// 会话共享材料。这些门禁只认存储里的 host 标记(store.IsScratchProject),调用点
|
||||
// 不自己比对标记字符串。
|
||||
|
||||
const (
|
||||
// scratchNoKnowledge 知识库是项目域能力,临时会话一律没有。
|
||||
scratchNoKnowledge = "scratch sessions have no knowledge base"
|
||||
// scratchNoSettings 临时会话只有会话,没有可写的项目设置。
|
||||
scratchNoSettings = "scratch sessions have no project settings"
|
||||
// scratchNoCommand 项目域命令(RDM 及其它需要基座项目的命令)在临时会话不可用。
|
||||
scratchNoCommand = "not available in scratch sessions"
|
||||
)
|
||||
|
||||
// scratchProjectOf reports whether projectID is the caller's own scratch project.
|
||||
// Other owners' projects (and unknown ids) fall through to the normal decision
|
||||
// path, so the gate never leaks the existence of someone else's scratch session.
|
||||
func (s *Server) scratchProjectOf(projectID, owner string) bool {
|
||||
if s.st == nil {
|
||||
return false
|
||||
}
|
||||
handled, err := s.st.ScratchProjectDecision(projectID, owner)
|
||||
return handled && err == nil
|
||||
}
|
||||
|
||||
// refuseScratchProjectCapability 门禁拒绝:只对调用者自己的临时会话生效。
|
||||
func (s *Server) refuseScratchProjectCapability(w http.ResponseWriter, projectID, owner, message string) bool {
|
||||
if !s.scratchProjectOf(projectID, owner) {
|
||||
return false
|
||||
}
|
||||
http.Error(w, message, http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
// refuseScratchCommandHost 命令门禁(403):临时会话里不得分发需要基座项目的命令。
|
||||
// rdm.* 全族与正式项目命令(cmdbus.RequiresProjectWorkDecision)都不行;内置的
|
||||
// 会话类命令照旧可用。
|
||||
func (s *Server) refuseScratchCommandHost(w http.ResponseWriter, hostProjectID, owner, commandID string) bool {
|
||||
if !s.scratchProjectOf(hostProjectID, owner) {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(commandID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(commandID) {
|
||||
return false
|
||||
}
|
||||
http.Error(w, scratchNoCommand, http.StatusForbidden)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// 临时会话(U1)HTTP 契约:POST /api/scratch-sessions 对任何已启用账号内生可用,
|
||||
// 不经 project.create;scratch 项目不进项目列表但会话仍出现在任务列表;1:1、
|
||||
// 标记只归服务端、目录里没有可配置动作。
|
||||
|
||||
type scratchSessionHTTPView struct {
|
||||
Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
} `json:"project"`
|
||||
Conversation struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
CrossPrivate bool `json:"crossPrivate"`
|
||||
AllowCrossRead bool `json:"allowCrossRead"`
|
||||
} `json:"conversation"`
|
||||
}
|
||||
|
||||
func decodeScratchSessionHTTP(t *testing.T, rec *httptest.ResponseRecorder) scratchSessionHTTPView {
|
||||
t.Helper()
|
||||
var view scratchSessionHTTPView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil {
|
||||
t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func projectIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("decode projects: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func conversationIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
|
||||
t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func createdProjectID(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||
t.Helper()
|
||||
var project struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &project); err != nil || project.ID == "" {
|
||||
t.Fatalf("decode project: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
return project.ID
|
||||
}
|
||||
|
||||
// scratchAccountKey 造一个没有密码的普通账号并签发 API key(与 role_authorization_test 同款)。
|
||||
func scratchAccountKey(t *testing.T, st *store.Store, owner string) string {
|
||||
t.Helper()
|
||||
if err := st.CreateAccount(owner, owner, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := st.CreateAPIKey(owner, owner+"-scratch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// TestScratchSessionNeedsNoProjectCreatePermission 是 U1 的核心契约:没有
|
||||
// project.create 的已启用账号照样能建临时会话,而同一个账号仍然建不了普通项目。
|
||||
// 状态码沿用同类创建入口的 200(writeJSON),不是 201。
|
||||
func TestScratchSessionNeedsNoProjectCreatePermission(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
key := scratchAccountKey(t, st, "engineer")
|
||||
|
||||
if allowed, _, reason := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); allowed || reason != "not-granted" {
|
||||
t.Fatalf("fixture must not hold project.create: allowed=%v reason=%s", allowed, reason)
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("project created without project.create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("scratch session denied without project.create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
view := decodeScratchSessionHTTP(t, rec)
|
||||
if !store.IsScratchProject(view.Project.Settings) || view.Project.ID == "" {
|
||||
t.Fatalf("scratch project marker missing: %s", rec.Body.String())
|
||||
}
|
||||
if view.Project.Name == "" || view.Conversation.ID == "" || view.Conversation.ProjectID != view.Project.ID {
|
||||
t.Fatalf("incomplete scratch session: %s", rec.Body.String())
|
||||
}
|
||||
if !view.Conversation.CrossPrivate || !view.Conversation.AllowCrossRead {
|
||||
t.Fatalf("scratch conversation flags: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 项目列表里没有 scratch,会话列表与项目会话列表里都有它。
|
||||
projects := bearerRequest(h, "GET", "/api/projects", "", key)
|
||||
if projects.Code != http.StatusOK || containsString(projectIDsOf(t, projects), view.Project.ID) {
|
||||
t.Fatalf("scratch project listed as a normal project: %d %s", projects.Code, projects.Body.String())
|
||||
}
|
||||
tasks := bearerRequest(h, "GET", "/api/conversations", "", key)
|
||||
if tasks.Code != http.StatusOK || !containsString(conversationIDsOf(t, tasks), view.Conversation.ID) {
|
||||
t.Fatalf("scratch conversation missing from task list: %d %s", tasks.Code, tasks.Body.String())
|
||||
}
|
||||
under := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key)
|
||||
if under.Code != http.StatusOK || len(conversationIDsOf(t, under)) != 1 {
|
||||
t.Fatalf("scratch project conversations: %d %s", under.Code, under.Body.String())
|
||||
}
|
||||
|
||||
// 1:1:第二个会话被拒;普通项目下的会话创建不受影响。
|
||||
second := bearerRequest(h, "POST", "/api/projects/"+view.Project.ID+"/conversations", `{"title":"第二个"}`, key)
|
||||
if second.Code != http.StatusConflict || !strings.Contains(second.Body.String(), "scratch") {
|
||||
t.Fatalf("second conversation under scratch project: %d %s", second.Code, second.Body.String())
|
||||
}
|
||||
after := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key)
|
||||
if len(conversationIDsOf(t, after)) != 1 {
|
||||
t.Fatalf("refused create still wrote a conversation: %s", after.Body.String())
|
||||
}
|
||||
normal, err := st.CreateProjectAccountChecked("engineer", "普通项目", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/projects/"+string(normal.ID)+"/conversations", `{"title":"普通会话"}`, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("normal project lost conversation create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchSessionCreateForProjectCreateHolder 覆盖另一侧:已经持有
|
||||
// project.create 的账号走同一条内生路径,不需要额外的开关。
|
||||
func TestScratchSessionCreateForProjectCreateHolder(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
superAdmin := bootSuperAdmin(t, h, st)
|
||||
key := scratchAccountKey(t, st, "creator")
|
||||
createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true)
|
||||
if allowed, source, _ := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); !allowed || source != "role" {
|
||||
t.Fatalf("fixture must hold project.create: allowed=%v source=%s", allowed, source)
|
||||
}
|
||||
|
||||
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"name":"方案草稿","title":"临时会话"}`, key)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("scratch session denied for project creator: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
view := decodeScratchSessionHTTP(t, rec)
|
||||
if !store.IsScratchProject(view.Project.Settings) || view.Project.Name != "方案草稿" || view.Conversation.Title != "临时会话" {
|
||||
t.Fatalf("scratch session body: %s", rec.Body.String())
|
||||
}
|
||||
// 显式空体同样可用(默认名)。
|
||||
if rec := bearerRequest(h, "POST", "/api/scratch-sessions", ``, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("empty scratch body: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchSessionCreateRejectsDisabledAccount 停用账号既进不来(凭证被撤),
|
||||
// 也不会绕过账号状态写出项目。
|
||||
func TestScratchSessionCreateRejectsDisabledAccount(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
key := scratchAccountKey(t, st, "disabled")
|
||||
account, err := st.GetAccountByOwner("disabled")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := do(h, "PUT", "/api/admin/users/"+account.ID+"/state",
|
||||
`{"expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"enabled":false}`, admin)
|
||||
if state.Code != http.StatusOK {
|
||||
t.Fatalf("disable account: %d %s", state.Code, state.Body.String())
|
||||
}
|
||||
|
||||
rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
|
||||
// 停用会消费已有凭证,所以请求在认证中间件就被拒(401);即使凭证仍在,
|
||||
// store 的账号有效性边界也会返回 403——两条路径都不写项目。
|
||||
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("disabled account scratch create: unexpected status %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Code == http.StatusUnauthorized && !strings.Contains(rec.Body.String(), "revoked") {
|
||||
t.Fatalf("disabled account scratch create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, _, err := st.CreateScratchSessionAccountChecked("disabled", "临时会话", "临时会话", "", false); err == nil {
|
||||
t.Fatal("disabled account reached the scratch create past the account fence")
|
||||
}
|
||||
if projects, err := st.ListProjects("disabled"); err != nil || len(projects) != 0 {
|
||||
t.Fatalf("disabled account left a project row: %+v %v", projects, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchMarkerIsServerOwned 客户端既不能在普通项目上加标记,也不能从
|
||||
// scratch 项目上摘掉,更不能借 POST /api/projects 直接造一个 scratch。
|
||||
func TestScratchMarkerIsServerOwned(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
superAdmin := bootSuperAdmin(t, h, st)
|
||||
key := scratchAccountKey(t, st, "creator")
|
||||
createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true)
|
||||
|
||||
normal := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)
|
||||
if normal.Code != http.StatusOK {
|
||||
t.Fatalf("create project: %d %s", normal.Code, normal.Body.String())
|
||||
}
|
||||
normalID := createdProjectID(t, normal)
|
||||
scratch := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key)
|
||||
if scratch.Code != http.StatusOK {
|
||||
t.Fatalf("create scratch: %d %s", scratch.Code, scratch.Body.String())
|
||||
}
|
||||
scratchID := decodeScratchSessionHTTP(t, scratch).Project.ID
|
||||
|
||||
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("patch added the scratch marker: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "PATCH", "/api/projects/"+scratchID, `{"settings":{"host":{"kind":"normal"}}}`, key); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("patch cleared the scratch marker: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"伪 scratch","settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("POST /api/projects created a scratch: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("scratch endpoint accepted client settings: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 不改标记的普通设置写入照旧放行。
|
||||
if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"note":"保留"}}`, key); rec.Code != http.StatusOK {
|
||||
t.Fatalf("unrelated settings patch refused: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// 被拒的写入没有落库:普通项目仍然不是 scratch。
|
||||
stored, err := st.GetProject("creator", normalID)
|
||||
if err != nil || store.IsScratchProject(stored.Settings) {
|
||||
t.Fatalf("rejected patch changed the marker: %+v %v", stored.Settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScratchIsNotAConfigurablePermission 权限目录与 replaceRolePermissions
|
||||
// 都没有 scratch 创建动作:能力内生,不可授予也不可撤销。
|
||||
func TestScratchIsNotAConfigurablePermission(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
viewerKey := scratchAccountKey(t, st, "viewer")
|
||||
|
||||
rec := do(h, "GET", "/api/admin/permissions/catalog", "", admin)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("catalog: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, descriptor := range catalog.Catalog() {
|
||||
if strings.Contains(strings.ToLower(descriptor.ActionID), "scratch") {
|
||||
t.Fatalf("catalog exposes a scratch action: %s", descriptor.ActionID)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "scratch") {
|
||||
t.Fatalf("catalog body mentions scratch: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"临时会话职位"}`, admin)
|
||||
if roleRec.Code != http.StatusOK {
|
||||
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
|
||||
}
|
||||
role := decodeRoleHTTP(t, roleRec)
|
||||
for _, actionID := range []string{"project.scratch.create", "scratch.create", "scratch.session.create"} {
|
||||
body := `{"expectedRoleVersion":1,"idempotencyKey":"scratch-action","changes":[{"actionId":"` + actionID + `","allowed":true,"capAllowed":true}]}`
|
||||
if rec := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", body, admin); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("role policy accepted %s: %d %s", actionID, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
view := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
|
||||
if strings.Contains(view.Body.String(), "scratch") {
|
||||
t.Fatalf("role view mentions scratch: %s", view.Body.String())
|
||||
}
|
||||
me := bearerRequest(h, "GET", "/api/me", "", viewerKey)
|
||||
if me.Code != http.StatusOK || strings.Contains(me.Body.String(), "scratch") {
|
||||
t.Fatalf("me mentions a scratch capability: %d %s", me.Code, me.Body.String())
|
||||
}
|
||||
}
|
||||
+133
-11
@@ -140,6 +140,9 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action
|
||||
// Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed
|
||||
// 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。
|
||||
func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool {
|
||||
if s.sharedScratchDenied(actor, projectID) {
|
||||
return false
|
||||
}
|
||||
if s.ProjectAuthzV2 == nil {
|
||||
return s.authorizeSharedProject(actor, projectID)
|
||||
}
|
||||
@@ -164,10 +167,24 @@ func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
|
||||
if s.ProjectAuthz == nil {
|
||||
return false
|
||||
}
|
||||
if s.sharedScratchDenied(owner, projectID) {
|
||||
return false
|
||||
}
|
||||
ok, err := s.ProjectAuthz(owner, projectID)
|
||||
return err == nil && ok
|
||||
}
|
||||
|
||||
// sharedScratchDenied 保证共享域授权永远打不开临时会话:临时会话是私有的
|
||||
// 1:1 会话外壳,不是共享资源,EXT-8/EXT-8b 适配器不得为他人授予它。属主
|
||||
// 不受影响(属主的读写按存储归属判定)。
|
||||
func (s *Server) sharedScratchDenied(actor string, projectID core.ID) bool {
|
||||
if s.st == nil || projectID == "" {
|
||||
return false
|
||||
}
|
||||
handled, err := s.st.ScratchProjectDecision(string(projectID), actor)
|
||||
return handled && err != nil
|
||||
}
|
||||
|
||||
func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server {
|
||||
sub, err := web.Dist()
|
||||
if err != nil {
|
||||
@@ -197,11 +214,14 @@ func (s *Server) recognize(ctx context.Context, text string) intentgate.MatchRes
|
||||
|
||||
// WireBuiltinCommands 注册内置命令(`/` 菜单全清单的数据源;处理器为宿主用例适配)。
|
||||
// 响应保持既有 {command, ...} 契约(frontend store.sendMessage 按此消费)。
|
||||
// 组合根在注入 Bus 之后调用一次(启动期);此处也是 operationID 对账查询端口的
|
||||
// 装配点:总线只在启动时刻拿到宿主端口,不在请求路径上改写总线状态。
|
||||
func (s *Server) WireBuiltinCommands() {
|
||||
b := s.Bus
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.SetOperationQuery(commandOperationQuery{st: s.st})
|
||||
must := func(err error) {
|
||||
if err != nil {
|
||||
panic("server: builtin command: " + err.Error()) // 装配期错误属编程错误
|
||||
@@ -334,6 +354,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/capability/explain", s.explainCapability)
|
||||
|
||||
// ---- 项目 / 会话 ----
|
||||
mux.HandleFunc("POST /api/scratch-sessions", s.createScratchSession)
|
||||
mux.HandleFunc("GET /api/projects", s.listProjects)
|
||||
mux.HandleFunc("POST /api/projects", s.createProject)
|
||||
mux.HandleFunc("PATCH /api/projects/{id}", s.patchProject)
|
||||
@@ -427,6 +448,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/workspaces", s.listWorkspaces)
|
||||
mux.HandleFunc("POST /api/commands/{id}", s.execCommand)
|
||||
mux.HandleFunc("GET /api/commands/audit", s.listAudits)
|
||||
// U7b/R7:operationID 对账查询(unknown 回执的收敛入口;owner 取自认证
|
||||
// 上下文,projectId 只收窄作用域)。
|
||||
mux.HandleFunc("GET /api/commands/operations/{operationId}", s.getCommandOperation)
|
||||
mux.HandleFunc("GET /api/parsers", s.listParsers)
|
||||
mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge)
|
||||
mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge)
|
||||
@@ -710,6 +734,10 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
out := make([]projectView, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
// scratch 项目不作为普通项目出现(provider 列表同样过滤)。
|
||||
if store.IsScratchProject(p.Settings) {
|
||||
continue
|
||||
}
|
||||
out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
@@ -748,12 +776,18 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 普通项目创建路径不能造 scratch:标记只由 POST /api/scratch-sessions 写。
|
||||
if store.IsScratchProject(req.Settings) {
|
||||
http.Error(w, "scratch sessions are created by POST /api/scratch-sessions", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.projectLifecycle != nil {
|
||||
if s.projectLifecycle.Create == nil {
|
||||
writeProjectErr(w, store.ErrNotFound)
|
||||
@@ -769,6 +803,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
owner := s.owner(r)
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Settings map[string]any `json:"settings"`
|
||||
@@ -777,6 +812,11 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 临时会话只有会话:除了改名没有任何项目设置可写(标记保护之外再加一道)。
|
||||
if req.Settings != nil && s.scratchProjectOf(id, owner) {
|
||||
http.Error(w, scratchNoSettings, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Settings != nil {
|
||||
values, err := s.set.PrepareProjectMetadataWrite(req.Settings)
|
||||
if err != nil {
|
||||
@@ -861,6 +901,37 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled))
|
||||
}
|
||||
|
||||
// conversationView 是下发给前端的会话视图:core.Conversation 原样透出,外加一个
|
||||
// 只读的 scratch 标记,供前端把临时会话归到“无项目对话”,不用再从项目列表反推。
|
||||
// core.Conversation 在另一个仓库,所以标记只存在于这份 server 本地视图里。
|
||||
// 标记 omitempty:普通项目的会话 JSON 与加标记之前逐字节同形。
|
||||
type conversationView struct {
|
||||
core.Conversation
|
||||
Scratch bool `json:"scratch,omitempty"`
|
||||
}
|
||||
|
||||
// scratchProjectSet 读出 owner 的 scratch 项目 id 集合;读不到时按空集处理——
|
||||
// 标记只是列表上的附加信息,取不到不改变列表本身(与详情里的辅助查询同款取舍)。
|
||||
func (s *Server) scratchProjectSet(owner string) map[string]bool {
|
||||
if s.st == nil {
|
||||
return map[string]bool{}
|
||||
}
|
||||
ids, err := s.st.ScratchProjectIDs(owner)
|
||||
if err != nil {
|
||||
return map[string]bool{}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// conversationViews 给一批会话打 scratch 标记;返回的每一项与 store 的会话一一对应。
|
||||
func (s *Server) conversationViews(scratch map[string]bool, cs []core.Conversation) []conversationView {
|
||||
out := make([]conversationView, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
out = append(out, conversationView{Conversation: c, Scratch: scratch[string(c.ProjectID)]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil {
|
||||
@@ -876,14 +947,15 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
cs, err := s.st.ListConversations(owner, r.PathValue("id"))
|
||||
writeJSONOr(w, cs, err)
|
||||
writeJSONOr(w, s.conversationViews(s.scratchProjectSet(owner), cs), err)
|
||||
}
|
||||
|
||||
// listTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档,?archived=1 含归档。
|
||||
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
v := r.URL.Query().Get("archived")
|
||||
cs, err := s.st.ListTasks(s.owner(r), v == "1" || v == "true")
|
||||
writeJSONOr(w, cs, err)
|
||||
owner := s.owner(r)
|
||||
cs, err := s.st.ListTasks(owner, v == "1" || v == "true")
|
||||
writeJSONOr(w, s.conversationViews(s.scratchProjectSet(owner), cs), err)
|
||||
}
|
||||
|
||||
// me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。
|
||||
@@ -949,8 +1021,9 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) {
|
||||
resumeSeq = seq - 1
|
||||
}
|
||||
}
|
||||
view := conversationView{Conversation: conv, Scratch: s.scratchProjectSet(owner)[string(conv.ProjectID)]}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"conversation": conv, "messages": msgs, "runs": runs,
|
||||
"conversation": view, "messages": msgs, "runs": runs,
|
||||
"activeHitl": cards, "lastSeq": lastSeq, "resumeSeq": resumeSeq,
|
||||
})
|
||||
}
|
||||
@@ -1156,6 +1229,8 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
routeKind := route.KindTurn
|
||||
var recognized intentgate.MatchResult
|
||||
var workflowDef core.WorkflowDef
|
||||
var admission *intentAdmission
|
||||
var clarify *clarifyPayload
|
||||
if s.Gate != nil && !strings.HasPrefix(text, "/") && req.TaskRef == nil {
|
||||
recognized = s.recognize(r.Context(), text)
|
||||
dec := route.Decide(recognized, route.Env{
|
||||
@@ -1170,13 +1245,23 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
switch dec.Kind {
|
||||
case route.KindCommand:
|
||||
routeKind = route.KindCommand
|
||||
recognized.ID = dec.CommandID
|
||||
recognized.Slots = dec.Slots
|
||||
// 接纳:按命令 DECLARED schema 把槽位映射成参数;准入未过
|
||||
// (必填参数无法由原文+槽位满足)就不派发,降为澄清回合(F09)。
|
||||
adm := admitIntentCommand(s.Bus, dec.CommandID, dec.Slots, text)
|
||||
clarify = newClarifyPayload(recognized, dec, &adm)
|
||||
if !adm.Blocked {
|
||||
routeKind = route.KindCommand
|
||||
recognized.ID = dec.CommandID
|
||||
recognized.Slots = dec.Slots
|
||||
admission = &adm
|
||||
}
|
||||
case route.KindWorkflow:
|
||||
routeKind = route.KindWorkflow
|
||||
workflowDef = dec.Workflow
|
||||
recognized.Slots = dec.Slots
|
||||
clarify = newClarifyPayload(recognized, dec, nil)
|
||||
default:
|
||||
clarify = newClarifyPayload(recognized, dec, nil)
|
||||
}
|
||||
}
|
||||
claimed := false
|
||||
@@ -1210,8 +1295,14 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if routeKind == route.KindCommand {
|
||||
// 参数按命令 DECLARED schema 映射(槽位→声明键/声明类型);原文恒供。
|
||||
params := map[string]any{chatTextParam: text}
|
||||
if admission != nil && admission.Params != nil {
|
||||
params = admission.Params
|
||||
params[chatTextParam] = text
|
||||
}
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner,
|
||||
ID: recognized.ID, Params: params, Entrance: "chat", Actor: owner,
|
||||
Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID, HostProjectID: string(conv.ProjectID),
|
||||
})
|
||||
out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": recognized.ID}
|
||||
@@ -1248,7 +1339,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
msg.RunID = run.ID
|
||||
writeJSON(w, http.StatusOK, map[string]any{"message": msg, "run": run})
|
||||
out := map[string]any{"message": msg, "run": run}
|
||||
if clarify != nil {
|
||||
// 澄清诉求(业务动作未派发):既有端点上的附加字段,缺失/非法各带结构化原因。
|
||||
out["clarify"] = clarify
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// writeExistingMessage 把同一 requestId 的已有消息和仍在进行的 run 原样返回。
|
||||
@@ -1280,6 +1376,10 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core
|
||||
http.Error(w, "未知命令 "+slash+"(/help 查看)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 临时会话里没有项目域:rdm.* 与正式项目命令一律 403。
|
||||
if s.refuseScratchCommandHost(w, string(conv.ProjectID), s.owner(r), id) {
|
||||
return
|
||||
}
|
||||
params, err := parseSlashParams(id, fields[1:])
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -1363,6 +1463,10 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 基座项目是临时会话时,项目域命令一律 403(无会话上下文的命令不在此门)。
|
||||
if s.refuseScratchCommandHost(w, hostProject, s.owner(r), commandID) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch := cmdbus.Request{
|
||||
ID: commandID, Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
|
||||
@@ -1703,7 +1807,9 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// 文本可解析的进检索索引(管线分块+全文+可选向量)。
|
||||
// EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。
|
||||
if !s.DisableUploadIndexing {
|
||||
// 临时会话只有会话:附件进入对话,但不进任何知识索引(否则会话材料会
|
||||
// 漏进别的会话的检索结果)。
|
||||
if !s.DisableUploadIndexing && !s.scratchProjectOf(projectID, owner) {
|
||||
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
|
||||
var indexErr error
|
||||
if s.Indexer != nil {
|
||||
@@ -2283,6 +2389,10 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
// 知识库是项目域能力:临时会话不接收任何文档入库。
|
||||
if s.refuseScratchProjectCapability(w, r.PathValue("id"), owner, scratchNoKnowledge) {
|
||||
return
|
||||
}
|
||||
for i := range docs {
|
||||
docs[i].Owner = owner
|
||||
docs[i].ProjectID = r.PathValue("id")
|
||||
@@ -2302,6 +2412,10 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
|
||||
query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK}
|
||||
owner := s.owner(r)
|
||||
// 检索面同样先问门禁:临时会话没有知识库(而不是“搜出来是空的”)。
|
||||
if s.refuseScratchProjectCapability(w, query.ProjectID, owner, scratchNoKnowledge) {
|
||||
return
|
||||
}
|
||||
if s.projectLifecycle != nil && query.ProjectID != "" {
|
||||
id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID)
|
||||
if err != nil {
|
||||
@@ -2409,6 +2523,14 @@ func writeOKOr(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, store.ErrScratchMarkerReserved) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrScratchSessionSingle) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrAccountForbidden) {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
|
||||
@@ -59,10 +59,14 @@ func TestCommandBusBeatsWorkflow(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, convID := setupConversation(t, srv.Handler())
|
||||
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
|
||||
// 槽位齐全才派发命令(F09):必填槽位缺失时命令不得执行,改走澄清回合。
|
||||
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "主题:核能 写一份简报"})
|
||||
if code != 200 || out["command"] != "demo.brief" {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, ok := out["clarify"]; ok {
|
||||
t.Fatalf("complete slots must not clarify: %v", out)
|
||||
}
|
||||
if _, ok := out["run"]; ok {
|
||||
t.Fatal("a command hit must not start a workflow run")
|
||||
}
|
||||
|
||||
@@ -225,6 +225,11 @@ func isHoldStatus(s string) bool {
|
||||
// AgentNativeOptions 可选组合根注入:返回配置且启用时,为每次小队运行注册
|
||||
// "agent-native" 内核后端工厂(模型/搜索/执行/MCP/技能根由组合根从 settings
|
||||
// 解析;Subagent 工厂经 rc 桥接产品 SpawnSubagent)。
|
||||
//
|
||||
// 组合根必须按「本次运行」解析配置:模型、端点与密钥整条来自 rc.Settings()
|
||||
// 已合并的当前用户/项目快照(叠加 rc 的画像模型覆盖),per-route 规则与直连
|
||||
// 共用同一构造器。按默认账号重新解析会让一个用户的模型名配上另一个账号的
|
||||
// 端点/密钥。
|
||||
var AgentNativeOptions func(rc Ctx) (nativeadp.Config, bool)
|
||||
|
||||
// SubagentFactory 把内核委派端口(delegate_task)桥接到产品 SpawnSubagent:
|
||||
|
||||
@@ -27,7 +27,9 @@ type EntryOptions struct {
|
||||
MaxTurns int
|
||||
MaxToolCalls int
|
||||
RepairBudget int
|
||||
Model string
|
||||
// Model 是本次运行的主模型(与 AgentNativeOptions 解析出的路由同源:
|
||||
// 同一份运行快照 + 画像覆盖),不得与工厂配的端点/密钥分属不同属主。
|
||||
Model string
|
||||
}
|
||||
|
||||
// EntryResult 是入口跑完后的文本。工件登记由调用方在虚假交付检查之后做。
|
||||
|
||||
@@ -40,6 +40,67 @@ func TestContextManifestTerminalRequiresMatchingAttempt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextManifestAttemptIsNotReusableAcrossProjectOrOwner(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "attempt-identity.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
base := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "attempt-1", InputSHA256: "input-hash", Delivery: "attempted"}
|
||||
if err := st.AppendContextManifest("alice", "run-1", base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.Delivery = "responded"
|
||||
if err := st.AppendContextManifest("alice", "run-1", base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A terminal receipt is matched against the whole persisted identity
|
||||
// (attempt, owner, target, project, input hash), so the same attempt id
|
||||
// cannot claim a trusted response under another project or owner.
|
||||
crossProject := base
|
||||
crossProject.ProjectID = "p2"
|
||||
if err := st.AppendContextManifest("alice", "run-1", crossProject); err == nil {
|
||||
t.Fatal("attempt id reused across project ids to claim a trusted response")
|
||||
}
|
||||
crossOwner := base
|
||||
if err := st.AppendContextManifest("bob", "run-1", crossOwner); err == nil {
|
||||
t.Fatal("attempt id reused across owners to claim a trusted response")
|
||||
}
|
||||
// Planting the send under a foreign scope is refused too: attempt identity
|
||||
// is global, so one trusted send has exactly one receipt pair.
|
||||
if err := st.AppendContextManifest("alice", "run-1", base); err == nil {
|
||||
t.Fatal("attempt id reused across projects to open a second send")
|
||||
}
|
||||
if err := st.AppendContextManifest("bob", "run-1", base); err == nil {
|
||||
t.Fatal("attempt id reused across owners to open a second send")
|
||||
}
|
||||
kept, err := st.ListContextManifests("alice", "run-1")
|
||||
if err != nil || len(kept) != 2 {
|
||||
t.Fatalf("refused cross-scope receipts changed the trusted set: %+v err=%v", kept, err)
|
||||
}
|
||||
for _, m := range kept {
|
||||
if m.ProjectID != "p1" || m.AttemptID != "attempt-1" {
|
||||
t.Fatalf("foreign scope leaked into the trusted receipts: %+v", m)
|
||||
}
|
||||
}
|
||||
if foreign, err := st.ListContextManifests("bob", "run-1"); err != nil || len(foreign) != 0 {
|
||||
t.Fatalf("cross-owner attempt became visible: %+v err=%v", foreign, err)
|
||||
}
|
||||
// A genuinely different attempt in another owner/project still works, so
|
||||
// the refusals above are identity-based rather than scope-based.
|
||||
other := extension.ProjectContextManifest{ProjectID: "p2", AttemptID: "attempt-2", InputSHA256: "other-input", Delivery: "attempted"}
|
||||
if err := st.AppendContextManifest("bob", "run-2", other); err != nil {
|
||||
t.Fatalf("a distinct attempt in another project/owner was refused: %v", err)
|
||||
}
|
||||
other.Delivery = "responded"
|
||||
if err := st.AppendContextManifest("bob", "run-2", other); err != nil {
|
||||
t.Fatalf("a distinct attempt could not record its own response: %v", err)
|
||||
}
|
||||
if mine, err := st.ListContextManifests("alice", "run-2"); err != nil || len(mine) != 0 {
|
||||
t.Fatalf("another owner's response became visible: %+v err=%v", mine, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextManifestDoesNotPersistWorkflowOutputInResolveScope(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "scope-privacy.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -148,6 +148,10 @@ func (s *Store) SaveProjectAuditLink(link ProjectAuditLink) (ProjectAuditLink, e
|
||||
|
||||
// ProjectAuditLinkedReceipt reads one exact association and the underlying
|
||||
// host row. It does not enumerate private links or decide field disclosure.
|
||||
// The published binding is immutable: a row that was deleted, or edited after
|
||||
// publication (state, operation, owner, or the stored envelope), no longer
|
||||
// resolves as this link. A read must never project something the domain
|
||||
// verifier never vouched for, so drift is refused instead of re-projected.
|
||||
func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) {
|
||||
var row ProjectCommandAuditRow
|
||||
var link ProjectAuditLink
|
||||
@@ -157,6 +161,7 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string
|
||||
}
|
||||
err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l
|
||||
JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id
|
||||
AND a.state='committed' AND a.operation_id=l.operation_id
|
||||
WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`),
|
||||
projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner,
|
||||
&link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID,
|
||||
@@ -171,5 +176,11 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string
|
||||
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
|
||||
return link, row, err
|
||||
}
|
||||
// The link was published only against a committed row owned by link.Owner whose
|
||||
// envelope carried this operation. Re-check the envelope itself: a payload that
|
||||
// no longer matches the verified binding is drift, not a new fact.
|
||||
if row.Envelope.OperationID != link.OperationID || row.Envelope.Actor != link.Owner || string(row.Envelope.State) != "committed" {
|
||||
return link, row, ErrConflict
|
||||
}
|
||||
return link, row, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -95,3 +97,84 @@ func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) {
|
||||
t.Fatalf("ambiguous host operation must not choose one: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 已发布链接的绑定不可变:宿主回执在发布后被删或被改(状态、operation、载荷)
|
||||
// 时,读取侧不得再把它当作领域验证过的那一条返回。SQLite 是本测试的落点——
|
||||
// PostgreSQL 由 all-writer 触发器在写侧直接拒绝改动。
|
||||
func TestProjectAuditLinkedReceiptRefusesEditedOrDeletedHostRows(t *testing.T) {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "links.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := st.EnsureUser("alice", "Alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := st.CreateProject("alice", "A")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := st.CreateConversation("alice", string(p.ID), "work", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
|
||||
OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(p.ID), Owner: "alice", SourceKind: "domain-commit",
|
||||
SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
|
||||
if _, err := st.SaveProjectAuditLink(link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, audit, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); err != nil ||
|
||||
got.OperationID != "op-1" || audit.Envelope.CommandID != "rdm.confirm" {
|
||||
t.Fatalf("baseline link: %+v %+v %v", got, audit, err)
|
||||
}
|
||||
var stored string
|
||||
if err := st.db.QueryRow(st.q(`SELECT payload FROM command_audit WHERE id=?`), rows[0].Offset).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
|
||||
OperationID: "op-forged", ConversationID: string(c.ID), State: agentcommand.StateCommitted})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), string(forged), rows[0].Offset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("edited envelope still resolved as the verified link: %v", err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), stored, rows[0].Offset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-other", rows[0].Offset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("edited operation column still resolved: %v", err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-1", rows[0].Offset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "accepted", rows[0].Offset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("uncommitted row still resolved: %v", err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "committed", rows[0].Offset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET project_id=? WHERE id=?`), "", rows[0].Offset); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("re-attributed row still resolved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+52
-17
@@ -19,17 +19,34 @@ func (s *Store) CreateProject(owner, name string) (core.Project, error) {
|
||||
return p, err
|
||||
}
|
||||
|
||||
// ListProjects 不含 scratch 项目:临时会话不作为普通项目出现在列表里。
|
||||
func (s *Store) ListProjects(owner string) ([]core.Project, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE owner=? ORDER BY updated_at DESC`), owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjects(rows)
|
||||
all, err := scanProjects(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return withoutScratchProjects(all), nil
|
||||
}
|
||||
|
||||
func withoutScratchProjects(in []core.Project) []core.Project {
|
||||
out := make([]core.Project, 0, len(in))
|
||||
for _, p := range in {
|
||||
if IsScratchProject(p.Settings) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
|
||||
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
|
||||
// 与 ListProjects 一致,scratch 项目不进入候选。
|
||||
func (s *Store) ListAllProjects() ([]core.Project, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
|
||||
if err != nil {
|
||||
@@ -48,6 +65,9 @@ func (s *Store) ListAllProjects() ([]core.Project, error) {
|
||||
if settings != "" && settings != "{}" {
|
||||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
if IsScratchProject(p.Settings) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -91,11 +111,11 @@ func (s *Store) UpdateProjectSettings(owner, id string, values map[string]any) e
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur := map[string]any{}
|
||||
if settings != "" && settings != "{}" {
|
||||
cur, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
cur := decodeProjectSettings(settings)
|
||||
merged := core.MergeSettings(cur, values)
|
||||
if err := rejectScratchMarkerWrite(cur, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?, updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -215,11 +235,11 @@ func (s *Store) UpdateProjectSettingsAccountChecked(actor, id string, values map
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur := map[string]any{}
|
||||
if settings != "" && settings != "{}" {
|
||||
cur, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
cur := decodeProjectSettings(settings)
|
||||
merged := core.MergeSettings(cur, values)
|
||||
if err := rejectScratchMarkerWrite(cur, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?,updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -255,15 +275,18 @@ func scanProjects(rows *sql.Rows) ([]core.Project, error) {
|
||||
// ---- 会话 ----
|
||||
|
||||
func (s *Store) CreateConversation(owner, projectID, title, profileID string) (core.Conversation, error) {
|
||||
// 项目归属校验:会话必须落在请求者的项目下。
|
||||
var one int
|
||||
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&one)
|
||||
// 项目归属校验:会话必须落在请求者的项目下;scratch 项目保持 1:1。
|
||||
var settings string
|
||||
err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&settings)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.Conversation{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
if err := refuseScratchConversation(settings); err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
|
||||
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
|
||||
@@ -273,14 +296,17 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c
|
||||
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
|
||||
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
|
||||
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
|
||||
var one int
|
||||
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
|
||||
var settings string
|
||||
err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=?`), projectID).Scan(&settings)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.Conversation{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
if err := refuseScratchConversation(settings); err != nil {
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
|
||||
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
|
||||
@@ -438,8 +464,13 @@ func (s *Store) SetPermissionMode(owner, id, mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteConversation deletes one conversation; deleting the sole conversation of
|
||||
// a scratch project also removes that scratch project, so a scratch session is
|
||||
// never left as an empty shell project.
|
||||
func (s *Store) DeleteConversation(owner, id string) error {
|
||||
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
|
||||
return s.deleteConversationCascade(s.db, owner, id, func() error {
|
||||
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
|
||||
})
|
||||
}
|
||||
|
||||
// withOwnedConversationWrite locks the actor before the conversation and
|
||||
@@ -495,10 +526,14 @@ func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID str
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteConversationAccountChecked retains the legacy owner-only delete rule.
|
||||
// DeleteConversationAccountChecked deletes the conversation; when it was the
|
||||
// sole conversation of a scratch project, that scratch project goes with it in
|
||||
// the same write transaction, so a scratch session cannot be left half-deleted.
|
||||
func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
||||
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
|
||||
return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor)
|
||||
return s.deleteConversationCascade(tx, actor, id, func() error {
|
||||
return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2252,7 +2252,7 @@ func (s *Store) upsertRolePermissionsTx(tx *sql.Tx, actorID, roleID string, curr
|
||||
for _, row := range writes {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?) ON CONFLICT(role_id,action_id) DO UPDATE SET allowed=excluded.allowed,cap_allowed=excluded.cap_allowed,
|
||||
permission_version=permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
|
||||
permission_version=role_permissions.permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`),
|
||||
roleID, row.ActionID, row.Allowed, row.CapAllowed, 1, actorID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// 临时会话(scratch session):一个 scratch 项目 + 恰好一个私有会话。
|
||||
// scratch 属性只由项目 settings 的 host.kind 表达——它随项目行一起存储,
|
||||
// 所以判定只需要一个读函数,任何调用点都不自己比对字符串。
|
||||
|
||||
const (
|
||||
// ScratchHostKind 是 projects.settings.host.kind 的临时会话取值。
|
||||
ScratchHostKind = "scratch"
|
||||
// ScratchDefaultProjectName 是未指定名称时临时会话项目使用的默认名。
|
||||
ScratchDefaultProjectName = "临时会话"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrScratchSessionSingle 会话与临时会话项目是 1:1:第二个会话被拒。
|
||||
ErrScratchSessionSingle = errors.New("store: scratch session holds exactly one conversation")
|
||||
// ErrScratchMarkerReserved 标记归服务端所有:客户端既不能给普通项目
|
||||
// 加上 scratch,也不能从已有 scratch 项目上摘掉它。
|
||||
ErrScratchMarkerReserved = errors.New("store: scratch host marker is server-owned")
|
||||
)
|
||||
|
||||
// ScratchHostSettings 返回标记 scratch 项目的 settings 值。
|
||||
func ScratchHostSettings() map[string]any {
|
||||
return map[string]any{"host": map[string]any{"kind": ScratchHostKind}}
|
||||
}
|
||||
|
||||
// IsScratchProject 报告 settings 是否带 scratch 宿主标记。已存储的
|
||||
// projects.settings 与尚未合并的客户端 settings 都用它判定。
|
||||
func IsScratchProject(settings map[string]any) bool {
|
||||
host, ok := settings["host"].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
kind, _ := host["kind"].(string)
|
||||
return kind == ScratchHostKind
|
||||
}
|
||||
|
||||
// ScratchProjectIDs 返回 owner 名下带 scratch 标记的项目 id 集合:一次查询读出
|
||||
// 该 owner 的全部 projects.settings,逐行按 IsScratchProject 判定。列表响应要给
|
||||
// 一批会话打标时走它,不必逐个会话回查项目;别人的 scratch 项目不在结果里。
|
||||
func (s *Store) ScratchProjectIDs(owner string) (map[string]bool, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,settings FROM projects WHERE owner=?`), owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id, settings string
|
||||
if err := rows.Scan(&id, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsScratchProject(decodeProjectSettings(settings)) {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// IsScratchProjectID 是内部可信判定:按 id 读 projects.settings 的 host 标记。
|
||||
// 调用点(HTTP 门禁、回合门禁、命令分发)一律走它,不自己比对标记字符串。
|
||||
func (s *Store) IsScratchProjectID(projectID string) (bool, error) {
|
||||
if projectID == "" {
|
||||
return false, nil
|
||||
}
|
||||
p, err := s.ProjectMetadata(projectID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return IsScratchProject(p.Settings), nil
|
||||
}
|
||||
|
||||
// ScratchProjectDecision 是临时会话的归属判定,回答“这个调用是否发生在某个
|
||||
// 临时会话里”:
|
||||
// - handled=false:不是临时会话(或项目读不到),调用点继续走原来的判定面;
|
||||
// - handled=true, err=nil:调用者是该临时会话的属主,放行且不必再问 RDM;
|
||||
// - handled=true, err=ErrNotFound:临时会话不是共享资源,其他人一律不可见。
|
||||
//
|
||||
// 判据只来自存储里的属主列与 host 标记,不接受调用方自称的身份。
|
||||
func (s *Store) ScratchProjectDecision(projectID, actor string) (bool, error) {
|
||||
if projectID == "" {
|
||||
return false, nil
|
||||
}
|
||||
p, err := s.ProjectMetadata(projectID)
|
||||
if err != nil {
|
||||
// 读不到项目(不存在或存储异常):存在性与共享判定留给原路径,
|
||||
// 这里不把“读不到”当成临时会话,也就不会放宽任何门禁。
|
||||
return false, nil
|
||||
}
|
||||
if !IsScratchProject(p.Settings) {
|
||||
return false, nil
|
||||
}
|
||||
if p.Owner != core.ID(actor) {
|
||||
return true, ErrNotFound
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// scratchQuerier 是本文件里临时会话判定可用的两种执行面(*sql.DB 与 *sql.Tx),
|
||||
// 于是同一个事务内的级联删除与 store 级删除共用一份语义。
|
||||
type scratchQuerier interface {
|
||||
QueryRow(string, ...any) *sql.Row
|
||||
Exec(string, ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// scratchProjectOfConversation 返回会话所属的 scratch 项目 id;普通项目返回空串。
|
||||
func (s *Store) scratchProjectOfConversation(q scratchQuerier, owner, conversationID string) (string, error) {
|
||||
var projectID, settings string
|
||||
err := q.QueryRow(s.q(`SELECT c.project_id, COALESCE(p.settings,'') FROM conversations c LEFT JOIN projects p ON p.id=c.project_id WHERE c.id=? AND c.owner=?`),
|
||||
conversationID, owner).Scan(&projectID, &settings)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !IsScratchProject(decodeProjectSettings(settings)) {
|
||||
return "", nil
|
||||
}
|
||||
return projectID, nil
|
||||
}
|
||||
|
||||
// deleteScratchProjectIfEmpty 在会话已被删掉之后连带删掉它的临时会话项目:
|
||||
// 临时会话项目只承载这一个会话,删会话即删临时会话(同一个执行面、同一个门禁)。
|
||||
// 仍有会话时不动(1:1 由创建面保证,删除面不依赖该不变量做正确性);普通项目
|
||||
// id 传进来也不会被删。
|
||||
func (s *Store) deleteScratchProjectIfEmpty(q scratchQuerier, owner, projectID string) error {
|
||||
if projectID == "" {
|
||||
return nil
|
||||
}
|
||||
var remaining int
|
||||
if err := q.QueryRow(s.q(`SELECT COUNT(1) FROM conversations WHERE project_id=?`), projectID).Scan(&remaining); err != nil {
|
||||
return err
|
||||
}
|
||||
if remaining > 0 {
|
||||
return nil
|
||||
}
|
||||
res, err := q.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), projectID, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteConversationCascade 删除一个会话,并在它是临时会话唯一会话时连带删掉
|
||||
// 临时会话项目。account-checked 路径在最终写事务内调用,legacy 路径在同一执行
|
||||
// 面上调用:语义只有一份。
|
||||
func (s *Store) deleteConversationCascade(q scratchQuerier, owner, id string, del func() error) error {
|
||||
scratchProject, err := s.scratchProjectOfConversation(q, owner, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := del(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteScratchProjectIfEmpty(q, owner, scratchProject)
|
||||
}
|
||||
|
||||
// decodeProjectSettings 把存储的 settings JSON 读成 map;缺省或损坏都是空表。
|
||||
func decodeProjectSettings(raw string) map[string]any {
|
||||
if raw == "" || raw == "{}" {
|
||||
return map[string]any{}
|
||||
}
|
||||
cur, err := unmarshalJSON[map[string]any](raw)
|
||||
if err != nil || cur == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// rejectScratchMarkerWrite 拒绝改变 scratch 标记的客户端写入。保持标记不变的
|
||||
// 普通设置写入(例如给 scratch 项目改别的键)照旧通过。
|
||||
func rejectScratchMarkerWrite(current, merged map[string]any) error {
|
||||
if IsScratchProject(current) != IsScratchProject(merged) {
|
||||
return ErrScratchMarkerReserved
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refuseScratchConversation 按存储的 projects.settings 判定 1:1:scratch 项目
|
||||
// 下再建会话即拒,错误在会话插入之前返回。
|
||||
func refuseScratchConversation(rawSettings string) error {
|
||||
if IsScratchProject(decodeProjectSettings(rawSettings)) {
|
||||
return ErrScratchSessionSingle
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateScratchSessionAccountChecked 在一个事务里建出 scratch 项目与它唯一的
|
||||
// 会话:项目写入 host.kind 标记,会话写 cross_private=true。两者都发生在校验
|
||||
// 过的 actor 行锁之下,所以临时会话与普通项目走同一条账号有效性边界。
|
||||
// 调用方不需要(也不应)先调 CreateProjectAccountChecked——该路径不带标记,
|
||||
// 无法表示临时会话。
|
||||
func (s *Store) CreateScratchSessionAccountChecked(actor, name, title, profileID string, allowImplicitDefault bool) (core.Project, core.Conversation, error) {
|
||||
now := time.Now().UTC()
|
||||
project := core.Project{
|
||||
ID: newID("prj"), Owner: core.ID(actor), Name: name,
|
||||
CreatedAt: now, UpdatedAt: now, Settings: ScratchHostSettings(),
|
||||
}
|
||||
conversation := core.Conversation{
|
||||
ID: newID("cnv"), ProjectID: project.ID, Owner: core.ID(actor), Title: title, ProfileID: profileID,
|
||||
AllowCrossRead: true, CrossPrivate: true, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
|
||||
project.ID, actor, project.Name, marshalJSON(project.Settings),
|
||||
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,cross_private,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`),
|
||||
conversation.ID, conversation.ProjectID, actor, conversation.Title, conversation.ProfileID,
|
||||
true, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return core.Project{}, core.Conversation{}, err
|
||||
}
|
||||
return project, conversation, nil
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 临时会话 store 契约(U1):一个 scratch 项目 + 恰好一个私有会话,
|
||||
// 项目列表不出现它,会话列表仍然出现它,标记不可被客户端写入改动。
|
||||
|
||||
func scratchAccountFixture(t *testing.T, st *Store, owner string, admin bool) {
|
||||
t.Helper()
|
||||
if err := st.CreateAccount(owner, owner, "", admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchSessionCreatesOnePrivateConversationUnderScratchProject(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
|
||||
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("create scratch session: %v", err)
|
||||
}
|
||||
if !IsScratchProject(project.Settings) {
|
||||
t.Fatalf("project is not marked scratch: %+v", project.Settings)
|
||||
}
|
||||
if string(project.Owner) != "alice" || project.ID == "" {
|
||||
t.Fatalf("unexpected project: %+v", project)
|
||||
}
|
||||
if string(conversation.ProjectID) != string(project.ID) || string(conversation.Owner) != "alice" {
|
||||
t.Fatalf("conversation is not under the scratch project: %+v", conversation)
|
||||
}
|
||||
if !conversation.CrossPrivate || !conversation.AllowCrossRead {
|
||||
t.Fatalf("scratch conversation must be private and cross-readable: %+v", conversation)
|
||||
}
|
||||
// 读回的项目行带着同一个标记,判定只有 IsScratchProject 一个来源。
|
||||
stored, err := st.GetProject("alice", string(project.ID))
|
||||
if err != nil || !IsScratchProject(stored.Settings) {
|
||||
t.Fatalf("stored project marker: %+v %v", stored.Settings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchProjectsStayOutOfProjectListsButNotConversationLists(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
scratchAccountFixture(t, st, "bob", false)
|
||||
|
||||
normal, err := st.CreateProject("alice", "普通项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 他人的 scratch 也不进全量候选(共享适配器枚举)。
|
||||
if _, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
projects, err := st.ListProjects("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(projects) != 1 || projects[0].ID != normal.ID {
|
||||
t.Fatalf("ListProjects must hide scratch projects: %+v", projects)
|
||||
}
|
||||
all, err := st.ListAllProjects()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 1 || all[0].ID != normal.ID {
|
||||
t.Fatalf("ListAllProjects must hide scratch projects: %+v", all)
|
||||
}
|
||||
// 侧栏任务列表仍然要能看到临时会话。
|
||||
tasks, err := st.ListTasks("alice", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, task := range tasks {
|
||||
if task.ID == conversation.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("ListTasks dropped the scratch conversation: %+v", tasks)
|
||||
}
|
||||
conversations, err := st.ListConversations("alice", string(project.ID))
|
||||
if err != nil || len(conversations) != 1 || conversations[0].ID != conversation.ID {
|
||||
t.Fatalf("ListConversations under scratch: %+v %v", conversations, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchProjectRefusesSecondConversationOnEveryStoreCreatePath(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// account-checked 路径由服务端共用的会话创建入口(createPrivateConversation,
|
||||
// 覆盖 POST /api/projects/{id}/conversations 与 /new)先行拒绝,见
|
||||
// internal/server/scratch_session_test.go;这里的 store 契约覆盖本文件内
|
||||
// 的两个创建函数。
|
||||
for name, create := range map[string]func() error{
|
||||
"legacy": func() error {
|
||||
_, err := st.CreateConversation("alice", string(project.ID), "第二个", "")
|
||||
return err
|
||||
},
|
||||
"shared": func() error {
|
||||
_, err := st.CreateConversationInProject("alice", string(project.ID), "第二个", "")
|
||||
return err
|
||||
},
|
||||
} {
|
||||
if err := create(); !errors.Is(err, ErrScratchSessionSingle) {
|
||||
t.Fatalf("%s path allowed a second conversation: %v", name, err)
|
||||
}
|
||||
}
|
||||
conversations, err := st.ListConversations("alice", string(project.ID))
|
||||
if err != nil || len(conversations) != 1 {
|
||||
t.Fatalf("refused create still wrote a row: %+v %v", conversations, err)
|
||||
}
|
||||
// 普通项目不受影响。
|
||||
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "正常会话", "", false, true); err != nil {
|
||||
t.Fatalf("normal project lost conversation create: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchHostMarkerIsServerOwned(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
scratchAccountFixture(t, st, "bob", false)
|
||||
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 普通项目不能被加上标记(两种写入路径都不行)。
|
||||
for name, write := range map[string]func(id string) error{
|
||||
"legacy": func(id string) error {
|
||||
return st.UpdateProjectSettings("alice", id, ScratchHostSettings())
|
||||
},
|
||||
"account-checked": func(id string) error {
|
||||
return st.UpdateProjectSettingsAccountChecked("alice", id, ScratchHostSettings(), false)
|
||||
},
|
||||
} {
|
||||
if err := write(string(normal.ID)); !errors.Is(err, ErrScratchMarkerReserved) {
|
||||
t.Fatalf("%s path added the scratch marker: %v", name, err)
|
||||
}
|
||||
}
|
||||
// scratch 项目也不能被摘掉标记。
|
||||
cleared := map[string]any{"host": map[string]any{"kind": "normal"}}
|
||||
if err := st.UpdateProjectSettings("alice", string(project.ID), cleared); !errors.Is(err, ErrScratchMarkerReserved) {
|
||||
t.Fatalf("scratch marker cleared: %v", err)
|
||||
}
|
||||
if err := st.UpdateProjectSettingsAccountChecked("alice", string(project.ID), map[string]any{"host": nil}, false); !errors.Is(err, ErrScratchMarkerReserved) {
|
||||
t.Fatalf("scratch marker dropped by null: %v", err)
|
||||
}
|
||||
// 不改变标记的普通写入照旧放行。
|
||||
if err := st.UpdateProjectSettings("alice", string(project.ID), map[string]any{"note": "保留标记"}); err != nil {
|
||||
t.Fatalf("unrelated settings write refused: %v", err)
|
||||
}
|
||||
kept, err := st.GetProject("alice", string(project.ID))
|
||||
if err != nil || !IsScratchProject(kept.Settings) || kept.Settings["note"] != "保留标记" {
|
||||
t.Fatalf("scratch project settings after unrelated write: %+v %v", kept.Settings, err)
|
||||
}
|
||||
if err := st.UpdateProjectSettings("alice", string(normal.ID), map[string]any{"note": "普通"}); err != nil {
|
||||
t.Fatalf("normal project settings write refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchSessionCreateFollowsAccountState(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "admin", true)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
|
||||
if _, _, err := st.CreateScratchSessionAccountChecked("missing", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("unknown actor created a scratch session: %v", err)
|
||||
}
|
||||
account, err := st.GetAccountByOwner("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("disabled account created a scratch session: %v", err)
|
||||
}
|
||||
projects, err := st.ListProjects("alice")
|
||||
if err != nil || len(projects) != 0 {
|
||||
t.Fatalf("refused create left a project row: %+v %v", projects, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchSessionTransactionLeavesNothingOnFailure(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
// 缺账号时事务回滚,项目与会话都不落库。
|
||||
if _, _, err := st.CreateScratchSessionAccountChecked("ghost", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) {
|
||||
t.Fatalf("ghost actor: %v", err)
|
||||
}
|
||||
all, err := st.ListAllProjects()
|
||||
if err != nil || len(all) != 0 {
|
||||
t.Fatalf("rolled back create left rows: %+v %v", all, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- U2:隔离门禁的 store 契约 ----
|
||||
|
||||
// IsScratchProjectID / ScratchProjectDecision 是所有门禁唯一的判定来源:
|
||||
// 属主放行,其他人 NotFound,普通项目不接管(交回原判定面)。
|
||||
func TestScratchProjectLookupAndOwnershipDecision(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
scratchAccountFixture(t, st, "bob", false)
|
||||
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
projectID string
|
||||
scratch bool
|
||||
err error
|
||||
}{
|
||||
{"scratch", string(project.ID), true, nil},
|
||||
{"normal", string(normal.ID), false, nil},
|
||||
{"empty", "", false, nil},
|
||||
{"missing", "prj_missing", false, ErrNotFound},
|
||||
} {
|
||||
got, err := st.IsScratchProjectID(tc.projectID)
|
||||
if got != tc.scratch || !errors.Is(err, tc.err) {
|
||||
t.Fatalf("%s: IsScratchProjectID = %v, %v (want %v, %v)", tc.name, got, err, tc.scratch, tc.err)
|
||||
}
|
||||
}
|
||||
|
||||
// 属主:门禁接管并放行(不查 RDM)。
|
||||
handled, err := st.ScratchProjectDecision(string(project.ID), "alice")
|
||||
if !handled || err != nil {
|
||||
t.Fatalf("scratch owner decision = %v, %v", handled, err)
|
||||
}
|
||||
// 其他人:NotFound——临时会话不是共享资源。
|
||||
if handled, err := st.ScratchProjectDecision(string(project.ID), "bob"); !handled || !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("scratch non-owner decision = %v, %v", handled, err)
|
||||
}
|
||||
// 普通项目与空项目 id 都不接管,原路径继续。
|
||||
if handled, err := st.ScratchProjectDecision(string(normal.ID), "bob"); handled || err != nil {
|
||||
t.Fatalf("normal project decision = %v, %v", handled, err)
|
||||
}
|
||||
if handled, err := st.ScratchProjectDecision("", "bob"); handled || err != nil {
|
||||
t.Fatalf("empty project decision = %v, %v", handled, err)
|
||||
}
|
||||
// 读不到的项目不按临时会话处理(不因此放宽任何门禁)。
|
||||
if handled, err := st.ScratchProjectDecision("prj_missing", "alice"); handled || err != nil {
|
||||
t.Fatalf("missing project decision = %v, %v", handled, err)
|
||||
}
|
||||
_ = conversation
|
||||
}
|
||||
|
||||
// ScratchProjectIDs 只报属主自己的 scratch 项目:列表响应靠它给会话打标,
|
||||
// 所以普通项目与他人临时会话都不在里面。
|
||||
func TestScratchProjectIDsCoverOnlyOwnScratchProjects(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
scratchAccountFixture(t, st, "bob", false)
|
||||
scratch, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, err := st.CreateScratchSessionAccountChecked("alice", "方案草稿", "方案草稿", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ids, err := st.ScratchProjectIDs("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 2 || !ids[string(scratch.ID)] || !ids[string(second.ID)] {
|
||||
t.Fatalf("alice scratch ids = %+v", ids)
|
||||
}
|
||||
if ids[string(normal.ID)] {
|
||||
t.Fatalf("normal project marked scratch: %+v", ids)
|
||||
}
|
||||
// 租户边界:他人的临时会话不出现在 alice 的集合里。
|
||||
if ids[string(other.ID)] {
|
||||
t.Fatalf("other owner's scratch project leaked: %+v", ids)
|
||||
}
|
||||
others, err := st.ScratchProjectIDs("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(others) != 1 || !others[string(other.ID)] {
|
||||
t.Fatalf("bob scratch ids = %+v", others)
|
||||
}
|
||||
// 没有项目的人拿到空集合而不是 nil 之外的东西(判空与遍历都安全)。
|
||||
if empty, err := st.ScratchProjectIDs("carol"); err != nil || len(empty) != 0 {
|
||||
t.Fatalf("empty scratch ids = %+v, %v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 删掉临时会话的唯一会话即删掉它的 scratch 项目(不留空壳);普通项目照旧。
|
||||
func TestDeletingScratchConversationDeletesScratchProject(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
scratchAccountFixture(t, st, "bob", false)
|
||||
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 他人删除:仍然 NotFound,且临时会话原样存在。
|
||||
if err := st.DeleteConversationAccountChecked("bob", string(conversation.ID), false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("non-owner delete = %v", err)
|
||||
}
|
||||
if _, err := st.ProjectMetadata(string(project.ID)); err != nil {
|
||||
t.Fatalf("non-owner delete removed the scratch project: %v", err)
|
||||
}
|
||||
|
||||
if err := st.DeleteConversationAccountChecked("alice", string(conversation.ID), false); err != nil {
|
||||
t.Fatalf("owner delete: %v", err)
|
||||
}
|
||||
if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("scratch project survived its only conversation: %v", err)
|
||||
}
|
||||
if scratch, err := st.IsScratchProjectID(string(project.ID)); scratch || !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("deleted scratch project still answers as scratch: %v %v", scratch, err)
|
||||
}
|
||||
|
||||
// 普通项目的会话删除不动项目。
|
||||
normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "会话", "", false, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DeleteConversationAccountChecked("alice", string(conv.ID), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ProjectMetadata(string(normal.ID)); err != nil {
|
||||
t.Fatalf("normal project removed with its conversation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// legacy DeleteConversation 是同一条语义:删会话即删临时会话外壳。
|
||||
func TestLegacyDeleteConversationCascadesScratchProject(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
scratchAccountFixture(t, st, "alice", false)
|
||||
project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DeleteConversation("alice", string(conversation.ID)); err != nil {
|
||||
t.Fatalf("legacy delete: %v", err)
|
||||
}
|
||||
if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("legacy delete left the scratch shell: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -558,6 +558,13 @@ func (r *hostRunner) readStepOutput(workDir, stepID string) string {
|
||||
|
||||
// renderRefs 运行期解析残留的 {{steps.X.output}} 占位符(宿主步实参/正文;
|
||||
// workDir 为本步子工作区,经其上两级回到运行根再定位目标步产出)。
|
||||
// 信任不变式(本函数不校验任何东西,只依赖调用顺序):直接调用方只有 doTool
|
||||
// 与 doArtifact,二者所在的宿主步在 Run 里以 strict=true 先跑
|
||||
// verifyPriorWorkflowOutputs,逐个 prompt 步调用 VerifyProjectStepOutput
|
||||
// 重校验来源之后才会走到这里;strict 覆盖 wf.tool/wf.artifact/wf.script/
|
||||
// wf.critic,即所有把上文送模型或落盘的目的步。因此 renderRefs 不会读到
|
||||
// 未重校验的机器产出。新增不以 strict 运行的调用方(如 wf.card/wf.hitl
|
||||
// 直读他步产出)会打破该不变式,必须在新增调用点自行校验,不得依赖本函数。
|
||||
func (r *hostRunner) renderRefs(s, workDir string) string {
|
||||
if !strings.Contains(s, "{{steps.") {
|
||||
return s
|
||||
|
||||
@@ -75,7 +75,13 @@ import (
|
||||
// directory: RoleOption roleId+name, no permissions or bindings) and the
|
||||
// AccountDirectory purpose project.member-roster, an explicit project member
|
||||
// enumeration inside the trusted RDM scope.
|
||||
const SurfaceVersion = "2.28.0-experimental"
|
||||
// 2.29.0-experimental: X2 project audit read completion. ProjectAuditQuery gains
|
||||
// optional linked resource/run locators, IncludeBody, and Export; receipts may
|
||||
// carry ProjectAuditReceiptResource only under the domain's per-object body
|
||||
// right; ProjectAuditCoverage states Export scope. Every addition is an optional
|
||||
// field or a new exported type: existing ProjectAuditHost/ProjectAuditRegistry
|
||||
// providers and queries keep compiling and behaving unchanged.
|
||||
const SurfaceVersion = "2.29.0-experimental"
|
||||
|
||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||
|
||||
@@ -74,6 +74,19 @@ type ProjectAuditDisclosure struct {
|
||||
Body bool
|
||||
}
|
||||
|
||||
// ProjectAuditDisclosureAuthorizer answers one per-object decision for the
|
||||
// authenticated actor on a stored, immutable link. Metadata and Body are
|
||||
// separate authorities: the host never substitutes one for the other, and it
|
||||
// never answers a body request from a metadata-only right.
|
||||
//
|
||||
// TODO(RDM consumer, out of this repository): the decision itself — the current
|
||||
// project audit.read grant, the document read duty plus stage scope that carries
|
||||
// 正文权, and any export authority — belongs to the domain plugin that registers
|
||||
// this callback. The Backend only refuses, trims, and reports coverage; it must
|
||||
// not grow a local account/project policy to answer in the domain's place. A
|
||||
// later RDM unit implements the callbacks and can then widen Coverage
|
||||
// (Export, ResourceBodies, RunRelations) from this base's current honest
|
||||
// "unsupported" statements.
|
||||
type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error)
|
||||
|
||||
// ProjectAuditPublisherFactory is issued only by successful verifier
|
||||
@@ -107,6 +120,11 @@ type ProjectAuditPublisher interface {
|
||||
// database guards prove a
|
||||
// committed prefix; otherwise the host rejects the read instead of claiming a
|
||||
// fixed snapshot. See Coverage.CutoffConsistency.
|
||||
//
|
||||
// A read is keyed by the trusted project, the authenticated actor, the verified
|
||||
// resource and run, and the source offset plus cutoff. Every caller-supplied
|
||||
// field is a locator, never an authorization: the host re-checks it against
|
||||
// stored host facts and the domain callbacks before a receipt is projected.
|
||||
type ProjectAuditQuery struct {
|
||||
ProjectID core.ID `json:"projectId"`
|
||||
BeforeOffset int64 `json:"beforeOffset,omitempty"`
|
||||
@@ -117,20 +135,48 @@ type ProjectAuditQuery struct {
|
||||
LinkedSourceKind string `json:"linkedSourceKind,omitempty"`
|
||||
LinkedSourceID string `json:"linkedSourceId,omitempty"`
|
||||
LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"`
|
||||
// LinkedResourceKind/LinkedResourceID/LinkedRunID narrow the same exact
|
||||
// locator to the verified binding. A value that disagrees with stored host
|
||||
// state denies the read; it never widens it and never stands in for the
|
||||
// domain's current decision on that object.
|
||||
LinkedResourceKind string `json:"linkedResourceKind,omitempty"`
|
||||
LinkedResourceID string `json:"linkedResourceId,omitempty"`
|
||||
LinkedRunID string `json:"linkedRunId,omitempty"`
|
||||
// IncludeBody asks for the per-object body projection of one verified link.
|
||||
// The host asks the domain disclosure authorizer again for that object: a
|
||||
// metadata-only right serves metadata only, and a request without a verified
|
||||
// object binding is reported unsupported instead of a silently trimmed page.
|
||||
IncludeBody bool `json:"includeBody,omitempty"`
|
||||
// Export asks for a whole-history export. No base capability serves one, so
|
||||
// the host refuses instead of degrading into a partial paginated read that
|
||||
// would claim a coverage it cannot establish. See Coverage.Export.
|
||||
Export bool `json:"export,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectAuditReceiptResource names the object one verified link attributes a
|
||||
// receipt to. The host copies it from the stored binding and only when the
|
||||
// domain granted the per-object body right; it is never derived from caller
|
||||
// input and never carries resource content.
|
||||
type ProjectAuditReceiptResource struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
}
|
||||
|
||||
// HostAuditReceipt is a server-projected receipt, never a raw audit payload.
|
||||
// ActorName is the name recorded with this command; old rows have no snapshot.
|
||||
// Resource appears only for a body-permitted verified link.
|
||||
type HostAuditReceipt struct {
|
||||
SourceOffset int64 `json:"sourceOffset"`
|
||||
Time time.Time `json:"time"`
|
||||
ActorID string `json:"actorId,omitempty"`
|
||||
ActorName string `json:"actorName,omitempty"`
|
||||
ActorNameStatus string `json:"actorNameStatus"`
|
||||
CommandID string `json:"commandId"`
|
||||
State string `json:"state"`
|
||||
OperationID string `json:"operationId,omitempty"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
SourceOffset int64 `json:"sourceOffset"`
|
||||
Time time.Time `json:"time"`
|
||||
ActorID string `json:"actorId,omitempty"`
|
||||
ActorName string `json:"actorName,omitempty"`
|
||||
ActorNameStatus string `json:"actorNameStatus"`
|
||||
CommandID string `json:"commandId"`
|
||||
State string `json:"state"`
|
||||
OperationID string `json:"operationId,omitempty"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
Resource *ProjectAuditReceiptResource `json:"resource,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing
|
||||
@@ -144,6 +190,9 @@ type ProjectAuditCoverage struct {
|
||||
ModelReceipts string `json:"modelReceipts"`
|
||||
BackgroundReceipts string `json:"backgroundReceipts"`
|
||||
ResourceBodies string `json:"resourceBodies"`
|
||||
// Export states whether a whole-history export is served. An audit grant is
|
||||
// not an export grant: this slice only pages, and says so.
|
||||
Export string `json:"export"`
|
||||
}
|
||||
|
||||
type ProjectAuditPage struct {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package extension
|
||||
|
||||
// W9 X2 读取面补完(2.29.0-experimental)的公开面契约:
|
||||
// 1. 新增项全部是可选字段/新导出类型:既有 ProjectAuditHost、ProjectAuditRegistry
|
||||
// 提供者与既有查询字面量照旧编译、照旧语义(不破坏旧 provider);
|
||||
// 2. 新字段序列化带 omitempty:未使用的读取请求与旧消费者的线格式不变;
|
||||
// 3. 正文投影与导出声明是显式契约字段,未开启时不下发。
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// legacyAuditHost 是 2.14.0 形态的消费者:只实现既有方法,不感知新增可选字段。
|
||||
type legacyAuditHost struct{}
|
||||
|
||||
func (legacyAuditHost) QueryProjectAudit(context.Context, ProjectAuditQuery) (ProjectAuditPage, error) {
|
||||
return ProjectAuditPage{}, nil
|
||||
}
|
||||
|
||||
// legacyAuditRegistry 是 2.14.0 形态的注册方实现。
|
||||
type legacyAuditRegistry struct{}
|
||||
|
||||
func (legacyAuditRegistry) RegisterProjectAuditAuthorizer(ProjectAuditAuthorizer) error { return nil }
|
||||
|
||||
var (
|
||||
_ ProjectAuditHost = legacyAuditHost{}
|
||||
_ ProjectAuditRegistry = legacyAuditRegistry{}
|
||||
)
|
||||
|
||||
func TestProjectAuditReadContractIsAdditiveAndOptional(t *testing.T) {
|
||||
if SurfaceVersion < "2.29.0" {
|
||||
t.Fatalf("SurfaceVersion must record the audit read completion, got %s", SurfaceVersion)
|
||||
}
|
||||
// 既有查询字面量(只填项目与游标)仍然可用,新增字段全部零值。
|
||||
legacy := ProjectAuditQuery{ProjectID: core.ID("p1"), Limit: 10, BeforeOffset: 4, CutoffOffset: 9}
|
||||
if legacy.IncludeBody || legacy.Export || legacy.LinkedResourceID != "" || legacy.LinkedRunID != "" {
|
||||
t.Fatalf("optional fields defaulted to non-zero: %+v", legacy)
|
||||
}
|
||||
raw, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, unwanted := range []string{"includeBody", "export", "linkedResourceKind", "linkedResourceId", "linkedRunId"} {
|
||||
if strings.Contains(string(raw), unwanted) {
|
||||
t.Fatalf("omitted optional field %s reached the wire: %s", unwanted, raw)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`"projectId":"p1"`, `"beforeOffset":4`, `"cutoffOffset":9`, `"limit":10`} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Fatalf("existing field %s missing from the wire form: %s", want, raw)
|
||||
}
|
||||
}
|
||||
// 显式请求时字段可见,消费端能区分“正文/导出请求”与普通分页。
|
||||
full, err := json.Marshal(ProjectAuditQuery{ProjectID: core.ID("p1"), IncludeBody: true, Export: true,
|
||||
LinkedResourceKind: "document", LinkedResourceID: "doc-1", LinkedRunID: "run-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"includeBody":true`, `"export":true`, `"linkedResourceKind":"document"`,
|
||||
`"linkedResourceId":"doc-1"`, `"linkedRunId":"run-1"`} {
|
||||
if !strings.Contains(string(full), want) {
|
||||
t.Fatalf("requested field %s missing: %s", want, full)
|
||||
}
|
||||
}
|
||||
// 未授予正文时回执不带 resource 字段;授予后才出现对象身份(不含正文载荷)。
|
||||
metadataOnly, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(metadataOnly), `"resource"`) {
|
||||
t.Fatalf("metadata-only receipt carried a resource: %s", metadataOnly)
|
||||
}
|
||||
withBody, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm",
|
||||
Resource: &ProjectAuditReceiptResource{Kind: "document", ID: "doc-1", RunID: "run-1"}}},
|
||||
Coverage: ProjectAuditCoverage{ResourceBodies: "verified-exact-link-body", Export: "unsupported"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"resource":{"kind":"document","id":"doc-1","runId":"run-1"}`, `"export":"unsupported"`} {
|
||||
if !strings.Contains(string(withBody), want) {
|
||||
t.Fatalf("body-permitted projection missing %s: %s", want, withBody)
|
||||
}
|
||||
}
|
||||
// 四个审计哨兵互不相等:unsupported(缺基座能力)与 denied(策略拒绝)
|
||||
// 不得互相冒充。
|
||||
if !errors.Is(ErrProjectAuditUnsupported, ErrProjectAuditUnsupported) {
|
||||
t.Fatal("unsupported sentinel must stay comparable with errors.Is")
|
||||
}
|
||||
for _, distinct := range []error{ErrProjectAuditDenied, ErrProjectAuditLinkConflict, ErrProjectAuditPending} {
|
||||
if errors.Is(ErrProjectAuditUnsupported, distinct) || errors.Is(distinct, ErrProjectAuditUnsupported) {
|
||||
t.Fatalf("audit sentinels must stay distinct: %v vs %v", distinct, ErrProjectAuditUnsupported)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,17 @@ type ProjectContextSourceRecheckResult struct{ Bundle ProjectContextBundle }
|
||||
|
||||
// ProjectContextSourceRechecker is an optional method of the same registered
|
||||
// ProjectContextProvider. Project workflow reuse fails closed without it.
|
||||
//
|
||||
// Fail-closed comparison contract: Backend compares the returned Bundle with
|
||||
// the recorded receipt as a set of stable identities, never as provider-ordered
|
||||
// output and never by the provider's own echo. Every recorded Included entry
|
||||
// must be answered by exactly one item with the same SourceID, Revision and
|
||||
// Hash whose text still hashes to the recorded InputSHA256; every recorded
|
||||
// OfferedReference and ReadEvidence must be answered by the same
|
||||
// (ID, SourceID, Revision, Hash); Missing must be equal as a set, so a benign
|
||||
// reordering is accepted while an added, dropped or replaced entry is refused.
|
||||
// Anything unproven, extra or absent rejects; reordering never widens
|
||||
// authorization.
|
||||
type ProjectContextSourceRechecker interface {
|
||||
Recheck(context.Context, ProjectContextSourceRecheckRequest) (ProjectContextSourceRecheckResult, error)
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ ProjectAuditPublisher.PublishProjectAuditLink
|
||||
ProjectAuditPublisherFactory
|
||||
ProjectAuditPublisherFactory.ForOwner
|
||||
ProjectAuditQuery
|
||||
ProjectAuditReceiptResource
|
||||
ProjectAuditRegistry
|
||||
ProjectAuditRegistry.RegisterProjectAuditAuthorizer
|
||||
ProjectContextBundle
|
||||
|
||||
Reference in New Issue
Block a user