Files
Agent-System-Backend/internal/tools/external_effect_test.go
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

190 lines
6.6 KiB
Go

package tools
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
agenttools "agent-native/runtime/tools"
core "agent-system-core"
)
var errToolActorDisabled = errors.New("tool actor disabled")
type guardedToolContext struct {
ToolExecCtx
ctx context.Context
check func() error
}
func (c guardedToolContext) Context() context.Context {
if c.ctx != nil {
return c.ctx
}
return context.Background()
}
func (c guardedToolContext) CheckExternalEffect() error {
if c.check != nil {
return c.check()
}
return nil
}
func TestHTTPToolChecksActorAndPropagatesCancellation(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
<-r.Context().Done()
}))
defer srv.Close()
exec := HTTPExecutor(core.HTTPToolSpec{Method: "POST", URL: srv.URL}, nil)
denied := guardedToolContext{ToolExecCtx: dummyCtx{}, check: func() error { return errToolActorDisabled }}
if _, err := exec(denied, nil); !errors.Is(err, errToolActorDisabled) || calls.Load() != 0 {
t.Fatalf("disabled HTTP effect: %v, calls=%d", err, calls.Load())
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := exec(guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx}, nil); !errors.Is(err, context.Canceled) || calls.Load() != 0 {
t.Fatalf("canceled HTTP effect: %v, calls=%d", err, calls.Load())
}
}
func TestHTTPToolCancelsInFlightRequest(t *testing.T) {
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-r.Context().Done()
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rc := guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx}
done := make(chan error, 1)
go func() {
_, err := HTTPExecutor(core.HTTPToolSpec{Method: "GET", URL: srv.URL}, nil)(rc, nil)
done <- err
}()
select {
case <-started:
case <-time.After(3 * time.Second):
t.Fatal("HTTP request did not reach server")
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("in-flight request cancellation: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("in-flight HTTP request ignored cancellation")
}
}
type observedSearchProvider struct {
calls int
ctx context.Context
}
func (p *observedSearchProvider) Search(ctx context.Context, _ agenttools.SearchRequest) (agenttools.SearchResponse, error) {
p.calls++
p.ctx = ctx
return agenttools.SearchResponse{}, nil
}
func TestSearchToolChecksActorAndPassesRunContext(t *testing.T) {
p := &observedSearchProvider{}
r := NewRegistry()
if err := RegisterSearchTools(r, p); err != nil {
t.Fatal(err)
}
denied := guardedToolContext{ToolExecCtx: dummyCtx{}, check: func() error { return errToolActorDisabled }}
if res := r.Execute(denied, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "error" || !strings.Contains(res.Output, errToolActorDisabled.Error()) || p.calls != 0 {
t.Fatalf("disabled search effect: %+v, calls=%d", res, p.calls)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if res := r.Execute(guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx}, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "error" || p.calls != 0 {
t.Fatalf("canceled search effect: %+v, calls=%d", res, p.calls)
}
if res := r.Execute(guardedToolContext{ToolExecCtx: dummyCtx{}}, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "ok" || p.calls != 1 || p.ctx == nil {
t.Fatalf("search context: %+v, calls=%d, context=%v", res, p.calls, p.ctx)
}
}
func TestFSAndInterpreterCheckBeforeFilesystemEffects(t *testing.T) {
root := t.TempDir()
fsCtx := &fakeFSCtx{root: root, owner: "o", conv: "c", fsToolsOn: true}
deniedFS := guardedToolContext{ToolExecCtx: fsCtx, check: func() error { return errToolActorDisabled }}
if _, err := execFSWrite(deniedFS, json.RawMessage(`{"path":"note.md","content":"x"}`)); !errors.Is(err, errToolActorDisabled) {
t.Fatalf("disabled fs write: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "o", "c")); !os.IsNotExist(err) {
t.Fatalf("disabled fs write created sandbox: %v", err)
}
execCtx := &execFake{root: root, owner: "o", conv: "c", execOn: true}
deniedExec := guardedToolContext{ToolExecCtx: execCtx, check: func() error { return errToolActorDisabled }}
if _, err := execShell(deniedExec, json.RawMessage(`{"script":"echo x"}`)); !errors.Is(err, errToolActorDisabled) {
t.Fatalf("disabled shell write/start: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "o", "c")); !os.IsNotExist(err) {
t.Fatalf("disabled shell created sandbox: %v", err)
}
}
func TestInterpreterRechecksBeforeProcessStart(t *testing.T) {
root := t.TempDir()
mark := filepath.Join(t.TempDir(), "executed")
base := &execFake{root: root, owner: "o", conv: "c", execOn: true}
checks := 0
rc := guardedToolContext{ToolExecCtx: base, check: func() error {
checks++
if checks >= 4 {
return errToolActorDisabled
}
return nil
}}
_, err := execShell(rc, json.RawMessage(`{"script":"touch `+mark+`"}`))
if !errors.Is(err, errToolActorDisabled) || checks < 4 {
t.Fatalf("process start recheck: %v, checks=%d", err, checks)
}
if _, err := os.Stat(mark); !os.IsNotExist(err) {
t.Fatalf("script ran after actor disable: %v", err)
}
}
type guardedCodeContext struct{ guardedToolContext }
func (guardedCodeContext) ExecuteTool(core.ToolCallRequest) core.ToolCallResult {
return core.ToolCallResult{Status: "ok"}
}
func TestCodeOrchestrationChecksBeforeStarting(t *testing.T) {
root := t.TempDir()
base := &execFake{root: root, owner: "o", conv: "c", settings: map[string]bool{"runtime.codeOrchestration": true}}
rc := guardedCodeContext{guardedToolContext{ToolExecCtx: base, check: func() error { return errToolActorDisabled }}}
if _, err := execCodeScript(rc, json.RawMessage(`{"script":"print('hi')"}`)); !errors.Is(err, errToolActorDisabled) {
t.Fatalf("disabled code orchestration: %v", err)
}
}
func TestApplyUpperCheckedStopsBeforeWorkspaceWrite(t *testing.T) {
dir := t.TempDir()
upper := t.TempDir()
write(t, filepath.Join(upper, "new.txt"), "new")
if _, err := applyUpperChecked(context.Background(), func() error { return errToolActorDisabled }, upper, dir, map[string]string{}); !errors.Is(err, errToolActorDisabled) {
t.Fatalf("disabled workspace apply: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("disabled workspace apply wrote file: %v", err)
}
}