Files
goclaw/internal/mcp/bridge_server_test.go
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

164 lines
6.0 KiB
Go

package mcp
import (
"context"
"testing"
"time"
mcpgo "github.com/mark3labs/mcp-go/mcp"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// fakeBridgeTool is a minimal tools.Tool for registry-driven bridge tests.
type fakeBridgeTool struct{ name string }
func (f *fakeBridgeTool) Name() string { return f.name }
func (f *fakeBridgeTool) Description() string { return "fake " + f.name }
func (f *fakeBridgeTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
func (f *fakeBridgeTool) Execute(context.Context, map[string]any) *tools.Result {
return &tools.Result{ForLLM: "ok"}
}
func bridgeTestRegistry(names ...string) *tools.Registry {
reg := tools.NewRegistry()
for _, n := range names {
reg.Register(&fakeBridgeTool{name: n})
}
return reg
}
// Without an agent policy in ctx (anonymous caller, or agent without a
// tools_config), the bridge must fall back to the conservative legacy set —
// deriving from a nil policy would WIDEN exposure for unauthenticated callers.
func TestBridgeToolAllowed_NoAgentPolicy_FallsBackToLegacySet(t *testing.T) {
reg := bridgeTestRegistry("read_file", "use_skill")
pe := tools.NewPolicyEngine(&config.ToolsConfig{})
ctx := context.Background()
if !bridgeToolAllowed(reg, pe, ctx, "read_file") {
t.Error("read_file is in the legacy set — must stay allowed for policy-less callers")
}
if bridgeToolAllowed(reg, pe, ctx, "use_skill") {
t.Error("use_skill is NOT in the legacy set — must stay blocked for policy-less callers")
}
}
// With a verified agent policy in ctx, the bridge derives its tool surface
// from that policy — the fix for the BridgeToolNames drift (#1373): tools the
// agent's policy allows (like use_skill) become callable; tools outside the
// agent's allow list are rejected even if the legacy set contained them.
func TestBridgeToolAllowed_AgentPolicyDerivesSurface(t *testing.T) {
reg := bridgeTestRegistry("read_file", "use_skill", "datetime")
pe := tools.NewPolicyEngine(&config.ToolsConfig{})
ctx := tools.WithToolAgentPolicy(context.Background(),
&config.ToolPolicySpec{Allow: []string{"use_skill", "datetime"}})
if !bridgeToolAllowed(reg, pe, ctx, "use_skill") {
t.Error("use_skill allowed by agent policy — bridge must expose it")
}
if !bridgeToolAllowed(reg, pe, ctx, "datetime") {
t.Error("datetime allowed by agent policy — bridge must expose it")
}
if bridgeToolAllowed(reg, pe, ctx, "read_file") {
t.Error("read_file NOT in the agent allow list — bridge must reject it")
}
}
// Hard-excluded tools can never cross the bridge, regardless of policy.
func TestBridgeToolAllowed_HardExclusionsWinOverPolicy(t *testing.T) {
reg := bridgeTestRegistry("spawn", "create_forum_topic")
pe := tools.NewPolicyEngine(&config.ToolsConfig{})
ctx := tools.WithToolAgentPolicy(context.Background(),
&config.ToolPolicySpec{Allow: []string{"spawn", "create_forum_topic"}})
for _, name := range []string{"spawn", "create_forum_topic"} {
if bridgeToolAllowed(reg, pe, ctx, name) {
t.Errorf("%s is bridge-excluded — policy must not re-enable it", name)
}
}
}
// Nil policy engine (legacy constructor path) keeps the legacy static behavior.
func TestBridgeToolAllowed_NilEngine_LegacyBehavior(t *testing.T) {
reg := bridgeTestRegistry("exec", "use_skill")
ctx := tools.WithToolAgentPolicy(context.Background(),
&config.ToolPolicySpec{Allow: []string{"use_skill"}})
if !bridgeToolAllowed(reg, nil, ctx, "exec") {
t.Error("nil engine: legacy set must apply (exec allowed)")
}
if bridgeToolAllowed(reg, nil, ctx, "use_skill") {
t.Error("nil engine: legacy set must apply (use_skill blocked)")
}
}
// The tools/list filter must show each caller exactly the surface it can call.
func TestBridgeToolFilter_ListsMatchCallableSurface(t *testing.T) {
reg := bridgeTestRegistry("read_file", "use_skill")
pe := tools.NewPolicyEngine(&config.ToolsConfig{})
filter := newBridgeToolFilter(reg, pe)
listed := []mcpgo.Tool{{Name: "read_file"}, {Name: "use_skill"}}
// Anonymous caller → legacy set only.
got := filter(context.Background(), listed)
if len(got) != 1 || got[0].Name != "read_file" {
t.Errorf("anonymous list = %v, want [read_file]", toolNames(got))
}
// Policy'd agent → derived surface.
ctx := tools.WithToolAgentPolicy(context.Background(),
&config.ToolPolicySpec{Allow: []string{"use_skill"}})
got = filter(ctx, listed)
if len(got) != 1 || got[0].Name != "use_skill" {
t.Errorf("policy'd list = %v, want [use_skill]", toolNames(got))
}
}
// Registration must cover the whole registry minus hard exclusions, so a
// policy'd agent can actually call tools beyond the legacy set (the drift bug).
func TestBridgeRegisteredToolNames_RegistryMinusExclusions(t *testing.T) {
reg := bridgeTestRegistry("read_file", "use_skill", "spawn")
names := bridgeRegisteredToolNames(reg)
got := make(map[string]bool, len(names))
for _, n := range names {
got[n] = true
}
if !got["read_file"] || !got["use_skill"] {
t.Errorf("registered = %v, want read_file + use_skill included", names)
}
if got["spawn"] {
t.Errorf("registered = %v, spawn must be excluded", names)
}
}
func toolNames(ts []mcpgo.Tool) []string {
out := make([]string, len(ts))
for i, tt := range ts {
out[i] = tt.Name
}
return out
}
func TestForwardMediaToOutboundSkipsDelegationArtifactRun(t *testing.T) {
msgBus := bus.New()
ctx := tools.WithToolChannel(context.Background(), "telegram")
ctx = tools.WithToolChatID(ctx, "chat-id")
ctx = tools.WithDelegationID(ctx, "delegation-id")
ctx = tools.WithDelegationArtifactInputs(ctx, "/runtime/inputs")
forwardMediaToOutbound(ctx, msgBus, "create_image", &tools.Result{
Media: []bus.MediaFile{{Path: "/runtime/outputs/generated.png", MimeType: "image/png"}},
})
waitCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if msg, ok := msgBus.SubscribeOutbound(waitCtx); ok {
t.Fatalf("unpublished delegation media forwarded: %#v", msg)
}
}