mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
* 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>
173 lines
5.5 KiB
Go
173 lines
5.5 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
)
|
|
|
|
// stubAgentKeyStore satisfies store.AgentStore via an embedded (nil) interface and
|
|
// implements only GetByIDUnscoped — the single method bridgeContextMiddleware calls.
|
|
type stubAgentKeyStore struct {
|
|
store.AgentStore
|
|
ag *store.AgentData
|
|
}
|
|
|
|
func (s *stubAgentKeyStore) GetByIDUnscoped(context.Context, uuid.UUID) (*store.AgentData, error) {
|
|
return s.ag, nil
|
|
}
|
|
|
|
// TestBridgeContextMiddleware_InjectsAgentKey guards the MCP bridge identity path:
|
|
// a signed X-Agent-ID must put the agent key into the tool context
|
|
// (tools.ToolAgentKeyFromCtx). Session tools (sessions_list/history/send) resolve the
|
|
// caller via that key and otherwise fail with "agent context required".
|
|
func TestBridgeContextMiddleware_InjectsAgentKey(t *testing.T) {
|
|
const (
|
|
gatewayToken = "test-gateway-token"
|
|
wantKey = "vault-keeper"
|
|
)
|
|
agentID := uuid.New()
|
|
agentStore := &stubAgentKeyStore{ag: &store.AgentData{AgentKey: wantKey}}
|
|
|
|
var handlerCalled bool
|
|
var gotKey string
|
|
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
handlerCalled = true
|
|
gotKey = tools.ToolAgentKeyFromCtx(r.Context())
|
|
})
|
|
|
|
mw := bridgeContextMiddleware(gatewayToken, agentStore, next)
|
|
|
|
// Sign X-Agent-ID exactly as the claude-cli provider does: empty user/channel/
|
|
// chat/peer/workspace/tenant plus the two trailing extras (localKey, sessionKey).
|
|
sig := providers.SignBridgeContext(gatewayToken, agentID.String(), "", "", "", "", "", "", "", "")
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/bridge", nil)
|
|
req.Header.Set("X-Agent-ID", agentID.String())
|
|
req.Header.Set("X-Bridge-Sig", sig)
|
|
|
|
mw.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
if !handlerCalled {
|
|
t.Fatal("next handler was not called: middleware rejected the signed request")
|
|
}
|
|
if gotKey != wantKey {
|
|
t.Errorf("ToolAgentKeyFromCtx = %q, want %q", gotKey, wantKey)
|
|
}
|
|
}
|
|
|
|
// TestBridgeContextMiddleware_NoStore_NoAgentKey is the negative control: with no
|
|
// agent store wired (or an unsigned request), the agent key must stay empty so the
|
|
// regression that motivated this fix cannot silently reappear masked by a default.
|
|
func TestBridgeContextMiddleware_NoStore_NoAgentKey(t *testing.T) {
|
|
const gatewayToken = "test-gateway-token"
|
|
agentID := uuid.New()
|
|
|
|
var gotKey string
|
|
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
gotKey = tools.ToolAgentKeyFromCtx(r.Context())
|
|
})
|
|
|
|
// agentStore == nil: middleware injects the UUID but has no row to recover the key from.
|
|
mw := bridgeContextMiddleware(gatewayToken, nil, next)
|
|
|
|
sig := providers.SignBridgeContext(gatewayToken, agentID.String(), "", "", "", "", "", "", "", "")
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/bridge", nil)
|
|
req.Header.Set("X-Agent-ID", agentID.String())
|
|
req.Header.Set("X-Bridge-Sig", sig)
|
|
|
|
mw.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
if gotKey != "" {
|
|
t.Errorf("ToolAgentKeyFromCtx = %q, want empty when no agent store is wired", gotKey)
|
|
}
|
|
}
|
|
|
|
func TestBridgeContextMiddleware_InjectsSignedDelegationArtifactContext(t *testing.T) {
|
|
const (
|
|
gatewayToken = "test-gateway-token"
|
|
delegationID = "delegation-123"
|
|
inputsRoot = "/runtime/delegations/delegation-123/inputs"
|
|
)
|
|
agentID := uuid.New()
|
|
var gotDelegationID, gotInputs string
|
|
var artifactRun bool
|
|
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
gotDelegationID = tools.DelegationIDFromCtx(r.Context())
|
|
gotInputs = tools.DelegationArtifactInputsFromCtx(r.Context())
|
|
artifactRun = tools.IsDelegationArtifactRun(r.Context())
|
|
})
|
|
mw := bridgeContextMiddleware(gatewayToken, nil, next)
|
|
|
|
sig := providers.SignBridgeContext(
|
|
gatewayToken,
|
|
agentID.String(),
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"/runtime/delegations/delegation-123/outputs",
|
|
"",
|
|
"",
|
|
"",
|
|
delegationID,
|
|
inputsRoot,
|
|
)
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/bridge", nil)
|
|
req.Header.Set("X-Agent-ID", agentID.String())
|
|
req.Header.Set("X-Workspace", "/runtime/delegations/delegation-123/outputs")
|
|
req.Header.Set("X-Delegation-ID", delegationID)
|
|
req.Header.Set("X-Delegation-Inputs", inputsRoot)
|
|
req.Header.Set("X-Bridge-Sig", sig)
|
|
|
|
rec := httptest.NewRecorder()
|
|
mw.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK || !artifactRun {
|
|
t.Fatalf("status=%d artifactRun=%v body=%s", rec.Code, artifactRun, rec.Body.String())
|
|
}
|
|
if gotDelegationID != delegationID || gotInputs != inputsRoot {
|
|
t.Fatalf("delegation context = (%q, %q)", gotDelegationID, gotInputs)
|
|
}
|
|
}
|
|
|
|
func TestBridgeContextMiddleware_RejectsLegacySignatureWithDelegationHeaders(t *testing.T) {
|
|
const gatewayToken = "test-gateway-token"
|
|
agentID := uuid.New()
|
|
legacySig := providers.SignBridgeContext(
|
|
gatewayToken,
|
|
agentID.String(),
|
|
"",
|
|
"",
|
|
"",
|
|
"",
|
|
"/runtime/outputs",
|
|
"",
|
|
"",
|
|
"",
|
|
)
|
|
req := httptest.NewRequest(http.MethodPost, "/mcp/bridge", nil)
|
|
req.Header.Set("X-Agent-ID", agentID.String())
|
|
req.Header.Set("X-Workspace", "/runtime/outputs")
|
|
req.Header.Set("X-Delegation-ID", "delegation-123")
|
|
req.Header.Set("X-Delegation-Inputs", "/runtime/inputs")
|
|
req.Header.Set("X-Bridge-Sig", legacySig)
|
|
|
|
var called bool
|
|
mw := bridgeContextMiddleware(gatewayToken, nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
called = true
|
|
}))
|
|
rec := httptest.NewRecorder()
|
|
mw.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden || called {
|
|
t.Fatalf("status=%d called=%v, want forbidden", rec.Code, called)
|
|
}
|
|
}
|