Files
goclaw/internal/providers/claude_cli_mcp_test.go
T
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

248 lines
8.2 KiB
Go

package providers
import (
"context"
"encoding/json"
"os"
"testing"
)
// --- SignBridgeContext tests ---
func TestSignBridgeContext_Deterministic(t *testing.T) {
key := "test-secret"
sig1 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/workspace", "tenant-abc")
sig2 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/workspace", "tenant-abc")
if sig1 != sig2 {
t.Errorf("expected deterministic output, got %q and %q", sig1, sig2)
}
if sig1 == "" {
t.Error("expected non-empty signature")
}
}
func TestSignBridgeContext_DifferentKey(t *testing.T) {
sig1 := SignBridgeContext("key-a", "agent1", "user1", "", "", "", "", "")
sig2 := SignBridgeContext("key-b", "agent1", "user1", "", "", "", "", "")
if sig1 == sig2 {
t.Error("different keys should produce different signatures")
}
}
func TestSignBridgeContext_FieldOrder(t *testing.T) {
key := "test-secret"
sig1 := SignBridgeContext(key, "a", "b", "c", "d", "e", "f", "g")
sig2 := SignBridgeContext(key, "b", "a", "c", "d", "e", "f", "g")
if sig1 == sig2 {
t.Error("swapping field values should produce different signatures")
}
}
// --- VerifyBridgeContext tests ---
func TestVerifyBridgeContext_Level1_AllFields(t *testing.T) {
key := "gateway-token"
sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant-123")
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant-123", sig)
if !ok {
t.Error("expected ok=true for valid level 1 signature")
}
if !tenantVerified {
t.Error("expected tenantVerified=true for level 1 match")
}
}
func TestVerifyBridgeContext_Level2_OldSessionWithWorkspace(t *testing.T) {
key := "gateway-token"
// Pre-tenantID session: signed with workspace but empty tenantID.
// Middleware now receives X-Tenant-ID header (e.g. new code adds it).
// Level 1 fails (tenantID mismatch), level 2 matches (ignores tenantID).
sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "")
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "new-tenant-id", sig)
if !ok {
t.Error("expected ok=true for level 2 fallback")
}
if tenantVerified {
t.Error("expected tenantVerified=false — tenant was not in original signature")
}
}
func TestVerifyBridgeContext_Level3_NoWorkspaceNoTenant(t *testing.T) {
key := "gateway-token"
// Signature from the oldest format (no workspace, no tenantID)
sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "", "")
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant-123", sig)
if !ok {
t.Error("expected ok=true for level 3 fallback")
}
if tenantVerified {
t.Error("expected tenantVerified=false for level 3 fallback")
}
}
func TestVerifyBridgeContext_InvalidSig(t *testing.T) {
ok, tenantVerified := VerifyBridgeContext("key", "agent1", "user1", "", "", "", "", "", "invalid-sig")
if ok {
t.Error("expected ok=false for invalid signature")
}
if tenantVerified {
t.Error("expected tenantVerified=false for invalid signature")
}
}
func TestVerifyBridgeContext_TenantNotTrustedOnFallback(t *testing.T) {
key := "gateway-token"
// Old session signed WITHOUT tenantID
oldSig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "")
// Attacker replays old sig but adds a fake tenantID header
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "fake-tenant-id", oldSig)
if !ok {
t.Error("expected ok=true (sig valid via fallback)")
}
if tenantVerified {
t.Error("expected tenantVerified=false — tenant header not covered by HMAC, must not be trusted")
}
}
func TestVerifyBridgeContext_EmptyFields(t *testing.T) {
key := "test-key"
sig := SignBridgeContext(key, "", "", "", "", "", "", "")
ok, tenantVerified := VerifyBridgeContext(key, "", "", "", "", "", "", "", sig)
if !ok {
t.Error("expected ok=true for empty fields with valid signature")
}
// Empty fields match at all levels; level 1 matches first → tenantVerified=true
if !tenantVerified {
t.Error("expected tenantVerified=true when all fields empty (level 1 matches)")
}
}
// --- Extra params (localKey, sessionKey) tests ---
func TestSignBridgeContext_WithExtraParams(t *testing.T) {
key := "test-secret"
// Without extra params
sig1 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1")
// With extra params
sig2 := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", "-100123:topic:42", "session-abc")
if sig1 == sig2 {
t.Error("signature with extra params should differ from signature without")
}
}
func TestVerifyBridgeContext_WithExtraParams(t *testing.T) {
key := "gateway-token"
localKey := "-100123:topic:42"
sessionKey := "session-abc"
sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", localKey, sessionKey)
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", sig, localKey, sessionKey)
if !ok {
t.Error("expected ok=true for valid signature with extra params")
}
if !tenantVerified {
t.Error("expected tenantVerified=true for full match")
}
}
func TestVerifyBridgeContext_FallbackWithoutExtraParams(t *testing.T) {
key := "gateway-token"
// Pre-localKey session: signed without extra params
sig := SignBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1")
// New code passes localKey/sessionKey but signature was created without them
ok, tenantVerified := VerifyBridgeContext(key, "agent1", "user1", "telegram", "chat1", "direct", "/ws", "tenant1", sig, "-100123:topic:42", "session-abc")
if !ok {
t.Error("expected ok=true for fallback (pre-localKey session)")
}
if !tenantVerified {
t.Error("expected tenantVerified=true — base fields match at fallback level")
}
}
func TestVerifyBridgeContext_ExtraParamOrderMatters(t *testing.T) {
key := "gateway-token"
sig := SignBridgeContext(key, "agent1", "user1", "", "", "", "", "", "localKey", "sessionKey")
// Verify with same order
ok, _ := VerifyBridgeContext(key, "agent1", "user1", "", "", "", "", "", sig, "localKey", "sessionKey")
if !ok {
t.Error("expected ok=true for same order")
}
// Verify with swapped order
ok2, _ := VerifyBridgeContext(key, "agent1", "user1", "", "", "", "", "", sig, "sessionKey", "localKey")
if ok2 {
t.Error("expected ok=false for swapped extra param order")
}
}
func TestBridgeContextFromOptsCarriesDelegationArtifactContext(t *testing.T) {
got := bridgeContextFromOpts(map[string]any{
OptAgentID: "agent-id",
OptWorkspace: "/runtime/outputs",
OptDelegationID: "delegation-id",
OptDelegationInputs: "/runtime/inputs",
})
if got.DelegationID != "delegation-id" || got.DelegationInputs != "/runtime/inputs" {
t.Fatalf("delegation bridge context = %#v", got)
}
}
func TestWriteMCPConfigSignsDelegationArtifactHeaders(t *testing.T) {
t.Setenv("GOCLAW_DATA_DIR", t.TempDir())
const (
token = "gateway-token"
sessionKey = "session-key"
)
data := &MCPConfigData{GatewayAddr: "127.0.0.1:18790", GatewayToken: token}
bc := BridgeContext{
AgentID: "agent-id",
Workspace: "/runtime/outputs",
DelegationID: "delegation-id",
DelegationInputs: "/runtime/inputs",
}
path := data.WriteMCPConfig(context.Background(), sessionKey, bc)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var cfg struct {
MCPServers map[string]struct {
Headers map[string]string `json:"headers"`
} `json:"mcpServers"`
}
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
headers := cfg.MCPServers["goclaw-bridge"].Headers
if headers["X-Delegation-ID"] != bc.DelegationID ||
headers["X-Delegation-Inputs"] != bc.DelegationInputs {
t.Fatalf("delegation headers = %#v", headers)
}
wantSig := SignBridgeContext(
token,
bc.AgentID,
"",
"",
"",
"",
bc.Workspace,
"",
"",
sessionKey,
bc.DelegationID,
bc.DelegationInputs,
)
if headers["X-Bridge-Sig"] != wantSig {
t.Fatal("delegation headers were not covered by the exact bridge signature")
}
}