mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
260 lines
9.9 KiB
Go
260 lines
9.9 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/guard"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
"github.com/Autumn-27/norma/llm"
|
|
actool "github.com/Autumn-27/norma/tool"
|
|
)
|
|
|
|
// Step 1.9: the WebFetch tool of every agent kind checks each redirect hop
|
|
// against the target gate. With the gate off it follows redirects as before.
|
|
// Step 2.1: in a gated scope WebFetch has no direct retry.
|
|
|
|
// passHooks lets every tool call run.
|
|
type passHooks struct{}
|
|
|
|
func (passHooks) PreToolUse(context.Context, string, []byte) (bool, string, []byte) {
|
|
return false, "", nil
|
|
}
|
|
|
|
func (passHooks) PostToolUse(context.Context, string, []byte, []byte, bool) {}
|
|
|
|
func (passHooks) Stop(context.Context, []llm.Message) (bool, []string, string) {
|
|
return false, nil, ""
|
|
}
|
|
|
|
// redirectPair is a server that redirects to a second one on another port of
|
|
// 127.0.0.1, so the hop is another target (an IP literal: never resolved).
|
|
type redirectPair struct {
|
|
start, final string // URLs
|
|
startHits, finalHits *atomic.Int64
|
|
finalKey string // host:port of the redirect target
|
|
}
|
|
|
|
func newRedirectPair(t *testing.T) redirectPair {
|
|
t.Helper()
|
|
p := redirectPair{startHits: &atomic.Int64{}, finalHits: &atomic.Int64{}}
|
|
final := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p.finalHits.Add(1)
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
_, _ = w.Write([]byte("redirect-target-reached"))
|
|
}))
|
|
t.Cleanup(final.Close)
|
|
start := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p.startHits.Add(1)
|
|
http.Redirect(w, r, final.URL+"/final", http.StatusFound)
|
|
}))
|
|
t.Cleanup(start.Close)
|
|
u, _ := url.Parse(final.URL)
|
|
p.start, p.final, p.finalKey = start.URL+"/start", final.URL+"/final", u.Host
|
|
return p
|
|
}
|
|
|
|
// gatedScope is a gated target scope on task taskID, whose rows are removed
|
|
// afterwards.
|
|
func gatedScope(t *testing.T, d *db.DB, taskID string) intercept.TargetScope {
|
|
t.Helper()
|
|
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id=$1`, taskID) })
|
|
return intercept.TargetScope{TaskID: taskID, Enabled: func() bool { return true },
|
|
WaitBound: intercept.DefaultTargetWaitBound, Interceptor: intercept.New(d)}
|
|
}
|
|
|
|
// openRedirectRequests returns the target keys of the open requests of task.
|
|
func openRedirectRequests(t *testing.T, d *db.DB, taskID string) []string {
|
|
t.Helper()
|
|
rows, err := d.Query(`SELECT target_key FROM intercept_pending WHERE task_id=$1 AND status='pending' AND target_key IS NOT NULL ORDER BY id`, taskID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
var keys []string
|
|
for rows.Next() {
|
|
var k string
|
|
if err := rows.Scan(&k); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func fetchInputJSON(u string) string {
|
|
b, _ := json.Marshal(map[string]string{"url": u})
|
|
return string(b)
|
|
}
|
|
|
|
// Each agent kind runs a scripted WebFetch on a URL that redirects to a target
|
|
// no pin covers. The hooks let the call run, so the hop is what the gate sees:
|
|
// each kind must stop at the hop, leave the redirect target untouched and open
|
|
// one request for it. A fifth agent kind added without the redirect policy
|
|
// fails this table once it is added to it. The origin is pinned first: the
|
|
// hooks never check it here, and the pinned dialer (step 2.1) connects only to
|
|
// a pinned target.
|
|
func TestWebFetchRedirectCheckWiredOnEveryAgentKind(t *testing.T) {
|
|
for _, kind := range []string{"worker", "planner", "mainagent", "chat"} {
|
|
t.Run(kind, func(t *testing.T) {
|
|
f := newHookReachFixture(t)
|
|
d := testDB(t)
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
pair := newRedirectPair(t)
|
|
scope := gatedScope(t, d, f.taskID)
|
|
pinTarget(t, d, scope, pair.start)
|
|
ctx := intercept.WithTargetScope(t.Context(), scope)
|
|
prov := toolCallThenStop("WebFetch", fetchInputJSON(pair.start))
|
|
|
|
switch kind {
|
|
case "worker":
|
|
payload := map[string]any{"summary": "redirect probe"}
|
|
intentID, err := f.ts.AddNode("intent", payload, 0, "running", "planner", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(payload)
|
|
w := NewWorker(prov, "test-model", f.workDir, nil, 0, 3)
|
|
if _, _, err := w.Execute(ctx, "work#1", f.expID, nil, f.ts, &db.Node{ID: intentID, Payload: raw}, passHooks{}, nil, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "planner":
|
|
p := NewPlanner(prov, "test-model", f.workDir, nil, 0, 3)
|
|
if _, _, err := p.Plan(ctx, f.expID, nil, f.ts, "probe goal", nil, passHooks{}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "mainagent":
|
|
m := NewMainAgent(prov, "test-model", f.workDir, nil, 0, 3)
|
|
if _, err := m.Chat(ctx, f.expID, 0, nil, f.ts, "probe goal", "status?", passHooks{}, nil, nil, nil, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case "chat":
|
|
c := NewChatAgent(prov, "test-model", t.TempDir(), nil, 0)
|
|
c.SetGuard(guard.New())
|
|
if _, err := c.Chat(ctx, "assistant", "conv-redirect-probe", "fetch it", 3, 0, false, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if pair.startHits.Load() != 1 {
|
|
t.Fatalf("the fetch did not run: %d hits on the origin", pair.startHits.Load())
|
|
}
|
|
if pair.finalHits.Load() != 0 {
|
|
t.Fatal("the redirect was followed to a target no pin covers")
|
|
}
|
|
if keys := openRedirectRequests(t, d, f.taskID); len(keys) != 1 || keys[0] != pair.finalKey {
|
|
t.Fatalf("open requests = %v, want one for %s", keys, pair.finalKey)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// In a gated scope the direct retry is off (D-8): with a proxy that refuses the
|
|
// connection, the fetch fails with the proxy's error and never leaves without
|
|
// it. The proxy is a derived exemption, so its dial asks for nothing.
|
|
func TestWebFetchGatedScopeHasNoDirectRetry(t *testing.T) {
|
|
d := testDB(t)
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
expID, err := d.CreateExploration("no direct retry probe", "a gated scope has no direct retry")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM explorations WHERE id=$1`, expID) })
|
|
taskID := fmt.Sprint(expID)
|
|
pair := newRedirectPair(t)
|
|
scope := gatedScope(t, d, taskID)
|
|
scope.Interceptor.SetDerivedExemptions(func() []string { return []string{"http://127.0.0.1:1"} })
|
|
ctx := intercept.WithTargetScope(t.Context(), scope)
|
|
|
|
out, isErr := callWebFetch(t, ctx, "http://127.0.0.1:1", pair.start)
|
|
if !isErr || !strings.Contains(out, "proxyconnect") || strings.Contains(out, "TARGET_") {
|
|
t.Fatalf("proxied fetch: error=%v out=%q, want the proxy's error", isErr, out)
|
|
}
|
|
if pair.startHits.Load() != 0 || pair.finalHits.Load() != 0 {
|
|
t.Fatalf("hits origin=%d target=%d, want no direct retry", pair.startHits.Load(), pair.finalHits.Load())
|
|
}
|
|
var requests int
|
|
if err := d.QueryRow(`SELECT count(*) FROM intercept_pending WHERE task_id=$1 AND target_key IS NOT NULL`, taskID).Scan(&requests); err != nil || requests != 0 {
|
|
t.Fatalf("%d requests, %v; want none", requests, err)
|
|
}
|
|
}
|
|
|
|
// With the gate off, or without a scope, WebFetch follows redirects as it did
|
|
// with agentcore's own tool: to the end of the chain, and the same error after
|
|
// 10 hops. No database.
|
|
func TestWebFetchRedirectGateOffUnchanged(t *testing.T) {
|
|
pinNoiseOff(t) // a gate-off regression test: independent of ARTEX_NOISE_PROFILE
|
|
loop := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, r.URL.Path+"x", http.StatusFound)
|
|
}))
|
|
t.Cleanup(loop.Close)
|
|
for name, ctx := range map[string]context.Context{
|
|
"no scope": t.Context(),
|
|
"flag off": intercept.WithTargetScope(t.Context(), intercept.TargetScope{TaskID: "7", Enabled: func() bool { return false },
|
|
WaitBound: time.Minute, Interceptor: intercept.New(nil)}),
|
|
} {
|
|
pair := newRedirectPair(t)
|
|
for _, proxy := range []string{"", "http://127.0.0.1:1"} {
|
|
if out, isErr := callWebFetch(t, ctx, proxy, pair.start); isErr || !strings.Contains(out, "redirect-target-reached") {
|
|
t.Fatalf("%s, proxy %q: error=%v out=%q", name, proxy, isErr, out)
|
|
}
|
|
}
|
|
if pair.finalHits.Load() != 2 {
|
|
t.Fatalf("%s: %d hits on the redirect target, want 2", name, pair.finalHits.Load())
|
|
}
|
|
got, _ := callWebFetch(t, ctx, "", loop.URL+"/l")
|
|
want := actool.NewWebFetch(actool.WebFetchConfig{}).Call
|
|
res, err := want(ctx, json.RawMessage(fetchInputJSON(loop.URL+"/l")), &actool.ToolContext{})
|
|
if err != nil || got != res.Flatten() || !strings.Contains(got, "stopped after 10 redirects") {
|
|
t.Fatalf("%s: redirect loop: %q, want agentcore's %q", name, got, res.Flatten())
|
|
}
|
|
}
|
|
}
|
|
|
|
// pinTarget opens the request for the target of rawURL in scope and approves
|
|
// it, as a click on the approval card does.
|
|
func pinTarget(t *testing.T, d *db.DB, scope intercept.TargetScope, rawURL string) {
|
|
t.Helper()
|
|
ic, input := scope.Interceptor, []byte(fetchInputJSON(rawURL))
|
|
reqs, res := ic.CheckTargets(t.Context(), scope, "WebFetch", input)
|
|
if res.Code != "" || len(reqs) != 1 {
|
|
t.Fatalf("%s: requests=%+v result=%+v, want one request", rawURL, reqs, res)
|
|
}
|
|
scope.WaitBound = 0
|
|
if res = ic.RequestTargets(t.Context(), scope, "WebFetch", input, reqs, nil); res.Code != intercept.TargetApprovalPending || res.PendingID == 0 {
|
|
t.Fatalf("%s: request = %+v", rawURL, res)
|
|
}
|
|
if err := ic.Decide(res.PendingID, true, "test", "192.0.2.1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
taskID, err := strconv.ParseInt(scope.TaskID, 10, 64)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM target_pins WHERE task_id=$1`, taskID) })
|
|
}
|
|
|
|
// callWebFetch runs the WebFetch tool the agents build, with proxy.
|
|
func callWebFetch(t *testing.T, ctx context.Context, proxy, u string) (string, bool) {
|
|
t.Helper()
|
|
tools := withWebFetch(ctx, nil, proxy, "")
|
|
if len(tools) != 1 || tools[0].Name() != "WebFetch" {
|
|
t.Fatalf("withWebFetch built %d tools", len(tools))
|
|
}
|
|
res, err := tools[0].Call(ctx, json.RawMessage(fetchInputJSON(u)), &actool.ToolContext{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return res.Flatten(), res.IsError
|
|
}
|