Files
Agent-Native/adapters/native/backend_test.go
weichen afbb567301 Bind session scope identity, enforce write sets, repair torn JSONL
Why: F02/F04/F15 - contract requests shared one transcript across
session scopes, Writable was dropped, and a torn JSONL tail poisoned
the next append; pure-text contract results lost their text.
Evidence: adapters/native, runtime/session, runtime/tools and
conformance tests pass (verify-u2.json); scope-isolation, write-set and
torn-tail regression cases added.
Impact: empty scope keeps legacy one-shot semantics; identity-less old
transcripts are never auto-adopted by a new scope.
Refs: feature/agent-stack-audit-remediation-b1 U2
2026-10-21 16:03:04 +00:00

249 lines
7.6 KiB
Go

package native
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
)
// script 按序回放假助手轮次(对齐 runtime/loop_test 的伪造方式)。
func script(turns ...llm.Message) llm.StreamFunc {
var mu sync.Mutex
i := 0
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
mu.Lock()
defer mu.Unlock()
if i >= len(turns) {
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
return
}
msg := turns[i]
i++
if msg.Content != "" {
ch <- llm.Event{Kind: llm.KindTextDelta, Text: msg.Content}
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
}()
return ch
}
}
func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"# 交付\n\n工具写入的交付物内容。"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd},
),
},
})
var events []exec.ProcessEvent
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色",
Targets: []string{"output.md"}, OnEvent: func(ev exec.ProcessEvent) { events = append(events, ev) },
})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Fatalf("want success, got %+v", res)
}
data, err := os.ReadFile(filepath.Join(dir, "output.md"))
if err != nil || !strings.Contains(string(data), "交付物内容") {
t.Fatalf("deliverable missing: %v %q", err, data)
}
if res.Patches["output.md"] == nil {
t.Fatal("patches missing output.md")
}
for _, f := range []string{"TASK.md", "AGENTS.md", "INSTRUCTIONS.md", "execution.log"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
t.Errorf("workspace file missing: %s", f)
}
}
sawTool := false
for _, ev := range events {
if ev.Type == "tool_execution_start" && ev.Tool == "write_file" {
sawTool = true
}
}
if !sawTool {
t.Errorf("tool process events not forwarded: %+v", events)
}
}
func TestBackendReasoningProfileWritesText(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd},
),
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning,
Targets: []string{"notes.md"},
})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Fatalf("want success, got %+v", res)
}
data, _ := os.ReadFile(filepath.Join(dir, "notes.md"))
if !strings.Contains(string(data), "纯文本交付内容") {
t.Fatalf("reasoning text not written: %q", data)
}
}
func TestBackendReasoningProfileRejectsPathEscape(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "work")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(parent, "pwned.md")
leak := "SHOULD-NOT-ESCAPE"
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: leak, StopReason: llm.StopEnd},
),
},
})
// Traversal: authorize-before-write must reject and leave zero target writes.
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "escape", ToolProfile: exec.ToolProfileReasoning,
Targets: []string{"safe.md", "../pwned.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
t.Fatalf("want path escape failure, got %+v", res)
}
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
t.Fatalf("traversal must not create outside file: %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(dir, "safe.md")); !os.IsNotExist(statErr) {
t.Fatal("rejected batch must perform zero target writes")
}
// Absolute path outside workDir.
res, err = b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "escape-abs", ToolProfile: exec.ToolProfileReasoning,
Targets: []string{outside},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
t.Fatalf("want absolute escape failure, got %+v", res)
}
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
t.Fatalf("absolute escape must not create outside file: %v", statErr)
}
// Symlink component leaving the workspace.
outsideDir := filepath.Join(parent, "outside-dir")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideDir, filepath.Join(dir, "link")); err != nil {
t.Fatal(err)
}
res, err = b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "escape-symlink", ToolProfile: exec.ToolProfileReasoning,
Targets: []string{"link/secret.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
t.Fatalf("want symlink escape failure, got %+v", res)
}
if _, statErr := os.Stat(filepath.Join(outsideDir, "secret.md")); !os.IsNotExist(statErr) {
t.Fatalf("symlink escape must not write outside: %v", statErr)
}
}
func TestBackendFailClosedWithoutConfig(t *testing.T) {
b := New(Options{})
res, err := b.Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "requires") {
t.Fatalf("want fail-closed config error, got %+v", res)
}
res2, _ := New(Options{
ModelOptions: ModelOptions{
APIBase: "http://x",
APIKey: "k",
},
}).Execute(context.Background(),
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
if res2.Success || !strings.Contains(res2.ErrorReason, "model") {
t.Fatalf("want model required error, got %+v", res2)
}
res3, _ := New(Options{
ModelOptions: ModelOptions{
Stream: script(),
},
}).Execute(context.Background(),
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"})
if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") {
t.Fatalf("want profile error, got %+v", res3)
}
}
func TestBeforeToolReachesSquadMembers(t *testing.T) {
dir := t.TempDir()
var seen string
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"nope"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "blocked", StopReason: llm.StopEnd},
),
},
GateOptions: GateOptions{BeforeTool: func(call llm.ToolCall) error {
seen = call.Name
return errors.New("计划模式拒绝了写工具")
}},
})
res, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if seen != "write_file" {
t.Fatalf("before tool saw %q", seen)
}
if res.Success {
t.Fatal("denied write still succeeded")
}
if _, err := os.Stat(filepath.Join(dir, "output.md")); err == nil {
t.Fatal("write_file ran after deny")
}
}