Why: two field reports against the live build — deliverable cards never appeared in chat (only model-written filenames) and the artifacts panel felt dead. Root cause found in runmgr.ExecuteTool: registry tools that write artifacts via rc.WriteArtifact never had res.Artifact set (only the oversized-result path did), so llm.go never emitted artifact_ref parts for real tool output; the unit suite passed via fakes that set it directly. Architecturally, document generation moves back to the agent (user directive): system provides execution primitives and the delivery bridge, not domain tools like write_docx. Evidence: - runmgr: toolCtx tracks artifacts written during a call and ExecuteTool backfills res.Artifact (new runmgr test proves write_artifact now surfaces one; negative test: no phantom artifact) - new tools/register_artifact: bridge from workspace files (binary docx/xlsx/pptx/pdf/images, <=10MB) to deliverable artifacts; run_shell/run_python interpreter executors are designed (script-to-disk + whitelisted bash/python3, sandbox cwd, caps) and land in a follow-up batch behind the security gate decision - removed builtin write_docx + its prompt; write_artifact prompt now text-only with explicit no-binary guidance; new always-on base prompt base.deliverables teaches the generate->register->summarize workflow with python-docx/openpyxl/python-pptx recipes; task.doc-minutes rewritten to the workflow - final-output contract: inline <think> blocks in streamed content are split into thinking (process) parts so the final reply stays clean (unit + runner tests) - go test ./... 19 packages ok; vet clean Impact: every tool artifact now lands as an in-chat deliverable card; mock #doc unchanged as demo fixture; binary office mimes stay out of the text index; write_docx callers see the tool gone (prompt reroutes them). Refs: task feature/agent-native-deliverables-r1
190 lines
6.9 KiB
Go
190 lines
6.9 KiB
Go
// 跨会话读取门禁矩阵(提案 2026-09-30-cross-session-reading 验收表):
|
||
// 项目开关 × 出站 allowCrossRead × 入站 crossPrivate × 跨项目/跨租户 × 自读 × 归档。
|
||
// 门禁实现在 toolCtx(本包),经真实 store + settings 覆盖链验证,fail-closed。
|
||
package runmgr
|
||
|
||
import (
|
||
"errors"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"agent-system-backend/internal/settings"
|
||
"agent-system-backend/internal/store"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
type crossEnv struct {
|
||
m *Manager
|
||
svc *settings.Service
|
||
st *store.Store
|
||
projA core.Project
|
||
projA2 core.Project // alice 的另一项目(跨项目负例)
|
||
projB core.Project // bob 的项目(跨租户负例)
|
||
convA core.Conversation
|
||
convB core.Conversation // 普通同伴
|
||
convC core.Conversation // 私有同伴
|
||
convD core.Conversation // 归档同伴
|
||
convE core.Conversation // bob 名下
|
||
convF core.Conversation // alice 另一项目下
|
||
}
|
||
|
||
func newCrossEnv(t *testing.T) *crossEnv {
|
||
t.Helper()
|
||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "cross.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { st.Close() })
|
||
e := &crossEnv{m: &Manager{st: st}, svc: settings.NewService(st), st: st}
|
||
for i, mk := range []struct {
|
||
owner string
|
||
dst *core.Project
|
||
}{
|
||
{"alice", &e.projA}, {"alice", &e.projA2}, {"bob", &e.projB},
|
||
} {
|
||
p, err := st.CreateProject(mk.owner, "p"+string(rune('a'+i)))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
*mk.dst = p
|
||
}
|
||
newConv := func(owner string, p core.Project, title string) core.Conversation {
|
||
c, err := st.CreateConversation(owner, p.ID, title, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return c
|
||
}
|
||
e.convA = newConv("alice", e.projA, "A 调用方")
|
||
e.convB = newConv("alice", e.projA, "B 同伴")
|
||
e.convC = newConv("alice", e.projA, "C 私有")
|
||
e.convD = newConv("alice", e.projA, "D 归档")
|
||
e.convE = newConv("bob", e.projB, "E 他租户")
|
||
e.convF = newConv("alice", e.projA2, "F 他项目")
|
||
yes := true
|
||
if err := st.SetConversationFlags("alice", e.convC.ID, nil, nil, nil, &yes); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.SetConversationFlags("alice", e.convD.ID, nil, &yes, nil, nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
addMsgs := func(c core.Conversation, texts ...string) {
|
||
for _, txt := range texts {
|
||
m := core.Message{ConversationID: c.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: txt}}}
|
||
if err := st.AppendMessage(&m); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
}
|
||
addMsgs(e.convA, "A-1")
|
||
addMsgs(e.convB, "B-1", "B-2", "B-3")
|
||
return e
|
||
}
|
||
|
||
// peerCtx 构造门禁视角:owner=alice 在 projA 下经 convA 发起读取。
|
||
func (e *crossEnv) peerCtx(t *testing.T) *toolCtx {
|
||
t.Helper()
|
||
eff := e.svc.Effective("alice", e.projA.ID)
|
||
return &toolCtx{rc: &runCtx{mgr: e.m, eff: eff, run: core.Run{Owner: "alice", ProjectID: e.projA.ID, ConversationID: e.convA.ID}}}
|
||
}
|
||
|
||
func (e *crossEnv) setGate(t *testing.T, on bool) {
|
||
t.Helper()
|
||
if err := e.svc.MergeProject("alice", e.projA.ID, map[string]any{"crossSession": map[string]any{"read": on}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestCrossReadGateMatrix(t *testing.T) {
|
||
e := newCrossEnv(t)
|
||
tru, fls := true, false
|
||
|
||
// 1) 全关默认:项目闸门关,读与列均拒绝。
|
||
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
|
||
t.Fatalf("gate off must disable read, got %v", err)
|
||
}
|
||
if _, err := e.peerCtx(t).PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) {
|
||
t.Fatalf("gate off must disable list, got %v", err)
|
||
}
|
||
|
||
// 2) 正常放行:开闸后读 B 最近记录(默认 limit 50 ≥ 3 条全量)。
|
||
e.setGate(t, true)
|
||
msgs, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(msgs) != 3 || msgs[0].Parts[0].Text != "B-1" || msgs[2].Parts[0].Text != "B-3" {
|
||
t.Fatalf("expected 3 ordered messages, got %+v", msgs)
|
||
}
|
||
|
||
// 3) 出站关闭:A 不读他人(列表与读取都拒;自读仍可,见 7)。
|
||
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, &fls, nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
|
||
t.Fatalf("outbound off must disable read, got %v", err)
|
||
}
|
||
if _, err := e.peerCtx(t).PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) {
|
||
t.Fatalf("outbound off must disable list, got %v", err)
|
||
}
|
||
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, &tru, nil); err != nil { // 恢复出站
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 4) 目标私有:C 拒读且不出现在列表;列表含归档 D、不含自身。
|
||
if _, err := e.peerCtx(t).PeerConversationMessages(e.convC.ID, 0); !errors.Is(err, ErrCrossReadForbidden) {
|
||
t.Fatalf("private target must be forbidden, got %v", err)
|
||
}
|
||
convs, err := e.peerCtx(t).PeerConversations()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ids := map[string]bool{}
|
||
for _, c := range convs {
|
||
ids[c.ID] = true
|
||
}
|
||
if !ids[e.convB.ID] || !ids[e.convD.ID] || ids[e.convC.ID] || ids[e.convA.ID] {
|
||
t.Fatalf("list must include B/D, exclude private C and self A, got %v", ids)
|
||
}
|
||
|
||
// 5) 跨项目:同租户另一项目的 F 拒读。
|
||
if _, err := e.peerCtx(t).PeerConversationMessages(e.convF.ID, 0); !errors.Is(err, ErrCrossReadForbidden) {
|
||
t.Fatalf("cross-project target must be forbidden, got %v", err)
|
||
}
|
||
|
||
// 6) 跨租户:bob 名下 E 一律 NotFound(存在性不泄露)。
|
||
if _, err := e.peerCtx(t).PeerConversationMessages(e.convE.ID, 0); !errors.Is(err, store.ErrNotFound) {
|
||
t.Fatalf("cross-tenant target must be NotFound, got %v", err)
|
||
}
|
||
|
||
// 7) 自读:A 读自身(含窗口外历史)放行;A 设为私有后自读仍放行(入站豁免)。
|
||
selfMsgs, err := e.peerCtx(t).PeerConversationMessages(e.convA.ID, 0)
|
||
if err != nil || len(selfMsgs) != 1 {
|
||
t.Fatalf("self read must pass, got %d msgs err %v", len(selfMsgs), err)
|
||
}
|
||
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, nil, &tru); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := e.peerCtx(t).PeerConversationMessages(e.convA.ID, 0); err != nil {
|
||
t.Fatalf("self read of a private self must pass, got %v", err)
|
||
}
|
||
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, nil, &fls); err != nil { // 还原非私有
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 8) limit:正数生效;越界钳制。
|
||
last2, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 2)
|
||
if err != nil || len(last2) != 2 || last2[1].Parts[0].Text != "B-3" {
|
||
t.Fatalf("limit=2 must return last two, got %d err %v", len(last2), err)
|
||
}
|
||
if msgs, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 99999); err != nil || len(msgs) != 3 {
|
||
t.Fatalf("oversized limit must clamp without error, got %d err %v", len(msgs), err)
|
||
}
|
||
|
||
// 9) 闸门经设置覆盖链关闭后回 1) 行为。
|
||
e.setGate(t, false)
|
||
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
|
||
t.Fatalf("re-closed gate must disable read, got %v", err)
|
||
}
|
||
}
|