Merge pull request #863 from nextlevelbuilder/dev

fix(security): cross-group session leak + auto-inject scoping + vault graph + UI fixes
This commit is contained in:
Viet Tran authored and GitHub committed 2026-04-13 11:54:09 +07:00
commit fb60403e91
121 files changed
+5764 -242

No files matched your search

+9
View File
@@ -48,6 +48,15 @@ jobs:
# suites run in <20s, so 90s leaves ample breathing room.
- name: Unit tests
run: go test -race -timeout=90s -coverpkg=./... -coverprofile=coverage.out ./...
# Invariant tests (P0) - tenant isolation, permission enforcement
# These run against real DB and MUST pass for merge.
- name: Invariant tests (P0)
run: go test -race -timeout=90s -tags integration ./tests/invariants/...
# Contract tests (P1) - API schema validation
# Skip if no server available (requires CONTRACT_TEST_* env vars).
- name: Contract tests (P1)
run: go test -race -timeout=90s -tags integration ./tests/contracts/... || echo "::warning::Contract tests skipped (no server configured)"
continue-on-error: true
# Integration suite runs against the `pg` service container above.
# Longer -timeout=180s because the first test runs migrations from
# scratch (can take 10-30s on a cold CI runner). Local full suite
+6
View File
@@ -101,6 +101,12 @@ docker run -d --name pgtest -p 5433:5432 -e POSTGRES_PASSWORD=test -e POSTGRES_D
TEST_DATABASE_URL="postgres://postgres:test@localhost:5433/goclaw_test?sslmode=disable" \
go test -v -tags integration ./tests/integration/
# Layered tests
make test-invariants # P0 - tenant isolation (blocking)
make test-contracts # P1 - API schemas (requires server)
make test-scenarios # P2 - user journeys (requires server)
make test-critical # P0 + P1 (pre-merge)
cd ui/web && pnpm install && pnpm dev # Web dashboard (dev)
# Desktop (Wails + SQLite)
+32
View File
@@ -72,6 +72,38 @@ Shared predicate: `store.IsMasterScope(ctx)` (`internal/store/context.go`).
- `requireAuth(RoleAdmin)` as the **sole** gate on a global-state write
- Admin revoke/delete handlers that skip pre-fetch ownership verification (store SQL alone is not enough when it matches `IS NULL` arms)
## Test Layers
Tests are organized by priority and purpose:
| Layer | Priority | Location | Blocking? | Purpose |
|-------|----------|----------|-----------|---------|
| **Invariants** | P0 | `tests/invariants/` | YES | Tenant isolation, permission enforcement |
| **Contracts** | P1 | `tests/contracts/` | YES | API schema validation |
| **Scenarios** | P2 | `tests/scenarios/` | NO | End-to-end user journeys |
| **Integration** | P1 | `tests/integration/` | YES | DB/pipeline integration |
### Running Tests
```bash
make test # Unit tests (fast, no DB)
make test-invariants # P0 invariants (requires pgvector)
make test-contracts # P1 API contracts (requires server)
make test-scenarios # P2 scenarios (requires server)
make test-critical # P0 + P1 (run before merge)
```
### Test Layer Policy
- **P0 failures**: Block PR merge immediately
- **P1 failures**: Block merge, investigate contract breakage
- **P2 failures**: Warning only, may indicate flaky tests or environment issues
When adding new tests:
- Tenant isolation/permissions → `tests/invariants/`
- API response schemas → `tests/contracts/`
- User journeys (multi-step flows) → `tests/scenarios/`
### Commit Messages
Use conventional commits:
+16
View File
@@ -78,6 +78,22 @@ reset: version-file
test:
go test -race -timeout=90s ./...
# ── Layered Testing ──
# P0: Invariant tests - tenant isolation, permission enforcement (MUST pass)
test-invariants:
go test -race -timeout=90s -tags integration ./tests/invariants/...
# P1: Contract tests - API schema validation (MUST pass)
test-contracts:
go test -race -timeout=90s -tags integration ./tests/contracts/...
# P2: Scenario tests - end-to-end user journeys (warning only)
test-scenarios:
go test -race -timeout=180s -tags integration ./tests/scenarios/...
# Critical tests (P0 + P1) - run before merge
test-critical: test-invariants test-contracts
vet:
go vet ./...
+10
View File
@@ -181,6 +181,16 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
vh.SetEnrichProgress(d.enrichProgress)
vh.SetEnrichWorker(d.enrichWorker)
d.server.SetVaultHandler(vh)
// Lightweight graph visualization endpoints (vault + KG).
var kgGraph store.KGGraphStore
if d.pgStores.KnowledgeGraph != nil {
kgGraph = newKGGraphStore(d.pgStores.DB)
}
vgHandler := httpapi.NewVaultGraphHandler(
newVaultGraphStore(d.pgStores.DB), kgGraph, d.pgStores.Teams,
)
d.server.SetVaultGraphHandler(vgHandler)
}
// V3: Episodic memory summaries API
+1
View File
@@ -186,6 +186,7 @@ func wireExtras(
TenantStore: stores.Tenants,
BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs,
SkillTenantCfgs: stores.SkillTenantCfgs,
SystemConfigs: stores.SystemConfigs,
Workspace: workspace,
AutoInjector: autoInjector,
EvolutionMetricsStore: stores.EvolutionMetrics,
+9
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"fmt"
"log/slog"
@@ -133,4 +134,12 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
set("compaction.provider", pc.Provider)
set("compaction.model", pc.Model)
}
// Allowed paths (tenant-scoped filesystem access beyond workspace)
// Stored as JSON array, loaded per-tenant at request time.
if len(cfg.Agents.Defaults.AllowedPaths) > 0 {
if b, err := json.Marshal(cfg.Agents.Defaults.AllowedPaths); err == nil {
set("allowed_paths", string(b))
}
}
}
+23
View File
@@ -79,15 +79,38 @@ func wireExtraTools(
if pgStores.Skills != nil {
skillsAllowPaths = append(skillsAllowPaths, pgStores.Skills.Dirs()...)
}
// Expand user-configured allowed paths (for cross-drive access on Windows).
// These paths are validated per-request in resolvePath for tenant isolation.
var userAllowPaths []string
for _, p := range agentCfg.AllowedPaths {
expanded := config.ExpandHome(p)
if expanded != "" {
userAllowPaths = append(userAllowPaths, expanded)
}
}
if readTool, ok := toolsReg.Get("read_file"); ok {
if pa, ok := readTool.(tools.PathAllowable); ok {
pa.AllowPaths(skillsAllowPaths...)
pa.AllowPaths(filepath.Join(dataDir, "cli-workspaces"))
pa.AllowPaths(userAllowPaths...)
}
}
if listTool, ok := toolsReg.Get("list_files"); ok {
if pa, ok := listTool.(tools.PathAllowable); ok {
pa.AllowPaths(skillsAllowPaths...)
pa.AllowPaths(userAllowPaths...)
}
}
// Write and edit tools also get user-configured allowed paths for cross-drive access.
if writeTool, ok := toolsReg.Get("write_file"); ok {
if pa, ok := writeTool.(tools.PathAllowable); ok {
pa.AllowPaths(userAllowPaths...)
}
}
if editTool, ok := toolsReg.Get("edit"); ok {
if pa, ok := editTool.(tools.PathAllowable); ok {
pa.AllowPaths(userAllowPaths...)
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !sqlite && !sqliteonly
package cmd
import (
"database/sql"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
func newVaultGraphStore(db *sql.DB) store.VaultGraphStore { return pg.NewPGVaultGraphStore(db) }
func newKGGraphStore(db *sql.DB) store.KGGraphStore { return pg.NewPGKGGraphStore(db) }
+13
View File
@@ -0,0 +1,13 @@
//go:build sqlite || sqliteonly
package cmd
import (
"database/sql"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/sqlitestore"
)
func newVaultGraphStore(db *sql.DB) store.VaultGraphStore { return sqlitestore.NewSQLiteVaultGraphStore(db) }
func newKGGraphStore(db *sql.DB) store.KGGraphStore { return sqlitestore.NewSQLiteKGGraphStore(db) }
+6
View File
@@ -69,6 +69,12 @@ Tenant admins can override tool configuration without affecting other tenants. O
### Added
#### Vault & Knowledge Graph 10k Optimization (2026-04-12)
- **Graph visualization endpoints**: `GET /v1/vault/graph` (cross-tenant) + `GET /v1/agents/{agentID}/kg/graph/compact` for rendering document relationships and semantic entities with support for up to 10k nodes (up from 2k default limit)
- **FA2 layout optimization**: Graph layout computation moved to web worker (non-blocking frontend rendering)
- **Semantic zoom**: UI-level semantic zoom support for graph visualization
- **`DEFAULT_NODE_LIMIT` increase**: 200 → 2000 nodes per graph view to support larger knowledge bases
### Testing
#### Test Speed-Up + Coverage Ratchet Removal (2026-04-11)
+70 -2
View File
@@ -371,7 +371,74 @@ Returns outgoing links and backlinks for a document.
---
## 4. Orchestration Mode
## 4. Graph Visualization
Lightweight endpoints for rendering vault and knowledge graph relationships.
### Get Vault Graph
```
GET /v1/vault/graph
```
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `agent_id` | string | Filter by agent (optional). |
| `team_id` | string | Scope by team (optional). |
| `limit` | integer | Max nodes to return (default: 500, max: 2000). |
**Response:**
```json
{
"nodes": [
{
"id": "uuid",
"label": "Document Title",
"type": "document",
"agent_id": "uuid",
"metadata": {}
}
],
"edges": [
{
"id": "link-uuid",
"source": "doc-uuid-1",
"target": "doc-uuid-2",
"type": "wikilink"
}
],
"total_nodes": 150,
"total_edges": 120
}
```
Used for vault document relationship visualization with FA2 layout (web worker rendering).
### Get Knowledge Graph (Compact)
```
GET /v1/agents/{agentID}/kg/graph/compact
```
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `limit` | integer | Max nodes (default: 500, max: 2000). Optimized 10k node load. |
**Response:** Same format as vault graph; nodes represent semantic entities, edges represent relationship types.
**Features:**
- Semantic zoom support
- Optimized for 10k+ entity graphs
- FA2 layout computed via web worker
---
## 5. Orchestration Mode
Determines how an agent routes requests (standalone, delegation, team-based).
@@ -420,7 +487,7 @@ Or in team mode:
---
## 5. V3 Feature Flags
## 6. V3 Feature Flags
Per-agent feature flags control v3 system capabilities (evolution, episodic memory, vault, etc.).
@@ -478,6 +545,7 @@ Accepts partial updates. Flag keys are validated against recognized v3 flags.
| `internal/http/evolution_handlers.go` | Metrics + suggestions endpoints |
| `internal/http/episodic_handlers.go` | Episodic memory list + search endpoints |
| `internal/http/vault_handlers.go` | Knowledge vault document + link endpoints |
| `internal/http/vault_graph_handler.go` | Vault + KG graph visualization endpoints |
| `internal/http/orchestration_handlers.go` | Orchestration mode info endpoint |
| `internal/http/v3_flags_handlers.go` | V3 feature flag get/toggle endpoints |
| `internal/store/evolution_store.go` | Evolution metrics/suggestions store interface |
+9
View File
@@ -77,6 +77,10 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
if l.tenantToolSettings != nil {
ctx = tools.WithTenantToolSettings(ctx, l.tenantToolSettings)
}
// Inject tenant-specific allowed paths for filesystem tools.
if len(l.tenantAllowedPaths) > 0 {
ctx = tools.WithTenantAllowedPaths(ctx, l.tenantAllowedPaths)
}
// Inject channel type into context for tools (e.g. message tool needs it for Zalo group routing)
if req.ChannelType != "" {
ctx = tools.WithToolChannelType(ctx, req.ChannelType)
@@ -147,6 +151,9 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
if l.shouldShareKnowledgeGraph() {
ctx = store.WithSharedKG(ctx)
}
if l.shouldShareSessions() {
ctx = store.WithSharedSessions(ctx)
}
if err := os.MkdirAll(effectiveWorkspace, 0755); err != nil {
slog.Warn("failed to create user workspace directory", "workspace", effectiveWorkspace, "user", req.UserID, "error", err)
}
@@ -310,6 +317,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
SelfEvolve: l.selfEvolve,
SharedMemory: store.IsSharedMemory(ctx),
SharedKG: store.IsSharedKG(ctx),
SharedSessions: store.IsSharedSessions(ctx),
RestrictToWorkspace: l.restrictToWs != nil && *l.restrictToWs,
BuiltinToolSettings: l.builtinToolSettings,
ChannelType: req.ChannelType,
@@ -327,6 +335,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
TeamTaskID: req.TeamTaskID,
LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx),
AgentToolKey: l.id,
TenantAllowedPaths: l.tenantAllowedPaths,
}
ctx = store.WithRunContext(ctx, rc)
+1 -1
View File
@@ -96,7 +96,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
// DM only — group chats have permission checks and multiple senders.
if hadBootstrap && peerKind == "direct" {
if senderName := store.SenderNameFromContext(ctx); senderName != "" {
hint := fmt.Sprintf("Known user info (from %s): Name=%q\nDefault timezone: Asia/Saigon (GMT+7). User can correct this.", channelType, senderName)
hint := fmt.Sprintf("Known user info (from %s): Name=%q\nTimezone: not yet known. When the user mentions times, schedules, or reminders, ask for their timezone and update USER.md.", channelType, senderName)
if extraSystemPrompt != "" {
extraSystemPrompt += "\n\n"
}
+1 -1
View File
@@ -255,7 +255,7 @@ func (l *Loop) makeAutoInjectCallback(req *RunRequest) func(ctx context.Context,
return func(ctx context.Context, userMessage, userID, recentContext string) (string, error) {
result, err := l.autoInjector.Inject(ctx, memory.InjectParams{
AgentID: l.agentUUID.String(),
UserID: userID,
UserID: store.MemoryUserID(ctx),
TenantID: store.TenantIDFromContext(ctx).String(),
UserMessage: userMessage,
RecentContext: recentContext,
+8
View File
@@ -169,6 +169,10 @@ type Loop struct {
// BuiltinToolSettingsFromCtx with global winning at tool-name level.
tenantToolSettings tools.BuiltinToolSettings
// Tenant-specific allowed paths beyond workspace (from system_configs['allowed_paths']).
// Filesystem tools (read_file, write_file, edit, list_files) check these at execution time.
tenantAllowedPaths []string
// Per-tenant disabled tools (tool name → true means excluded from LLM)
disabledTools map[string]bool
@@ -339,6 +343,9 @@ type LoopConfig struct {
// Tenant-layer tool settings overlay (from builtin_tool_tenant_configs.settings).
TenantToolSettings tools.BuiltinToolSettings
// Tenant-specific allowed paths beyond workspace (from system_configs['allowed_paths']).
TenantAllowedPaths []string
// Per-tenant disabled tools (tool name → true means excluded)
DisabledTools map[string]bool
@@ -484,6 +491,7 @@ func NewLoop(cfg LoopConfig) *Loop {
maxMessageChars: cfg.MaxMessageChars,
builtinToolSettings: cfg.BuiltinToolSettings,
tenantToolSettings: cfg.TenantToolSettings,
tenantAllowedPaths: cfg.TenantAllowedPaths,
disabledTools: cfg.DisabledTools,
reasoningConfig: cfg.ReasoningConfig,
promptMode: cfg.PromptMode,
+6
View File
@@ -64,6 +64,12 @@ func (l *Loop) shouldShareKnowledgeGraph() bool {
return l.workspaceSharing != nil && l.workspaceSharing.ShareKnowledgeGraph
}
// shouldShareSessions returns true if sessions should be shared across
// all users/groups of the agent (no per-group session scoping).
func (l *Loop) shouldShareSessions() bool {
return l.workspaceSharing != nil && l.workspaceSharing.ShareSessions
}
// buildChannelMeta extracts channel metadata from RunRequest for bootstrap decisions.
// Returns nil when channel type is unknown (preserves normal bootstrap flow).
func (l *Loop) buildChannelMeta(req *RunRequest) *bootstrap.ChannelMeta {
+33
View File
@@ -109,6 +109,39 @@ func TestShouldShareKnowledgeGraph_DisabledByDefault(t *testing.T) {
}
}
// ─── shouldShareSessions ──────────────────────────────────────────────────────
func TestShouldShareSessions_NilConfig(t *testing.T) {
l := &Loop{workspaceSharing: nil}
if l.shouldShareSessions() {
t.Error("nil config should return false")
}
}
func TestShouldShareSessions_EnabledConfig(t *testing.T) {
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ShareSessions: true}}
if !l.shouldShareSessions() {
t.Error("ShareSessions=true should return true")
}
}
func TestShouldShareSessions_DisabledByDefault(t *testing.T) {
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{ShareMemory: true, ShareKnowledgeGraph: true}}
if l.shouldShareSessions() {
t.Error("ShareMemory and ShareKnowledgeGraph alone should not enable sessions sharing")
}
}
func TestShouldShareSessions_IndependentOfMemory(t *testing.T) {
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{
ShareMemory: true,
ShareSessions: false,
}}
if l.shouldShareSessions() {
t.Error("ShareMemory=true with ShareSessions=false should return false (independent)")
}
}
// ─── InvalidateUserWorkspace ──────────────────────────────────────────────
func TestInvalidateUserWorkspace_RemovesCachedSetup(t *testing.T) {
+21
View File
@@ -2,6 +2,7 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -109,6 +110,9 @@ type ResolverDeps struct {
BuiltinToolTenantCfgs store.BuiltinToolTenantConfigStore
SkillTenantCfgs store.SkillTenantConfigStore
// System config store for tenant-scoped settings (allowed_paths, etc.)
SystemConfigs store.SystemConfigStore
// Global workspace root (GOCLAW_WORKSPACE)
Workspace string
@@ -368,6 +372,22 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
}
}
// Load tenant-specific allowed paths (from system_configs['allowed_paths']).
// These extend filesystem tool access beyond the agent's workspace.
var tenantAllowedPaths []string
if deps.SystemConfigs != nil && ag.TenantID != uuid.Nil {
tenantCtx := store.WithTenantID(ctx, ag.TenantID)
if raw, err := deps.SystemConfigs.Get(tenantCtx, "allowed_paths"); err == nil && raw != "" {
if json.Unmarshal([]byte(raw), &tenantAllowedPaths) == nil && len(tenantAllowedPaths) > 0 {
// Expand home directory in paths
for i, p := range tenantAllowedPaths {
tenantAllowedPaths[i] = config.ExpandHome(p)
}
slog.Debug("tenant allowed paths loaded", "agent", agentKey, "tenant", ag.TenantID, "paths", len(tenantAllowedPaths))
}
}
}
// Filter skills by visibility + agent grants.
// Only public skills and explicitly granted internal skills appear in the system prompt.
var skillAllowList []string
@@ -467,6 +487,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
BuiltinToolSettings: builtinSettings,
TenantToolSettings: tenantToolSettings,
TenantAllowedPaths: tenantAllowedPaths,
DisabledTools: disabledTools,
ReasoningConfig: store.ResolveEffectiveReasoningConfig(providerReasoningDefaults, ag.ParseReasoningConfig()),
PromptMode: PromptMode(ag.ParsePromptMode()),
+1 -1
View File
@@ -5,7 +5,7 @@ _Learn about the person you're helping. Update this as you go._
- **Name:**
- **What to call them:**
- **Pronouns:** _(optional)_
- **Timezone:**
- **Timezone:** _(ask when user mentions times/schedules if empty)_
- **Notes:**
## Context
+1
View File
@@ -111,6 +111,7 @@ type AgentsConfig struct {
// AgentDefaults are default settings for all agents.
type AgentDefaults struct {
Workspace string `json:"workspace"`
AllowedPaths []string `json:"allowed_paths,omitempty"` // extra paths agents can access (cross-drive on Windows)
RestrictToWorkspace bool `json:"restrict_to_workspace"`
Provider string `json:"provider"`
Model string `json:"model"`
+9
View File
@@ -1,6 +1,7 @@
package config
import (
"encoding/json"
"strconv"
)
@@ -83,4 +84,12 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) {
str("compaction.provider", &pc.Provider)
str("compaction.model", &pc.Model)
}
// Allowed paths (JSON array)
if v, ok := configs["allowed_paths"]; ok && v != "" {
var paths []string
if err := json.Unmarshal([]byte(v), &paths); err == nil {
c.Agents.Defaults.AllowedPaths = paths
}
}
}
+5 -1
View File
@@ -312,19 +312,23 @@ func (m *TeamsMethods) handleUpdate(ctx context.Context, client *gateway.Client,
EscalationActions []string `json:"escalation_actions,omitempty"`
WorkspaceScope string `json:"workspace_scope,omitempty"`
WorkspaceQuotaMB *int `json:"workspace_quota_mb,omitempty"`
Notifications *struct {
Notifications *struct {
Dispatched *bool `json:"dispatched,omitempty"`
Progress *bool `json:"progress,omitempty"`
Failed *bool `json:"failed,omitempty"`
Completed *bool `json:"completed,omitempty"`
Commented *bool `json:"commented,omitempty"`
NewTask *bool `json:"new_task,omitempty"`
SlowTool *bool `json:"slow_tool,omitempty"`
Mode string `json:"mode,omitempty"`
} `json:"notifications,omitempty"`
MemberRequests *struct {
Enabled *bool `json:"enabled,omitempty"`
AutoDispatch *bool `json:"auto_dispatch,omitempty"`
} `json:"member_requests,omitempty"`
BlockerEscalation *struct {
Enabled *bool `json:"enabled,omitempty"`
} `json:"blocker_escalation,omitempty"`
}
raw, _ := json.Marshal(params.Settings)
var access teamAccessSettings
+5
View File
@@ -512,6 +512,11 @@ func (s *Server) SetEvolutionHandler(h *httpapi.EvolutionHandler) {
// SetVaultHandler sets the Knowledge Vault document handler.
func (s *Server) SetVaultHandler(h *httpapi.VaultHandler) { s.handlers = append(s.handlers, h) }
// SetVaultGraphHandler sets the lightweight graph visualization handler.
func (s *Server) SetVaultGraphHandler(h *httpapi.VaultGraphHandler) {
s.handlers = append(s.handlers, h)
}
// SetEpisodicHandler sets the episodic memory handler.
func (s *Server) SetEpisodicHandler(h *httpapi.EpisodicHandler) { s.handlers = append(s.handlers, h) }
+63
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"log/slog"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
@@ -134,6 +136,8 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/agents/{id}", h.authMiddleware(h.handleGet))
mux.HandleFunc("PUT /v1/agents/{id}", h.adminMiddleware(h.handleUpdate))
mux.HandleFunc("DELETE /v1/agents/{id}", h.adminMiddleware(h.handleDelete))
// Bulk operations (admin+)
mux.HandleFunc("POST /v1/agents/sync-workspace", h.adminMiddleware(h.handleSyncWorkspace))
// Sharing (admin+)
mux.HandleFunc("GET /v1/agents/{id}/shares", h.authMiddleware(h.handleListShares))
mux.HandleFunc("POST /v1/agents/{id}/shares", h.adminMiddleware(h.handleShare))
@@ -529,3 +533,62 @@ func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
emitAudit(h.msgBus, r, "agent.deleted", "agent", id.String())
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
}
// handleSyncWorkspace updates all agents to use the new workspace root.
// POST /v1/agents/sync-workspace
// Body: {"workspace": "E:\\project\\workspace"}
// Requires admin role.
func (h *AgentsHandler) handleSyncWorkspace(w http.ResponseWriter, r *http.Request) {
tenantID := store.TenantIDFromContext(r.Context())
var req struct {
Workspace string `json:"workspace"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "invalid JSON body")
return
}
if req.Workspace == "" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "workspace is required")
return
}
// Path sanity check: reject traversal attempts
if strings.Contains(req.Workspace, "..") {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "workspace path cannot contain '..'")
return
}
// List all agents (empty ownerID = all agents)
agents, err := h.agents.List(r.Context(), "")
if err != nil {
slog.Error("agents.sync_workspace: list failed", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "failed to list agents")
return
}
// Update each agent's workspace to use the new root
newWorkspace := config.ExpandHome(req.Workspace)
var updated int
for _, ag := range agents {
// Skip agents from other tenants
if ag.TenantID != tenantID {
continue
}
// Build new workspace path: {newWorkspace}/{agentKey}
newPath := filepath.Join(newWorkspace, ag.AgentKey)
if ag.Workspace == newPath {
continue // already using correct path
}
// Use Update with map[string]any
if err := h.agents.Update(r.Context(), ag.ID, map[string]any{"workspace": newPath}); err != nil {
slog.Warn("agents.sync_workspace: update failed", "agent", ag.AgentKey, "error", err)
continue
}
h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey)
updated++
}
slog.Info("agents.sync_workspace: completed", "updated", updated, "total", len(agents), "workspace", newWorkspace)
emitAudit(h.msgBus, r, "agents.workspace_synced", "updated", strconv.Itoa(updated))
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updated": updated})
}
+198
View File
@@ -0,0 +1,198 @@
package http
import (
"context"
"log/slog"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// VaultGraphHandler serves lightweight graph endpoints for visualization.
type VaultGraphHandler struct {
vaultGraph store.VaultGraphStore
kgGraph store.KGGraphStore
teamAccess store.TeamAccessStore // nil = skip team validation (lite edition)
}
// NewVaultGraphHandler creates a new graph handler.
func NewVaultGraphHandler(vg store.VaultGraphStore, kg store.KGGraphStore, ta store.TeamAccessStore) *VaultGraphHandler {
return &VaultGraphHandler{vaultGraph: vg, kgGraph: kg, teamAccess: ta}
}
// RegisterRoutes registers graph visualization routes.
func (h *VaultGraphHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/vault/graph", h.auth(h.handleVaultGraph))
if h.kgGraph != nil {
mux.HandleFunc("GET /v1/agents/{agentID}/kg/graph/compact", h.auth(h.handleKGGraphCompact))
}
}
func (h *VaultGraphHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth("", next)
}
// --- vault graph ---
type vaultGraphResponse struct {
Nodes []store.GraphNode `json:"nodes"`
Edges []store.GraphEdge `json:"edges"`
TotalNodes int `json:"total_nodes"`
TotalEdges int `json:"total_edges"`
}
func (h *VaultGraphHandler) handleVaultGraph(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tenantID := store.TenantIDFromContext(ctx).String()
agentID := r.URL.Query().Get("agent_id")
teamID := r.URL.Query().Get("team_id")
limit := parseGraphLimit(r.URL.Query().Get("limit"))
opts := store.VaultGraphListOptions{Limit: limit}
// Team scope validation.
if teamID != "" {
if !h.validateTeamMembership(ctx, w, teamID) {
return
}
opts.TeamID = &teamID
} else if !store.IsOwnerRole(ctx) {
h.applyNonOwnerTeamScope(ctx, &opts)
}
nodes, totalNodes, err := h.vaultGraph.ListGraphNodes(ctx, tenantID, agentID, opts)
if err != nil {
slog.Warn("vault_graph.nodes failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if nodes == nil {
nodes = []store.GraphNode{}
}
edges, totalEdges, err := h.vaultGraph.ListGraphEdges(ctx, tenantID, agentID, opts)
if err != nil {
slog.Warn("vault_graph.edges failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if edges == nil {
edges = []store.GraphEdge{}
}
writeJSON(w, http.StatusOK, vaultGraphResponse{
Nodes: nodes,
Edges: edges,
TotalNodes: totalNodes,
TotalEdges: totalEdges,
})
}
// --- KG graph compact ---
type kgGraphCompactResponse struct {
Nodes []store.KGGraphNode `json:"nodes"`
Edges []store.KGGraphEdge `json:"edges"`
TotalNodes int `json:"total_nodes"`
TotalEdges int `json:"total_edges"`
}
func (h *VaultGraphHandler) handleKGGraphCompact(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
agentID := r.PathValue("agentID")
userID := r.URL.Query().Get("user_id")
limit := parseGraphLimit(r.URL.Query().Get("limit"))
nodes, totalNodes, err := h.kgGraph.ListKGGraphNodes(ctx, agentID, userID, limit)
if err != nil {
slog.Warn("kg_graph_compact.nodes failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if nodes == nil {
nodes = []store.KGGraphNode{}
}
edges, totalEdges, err := h.kgGraph.ListKGGraphEdges(ctx, agentID, userID, limit*3)
if err != nil {
slog.Warn("kg_graph_compact.edges failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if edges == nil {
edges = []store.KGGraphEdge{}
}
writeJSON(w, http.StatusOK, kgGraphCompactResponse{
Nodes: nodes,
Edges: edges,
TotalNodes: totalNodes,
TotalEdges: totalEdges,
})
}
// --- helpers ---
func parseGraphLimit(s string) int {
limit, _ := strconv.Atoi(s)
if limit <= 0 {
limit = 2000
}
if limit > 10000 {
limit = 10000
}
return limit
}
// validateTeamMembership checks team membership for non-owner users.
func (h *VaultGraphHandler) validateTeamMembership(ctx context.Context, w http.ResponseWriter, teamID string) bool {
if store.IsOwnerRole(ctx) {
return true
}
if h.teamAccess == nil {
return true
}
userID := store.UserIDFromContext(ctx)
if userID == "" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "user identity required"})
return false
}
tid, err := uuid.Parse(teamID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid team_id"})
return false
}
ok, err := h.teamAccess.HasTeamAccess(ctx, tid, userID)
if err != nil || !ok {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "not a member of the specified team"})
return false
}
return true
}
// applyNonOwnerTeamScope restricts a non-owner graph query to personal + user's teams.
func (h *VaultGraphHandler) applyNonOwnerTeamScope(ctx context.Context, opts *store.VaultGraphListOptions) {
if h.teamAccess == nil {
return
}
userID := store.UserIDFromContext(ctx)
if userID == "" {
return
}
teams, err := h.teamAccess.ListUserTeams(ctx, userID)
if err != nil || len(teams) == 0 {
empty := ""
opts.TeamID = &empty
return
}
ids := make([]string, len(teams))
for i, t := range teams {
ids[i] = t.ID.String()
}
opts.TeamIDs = ids
}
+2 -1
View File
@@ -317,6 +317,7 @@ type WorkspaceSharingConfig struct {
SharedUsers []string `json:"shared_users,omitempty" db:"-"`
ShareMemory bool `json:"share_memory" db:"-"`
ShareKnowledgeGraph bool `json:"share_knowledge_graph" db:"-"`
ShareSessions bool `json:"share_sessions" db:"-"`
}
const (
@@ -406,7 +407,7 @@ func (a *AgentData) ParseWorkspaceSharing() *WorkspaceSharingConfig {
if json.Unmarshal(a.WorkspaceSharing, &ws) != nil {
return nil
}
if !ws.SharedDM && !ws.SharedGroup && len(ws.SharedUsers) == 0 && !ws.ShareMemory && !ws.ShareKnowledgeGraph {
if !ws.SharedDM && !ws.SharedGroup && len(ws.SharedUsers) == 0 && !ws.ShareMemory && !ws.ShareKnowledgeGraph && !ws.ShareSessions {
return nil
}
return &ws
+18
View File
@@ -27,6 +27,8 @@ const (
SharedMemoryKey contextKey = "goclaw_shared_memory"
// SharedKGKey indicates KG should be shared across all users of the agent (no per-user scoping).
SharedKGKey contextKey = "goclaw_shared_kg"
// SharedSessionsKey indicates sessions should be shared across all users (no per-group scoping).
SharedSessionsKey contextKey = "goclaw_shared_sessions"
// ShellDenyGroupsKey holds per-agent shell deny group overrides.
ShellDenyGroupsKey contextKey = "goclaw_shell_deny_groups"
// AgentKeyKey is the context key for the agent key/name (string identifier, e.g. "default").
@@ -236,6 +238,22 @@ func IsSharedKG(ctx context.Context) bool {
return false
}
// WithSharedSessions returns a context flagged for shared sessions (skip per-group scoping).
func WithSharedSessions(ctx context.Context) context.Context {
return context.WithValue(ctx, SharedSessionsKey, true)
}
// IsSharedSessions returns true if sessions should be shared across users/groups.
func IsSharedSessions(ctx context.Context) bool {
if v, ok := ctx.Value(SharedSessionsKey).(bool); ok {
return v
}
if rc := RunContextFromCtx(ctx); rc != nil {
return rc.SharedSessions
}
return false
}
// WithLocale returns a new context with the given locale.
func WithLocale(ctx context.Context, locale string) context.Context {
return context.WithValue(ctx, LocaleKey, locale)
+112
View File
@@ -0,0 +1,112 @@
package pg
import (
"context"
"database/sql"
"fmt"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGKGGraphStore implements store.KGGraphStore backed by PostgreSQL.
type PGKGGraphStore struct {
db *sql.DB
}
// NewPGKGGraphStore creates a new PG-backed KG graph store.
func NewPGKGGraphStore(db *sql.DB) *PGKGGraphStore {
return &PGKGGraphStore{db: db}
}
// ListKGGraphNodes returns lightweight KG entities for graph visualization.
func (s *PGKGGraphStore) ListKGGraphNodes(ctx context.Context, agentID, userID string, limit int) ([]store.KGGraphNode, int, error) {
aid := parseUUIDOrNil(agentID)
if limit <= 0 {
limit = 2000
}
if limit > 10000 {
limit = 10000
}
tid := store.TenantIDFromContext(ctx)
q := `SELECT id, name, entity_type, confidence
FROM kg_entities WHERE agent_id = $1 AND tenant_id = $2 AND valid_until IS NULL`
args := []any{aid, tid}
p := 3
if !store.IsSharedKG(ctx) && userID != "" {
q += fmt.Sprintf(" AND user_id = $%d", p)
args = append(args, userID)
p++
}
q += " ORDER BY updated_at DESC"
q += fmt.Sprintf(" LIMIT $%d", p)
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("kg graph nodes: %w", err)
}
defer rows.Close()
var nodes []store.KGGraphNode
for rows.Next() {
var n store.KGGraphNode
if err := rows.Scan(&n.ID, &n.Name, &n.EntityType, &n.Confidence); err != nil {
return nil, 0, fmt.Errorf("kg graph nodes scan: %w", err)
}
nodes = append(nodes, n)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("kg graph nodes rows: %w", err)
}
return nodes, len(nodes), nil
}
// ListKGGraphEdges returns lightweight KG relations for graph visualization.
func (s *PGKGGraphStore) ListKGGraphEdges(ctx context.Context, agentID, userID string, limit int) ([]store.KGGraphEdge, int, error) {
aid := parseUUIDOrNil(agentID)
if limit <= 0 {
limit = 6000
}
if limit > 30000 {
limit = 30000
}
tid := store.TenantIDFromContext(ctx)
q := `SELECT id, source_entity_id, target_entity_id, relation_type
FROM kg_relations WHERE agent_id = $1 AND tenant_id = $2 AND valid_until IS NULL`
args := []any{aid, tid}
p := 3
if !store.IsSharedKG(ctx) && userID != "" {
q += fmt.Sprintf(" AND user_id = $%d", p)
args = append(args, userID)
p++
}
q += fmt.Sprintf(" LIMIT $%d", p)
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("kg graph edges: %w", err)
}
defer rows.Close()
var edges []store.KGGraphEdge
for rows.Next() {
var e store.KGGraphEdge
if err := rows.Scan(&e.ID, &e.SourceID, &e.TargetID, &e.RelationType); err != nil {
return nil, 0, fmt.Errorf("kg graph edges scan: %w", err)
}
edges = append(edges, e)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("kg graph edges rows: %w", err)
}
return edges, len(edges), nil
}
+188
View File
@@ -0,0 +1,188 @@
package pg
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// PGVaultGraphStore implements store.VaultGraphStore backed by PostgreSQL.
type PGVaultGraphStore struct {
db *sql.DB
}
// NewPGVaultGraphStore creates a new PG-backed vault graph store.
func NewPGVaultGraphStore(db *sql.DB) *PGVaultGraphStore {
return &PGVaultGraphStore{db: db}
}
// ListGraphNodes returns lightweight vault nodes with pre-computed degree.
func (s *PGVaultGraphStore) ListGraphNodes(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) ([]store.GraphNode, int, error) {
tid := parseUUIDOrNil(tenantID)
q := `SELECT vd.id, vd.title, vd.path, vd.doc_type,
COALESCE(deg.cnt, 0) AS degree
FROM vault_documents vd
LEFT JOIN (
SELECT doc_id, COUNT(*) AS cnt FROM (
SELECT from_doc_id AS doc_id FROM vault_links vl
JOIN vault_documents d ON d.id = vl.from_doc_id AND d.tenant_id = $1
UNION ALL
SELECT to_doc_id AS doc_id FROM vault_links vl
JOIN vault_documents d ON d.id = vl.to_doc_id AND d.tenant_id = $1
) sub GROUP BY doc_id
) deg ON deg.doc_id = vd.id
WHERE vd.tenant_id = $1`
args := []any{tid}
p := 2
if agentID != "" {
aid := parseUUIDOrNil(agentID)
q += fmt.Sprintf(" AND vd.agent_id = $%d", p)
args = append(args, aid)
p++
}
q, args, p = appendGraphTeamFilter(q, args, p, "vd", opts.TeamID, opts.TeamIDs)
q += " ORDER BY degree DESC, vd.updated_at DESC"
limit := opts.Limit
if limit <= 0 {
limit = 2000
}
if limit > 10000 {
limit = 10000
}
q += fmt.Sprintf(" LIMIT $%d", p)
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("vault graph nodes: %w", err)
}
defer rows.Close()
var nodes []store.GraphNode
for rows.Next() {
var n store.GraphNode
if err := rows.Scan(&n.ID, &n.Title, &n.Path, &n.DocType, &n.Degree); err != nil {
return nil, 0, fmt.Errorf("vault graph nodes scan: %w", err)
}
nodes = append(nodes, n)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("vault graph nodes rows: %w", err)
}
// Count total nodes in scope (without LIMIT).
total, err := s.countGraphNodes(ctx, tid, agentID, opts)
if err != nil {
return nodes, len(nodes), nil // non-fatal: return what we have
}
return nodes, total, nil
}
// ListGraphEdges returns lightweight vault edges for nodes in scope.
func (s *PGVaultGraphStore) ListGraphEdges(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) ([]store.GraphEdge, int, error) {
tid := parseUUIDOrNil(tenantID)
// Subquery selects doc IDs in scope (same filters as ListGraphNodes).
subQ := `SELECT id FROM vault_documents WHERE tenant_id = $1`
args := []any{tid}
p := 2
if agentID != "" {
aid := parseUUIDOrNil(agentID)
subQ += fmt.Sprintf(" AND agent_id = $%d", p)
args = append(args, aid)
p++
}
subQ, args, p = appendGraphTeamFilter(subQ, args, p, "", opts.TeamID, opts.TeamIDs)
limit := opts.Limit
if limit <= 0 {
limit = 2000
}
if limit > 10000 {
limit = 10000
}
subQ += fmt.Sprintf(" LIMIT $%d", p)
args = append(args, limit)
p++
q := fmt.Sprintf(`SELECT vl.id, vl.from_doc_id, vl.to_doc_id, vl.link_type
FROM vault_links vl
WHERE vl.from_doc_id IN (%s) AND vl.to_doc_id IN (%s)`, subQ, subQ)
// PG positional params ($1,$2..) are reused across both IN subqueries — no duplication needed.
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("vault graph edges: %w", err)
}
defer rows.Close()
var edges []store.GraphEdge
for rows.Next() {
var e store.GraphEdge
if err := rows.Scan(&e.ID, &e.FromID, &e.ToID, &e.LinkType); err != nil {
return nil, 0, fmt.Errorf("vault graph edges scan: %w", err)
}
edges = append(edges, e)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("vault graph edges rows: %w", err)
}
return edges, len(edges), nil
}
func (s *PGVaultGraphStore) countGraphNodes(ctx context.Context, tid any, agentID string, opts store.VaultGraphListOptions) (int, error) {
q := `SELECT COUNT(*) FROM vault_documents WHERE tenant_id = $1`
args := []any{tid}
p := 2
if agentID != "" {
aid := parseUUIDOrNil(agentID)
q += fmt.Sprintf(" AND agent_id = $%d", p)
args = append(args, aid)
p++
}
q, args, _ = appendGraphTeamFilter(q, args, p, "", opts.TeamID, opts.TeamIDs)
var count int
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// appendGraphTeamFilter appends team_id clause. Optional tableAlias prefix (e.g. "vd").
func appendGraphTeamFilter(q string, args []any, p int, tableAlias string, teamID *string, teamIDs []string) (string, []any, int) {
col := "team_id"
if tableAlias != "" {
col = tableAlias + ".team_id"
}
if len(teamIDs) > 0 {
ph := make([]string, len(teamIDs))
for i, id := range teamIDs {
ph[i] = fmt.Sprintf("$%d", p)
args = append(args, parseUUIDOrNil(id))
p++
}
q += fmt.Sprintf(" AND (%s IS NULL OR %s IN (%s))", col, col, strings.Join(ph, ","))
} else if teamID != nil {
if *teamID != "" {
q += fmt.Sprintf(" AND %s = $%d", col, p)
args = append(args, parseUUIDOrNil(*teamID))
p++
} else {
q += fmt.Sprintf(" AND %s IS NULL", col)
}
}
return q, args, p
}
+11 -9
View File
@@ -33,6 +33,7 @@ type RunContext struct {
SelfEvolve bool
SharedMemory bool
SharedKG bool
SharedSessions bool
RestrictToWorkspace bool
// Tool configuration
@@ -46,15 +47,16 @@ type RunContext struct {
ShellDenyGroups map[string]bool
// Workspace
Workspace string
TeamWorkspace string
TeamID string
WorkspaceChannel string
WorkspaceChatID string
TeamTaskID string
DelegationID string // delegation identifier for vault auto-linking (empty when not in delegation)
LeaderAgentID string // leader's agent UUID for member memory read fallback
AgentToolKey string // tool-level agent key for registry routing
Workspace string
TeamWorkspace string
TeamID string
WorkspaceChannel string
WorkspaceChatID string
TeamTaskID string
DelegationID string // delegation identifier for vault auto-linking (empty when not in delegation)
LeaderAgentID string // leader's agent UUID for member memory read fallback
AgentToolKey string // tool-level agent key for registry routing
TenantAllowedPaths []string // tenant-specific allowed paths beyond workspace (from system_configs)
}
// WithRunContext stores a RunContext on the context.
+105
View File
@@ -0,0 +1,105 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteKGGraphStore implements store.KGGraphStore backed by SQLite.
type SQLiteKGGraphStore struct {
db *sql.DB
}
// NewSQLiteKGGraphStore creates a new SQLite-backed KG graph store.
func NewSQLiteKGGraphStore(db *sql.DB) *SQLiteKGGraphStore {
return &SQLiteKGGraphStore{db: db}
}
// ListKGGraphNodes returns lightweight KG entities for graph visualization.
func (s *SQLiteKGGraphStore) ListKGGraphNodes(ctx context.Context, agentID, userID string, limit int) ([]store.KGGraphNode, int, error) {
if limit <= 0 {
limit = 2000
}
if limit > 10000 {
limit = 10000
}
q := `SELECT id, name, entity_type, confidence
FROM kg_entities WHERE agent_id = ? AND valid_until IS NULL`
args := []any{agentID}
userClause, userArgs := kgUserClauseFor(ctx, userID)
if userClause != "" {
q += userClause
args = append(args, userArgs...)
}
q += " ORDER BY updated_at DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("kg graph nodes: %w", err)
}
defer rows.Close()
var nodes []store.KGGraphNode
for rows.Next() {
var n store.KGGraphNode
if err := rows.Scan(&n.ID, &n.Name, &n.EntityType, &n.Confidence); err != nil {
return nil, 0, fmt.Errorf("kg graph nodes scan: %w", err)
}
nodes = append(nodes, n)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("kg graph nodes rows: %w", err)
}
return nodes, len(nodes), nil
}
// ListKGGraphEdges returns lightweight KG relations for graph visualization.
func (s *SQLiteKGGraphStore) ListKGGraphEdges(ctx context.Context, agentID, userID string, limit int) ([]store.KGGraphEdge, int, error) {
if limit <= 0 {
limit = 6000
}
if limit > 30000 {
limit = 30000
}
q := `SELECT id, source_entity_id, target_entity_id, relation_type
FROM kg_relations WHERE agent_id = ? AND valid_until IS NULL`
args := []any{agentID}
userClause, userArgs := kgUserClauseFor(ctx, userID)
if userClause != "" {
q += userClause
args = append(args, userArgs...)
}
q += " LIMIT ?"
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("kg graph edges: %w", err)
}
defer rows.Close()
var edges []store.KGGraphEdge
for rows.Next() {
var e store.KGGraphEdge
if err := rows.Scan(&e.ID, &e.SourceID, &e.TargetID, &e.RelationType); err != nil {
return nil, 0, fmt.Errorf("kg graph edges scan: %w", err)
}
edges = append(edges, e)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("kg graph edges rows: %w", err)
}
return edges, len(edges), nil
}
+177
View File
@@ -0,0 +1,177 @@
//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// SQLiteVaultGraphStore implements store.VaultGraphStore backed by SQLite.
type SQLiteVaultGraphStore struct {
db *sql.DB
}
// NewSQLiteVaultGraphStore creates a new SQLite-backed vault graph store.
func NewSQLiteVaultGraphStore(db *sql.DB) *SQLiteVaultGraphStore {
return &SQLiteVaultGraphStore{db: db}
}
// ListGraphNodes returns lightweight vault nodes with pre-computed degree.
func (s *SQLiteVaultGraphStore) ListGraphNodes(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) ([]store.GraphNode, int, error) {
q := `SELECT vd.id, vd.title, vd.path, vd.doc_type,
COALESCE(deg.cnt, 0) AS degree
FROM vault_documents vd
LEFT JOIN (
SELECT doc_id, COUNT(*) AS cnt FROM (
SELECT from_doc_id AS doc_id FROM vault_links vl
JOIN vault_documents d ON d.id = vl.from_doc_id AND d.tenant_id = ?
UNION ALL
SELECT to_doc_id AS doc_id FROM vault_links vl
JOIN vault_documents d ON d.id = vl.to_doc_id AND d.tenant_id = ?
) sub GROUP BY doc_id
) deg ON deg.doc_id = vd.id
WHERE vd.tenant_id = ?`
args := []any{tenantID, tenantID, tenantID}
if agentID != "" {
q += " AND vd.agent_id = ?"
args = append(args, agentID)
}
q, args = sqliteAppendGraphTeamFilter(q, args, "vd", opts.TeamID, opts.TeamIDs)
q += " ORDER BY degree DESC, vd.updated_at DESC"
limit := opts.Limit
if limit <= 0 {
limit = 2000
}
if limit > 10000 {
limit = 10000
}
q += " LIMIT ?"
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("vault graph nodes: %w", err)
}
defer rows.Close()
var nodes []store.GraphNode
for rows.Next() {
var n store.GraphNode
if err := rows.Scan(&n.ID, &n.Title, &n.Path, &n.DocType, &n.Degree); err != nil {
return nil, 0, fmt.Errorf("vault graph nodes scan: %w", err)
}
nodes = append(nodes, n)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("vault graph nodes rows: %w", err)
}
total, err := s.countGraphNodes(ctx, tenantID, agentID, opts)
if err != nil {
return nodes, len(nodes), nil
}
return nodes, total, nil
}
// ListGraphEdges returns lightweight vault edges for nodes in scope.
func (s *SQLiteVaultGraphStore) ListGraphEdges(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) ([]store.GraphEdge, int, error) {
// Build subquery for doc IDs in scope.
subQ := `SELECT id FROM vault_documents WHERE tenant_id = ?`
subArgs := []any{tenantID}
if agentID != "" {
subQ += " AND agent_id = ?"
subArgs = append(subArgs, agentID)
}
subQ, subArgs = sqliteAppendGraphTeamFilter(subQ, subArgs, "", opts.TeamID, opts.TeamIDs)
limit := opts.Limit
if limit <= 0 {
limit = 2000
}
if limit > 10000 {
limit = 10000
}
subQ += " LIMIT ?"
subArgs = append(subArgs, limit)
q := fmt.Sprintf(`SELECT vl.id, vl.from_doc_id, vl.to_doc_id, vl.link_type
FROM vault_links vl
WHERE vl.from_doc_id IN (%s) AND vl.to_doc_id IN (%s)`, subQ, subQ)
// Duplicate args for second IN subquery.
args := make([]any, 0, len(subArgs)*2)
args = append(args, subArgs...)
args = append(args, subArgs...)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("vault graph edges: %w", err)
}
defer rows.Close()
var edges []store.GraphEdge
for rows.Next() {
var e store.GraphEdge
if err := rows.Scan(&e.ID, &e.FromID, &e.ToID, &e.LinkType); err != nil {
return nil, 0, fmt.Errorf("vault graph edges scan: %w", err)
}
edges = append(edges, e)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("vault graph edges rows: %w", err)
}
return edges, len(edges), nil
}
func (s *SQLiteVaultGraphStore) countGraphNodes(ctx context.Context, tenantID, agentID string, opts store.VaultGraphListOptions) (int, error) {
q := `SELECT COUNT(*) FROM vault_documents WHERE tenant_id = ?`
args := []any{tenantID}
if agentID != "" {
q += " AND agent_id = ?"
args = append(args, agentID)
}
q, args = sqliteAppendGraphTeamFilter(q, args, "", opts.TeamID, opts.TeamIDs)
var count int
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
// sqliteAppendGraphTeamFilter appends team_id clause for SQLite.
func sqliteAppendGraphTeamFilter(q string, args []any, tableAlias string, teamID *string, teamIDs []string) (string, []any) {
col := "team_id"
if tableAlias != "" {
col = tableAlias + ".team_id"
}
if len(teamIDs) > 0 {
ph := "?" + strings.Repeat(",?", len(teamIDs)-1)
q += fmt.Sprintf(" AND (%s IS NULL OR %s IN (%s))", col, col, ph)
for _, id := range teamIDs {
args = append(args, id)
}
} else if teamID != nil {
if *teamID != "" {
q += fmt.Sprintf(" AND %s = ?", col)
args = append(args, *teamID)
} else {
q += fmt.Sprintf(" AND %s IS NULL", col)
}
}
return q, args
}
+30 -2
View File
@@ -1,16 +1,44 @@
package store
import "fmt"
import (
"fmt"
"strings"
"unicode"
)
// MaxUserIDLength is the maximum allowed length for user identifier strings
// (user_id, owner_id, granted_by, requested_by, reviewed_by, etc.).
// Matches the VARCHAR(255) constraint in the database schema.
const MaxUserIDLength = 255
// ValidateUserID checks that a user identifier does not exceed MaxUserIDLength.
// ValidateUserID validates user identifiers for length and dangerous characters.
// Defense-in-depth: SQL injection is handled by parameterized queries, but we
// also reject clearly malicious patterns at the validation layer.
func ValidateUserID(id string) error {
if len(id) > MaxUserIDLength {
return fmt.Errorf("user identifier too long: %d chars (max %d)", len(id), MaxUserIDLength)
}
// Reject null bytes
if strings.ContainsRune(id, '\x00') {
return fmt.Errorf("user identifier contains null byte")
}
// Reject control characters (below space, including tab/newline/carriage return)
for _, r := range id {
if r < 32 {
return fmt.Errorf("user identifier contains control character: %U", r)
}
// Reject dangerous unicode categories
if unicode.Is(unicode.Cf, r) { // Format characters (ZWJ, RTL override, etc.)
return fmt.Errorf("user identifier contains format character: %U", r)
}
}
// Reject BOM
if strings.HasPrefix(id, "\uFEFF") {
return fmt.Errorf("user identifier starts with BOM")
}
return nil
}
+45
View File
@@ -27,3 +27,48 @@ func TestValidateUserID(t *testing.T) {
})
}
}
func TestValidateUserID_SecurityEdgeCases(t *testing.T) {
tests := []struct {
name string
id string
wantErr bool
}{
// Note: SQL injection is handled by parameterized queries, but these
// characters are still valid input (no control chars). We don't reject
// SQL-looking strings as they could be legitimate usernames.
{"sql_like_but_valid", "'; DROP TABLE users;--", false},
// Unicode edge cases - format characters should be rejected
{"unicode_zwj", "user\u200Dname", true}, // zero-width joiner
{"unicode_rtl", "user\u202Ename", true}, // RTL override
{"unicode_bom", "\uFEFFuser", true}, // BOM at start
{"unicode_zwnj", "user\u200Cname", true}, // zero-width non-joiner
// Null bytes - always reject
{"null_byte_middle", "user\x00name", true},
{"null_byte_end", "username\x00", true},
{"null_byte_start", "\x00username", true},
// Control characters - reject all
{"control_tab", "user\tname", true},
{"control_newline", "user\nname", true},
{"control_carriage", "user\rname", true},
{"control_bell", "user\x07name", true},
{"control_backspace", "user\x08name", true},
// Valid unicode - should pass
{"unicode_emoji", "user🚀name", false},
{"unicode_cjk", "用户名", false},
{"unicode_arabic", "مستخدم", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUserID(tt.id)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateUserID(%q) error = %v, wantErr %v", tt.id, err, tt.wantErr)
}
})
}
}
+59
View File
@@ -0,0 +1,59 @@
package store
import "context"
// GraphNode is a lightweight vault document for graph visualization.
type GraphNode struct {
ID string `json:"id"`
Title string `json:"t"` // short key for wire size
Path string `json:"p"` // workspace-relative path
DocType string `json:"dt"` // doc_type
Degree int `json:"deg"`
}
// GraphEdge is a lightweight vault link for graph visualization.
type GraphEdge struct {
ID string `json:"id"`
FromID string `json:"from"`
ToID string `json:"to"`
LinkType string `json:"type"`
}
// KGGraphNode is a lightweight KG entity for graph visualization.
type KGGraphNode struct {
ID string `json:"id"`
Name string `json:"n"`
EntityType string `json:"t"`
Confidence float64 `json:"c"`
}
// KGGraphEdge is a lightweight KG relation for graph visualization.
type KGGraphEdge struct {
ID string `json:"id"`
SourceID string `json:"src"`
TargetID string `json:"tgt"`
RelationType string `json:"type"`
}
// VaultGraphListOptions configures a vault graph query.
type VaultGraphListOptions struct {
TeamID *string
TeamIDs []string
Limit int // max 10000
}
// VaultGraphStore provides read-only graph data for visualization.
type VaultGraphStore interface {
// ListGraphNodes returns lightweight nodes with pre-computed degree.
ListGraphNodes(ctx context.Context, tenantID, agentID string, opts VaultGraphListOptions) ([]GraphNode, int, error)
// ListGraphEdges returns lightweight edges for nodes in scope.
ListGraphEdges(ctx context.Context, tenantID, agentID string, opts VaultGraphListOptions) ([]GraphEdge, int, error)
}
// KGGraphStore provides read-only KG graph data for visualization.
type KGGraphStore interface {
// ListKGGraphNodes returns lightweight entities.
ListKGGraphNodes(ctx context.Context, agentID, userID string, limit int) ([]KGGraphNode, int, error)
// ListKGGraphEdges returns lightweight relations.
ListKGGraphEdges(ctx context.Context, agentID, userID string, limit int) ([]KGGraphEdge, int, error)
}
+203 -1
View File
@@ -1,9 +1,11 @@
package tools
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -326,7 +328,7 @@ func TestIsPathInside(t *testing.T) {
}{
{"/a/b/c", "/a/b", true},
{"/a/b", "/a/b", true},
{"/a/bc", "/a/b", false}, // not a child, just prefix match
{"/a/bc", "/a/b", false}, // not a child, just prefix match
{"/a", "/a/b", false},
{"/x/y", "/a/b", false},
}
@@ -337,3 +339,203 @@ func TestIsPathInside(t *testing.T) {
}
}
}
func TestIsPathInside_WindowsCaseInsensitive(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-specific test")
}
tests := []struct {
child, parent string
want bool
}{
{`C:\Workspace\file.txt`, `c:\workspace`, true}, // case mismatch
{`c:\workspace\file.txt`, `C:\Workspace`, true}, // reverse case
{`C:\WORKSPACE\SUB\FILE`, `c:\workspace`, true}, // all caps child
{`D:\other`, `C:\workspace`, false}, // different drive
{`C:\workspaceX\file.txt`, `C:\workspace`, false}, // prefix but not child
}
for _, tt := range tests {
got := isPathInside(tt.child, tt.parent)
if got != tt.want {
t.Errorf("isPathInside(%q, %q) = %v, want %v", tt.child, tt.parent, got, tt.want)
}
}
}
func TestResolvePathWithAllowed_CrossDriveAccess(t *testing.T) {
// Simulates cross-drive access on Windows using separate temp directories
// (on Unix these are just separate paths, but the test logic is the same).
workspace := t.TempDir()
crossDrive := t.TempDir() // simulates a different drive (e.g., F:\ vs E:\)
// Create files in both locations
if err := os.WriteFile(filepath.Join(workspace, "local.txt"), []byte("local"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(crossDrive, "remote.txt"), []byte("remote"), 0644); err != nil {
t.Fatal(err)
}
// Without allowed paths: cross-drive should be BLOCKED
_, err := resolvePathWithAllowed(filepath.Join(crossDrive, "remote.txt"), workspace, true, nil)
if err == nil {
t.Fatal("expected error for cross-drive access without allowed paths")
}
// With allowed paths: cross-drive should be ALLOWED
_, err = resolvePathWithAllowed(filepath.Join(crossDrive, "remote.txt"), workspace, true, []string{crossDrive})
if err != nil {
t.Fatalf("expected success with cross-drive in allowed paths, got: %v", err)
}
// Allowed paths should not allow escaping to arbitrary locations
outsideAll := t.TempDir()
if err := os.WriteFile(filepath.Join(outsideAll, "secret.txt"), []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
_, err = resolvePathWithAllowed(filepath.Join(outsideAll, "secret.txt"), workspace, true, []string{crossDrive})
if err == nil {
t.Fatal("expected error for path outside both workspace and allowed paths")
}
}
func TestResolvePathWithAllowed_TenantIsolation(t *testing.T) {
// Ensure that allowed paths cannot be used to escape tenant boundaries.
// Scenario: tenant A's workspace, tenant B's workspace, and a shared allowed path.
tenantA := t.TempDir()
tenantB := t.TempDir()
sharedSkills := t.TempDir()
// Create files
if err := os.WriteFile(filepath.Join(tenantA, "a.txt"), []byte("A"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tenantB, "b.txt"), []byte("B"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sharedSkills, "skill.md"), []byte("skill"), 0644); err != nil {
t.Fatal(err)
}
// Tenant A can access their workspace
_, err := resolvePathWithAllowed(filepath.Join(tenantA, "a.txt"), tenantA, true, []string{sharedSkills})
if err != nil {
t.Fatalf("tenant A should access own workspace: %v", err)
}
// Tenant A can access shared skills
_, err = resolvePathWithAllowed(filepath.Join(sharedSkills, "skill.md"), tenantA, true, []string{sharedSkills})
if err != nil {
t.Fatalf("tenant A should access shared skills: %v", err)
}
// Tenant A CANNOT access tenant B's workspace (even with shared skills allowed)
_, err = resolvePathWithAllowed(filepath.Join(tenantB, "b.txt"), tenantA, true, []string{sharedSkills})
if err == nil {
t.Fatal("tenant A should NOT access tenant B's workspace")
}
// Verify the error message is about access denied
if !strings.Contains(err.Error(), "access denied") {
t.Errorf("expected 'access denied' error, got: %v", err)
}
}
func TestResolvePathWithAllowed_SymlinkEscapeBlocked(t *testing.T) {
// Ensure symlinks in allowed paths cannot be used to escape boundaries.
workspace := t.TempDir()
allowedDir := t.TempDir()
secretDir := t.TempDir()
// Create a secret file outside both workspace and allowed
if err := os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
// Create a symlink in allowed dir pointing to secret dir
evilLink := filepath.Join(allowedDir, "escape")
if err := os.Symlink(secretDir, evilLink); err != nil {
t.Skip("cannot create symlinks (permissions or OS limitation)")
}
// Attempt to access secret file via symlink escape
_, err := resolvePathWithAllowed(filepath.Join(evilLink, "secret.txt"), workspace, true, []string{allowedDir})
if err == nil {
t.Fatal("expected error for symlink escape attempt")
}
}
func TestAllowedWithTeamWorkspace_TenantPathsMerge(t *testing.T) {
// Test that allowedWithTeamWorkspace correctly merges:
// base (global) + tenant paths (from context) + team workspace (from context)
ctx := context.Background()
base := []string{"/global/skills", "/global/builtin"}
tenantPaths := []string{"/tenant/allowed1", "/tenant/allowed2"}
teamWs := "/team/workspace"
// Inject tenant paths and team workspace into context
ctx = WithTenantAllowedPaths(ctx, tenantPaths)
ctx = WithToolTeamWorkspace(ctx, teamWs)
result := allowedWithTeamWorkspace(ctx, base)
// Verify all paths are present in correct order
expected := []string{"/global/skills", "/global/builtin", "/tenant/allowed1", "/tenant/allowed2", "/team/workspace"}
if len(result) != len(expected) {
t.Fatalf("expected %d paths, got %d", len(expected), len(result))
}
for i, exp := range expected {
if result[i] != exp {
t.Errorf("path[%d]: expected %q, got %q", i, exp, result[i])
}
}
}
func TestAllowedWithTeamWorkspace_TenantPathsOnly(t *testing.T) {
// Test tenant paths without team workspace
ctx := context.Background()
base := []string{"/global/skills"}
tenantPaths := []string{"/tenant/data"}
ctx = WithTenantAllowedPaths(ctx, tenantPaths)
result := allowedWithTeamWorkspace(ctx, base)
if len(result) != 2 {
t.Fatalf("expected 2 paths, got %d", len(result))
}
if result[0] != "/global/skills" || result[1] != "/tenant/data" {
t.Errorf("unexpected result: %v", result)
}
}
func TestAllowedWithTeamWorkspace_EmptyContext(t *testing.T) {
// Test with no tenant paths or team workspace in context
ctx := context.Background()
base := []string{"/global/skills"}
result := allowedWithTeamWorkspace(ctx, base)
// Should return base unchanged
if len(result) != 1 || result[0] != "/global/skills" {
t.Errorf("expected base unchanged, got: %v", result)
}
}
func TestTenantAllowedPathsFromCtx_Inheritance(t *testing.T) {
// Test that TenantAllowedPathsFromCtx correctly reads from RunContext fallback
ctx := context.Background()
tenantPaths := []string{"/tenant/path1", "/tenant/path2"}
// Set via direct context
ctx = WithTenantAllowedPaths(ctx, tenantPaths)
result := TenantAllowedPathsFromCtx(ctx)
if len(result) != 2 {
t.Fatalf("expected 2 paths, got %d", len(result))
}
if result[0] != "/tenant/path1" || result[1] != "/tenant/path2" {
t.Errorf("unexpected result: %v", result)
}
}
+23
View File
@@ -710,3 +710,26 @@ func SandboxConfigFromCtx(ctx context.Context) *sandbox.Config {
}
return nil
}
// --- Per-tenant allowed paths (filesystem tool access beyond workspace) ---
const ctxTenantAllowedPaths toolContextKey = "tool_tenant_allowed_paths"
// WithTenantAllowedPaths injects tenant-specific allowed path prefixes into context.
// These paths extend filesystem tool access beyond the agent's workspace.
// Loaded from system_configs['allowed_paths'] per tenant.
func WithTenantAllowedPaths(ctx context.Context, paths []string) context.Context {
return context.WithValue(ctx, ctxTenantAllowedPaths, paths)
}
// TenantAllowedPathsFromCtx returns tenant-specific allowed paths from context.
// Falls back to RunContext for subagent inheritance.
func TenantAllowedPathsFromCtx(ctx context.Context) []string {
if v, _ := ctx.Value(ctxTenantAllowedPaths).([]string); len(v) > 0 {
return v
}
if rc := store.RunContextFromCtx(ctx); rc != nil {
return rc.TenantAllowedPaths
}
return nil
}
+9 -2
View File
@@ -16,7 +16,8 @@ import (
type EditTool struct {
workspace string
restrict bool
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
allowedPrefixes []string // extra allowed path prefixes (cross-drive on Windows)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
sandboxMgr sandbox.Manager
contextFileIntc *ContextFileInterceptor
memIntc *MemoryInterceptor
@@ -26,6 +27,12 @@ type EditTool struct {
func (t *EditTool) SetVaultInterceptor(v *VaultInterceptor) { t.vaultIntc = v }
// AllowPaths adds extra path prefixes that edit is allowed to access
// even when restrict_to_workspace is true (e.g. cross-drive on Windows).
func (t *EditTool) AllowPaths(prefixes ...string) {
t.allowedPrefixes = append(t.allowedPrefixes, prefixes...)
}
// DenyPaths adds path prefixes that edit must reject.
func (t *EditTool) DenyPaths(prefixes ...string) {
t.deniedPrefixes = append(t.deniedPrefixes, prefixes...)
@@ -163,7 +170,7 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result {
if workspace == "" {
workspace = t.workspace
}
allowed := allowedWithTeamWorkspace(ctx, nil)
allowed := allowedWithTeamWorkspace(ctx, t.allowedPrefixes)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed)
if err != nil {
return ErrorResult(err.Error())
+26 -6
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
@@ -303,16 +304,29 @@ func (t *ReadFileTool) paginateOutput(content string, args map[string]any) *Resu
return SilentResult(output)
}
// allowedWithTeamWorkspace returns the allowed prefixes with team workspace appended
// if present in context. Thread-safe: creates a new slice per request.
// allowedWithTeamWorkspace returns the allowed prefixes with team workspace and
// tenant-specific paths appended if present in context. Thread-safe: creates a
// new slice per request. Merge order: base (global) → tenant paths → team workspace.
func allowedWithTeamWorkspace(ctx context.Context, base []string) []string {
tenantPaths := TenantAllowedPathsFromCtx(ctx)
teamWs := ToolTeamWorkspaceFromCtx(ctx)
if teamWs == "" {
if len(tenantPaths) == 0 && teamWs == "" {
return base
}
out := make([]string, len(base)+1)
copy(out, base)
out[len(base)] = teamWs
// Pre-allocate capacity for all sources
capacity := len(base) + len(tenantPaths)
if teamWs != "" {
capacity++
}
out := make([]string, 0, capacity)
out = append(out, base...)
out = append(out, tenantPaths...)
if teamWs != "" {
out = append(out, teamWs)
}
return out
}
@@ -488,7 +502,13 @@ func resolvePath(path, workspace string, restrict bool) (string, error) {
}
// isPathInside checks whether child is inside or equal to parent directory.
// On Windows, comparison is case-insensitive since NTFS paths are case-insensitive.
func isPathInside(child, parent string) bool {
// Windows paths are case-insensitive; normalize to lowercase for comparison.
if runtime.GOOS == "windows" {
child = strings.ToLower(child)
parent = strings.ToLower(parent)
}
if child == parent {
return true
}
+9 -2
View File
@@ -15,7 +15,8 @@ import (
type WriteFileTool struct {
workspace string
restrict bool
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
allowedPrefixes []string // extra allowed path prefixes (cross-drive on Windows)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
sandboxMgr sandbox.Manager
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
memIntc *MemoryInterceptor // nil = no memory routing
@@ -24,6 +25,12 @@ type WriteFileTool struct {
vaultIntc *VaultInterceptor // nil = no vault registration
}
// AllowPaths adds extra path prefixes that write_file is allowed to access
// even when restrict_to_workspace is true (e.g. cross-drive on Windows).
func (t *WriteFileTool) AllowPaths(prefixes ...string) {
t.allowedPrefixes = append(t.allowedPrefixes, prefixes...)
}
// DenyPaths adds path prefixes that write_file must reject.
func (t *WriteFileTool) DenyPaths(prefixes ...string) {
t.deniedPrefixes = append(t.deniedPrefixes, prefixes...)
@@ -162,7 +169,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul
if workspace == "" {
workspace = t.workspace
}
allowed := allowedWithTeamWorkspace(ctx, nil)
allowed := allowedWithTeamWorkspace(ctx, t.allowedPrefixes)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed)
if err != nil {
return ErrorResult(err.Error())
+14 -11
View File
@@ -43,8 +43,9 @@ func TestScrubCredentials_GitHub(t *testing.T) {
func TestScrubCredentials_AWS(t *testing.T) {
input := "aws_key: AKIAIOSFODNN7EXAMPLE"
got := ScrubCredentials(input)
if got == input {
t.Errorf("AWS key not scrubbed: %s", got)
want := "aws_key: [REDACTED]"
if got != want {
t.Errorf("AWS key scrub: got %q, want %q", got, want)
}
}
@@ -52,18 +53,19 @@ func TestScrubCredentials_GenericKeyValue(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"api_key", "api_key=supersecretvalue123"},
{"token", "token: mysecrettoken12345"},
{"password", "password=MyStr0ngP@ssword!"},
{"bearer", "bearer: eyJhbGciOiJIUzI1NiJ9.abc"},
{"authorization", "authorization=eyJhbGciOiJIUzI1NiJ9abcdef"},
{"api_key", "api_key=supersecretvalue123", "[REDACTED]"},
{"token", "token: mysecrettoken12345", "[REDACTED]"},
{"password", "password=MyStr0ngP@ssword!", "[REDACTED]"},
{"bearer", "bearer: eyJhbGciOiJIUzI1NiJ9.abc", "[REDACTED]"},
{"authorization", "authorization=eyJhbGciOiJIUzI1NiJ9abcdef", "[REDACTED]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScrubCredentials(tt.input)
if got == tt.input {
t.Errorf("generic pattern %q not scrubbed: %s", tt.name, got)
if got != tt.want {
t.Errorf("generic pattern %q: got %q, want %q", tt.name, got, tt.want)
}
})
}
@@ -88,8 +90,9 @@ func TestScrubCredentials_NoFalsePositive(t *testing.T) {
func TestScrubCredentials_MultiplePatterns(t *testing.T) {
input := "openai=sk-abcdefghijklmnopqrstuvwxyz, github=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"
got := ScrubCredentials(input)
if got == input {
t.Errorf("multiple patterns not scrubbed: %s", got)
want := "openai=[REDACTED], github=[REDACTED]"
if got != want {
t.Errorf("multiple patterns scrub: got %q, want %q", got, want)
}
}
+53
View File
@@ -0,0 +1,53 @@
package tools
import (
"context"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// isSessionInScope checks whether a target session key falls within
// the current execution scope. Group-scoped runs can only access
// sessions belonging to the same group, unless share_sessions is enabled.
func isSessionInScope(ctx context.Context, targetKey, currentKey string) bool {
// Shared sessions mode — all sessions visible.
if store.IsSharedSessions(ctx) {
return true
}
// Always allow own session.
if targetKey == currentKey {
return true
}
// Extract group chat ID from user context.
userID := store.UserIDFromContext(ctx)
chatID := extractGroupChatID(userID)
if chatID == "" {
return true // DM or non-group scope — no restriction.
}
// Allow sessions containing this group's chat ID.
// Colon-bounded match prevents partial numeric collisions.
// Matches patterns like:
// agent:X:channel:group:{chatID}
// agent:X:channel:group:{chatID}:topic:N
marker := ":" + chatID
return strings.HasSuffix(targetKey, marker) ||
strings.Contains(targetKey, marker+":")
}
// extractGroupChatID extracts the chat ID from a group-scoped userID.
// Format: "group:channel:chatID" -> "chatID".
// Returns "" for non-group users (DM, guild, etc.).
func extractGroupChatID(userID string) string {
if !strings.HasPrefix(userID, "group:") {
return ""
}
parts := strings.SplitN(userID, ":", 3)
if len(parts) < 3 {
return ""
}
return parts[2]
}
+536
View File
@@ -0,0 +1,536 @@
package tools
import (
"context"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ─── extractGroupChatID ──────────────────────────────────────────────────────
func TestExtractGroupChatID_ValidGroupZalo(t *testing.T) {
tests := []struct {
name string
userID string
wantID string
}{
{
name: "group Zalo format",
userID: "group:tuelinhzalo:7296946457790431889",
wantID: "7296946457790431889",
},
{
name: "group Telegram format",
userID: "group:telegram:123456",
wantID: "123456",
},
{
name: "group with numeric chatID",
userID: "group:channel:999",
wantID: "999",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractGroupChatID(tt.userID)
if got != tt.wantID {
t.Errorf("extractGroupChatID(%q) = %q, want %q", tt.userID, got, tt.wantID)
}
})
}
}
func TestExtractGroupChatID_NonGroupUsers(t *testing.T) {
tests := []struct {
name string
userID string
}{
{
name: "guild format (not group)",
userID: "guild:abc:user:456",
},
{
name: "DM user (no group prefix)",
userID: "123456789",
},
{
name: "empty string",
userID: "",
},
{
name: "malformed group (missing parts)",
userID: "group:channel",
},
{
name: "malformed group (only prefix)",
userID: "group:",
},
{
name: "just 'group'",
userID: "group",
},
{
name: "guild with many parts",
userID: "guild:discord:server:channel:msg:123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractGroupChatID(tt.userID)
if got != "" {
t.Errorf("extractGroupChatID(%q) = %q, want empty string", tt.userID, got)
}
})
}
}
func TestExtractGroupChatID_EdgeCases(t *testing.T) {
tests := []struct {
name string
userID string
wantID string
}{
{
name: "chatID containing colon (edge case, unusual but possible)",
userID: "group:channel:abc:def",
wantID: "abc:def", // SplitN with n=3 takes everything after second colon
},
{
name: "numeric-only chatID",
userID: "group:telegram:9876543210",
wantID: "9876543210",
},
{
name: "alphanumeric chatID",
userID: "group:slack:C1234567890",
wantID: "C1234567890",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractGroupChatID(tt.userID)
if got != tt.wantID {
t.Errorf("extractGroupChatID(%q) = %q, want %q", tt.userID, got, tt.wantID)
}
})
}
}
// ─── isSessionInScope ────────────────────────────────────────────────────────
func TestIsSessionInScope_SharedSessionsEnabled(t *testing.T) {
ctx := store.WithSharedSessions(context.Background())
ctx = store.WithUserID(ctx, "group:telegram:111")
// With shared sessions enabled, all targets should be in scope
tests := []string{
"agent:x:zalo:group:222",
"agent:x:cron:job-abc",
"direct-dm-session",
"any-arbitrary-key",
}
for _, targetKey := range tests {
if !isSessionInScope(ctx, targetKey, "current:session") {
t.Errorf("shared sessions enabled: %q should be in scope", targetKey)
}
}
}
func TestIsSessionInScope_AllowOwnSession(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "user:123")
// Own session should always be allowed, regardless of group scoping
currentKey := "agent:x:zalo:group:111"
if !isSessionInScope(ctx, currentKey, currentKey) {
t.Error("own session should always be in scope")
}
}
func TestIsSessionInScope_GroupScopedUser_SameChatID(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "group:zalo:111")
tests := []struct {
name string
targetKey string
wantScope bool
}{
{
name: "basic group match",
targetKey: "agent:x:zalo:group:111",
wantScope: true,
},
{
name: "group with topic",
targetKey: "agent:x:tg:group:111:topic:5",
wantScope: true,
},
{
name: "group at end of key",
targetKey: "prefix:group:111",
wantScope: true,
},
{
name: "exact suffix match with colon",
targetKey: "agent:111",
wantScope: true, // HasSuffix(":111") matches, "agent:111".HasSuffix(":111") is true
},
{
name: "contains chatID in middle with colon after",
targetKey: "something:111:other",
wantScope: true, // Contains `:111:`
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isSessionInScope(ctx, tt.targetKey, "current:key")
if got != tt.wantScope {
t.Errorf("isSessionInScope(..., %q, ...) = %v, want %v", tt.targetKey, got, tt.wantScope)
}
})
}
}
func TestIsSessionInScope_GroupScopedUser_DifferentChatID(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "group:zalo:111") // User in group 111
tests := []struct {
name string
targetKey string
wantScope bool
}{
{
name: "different group",
targetKey: "agent:x:zalo:group:222",
wantScope: false,
},
{
name: "different group with topic",
targetKey: "agent:x:tg:group:222:topic:5",
wantScope: false,
},
{
name: "111 appears only as key suffix (HasSuffix matches)",
targetKey: "agent:x:channel:111",
wantScope: true, // HasSuffix(":111") is true, so this passes
},
{
name: "222 as suffix (not the user's group)",
targetKey: "agent:x:channel:222",
wantScope: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isSessionInScope(ctx, tt.targetKey, "current:key")
if got != tt.wantScope {
t.Errorf("isSessionInScope(..., %q, ...) = %v, want %v", tt.targetKey, got, tt.wantScope)
}
})
}
}
func TestIsSessionInScope_DMUser_NoRestriction(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "dm:user:12345") // Non-group user
// DM users have no group restriction — all sessions are accessible
tests := []string{
"agent:x:zalo:group:111",
"agent:x:zalo:group:222",
"agent:x:cron:job-abc",
"direct-session",
}
for _, targetKey := range tests {
if !isSessionInScope(ctx, targetKey, "current:key") {
t.Errorf("DM user: %q should be in scope", targetKey)
}
}
}
func TestIsSessionInScope_CronSessionWithGroupUser(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "group:telegram:111") // Group-scoped user
// Cron sessions (no group chatID) should not be accessible to group-scoped users
cronKey := "agent:x:cron:job-abc"
if isSessionInScope(ctx, cronKey, "current:key") {
t.Error("cron session should not be in scope for group-scoped user")
}
}
func TestIsSessionInScope_EmptyUserID(t *testing.T) {
ctx := context.Background()
// No user ID set
// Empty user (non-group) should have no restriction
if !isSessionInScope(ctx, "agent:x:zalo:group:111", "current:key") {
t.Error("empty user ID should allow all sessions")
}
}
func TestIsSessionInScope_MarkerBoundaryExactness(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "group:zalo:111")
tests := []struct {
name string
targetKey string
wantScope bool
}{
{
name: "exact suffix match with colon",
targetKey: "prefix:111",
wantScope: true,
},
{
name: "suffix with content after",
targetKey: "prefix:111:topic",
wantScope: true,
},
{
name: "no leading colon (numeric collision)",
targetKey: "group111", // No colon before 111
wantScope: false,
},
{
name: "111 in middle without proper bounds",
targetKey: "a111b", // 111 without colons
wantScope: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isSessionInScope(ctx, tt.targetKey, "current:key")
if got != tt.wantScope {
t.Errorf("isSessionInScope(..., %q, ...) = %v, want %v", tt.targetKey, got, tt.wantScope)
}
})
}
}
func TestIsSessionInScope_ComplexScenarios(t *testing.T) {
tests := []struct {
name string
userID string
sharedSessions bool
targetKey string
currentKey string
wantInScope bool
}{
{
name: "shared sessions overrides group restriction",
userID: "group:zalo:111",
sharedSessions: true,
targetKey: "agent:x:zalo:group:222",
currentKey: "agent:x:zalo:group:111",
wantInScope: true,
},
{
name: "own session always allowed despite group mismatch",
userID: "group:zalo:111",
sharedSessions: false,
targetKey: "agent:x:zalo:group:222",
currentKey: "agent:x:zalo:group:222",
wantInScope: true,
},
{
name: "group user, target from same group, with forum topic",
userID: "group:telegram:999",
sharedSessions: false,
targetKey: "agent:abc:telegram:group:999:topic:42",
currentKey: "current:key",
wantInScope: true,
},
{
name: "group user, cron job not accessible",
userID: "group:discord:555",
sharedSessions: false,
targetKey: "agent:xyz:cron:backup-job",
currentKey: "current:key",
wantInScope: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
if tt.sharedSessions {
ctx = store.WithSharedSessions(ctx)
}
ctx = store.WithUserID(ctx, tt.userID)
got := isSessionInScope(ctx, tt.targetKey, tt.currentKey)
if got != tt.wantInScope {
t.Errorf("isSessionInScope = %v, want %v", got, tt.wantInScope)
}
})
}
}
// ─── Guild user through isSessionInScope ─────────────────────────────────────
func TestIsSessionInScope_GuildUser_NoRestriction(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "guild:abc:user:456") // Non-group, guild-scoped
// Guild users have no group restriction — extractGroupChatID returns ""
tests := []string{
"agent:x:discord:group:111",
"agent:x:discord:group:222",
"agent:x:cron:job-abc",
"agent:x:discord:channel:789",
}
for _, targetKey := range tests {
if !isSessionInScope(ctx, targetKey, "current:key") {
t.Errorf("guild user: %q should be in scope (no group restriction)", targetKey)
}
}
}
// ─── Realistic long chatIDs (production Zalo IDs) ────────────────────────────
func TestIsSessionInScope_RealisticLongChatIDs(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "group:tuelinhzalo:7296946457790431889")
tests := []struct {
name string
targetKey string
wantScope bool
}{
{
name: "same group, realistic key",
targetKey: "agent:itstuelinh:tuelinhzalo:group:7296946457790431889",
wantScope: true,
},
{
name: "same group with topic",
targetKey: "agent:itstuelinh:tuelinhzalo:group:7296946457790431889:topic:5",
wantScope: true,
},
{
name: "different group, realistic key (THE BUG SCENARIO)",
targetKey: "agent:itstuelinh:tuelinhzalo:group:1541284681557250737",
wantScope: false,
},
{
name: "cron session, no chatID match",
targetKey: "agent:itstuelinh:cron:019d4345-abcd-1234-5678-abcdef123456",
wantScope: false,
},
{
name: "partial numeric overlap (different ID sharing digits)",
targetKey: "agent:itstuelinh:tuelinhzalo:group:72969464577904318891",
wantScope: false, // longer ID, should not match
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isSessionInScope(ctx, tt.targetKey, "agent:itstuelinh:cron:current-job")
if got != tt.wantScope {
t.Errorf("isSessionInScope(..., %q, ...) = %v, want %v", tt.targetKey, got, tt.wantScope)
}
})
}
}
// ─── Cron own session match ──────────────────────────────────────────────────
func TestIsSessionInScope_CronOwnSessionMatch(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, "group:telegram:111")
// Cron session as currentKey — accessing own session should always work
cronKey := "agent:mybot:cron:019d4345-daily-report"
if !isSessionInScope(ctx, cronKey, cronKey) {
t.Error("cron session accessing itself (own session) should always be in scope")
}
}
// ─── Multi-colon chatID through full flow ────────────────────────────────────
func TestIsSessionInScope_MultiColonChatID(t *testing.T) {
ctx := context.Background()
// SplitN("group:channel:abc:def", ":", 3) → chatID = "abc:def"
ctx = store.WithUserID(ctx, "group:channel:abc:def")
tests := []struct {
name string
targetKey string
wantScope bool
}{
{
name: "target contains multi-colon chatID as suffix",
targetKey: "agent:x:group:abc:def",
wantScope: true, // HasSuffix(":abc:def")
},
{
name: "target contains multi-colon chatID in middle",
targetKey: "agent:x:group:abc:def:topic:1",
wantScope: true, // Contains(":abc:def:")
},
{
name: "target has partial match only",
targetKey: "agent:x:group:abc",
wantScope: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isSessionInScope(ctx, tt.targetKey, "current:key")
if got != tt.wantScope {
t.Errorf("isSessionInScope(..., %q, ...) = %v, want %v", tt.targetKey, got, tt.wantScope)
}
})
}
}
// ─── Malformed group userID → no restriction ─────────────────────────────────
func TestIsSessionInScope_MalformedGroupUserID(t *testing.T) {
tests := []struct {
name string
userID string
}{
{name: "group with empty chatID", userID: "group:channel:"},
{name: "group missing chatID", userID: "group:channel"},
{name: "group only prefix", userID: "group:"},
{name: "just group", userID: "group"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
ctx = store.WithUserID(ctx, tt.userID)
// Malformed group → empty chatID → no restriction → allow all
target := "agent:x:zalo:group:222"
if tt.userID == "group:channel:" {
// "group:channel:" → chatID="" → no restriction
if !isSessionInScope(ctx, target, "current:key") {
t.Errorf("malformed group %q should have no restriction", tt.userID)
}
} else {
// These all return "" from extractGroupChatID
if !isSessionInScope(ctx, target, "current:key") {
t.Errorf("malformed group %q should have no restriction", tt.userID)
}
}
})
}
}
+18
View File
@@ -65,6 +65,18 @@ func (t *SessionsListTool) Execute(ctx context.Context, args map[string]any) *Re
}
sessions := t.sessions.List(ctx, agentKey)
// Scope filter: group-scoped users only see sessions for their group.
currentSession := ToolSandboxKeyFromCtx(ctx)
{
var scoped []store.SessionInfo
for _, s := range sessions {
if isSessionInScope(ctx, s.Key, currentSession) {
scoped = append(scoped, s)
}
}
sessions = scoped
}
// Filter by active_minutes
if activeMinutes > 0 {
cutoff := time.Now().Add(-time.Duration(activeMinutes) * time.Minute)
@@ -156,6 +168,12 @@ func (t *SessionStatusTool) Execute(ctx context.Context, args map[string]any) *R
return ErrorResult("access denied: session belongs to a different agent")
}
// Scope check: group-scoped users cannot access other groups' sessions.
currentSession := ToolSandboxKeyFromCtx(ctx)
if !isSessionInScope(ctx, sessionKey, currentSession) {
return ErrorResult("access denied: session outside current scope")
}
data := t.sessions.Get(ctx, sessionKey)
if data == nil {
return ErrorResult("session not found")
+6
View File
@@ -80,6 +80,12 @@ func (t *SessionsHistoryTool) Execute(ctx context.Context, args map[string]any)
return ErrorResult("access denied: session belongs to a different agent")
}
// Scope check: group-scoped users cannot read other groups' session history.
currentSession := ToolSandboxKeyFromCtx(ctx)
if !isSessionInScope(ctx, sessionKey, currentSession) {
return ErrorResult("access denied: session outside current scope")
}
history := t.sessions.GetHistory(ctx, sessionKey)
if history == nil {
out, _ := json.Marshal(map[string]any{
+6 -1
View File
@@ -95,9 +95,14 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re
return ErrorResult("access denied: target session belongs to a different agent")
}
// Scope check: group-scoped users cannot send to other groups' sessions.
currentSession := ToolSandboxKeyFromCtx(ctx)
if !isSessionInScope(ctx, sessionKey, currentSession) {
return ErrorResult("access denied: cannot send to session outside current scope")
}
// Block self-send: agent should not send to its own current session
// to prevent re-processing loops (same pattern as message tool).
currentSession := ToolSandboxKeyFromCtx(ctx)
if currentSession != "" && sessionKey == currentSession {
return ErrorResult("cannot send to your own current session — your response is already delivered to it")
}
+61
View File
@@ -0,0 +1,61 @@
# Contract Breaking Change Policy
## What is a Breaking Change?
A breaking change is any modification to the API response schema that could cause existing clients to fail:
| Change Type | Breaking? | Example |
|------------|-----------|---------|
| Remove required field | YES | `{id, name}` → `{id}` |
| Change field type | YES | `"count": 5` → `"count": "5"` |
| Rename field | YES | `user_id` → `userId` |
| Add new required field | YES | Client must send new field |
| Add optional field | NO | Backward compatible |
| Add new enum value | MAYBE | If client validates strictly |
## Contract Test Categories
### P0 - Critical (WS Core)
- `connect` - Session establishment
- `chat.send` - Message handling
### P1 - High (Data APIs)
- `agents.list`, `sessions.list`, `skills.list`
- `/v1/chat/completions` - OpenAI compatibility
- `/v1/agents` - Agent management
### P2 - Medium (Config/Admin)
- `config.get`, `config.apply`
- `/v1/providers`
## Breaking Change Process
1. **Detect**: Contract test fails in CI
2. **Evaluate**: Is this intentional or accidental?
3. **If Intentional**:
- Bump protocol version in `pkg/protocol/version.go`
- Update JSON schemas in `tests/contracts/schemas/`
- Update contract tests
- Document in CHANGELOG.md
4. **If Accidental**: Fix the regression
## Running Contract Tests
```bash
# Set environment variables
export CONTRACT_TEST_WS_URL="ws://localhost:8080/ws"
export CONTRACT_TEST_HTTP_URL="http://localhost:8080"
export CONTRACT_TEST_TOKEN="your-test-token"
# Run all contract tests
go test -tags integration ./tests/contracts/...
```
## Schema Files
JSON Schema definitions in `tests/contracts/schemas/`:
- `ws_connect.json` - WS connect response
- `ws_chat_send.json` - WS chat.send response
- `http_chat_completions.json` - OpenAI-compatible response
These schemas serve as documentation and can be used for automated validation.
+25
View File
@@ -0,0 +1,25 @@
//go:build integration
package http_api
import "testing"
// CONTRACT: /v1/agents response MUST include agents array.
func TestContract_HTTP_AgentsList(t *testing.T) {
baseURL, token := getTestServer(t)
client := newHTTPClient(baseURL, token)
resp := client.get(t, "/v1/agents")
assertField(t, resp, "agents", "array")
agents, ok := resp["agents"].([]any)
if !ok || len(agents) == 0 {
t.Log("No agents - skipping field checks")
return
}
agent := agents[0].(map[string]any)
assertField(t, agent, "id", "string")
assertField(t, agent, "agent_key", "string")
}
@@ -0,0 +1,71 @@
//go:build integration
package http_api
import "testing"
// CONTRACT: /v1/chat/completions response MUST match OpenAI format.
func TestContract_HTTP_ChatCompletions(t *testing.T) {
baseURL, token := getTestServer(t)
client := newHTTPClient(baseURL, token)
resp := client.post(t, "/v1/chat/completions", map[string]any{
"model": "test",
"messages": []map[string]any{
{"role": "user", "content": "Say hello in one word"},
},
})
// Required OpenAI-compatible fields
assertField(t, resp, "id", "string")
assertField(t, resp, "object", "string")
assertField(t, resp, "created", "number")
assertField(t, resp, "model", "string")
assertField(t, resp, "choices", "array")
// Verify choices structure
choices, ok := resp["choices"].([]any)
if !ok || len(choices) == 0 {
t.Error("CONTRACT VIOLATION: choices must be non-empty array")
return
}
choice, ok := choices[0].(map[string]any)
if !ok {
t.Error("CONTRACT VIOLATION: choices[0] is not an object")
return
}
assertField(t, choice, "index", "number")
assertField(t, choice, "message", "object")
assertField(t, choice, "finish_reason", "string")
message, ok := choice["message"].(map[string]any)
if !ok {
t.Error("CONTRACT VIOLATION: choice.message is not an object")
return
}
assertField(t, message, "role", "string")
assertField(t, message, "content", "string")
}
// CONTRACT: /v1/chat/completions MUST include usage when available.
func TestContract_HTTP_ChatCompletions_Usage(t *testing.T) {
baseURL, token := getTestServer(t)
client := newHTTPClient(baseURL, token)
resp := client.post(t, "/v1/chat/completions", map[string]any{
"model": "test",
"messages": []map[string]any{
{"role": "user", "content": "Hi"},
},
})
// Usage is optional but if present must have correct structure
if usage, ok := resp["usage"].(map[string]any); ok {
assertField(t, usage, "prompt_tokens", "number")
assertField(t, usage, "completion_tokens", "number")
assertField(t, usage, "total_tokens", "number")
}
}
+164
View File
@@ -0,0 +1,164 @@
//go:build integration
// Package http_api tests HTTP API contracts (OpenAI-compatible endpoints).
package http_api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
var (
testBaseURL string
testToken string
)
// getTestServer returns HTTP base URL and token from environment.
func getTestServer(t *testing.T) (baseURL string, token string) {
t.Helper()
baseURL = os.Getenv("CONTRACT_TEST_HTTP_URL")
token = os.Getenv("CONTRACT_TEST_TOKEN")
if baseURL == "" {
t.Skip("CONTRACT_TEST_HTTP_URL not set - skipping contract test")
}
if token == "" {
t.Skip("CONTRACT_TEST_TOKEN not set - skipping contract test")
}
testBaseURL = baseURL
testToken = token
return baseURL, token
}
// httpClient wraps HTTP client for contract testing.
type httpClient struct {
baseURL string
token string
client *http.Client
}
func newHTTPClient(baseURL, token string) *httpClient {
return &httpClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
token: token,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *httpClient) post(t *testing.T, path string, body any) map[string]any {
t.Helper()
bodyJSON, _ := json.Marshal(body)
req, err := http.NewRequest("POST", c.baseURL+path, bytes.NewReader(bodyJSON))
if err != nil {
t.Fatalf("create request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.client.Do(req)
if err != nil {
t.Fatalf("send request: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
t.Fatalf("request failed: %d - %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("parse response: %v", err)
}
return result
}
func (c *httpClient) get(t *testing.T, path string) map[string]any {
t.Helper()
req, err := http.NewRequest("GET", c.baseURL+path, nil)
if err != nil {
t.Fatalf("create request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.client.Do(req)
if err != nil {
t.Fatalf("send request: %v", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
t.Fatalf("request failed: %d - %s", resp.StatusCode, string(respBody))
}
var result map[string]any
if err := json.Unmarshal(respBody, &result); err != nil {
t.Fatalf("parse response: %v", err)
}
return result
}
// assertField verifies a field exists with expected type.
func assertField(t *testing.T, data map[string]any, path string, expectedType string) {
t.Helper()
parts := strings.Split(path, ".")
current := data
for i, part := range parts {
if i == len(parts)-1 {
val, ok := current[part]
if !ok {
t.Errorf("CONTRACT VIOLATION: missing field %q", path)
return
}
actualType := typeOf(val)
if actualType != expectedType {
t.Errorf("CONTRACT VIOLATION: field %q type mismatch: got %s, want %s", path, actualType, expectedType)
}
return
}
nested, ok := current[part].(map[string]any)
if !ok {
t.Errorf("CONTRACT VIOLATION: field %q is not an object", strings.Join(parts[:i+1], "."))
return
}
current = nested
}
}
func typeOf(v any) string {
if v == nil {
return "null"
}
switch v.(type) {
case string:
return "string"
case float64:
return "number"
case bool:
return "bool"
case []any:
return "array"
case map[string]any:
return "object"
default:
return fmt.Sprintf("%T", v)
}
}
+26
View File
@@ -0,0 +1,26 @@
//go:build integration
package http_api
import "testing"
// CONTRACT: /v1/providers response MUST include providers array.
func TestContract_HTTP_ProvidersList(t *testing.T) {
baseURL, token := getTestServer(t)
client := newHTTPClient(baseURL, token)
resp := client.get(t, "/v1/providers")
assertField(t, resp, "providers", "array")
providers, ok := resp["providers"].([]any)
if !ok || len(providers) == 0 {
t.Log("No providers - skipping field checks")
return
}
provider := providers[0].(map[string]any)
assertField(t, provider, "id", "string")
assertField(t, provider, "name", "string")
assertField(t, provider, "type", "string")
}
@@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "POST /v1/chat/completions response (OpenAI-compatible)",
"type": "object",
"required": ["id", "object", "created", "model", "choices"],
"properties": {
"id": { "type": "string" },
"object": { "type": "string", "const": "chat.completion" },
"created": { "type": "number" },
"model": { "type": "string" },
"choices": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["index", "message", "finish_reason"],
"properties": {
"index": { "type": "number" },
"message": {
"type": "object",
"required": ["role", "content"],
"properties": {
"role": { "type": "string" },
"content": { "type": "string" }
}
},
"finish_reason": { "type": "string" }
}
}
},
"usage": {
"type": "object",
"properties": {
"prompt_tokens": { "type": "number" },
"completion_tokens": { "type": "number" },
"total_tokens": { "type": "number" }
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "WS chat.send response",
"type": "object",
"required": ["message_id", "content", "role"],
"properties": {
"message_id": { "type": "string" },
"content": { "type": "string" },
"role": { "type": "string", "enum": ["assistant", "user", "system"] }
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "WS connect response",
"type": "object",
"required": ["protocol", "role", "user_id", "tenant_id", "is_owner", "server"],
"properties": {
"protocol": { "type": "number" },
"role": { "type": "string", "enum": ["admin", "operator", "viewer"] },
"user_id": { "type": "string" },
"tenant_id": { "type": "string" },
"is_owner": { "type": "boolean" },
"server": {
"type": "object",
"required": ["name", "version"],
"properties": {
"name": { "type": "string", "const": "goclaw" },
"version": { "type": "string" }
}
}
}
}
@@ -0,0 +1,37 @@
//go:build integration
package ws_methods
import "testing"
// CONTRACT: agents.list response MUST include agents array with required fields.
func TestContract_WS_AgentsList(t *testing.T) {
wsURL, _ := getTestServer(t)
client, _ := connect(t, wsURL, nil)
resp := client.send(t, "agents.list", map[string]any{})
// Response must have agents array
assertField(t, resp, "agents", "array")
// If agents exist, verify each has required fields
agents, ok := resp["agents"].([]any)
if !ok || len(agents) == 0 {
t.Log("No agents returned - skipping field checks")
return
}
agent, ok := agents[0].(map[string]any)
if !ok {
t.Error("CONTRACT VIOLATION: agents[0] is not an object")
return
}
// Required agent fields
assertField(t, agent, "id", "string")
assertField(t, agent, "agent_key", "string")
assertField(t, agent, "display_name", "string")
assertField(t, agent, "status", "string")
assertField(t, agent, "provider", "string")
assertField(t, agent, "model", "string")
}
@@ -0,0 +1,30 @@
//go:build integration
package ws_methods
import "testing"
// CONTRACT: chat.send response MUST include message fields.
func TestContract_WS_ChatSend(t *testing.T) {
wsURL, _ := getTestServer(t)
client, _ := connect(t, wsURL, nil)
resp := client.send(t, "chat.send", map[string]any{
"message": "Hello",
})
// Required fields for chat response
assertField(t, resp, "message_id", "string")
assertField(t, resp, "content", "string")
assertField(t, resp, "role", "string")
}
// CONTRACT: sessions.list response MUST include sessions array.
func TestContract_WS_SessionsList(t *testing.T) {
wsURL, _ := getTestServer(t)
client, _ := connect(t, wsURL, nil)
resp := client.send(t, "sessions.list", map[string]any{})
assertField(t, resp, "sessions", "array")
}
@@ -0,0 +1,19 @@
//go:build integration
package ws_methods
import "testing"
// CONTRACT: config.get response MUST include config, hash, path.
// Requires owner + master scope.
func TestContract_WS_ConfigGet(t *testing.T) {
wsURL, _ := getTestServer(t)
client, _ := connect(t, wsURL, nil)
resp := client.send(t, "config.get", map[string]any{})
// Required fields
assertField(t, resp, "config", "object")
assertField(t, resp, "hash", "string")
assertField(t, resp, "path", "string")
}
@@ -0,0 +1,32 @@
//go:build integration
package ws_methods
import (
"testing"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// CONTRACT: connect response MUST include these fields with correct types.
// Breaking changes require protocol version bump.
func TestContract_WS_Connect(t *testing.T) {
wsURL, _ := getTestServer(t)
_, resp := connect(t, wsURL, nil)
// Required fields
assertField(t, resp, "protocol", "number")
assertField(t, resp, "role", "string")
assertField(t, resp, "user_id", "string")
assertField(t, resp, "tenant_id", "string")
assertField(t, resp, "is_owner", "bool")
// Nested server object
assertField(t, resp, "server", "object")
assertField(t, resp, "server.name", "string")
assertField(t, resp, "server.version", "string")
// Value checks
assertFieldValue(t, resp, "protocol", float64(protocol.ProtocolVersion))
assertFieldValue(t, resp, "server.name", "goclaw")
}
+188
View File
@@ -0,0 +1,188 @@
//go:build integration
// Package ws_methods tests WebSocket RPC method contracts.
// Contract tests verify response schemas don't change without version bump.
package ws_methods
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
var testToken string
// getTestServer returns WS URL and token from environment.
// Set CONTRACT_TEST_WS_URL and CONTRACT_TEST_TOKEN to run against a real server.
func getTestServer(t *testing.T) (wsURL string, token string) {
t.Helper()
wsURL = os.Getenv("CONTRACT_TEST_WS_URL")
token = os.Getenv("CONTRACT_TEST_TOKEN")
if wsURL == "" {
t.Skip("CONTRACT_TEST_WS_URL not set - skipping contract test")
}
if token == "" {
t.Skip("CONTRACT_TEST_TOKEN not set - skipping contract test")
}
testToken = token
return wsURL, token
}
// wsClient wraps a WebSocket connection for contract testing.
type wsClient struct {
conn *websocket.Conn
nextID int
}
// connect creates a WS client and performs connect handshake.
func connect(t *testing.T, wsURL string, params map[string]any) (*wsClient, map[string]any) {
t.Helper()
if params == nil {
params = map[string]any{}
}
if _, ok := params["token"]; !ok {
params["token"] = testToken
}
if _, ok := params["user_id"]; !ok {
params["user_id"] = "contract-test-user"
}
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, _, err := dialer.DialContext(context.Background(), wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
c := &wsClient{conn: conn, nextID: 1}
t.Cleanup(func() { conn.Close() })
resp := c.send(t, protocol.MethodConnect, params)
return c, resp
}
// send sends a request and waits for response.
func (c *wsClient) send(t *testing.T, method string, params any) map[string]any {
t.Helper()
reqID := fmt.Sprintf("req-%d", c.nextID)
c.nextID++
paramsJSON, _ := json.Marshal(params)
req := protocol.RequestFrame{
Type: protocol.FrameTypeRequest,
ID: reqID,
Method: method,
Params: paramsJSON,
}
if err := c.conn.WriteJSON(req); err != nil {
t.Fatalf("send %s: %v", method, err)
}
c.conn.SetReadDeadline(time.Now().Add(10 * time.Second))
var resp protocol.ResponseFrame
if err := c.conn.ReadJSON(&resp); err != nil {
t.Fatalf("read %s response: %v", method, err)
}
if resp.ID != reqID {
t.Fatalf("response ID mismatch: got %s, want %s", resp.ID, reqID)
}
if !resp.OK {
t.Fatalf("%s failed: %s - %s", method, resp.Error.Code, resp.Error.Message)
}
payloadJSON, _ := json.Marshal(resp.Payload)
var result map[string]any
json.Unmarshal(payloadJSON, &result)
return result
}
// assertField verifies a field exists with expected type.
func assertField(t *testing.T, data map[string]any, path string, expectedType string) {
t.Helper()
parts := strings.Split(path, ".")
current := data
for i, part := range parts {
if i == len(parts)-1 {
val, ok := current[part]
if !ok {
t.Errorf("CONTRACT VIOLATION: missing field %q", path)
return
}
actualType := typeOf(val)
if actualType != expectedType {
t.Errorf("CONTRACT VIOLATION: field %q type mismatch: got %s, want %s", path, actualType, expectedType)
}
return
}
nested, ok := current[part].(map[string]any)
if !ok {
t.Errorf("CONTRACT VIOLATION: field %q is not an object", strings.Join(parts[:i+1], "."))
return
}
current = nested
}
}
// assertFieldValue verifies a field has the expected value.
func assertFieldValue(t *testing.T, data map[string]any, path string, expected any) {
t.Helper()
parts := strings.Split(path, ".")
current := data
for i, part := range parts {
if i == len(parts)-1 {
val, ok := current[part]
if !ok {
t.Errorf("CONTRACT VIOLATION: missing field %q", path)
return
}
if val != expected {
t.Errorf("CONTRACT VIOLATION: field %q value mismatch: got %v, want %v", path, val, expected)
}
return
}
nested, ok := current[part].(map[string]any)
if !ok {
t.Errorf("CONTRACT VIOLATION: field %q is not an object", strings.Join(parts[:i+1], "."))
return
}
current = nested
}
}
func typeOf(v any) string {
if v == nil {
return "null"
}
switch v.(type) {
case string:
return "string"
case float64:
return "number"
case bool:
return "bool"
case []any:
return "array"
case map[string]any:
return "object"
default:
return fmt.Sprintf("%T", v)
}
}
@@ -0,0 +1,33 @@
//go:build integration
package ws_methods
import "testing"
// CONTRACT: skills.list response MUST include skills array.
func TestContract_WS_SkillsList(t *testing.T) {
wsURL, _ := getTestServer(t)
client, _ := connect(t, wsURL, nil)
resp := client.send(t, "skills.list", map[string]any{})
// Response must have skills array
assertField(t, resp, "skills", "array")
// If skills exist, verify each has required fields
skills, ok := resp["skills"].([]any)
if !ok || len(skills) == 0 {
t.Log("No skills returned - skipping field checks")
return
}
skill, ok := skills[0].(map[string]any)
if !ok {
t.Error("CONTRACT VIOLATION: skills[0] is not an object")
return
}
// Required skill fields
assertField(t, skill, "name", "string")
assertField(t, skill, "description", "string")
}
+102
View File
@@ -3,6 +3,8 @@
package integration
import (
"fmt"
"sync"
"testing"
"github.com/google/uuid"
@@ -429,3 +431,103 @@ func TestStoreAgent_CrossTenantMode(t *testing.T) {
t.Errorf("crossTenant: expected ID %v, got %v", agent.ID, got.ID)
}
}
func TestStoreAgent_ConcurrentUpdate(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
as := pg.NewPGAgentStore(db)
agent := newTestAgent(tenantID, uuid.New().String()[:8])
agent.DisplayName = "Initial Name"
if err := as.Create(ctx, &agent); err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() { db.Exec("DELETE FROM agents WHERE id = $1", agent.ID) })
const goroutines = 15
var wg sync.WaitGroup
wg.Add(goroutines)
errChan := make(chan error, goroutines)
for i := range goroutines {
go func(i int) {
defer wg.Done()
err := as.Update(ctx, agent.ID, map[string]any{
"display_name": fmt.Sprintf("Name-%d", i),
})
if err != nil {
errChan <- fmt.Errorf("Update %d: %w", i, err)
}
}(i)
}
wg.Wait()
close(errChan)
for err := range errChan {
t.Error(err)
}
// Verify agent still readable and has one of the names
got, err := as.GetByID(ctx, agent.ID)
if err != nil {
t.Fatalf("GetByID after concurrent updates: %v", err)
}
if got.DisplayName == "" {
t.Error("DisplayName empty after concurrent updates")
}
}
func TestStoreAgent_ConcurrentCreateAndList(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
as := pg.NewPGAgentStore(db)
const goroutines = 10
var wg sync.WaitGroup
wg.Add(goroutines)
var createdIDs sync.Map
for i := range goroutines {
go func(i int) {
defer wg.Done()
a := newTestAgent(tenantID, fmt.Sprintf("conc-%d-%s", i, uuid.New().String()[:4]))
a.OwnerID = "concurrent-owner"
if err := as.Create(ctx, &a); err != nil {
t.Errorf("Create agent %d: %v", i, err)
return
}
createdIDs.Store(a.ID, true)
}(i)
}
wg.Wait()
// Cleanup
t.Cleanup(func() {
createdIDs.Range(func(key, _ any) bool {
db.Exec("DELETE FROM agents WHERE id = $1", key)
return true
})
})
// Verify all agents can be listed
list, err := as.List(ctx, "concurrent-owner")
if err != nil {
t.Fatalf("List: %v", err)
}
foundCount := 0
createdIDs.Range(func(key, _ any) bool {
for _, a := range list {
if a.ID == key.(uuid.UUID) {
foundCount++
break
}
}
return true
})
if foundCount != goroutines {
t.Errorf("expected %d agents, found %d", goroutines, foundCount)
}
}
+136
View File
@@ -5,6 +5,8 @@ package integration
import (
"database/sql"
"errors"
"fmt"
"sync"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -185,3 +187,137 @@ func TestStoreMemory_TenantIsolation(t *testing.T) {
t.Error("tenant B should not access tenant A's document")
}
}
func TestStoreMemory_ConcurrentPutDocument(t *testing.T) {
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ms := newMemoryStore(db)
aid := agentID.String()
uid := "user-conc-" + agentID.String()[:8]
const goroutines = 20
var wg sync.WaitGroup
wg.Add(goroutines)
errChan := make(chan error, goroutines)
for i := range goroutines {
go func(i int) {
defer wg.Done()
path := fmt.Sprintf("conc/doc-%d.md", i)
if err := ms.PutDocument(ctx, aid, uid, path, fmt.Sprintf("content-%d", i)); err != nil {
errChan <- fmt.Errorf("PutDocument %d: %w", i, err)
}
}(i)
}
wg.Wait()
close(errChan)
for err := range errChan {
t.Error(err)
}
// Verify all documents exist
docs, err := ms.ListDocuments(ctx, aid, uid)
if err != nil {
t.Fatalf("ListDocuments: %v", err)
}
foundCount := 0
for _, d := range docs {
for i := range goroutines {
if d.Path == fmt.Sprintf("conc/doc-%d.md", i) {
foundCount++
break
}
}
}
if foundCount != goroutines {
t.Errorf("expected %d concurrent docs, found %d", goroutines, foundCount)
}
}
func TestStoreMemory_ConcurrentSamePath(t *testing.T) {
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ms := newMemoryStore(db)
aid := agentID.String()
uid := "user-race-" + agentID.String()[:8]
path := "race/single.md"
const goroutines = 15
var wg sync.WaitGroup
wg.Add(goroutines)
for i := range goroutines {
go func(i int) {
defer wg.Done()
ms.PutDocument(ctx, aid, uid, path, fmt.Sprintf("version-%d", i))
}(i)
}
wg.Wait()
// Last-write-wins: content should be one of the versions
content, err := ms.GetDocument(ctx, aid, uid, path)
if err != nil {
t.Fatalf("GetDocument: %v", err)
}
if content == "" {
t.Error("expected non-empty content after concurrent writes")
}
// Verify content matches one of the written versions
validVersion := false
for i := range goroutines {
if content == fmt.Sprintf("version-%d", i) {
validVersion = true
break
}
}
if !validVersion {
t.Errorf("content %q doesn't match any written version", content)
}
}
func TestStoreMemory_ConcurrentIndexDocument(t *testing.T) {
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ms := newMemoryStore(db)
aid := agentID.String()
uid := "user-idx-" + agentID.String()[:8]
// Create documents first
for i := range 5 {
path := fmt.Sprintf("idx/doc-%d.md", i)
if err := ms.PutDocument(ctx, aid, uid, path, fmt.Sprintf("indexable content %d for search", i)); err != nil {
t.Fatalf("PutDocument %d: %v", i, err)
}
}
// Concurrent indexing
const goroutines = 5
var wg sync.WaitGroup
wg.Add(goroutines)
errChan := make(chan error, goroutines)
for i := range goroutines {
go func(i int) {
defer wg.Done()
path := fmt.Sprintf("idx/doc-%d.md", i)
if err := ms.IndexDocument(ctx, aid, uid, path); err != nil {
errChan <- fmt.Errorf("IndexDocument %d: %w", i, err)
}
}(i)
}
wg.Wait()
close(errChan)
for err := range errChan {
t.Error(err)
}
}
@@ -3,6 +3,8 @@
package integration
import (
"fmt"
"sync"
"testing"
"github.com/google/uuid"
@@ -293,3 +295,80 @@ func TestStoreSession_TenantIsolation(t *testing.T) {
t.Errorf("tenant isolation broken: tenant B got session created by tenant A")
}
}
func TestStoreSession_ConcurrentAddMessage(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ss := pg.NewPGSessionStore(db)
key := "concurrent-sess-" + uuid.New().String()[:8]
ss.GetOrCreate(ctx, key)
const goroutines = 20
var wg sync.WaitGroup
wg.Add(goroutines)
for i := range goroutines {
go func(i int) {
defer wg.Done()
ss.AddMessage(ctx, key, providers.Message{
Role: "user",
Content: fmt.Sprintf("message-%d", i),
})
}(i)
}
wg.Wait()
hist := ss.GetHistory(ctx, key)
if len(hist) != goroutines {
t.Errorf("expected %d messages, got %d", goroutines, len(hist))
}
}
func TestStoreSession_ConcurrentSave(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ss := pg.NewPGSessionStore(db)
key := "concurrent-save-" + uuid.New().String()[:8]
ss.GetOrCreate(ctx, key)
// Add messages then race Save() calls
for i := range 5 {
ss.AddMessage(ctx, key, providers.Message{
Role: "user",
Content: fmt.Sprintf("msg-%d", i),
})
}
const goroutines = 10
var wg sync.WaitGroup
wg.Add(goroutines)
errChan := make(chan error, goroutines)
for range goroutines {
go func() {
defer wg.Done()
if err := ss.Save(ctx, key); err != nil {
errChan <- err
}
}()
}
wg.Wait()
close(errChan)
for err := range errChan {
t.Errorf("concurrent Save failed: %v", err)
}
// Verify session still intact
var dbKey string
err := db.QueryRow(
"SELECT session_key FROM sessions WHERE session_key = $1 AND tenant_id = $2",
key, tenantID,
).Scan(&dbKey)
if err != nil {
t.Fatalf("DB verify after concurrent Save: %v", err)
}
}
@@ -3,6 +3,9 @@
package integration
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
@@ -363,3 +366,148 @@ func TestStoreTask_AccessControl(t *testing.T) {
t.Error("HasTeamAccess: expected false after revoke")
}
}
func TestStoreTask_BlockedUnblockFlow(t *testing.T) {
db := testDB(t)
pg.InitSqlx(db)
tenantID, agentID := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ts := pg.NewPGTeamStore(db)
teamID, memberID := seedTeam(t, db, tenantID, agentID)
// Task A: the blocker
taskA := makeTask(teamID, "blocker task", store.TeamTaskStatusPending)
if err := ts.CreateTask(ctx, taskA); err != nil {
t.Fatalf("CreateTask A: %v", err)
}
// Task B: blocked by A
taskB := makeTask(teamID, "blocked task", store.TeamTaskStatusPending)
if err := ts.CreateTask(ctx, taskB); err != nil {
t.Fatalf("CreateTask B: %v", err)
}
// Set B as blocked by A using direct SQL (status update via UpdateTask not allowed)
if _, err := db.Exec(
`UPDATE team_tasks SET status = 'blocked', blocked_by = $1 WHERE id = $2`,
[]uuid.UUID{taskA.ID}, taskB.ID,
); err != nil {
t.Fatalf("set blocked state: %v", err)
}
// Verify B is blocked
gotB, err := ts.GetTask(ctx, taskB.ID)
if err != nil {
t.Fatalf("GetTask B: %v", err)
}
if gotB.Status != store.TeamTaskStatusBlocked {
t.Errorf("B status: expected %q, got %q", store.TeamTaskStatusBlocked, gotB.Status)
}
if len(gotB.BlockedBy) != 1 || gotB.BlockedBy[0] != taskA.ID {
t.Errorf("B blocked_by: expected [%v], got %v", taskA.ID, gotB.BlockedBy)
}
// Claim and complete A - should trigger unblock of B
if err := ts.ClaimTask(ctx, taskA.ID, memberID, teamID); err != nil {
t.Fatalf("ClaimTask A: %v", err)
}
if err := ts.CompleteTask(ctx, taskA.ID, teamID, "done"); err != nil {
t.Fatalf("CompleteTask A: %v", err)
}
// Verify B is now pending (unblocked)
gotB2, err := ts.GetTask(ctx, taskB.ID)
if err != nil {
t.Fatalf("GetTask B after unblock: %v", err)
}
if gotB2.Status != store.TeamTaskStatusPending {
t.Errorf("B status after unblock: expected %q, got %q", store.TeamTaskStatusPending, gotB2.Status)
}
if len(gotB2.BlockedBy) != 0 {
t.Errorf("B blocked_by after unblock: expected empty, got %v", gotB2.BlockedBy)
}
}
func TestStoreTask_RaceToClaimSameTask(t *testing.T) {
db := testDB(t)
pg.InitSqlx(db)
tenantID, agentID := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ts := pg.NewPGTeamStore(db)
teamID, _ := seedTeam(t, db, tenantID, agentID)
// Create additional agents and add them as team members
var memberAgentIDs []uuid.UUID
for i := 0; i < 5; i++ {
memberAgentID := uuid.New()
memberAgentKey := fmt.Sprintf("race-member-%d-%s", i, memberAgentID.String()[:8])
// Insert agent with all required columns
if _, err := db.Exec(
`INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
VALUES ($1, $2, $3, 'predefined', 'active', 'test', 'test-model', 'test-owner')`,
memberAgentID, tenantID, memberAgentKey,
); err != nil {
t.Fatalf("create agent %d: %v", i, err)
}
// Add as team member
if _, err := db.Exec(
`INSERT INTO agent_team_members (team_id, agent_id, role, tenant_id)
VALUES ($1, $2, 'member', $3)`,
teamID, memberAgentID, tenantID,
); err != nil {
t.Fatalf("create member %d: %v", i, err)
}
memberAgentIDs = append(memberAgentIDs, memberAgentID)
}
// Cleanup test members and agents
t.Cleanup(func() {
for _, aid := range memberAgentIDs {
db.Exec("DELETE FROM agent_team_members WHERE agent_id = $1", aid)
db.Exec("DELETE FROM agents WHERE id = $1", aid)
}
})
task := makeTask(teamID, "race task", store.TeamTaskStatusPending)
if err := ts.CreateTask(ctx, task); err != nil {
t.Fatalf("CreateTask: %v", err)
}
// Concurrent claims
var wg sync.WaitGroup
var successCount atomic.Int32
var failCount atomic.Int32
for _, memberID := range memberAgentIDs {
wg.Add(1)
go func(mid uuid.UUID) {
defer wg.Done()
err := ts.ClaimTask(ctx, task.ID, mid, teamID)
if err == nil {
successCount.Add(1)
} else {
failCount.Add(1)
}
}(memberID)
}
wg.Wait()
// Exactly one should succeed
if successCount.Load() != 1 {
t.Errorf("race claim: expected 1 success, got %d (failures: %d)", successCount.Load(), failCount.Load())
}
// Verify task is claimed
got, err := ts.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("GetTask after race: %v", err)
}
if got.Status != store.TeamTaskStatusInProgress {
t.Errorf("Status after race: expected %q, got %q", store.TeamTaskStatusInProgress, got.Status)
}
if got.OwnerAgentID == nil {
t.Error("OwnerAgentID should be set after race claim")
}
}
+226
View File
@@ -0,0 +1,226 @@
//go:build integration
package invariants
import (
"context"
"database/sql"
"os"
"reflect"
"sync"
"testing"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
const defaultTestDSN = "postgres://postgres:test@localhost:5433/goclaw_test?sslmode=disable"
var (
sharedDB *sql.DB
sharedDBOnce sync.Once
sharedDBErr error
)
// testDB connects to the test PG instance, runs migrations once, and returns
// a shared *sql.DB. Skips test if PG is unreachable.
func testDB(t *testing.T) *sql.DB {
t.Helper()
sharedDBOnce.Do(func() {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = defaultTestDSN
}
db, err := sql.Open("pgx", dsn)
if err != nil {
sharedDBErr = err
return
}
if err := db.Ping(); err != nil {
sharedDBErr = err
return
}
m, err := migrate.New("file://../../migrations", dsn)
if err != nil {
sharedDBErr = err
return
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
sharedDBErr = err
return
}
m.Close()
pg.InitSqlx(db)
sharedDB = db
})
if sharedDBErr != nil {
t.Skipf("test PG not available: %v", sharedDBErr)
}
return sharedDB
}
// seedTenantAgent creates a minimal tenant + agent for FK satisfaction.
// Agent insert includes all columns expected by agentSelectCols (37 columns).
func seedTenantAgent(t *testing.T, db *sql.DB) (tenantID, agentID uuid.UUID) {
t.Helper()
tenantID = uuid.New()
agentID = uuid.New()
agentKey := "inv-" + agentID.String()[:8]
_, err := db.Exec(
`INSERT INTO tenants (id, name, slug, status) VALUES ($1, $2, $3, 'active')
ON CONFLICT DO NOTHING`,
tenantID, "inv-tenant-"+tenantID.String()[:8], "i"+tenantID.String()[:8])
if err != nil {
t.Fatalf("seed tenant: %v", err)
}
// Insert with all 37 columns expected by agentSelectCols
_, err = db.Exec(
`INSERT INTO agents (
id, agent_key, display_name, frontmatter, owner_id, provider, model,
context_window, max_tool_iterations, workspace, restrict_to_workspace,
tools_config, sandbox_config, subagents_config, memory_config,
compaction_config, context_pruning, other_config,
emoji, agent_description, thinking_level, max_tokens,
self_evolve, skill_evolve, skill_nudge_interval,
reasoning_config, workspace_sharing, chatgpt_oauth_routing,
shell_deny_groups, kg_dedup_config,
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id
) VALUES (
$1, $2, $3, NULL, $4, $5, $6,
8192, 10, '', false,
'{}', NULL, NULL, NULL,
NULL, NULL, '{}',
'', '', '', 4096,
false, false, 0,
'{}', '{}', '{}',
'{}', '{}',
'predefined', false, 'active', 0, NOW(), NOW(), $7
) ON CONFLICT DO NOTHING`,
agentID, agentKey, "Test Agent "+agentKey, "test-owner", "test", "test-model", tenantID)
if err != nil {
t.Fatalf("seed agent: %v", err)
}
t.Cleanup(func() {
cleanupTenant(db, tenantID, agentID)
})
return tenantID, agentID
}
// seedTwoTenants creates 2 independent tenants with agents for isolation testing.
func seedTwoTenants(t *testing.T, db *sql.DB) (tenantA, agentA, tenantB, agentB uuid.UUID) {
t.Helper()
tenantA, agentA = seedTenantAgent(t, db)
tenantB, agentB = seedTenantAgent(t, db)
return
}
// tenantCtx returns a context with tenant ID set for store scoping.
func tenantCtx(tenantID uuid.UUID) context.Context {
return store.WithTenantID(context.Background(), tenantID)
}
// userCtx returns a context with both tenant ID and user ID set.
func userCtx(tenantID uuid.UUID, userID string) context.Context {
ctx := store.WithTenantID(context.Background(), tenantID)
return store.WithUserID(ctx, userID)
}
// agentCtx returns a context with tenant, agent type and agent ID set.
func agentCtx(tenantID, agentID uuid.UUID, agentType string) context.Context {
ctx := store.WithTenantID(context.Background(), tenantID)
ctx = store.WithAgentID(ctx, agentID)
ctx = store.WithAgentType(ctx, agentType)
return ctx
}
// assertAccessDenied verifies that a cross-tenant access returns nil or error.
// INVARIANT: Cross-tenant access MUST NOT return data.
func assertAccessDenied(t *testing.T, result any, err error, msg string) {
t.Helper()
// Access denied can manifest as:
// 1. Non-nil error (explicit denial)
// 2. Nil result with nil error (no data found - implicit denial)
// Use reflection to check for nil because interface{} with typed nil pointer is not nil.
isNil := result == nil || (reflect.ValueOf(result).Kind() == reflect.Ptr && reflect.ValueOf(result).IsNil())
if err == nil && !isNil {
t.Errorf("INVARIANT VIOLATION: %s - expected nil or error, got data", msg)
}
}
// assertNotEmpty verifies that the result is not nil/empty (valid access).
func assertNotEmpty(t *testing.T, result any, msg string) {
t.Helper()
if result == nil {
t.Errorf("%s: expected non-nil result", msg)
}
}
// cleanupTenant removes all tenant data in FK order.
func cleanupTenant(db *sql.DB, tenantID, agentID uuid.UUID) {
// Team-related
db.Exec("DELETE FROM team_task_comments WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM team_task_events WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM team_task_attachments WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM team_tasks WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM agent_team_members WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM agent_teams WHERE tenant_id = $1", tenantID)
// Knowledge stores
db.Exec("DELETE FROM episodic_summaries WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM vault_links WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM vault_documents WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_dedup_candidates WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_relations WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM kg_entities WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_chunks WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM memory_documents WHERE tenant_id = $1", tenantID)
// Sessions
db.Exec("DELETE FROM sessions WHERE tenant_id = $1", tenantID)
// Skills
db.Exec("DELETE FROM skill_agent_grants WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM skill_user_grants WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM skills WHERE tenant_id = $1", tenantID)
// Cron
db.Exec("DELETE FROM cron_run_logs WHERE job_id IN (SELECT id FROM cron_jobs WHERE tenant_id = $1)", tenantID)
db.Exec("DELETE FROM cron_jobs WHERE tenant_id = $1", tenantID)
// Security stores
db.Exec("DELETE FROM mcp_user_credentials WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM mcp_access_requests WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM mcp_user_grants WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM mcp_agent_grants WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM mcp_servers WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM api_keys WHERE tenant_id = $1", tenantID)
db.Exec("DELETE FROM agent_config_permissions WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM channel_contacts WHERE tenant_id = $1", tenantID)
// Agent-related
db.Exec("DELETE FROM agent_shares WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_context_files WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM user_context_files WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM user_agent_overrides WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_user_profiles WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_evolution_suggestions WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agent_evolution_metrics WHERE agent_id = $1", agentID)
db.Exec("DELETE FROM agents WHERE id = $1", agentID)
db.Exec("DELETE FROM tenants WHERE id = $1", tenantID)
}
@@ -0,0 +1,269 @@
//go:build integration
package invariants
import (
"testing"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// INVARIANT: Admin-only operations MUST reject Operator role.
func TestPermission_AdminOnlyRejectsOperator(t *testing.T) {
pe := permissions.NewPolicyEngine(nil)
adminMethods := []string{
protocol.MethodConfigApply,
protocol.MethodConfigPatch,
protocol.MethodAgentsCreate,
protocol.MethodAgentsUpdate,
protocol.MethodAgentsDelete,
protocol.MethodTeamsCreate,
protocol.MethodTeamsDelete,
protocol.MethodAPIKeysCreate,
protocol.MethodAPIKeysRevoke,
}
for _, method := range adminMethods {
t.Run(method, func(t *testing.T) {
// INVARIANT: Operator MUST NOT access admin methods
if pe.CanAccess(permissions.RoleOperator, method) {
t.Errorf("INVARIANT VIOLATION: Operator can access admin method %s", method)
}
// INVARIANT: Viewer MUST NOT access admin methods
if pe.CanAccess(permissions.RoleViewer, method) {
t.Errorf("INVARIANT VIOLATION: Viewer can access admin method %s", method)
}
// Admin and Owner should have access
if !pe.CanAccess(permissions.RoleAdmin, method) {
t.Errorf("Admin should access %s", method)
}
if !pe.CanAccess(permissions.RoleOwner, method) {
t.Errorf("Owner should access %s", method)
}
})
}
}
// INVARIANT: Write operations MUST reject Viewer role.
func TestPermission_WriteRejectsViewer(t *testing.T) {
pe := permissions.NewPolicyEngine(nil)
writeMethods := []string{
protocol.MethodChatSend,
protocol.MethodChatAbort,
protocol.MethodSessionsDelete,
protocol.MethodSessionsReset,
protocol.MethodCronCreate,
protocol.MethodCronUpdate,
protocol.MethodCronDelete,
}
for _, method := range writeMethods {
t.Run(method, func(t *testing.T) {
// INVARIANT: Viewer MUST NOT access write methods
if pe.CanAccess(permissions.RoleViewer, method) {
t.Errorf("INVARIANT VIOLATION: Viewer can access write method %s", method)
}
// Operator, Admin, and Owner should have access
if !pe.CanAccess(permissions.RoleOperator, method) {
t.Errorf("Operator should access %s", method)
}
})
}
}
// INVARIANT: Owner MUST have access to all methods (superset of Admin).
func TestPermission_OwnerSupersetOfAdmin(t *testing.T) {
pe := permissions.NewPolicyEngine(nil)
allMethods := []string{
// Admin methods
protocol.MethodConfigApply,
protocol.MethodAgentsCreate,
protocol.MethodTeamsCreate,
// Write methods
protocol.MethodChatSend,
protocol.MethodSessionsDelete,
// Read methods
protocol.MethodSessionsList,
protocol.MethodAgentsList,
}
for _, method := range allMethods {
t.Run(method, func(t *testing.T) {
adminCan := pe.CanAccess(permissions.RoleAdmin, method)
ownerCan := pe.CanAccess(permissions.RoleOwner, method)
// INVARIANT: If Admin can access, Owner MUST be able to access
if adminCan && !ownerCan {
t.Errorf("INVARIANT VIOLATION: Admin can access %s but Owner cannot", method)
}
// INVARIANT: Owner MUST always have access
if !ownerCan {
t.Errorf("INVARIANT VIOLATION: Owner cannot access %s", method)
}
})
}
}
// INVARIANT: Role hierarchy MUST be strictly ordered: Owner > Admin > Operator > Viewer.
func TestPermission_RoleHierarchy(t *testing.T) {
tests := []struct {
name string
higher permissions.Role
lower permissions.Role
required permissions.Role
}{
{"owner_beats_admin", permissions.RoleOwner, permissions.RoleAdmin, permissions.RoleAdmin},
{"admin_beats_operator", permissions.RoleAdmin, permissions.RoleOperator, permissions.RoleAdmin},
{"operator_beats_viewer", permissions.RoleOperator, permissions.RoleViewer, permissions.RoleOperator},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
higherMeets := permissions.HasMinRole(tt.higher, tt.required)
lowerMeets := permissions.HasMinRole(tt.lower, tt.required)
// INVARIANT: Higher role MUST meet requirement that lower cannot
if lowerMeets && !higherMeets {
t.Errorf("INVARIANT VIOLATION: %s cannot meet %s but %s can",
tt.higher, tt.required, tt.lower)
}
})
}
}
// INVARIANT: API key scopes MUST map correctly to roles.
func TestPermission_ScopesToRoleMapping(t *testing.T) {
tests := []struct {
name string
scopes []permissions.Scope
expected permissions.Role
}{
{"admin_scope_is_admin", []permissions.Scope{permissions.ScopeAdmin}, permissions.RoleAdmin},
{"write_scope_is_operator", []permissions.Scope{permissions.ScopeWrite}, permissions.RoleOperator},
{"read_scope_is_viewer", []permissions.Scope{permissions.ScopeRead}, permissions.RoleViewer},
{"empty_scope_is_viewer", []permissions.Scope{}, permissions.RoleViewer},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := permissions.RoleFromScopes(tt.scopes)
if got != tt.expected {
t.Errorf("INVARIANT VIOLATION: scopes %v should map to %s, got %s",
tt.scopes, tt.expected, got)
}
})
}
}
// INVARIANT: Owner IDs MUST be consistently recognized.
func TestPermission_OwnerRecognition(t *testing.T) {
owners := []string{"owner-alice", "owner-bob"}
pe := permissions.NewPolicyEngine(owners)
// INVARIANT: Listed owners MUST be recognized
for _, owner := range owners {
if !pe.IsOwner(owner) {
t.Errorf("INVARIANT VIOLATION: %s should be recognized as owner", owner)
}
}
// INVARIANT: Non-owners MUST NOT be recognized as owners
nonOwners := []string{"charlie", "admin", "", "owner"}
for _, nonOwner := range nonOwners {
if pe.IsOwner(nonOwner) {
t.Errorf("INVARIANT VIOLATION: %s should NOT be recognized as owner", nonOwner)
}
}
}
// INVARIANT: Config permission store MUST enforce tenant scoping.
func TestPermission_ConfigPermissionTenantIsolation(t *testing.T) {
db := testDB(t)
tenantA, agentA, tenantB, _ := seedTwoTenants(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
cps := pg.NewPGConfigPermissionStore(db)
// Grant permission in tenant A using colon-delimited scope (matchWildcard requires :* format)
err := cps.Grant(ctxA, &store.ConfigPermission{
AgentID: agentA,
Scope: "group:*",
ConfigType: "file_writer",
Permission: "allow",
UserID: "user-a",
})
if err != nil {
t.Fatalf("Grant: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM agent_config_permissions WHERE agent_id = $1", agentA)
})
// Verify tenant A has permission (scope must match pattern: group:* matches group:telegram)
allowed, err := cps.CheckPermission(ctxA, agentA, "group:telegram", "file_writer", "user-a")
if err != nil {
t.Fatalf("CheckPermission A: %v", err)
}
if !allowed {
t.Error("tenant A should have permission on own agent")
}
// INVARIANT: Tenant B MUST NOT see tenant A's permissions
allowedB, err := cps.CheckPermission(ctxB, agentA, "group:telegram", "file_writer", "user-a")
if err != nil {
t.Fatalf("CheckPermission B: %v", err)
}
if allowedB {
t.Errorf("INVARIANT VIOLATION: tenant B sees tenant A's permission")
}
}
// INVARIANT: Scope-based access MUST require correct scope for method.
func TestPermission_ScopeBasedAccess(t *testing.T) {
pe := permissions.NewPolicyEngine(nil)
tests := []struct {
name string
scopes []permissions.Scope
method string
shouldAllow bool
}{
// Admin methods require admin scope
{"read_cannot_create_agent", []permissions.Scope{permissions.ScopeRead}, protocol.MethodAgentsCreate, false},
{"write_cannot_create_agent", []permissions.Scope{permissions.ScopeWrite}, protocol.MethodAgentsCreate, false},
{"admin_can_create_agent", []permissions.Scope{permissions.ScopeAdmin}, protocol.MethodAgentsCreate, true},
// Write methods require write or admin scope
{"read_cannot_send_chat", []permissions.Scope{permissions.ScopeRead}, protocol.MethodChatSend, false},
{"write_can_send_chat", []permissions.Scope{permissions.ScopeWrite}, protocol.MethodChatSend, true},
{"admin_can_send_chat", []permissions.Scope{permissions.ScopeAdmin}, protocol.MethodChatSend, true},
// Approval methods require approvals or admin scope
{"read_cannot_list_approvals", []permissions.Scope{permissions.ScopeRead}, "approvals.list", false},
{"approvals_can_list_approvals", []permissions.Scope{permissions.ScopeApprovals}, "approvals.list", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pe.CanAccessWithScopes(tt.scopes, tt.method)
if got != tt.shouldAllow {
if tt.shouldAllow {
t.Errorf("INVARIANT VIOLATION: scopes %v should allow %s", tt.scopes, tt.method)
} else {
t.Errorf("INVARIANT VIOLATION: scopes %v should NOT allow %s", tt.scopes, tt.method)
}
}
})
}
}
+264
View File
@@ -0,0 +1,264 @@
//go:build integration
package invariants
import (
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// INVARIANT: Session A's message history MUST NOT leak to Session B.
func TestSessionBoundary_MessageHistory(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ss := pg.NewPGSessionStore(db)
sessionA := "inv-sess-a-" + uuid.New().String()[:8]
sessionB := "inv-sess-b-" + uuid.New().String()[:8]
// Create messages in session A
ss.GetOrCreate(ctx, sessionA)
ss.AddMessage(ctx, sessionA, providers.Message{Role: "user", Content: "secret message A"})
ss.AddMessage(ctx, sessionA, providers.Message{Role: "assistant", Content: "response A"})
if err := ss.Save(ctx, sessionA); err != nil {
t.Fatalf("Save A: %v", err)
}
// Create session B with different messages
ss.GetOrCreate(ctx, sessionB)
ss.AddMessage(ctx, sessionB, providers.Message{Role: "user", Content: "message B"})
if err := ss.Save(ctx, sessionB); err != nil {
t.Fatalf("Save B: %v", err)
}
// INVARIANT: Session A's history must be separate from session B
histA := ss.GetHistory(ctx, sessionA)
histB := ss.GetHistory(ctx, sessionB)
if len(histA) != 2 {
t.Errorf("session A should have 2 messages, got %d", len(histA))
}
if len(histB) != 1 {
t.Errorf("session B should have 1 message, got %d", len(histB))
}
// INVARIANT: Content must not leak between sessions
for _, m := range histB {
if m.Content == "secret message A" || m.Content == "response A" {
t.Errorf("INVARIANT VIOLATION: session A's message leaked to session B: %q", m.Content)
}
}
}
// INVARIANT: Session A's summary MUST NOT leak to Session B.
func TestSessionBoundary_Summary(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ss := pg.NewPGSessionStore(db)
sessionA := "inv-sess-sum-a-" + uuid.New().String()[:8]
sessionB := "inv-sess-sum-b-" + uuid.New().String()[:8]
// Set summary in session A
ss.GetOrCreate(ctx, sessionA)
ss.SetSummary(ctx, sessionA, "secret summary for session A")
if err := ss.Save(ctx, sessionA); err != nil {
t.Fatalf("Save A: %v", err)
}
// Create session B without summary
ss.GetOrCreate(ctx, sessionB)
if err := ss.Save(ctx, sessionB); err != nil {
t.Fatalf("Save B: %v", err)
}
// INVARIANT: Session B MUST NOT see session A's summary
summaryB := ss.GetSummary(ctx, sessionB)
if summaryB == "secret summary for session A" {
t.Errorf("INVARIANT VIOLATION: session A's summary leaked to session B")
}
if summaryB != "" {
t.Errorf("session B should have empty summary, got %q", summaryB)
}
// Verify session A still has its summary
summaryA := ss.GetSummary(ctx, sessionA)
if summaryA != "secret summary for session A" {
t.Errorf("session A's summary should persist, got %q", summaryA)
}
}
// INVARIANT: Session A's metadata MUST NOT leak to Session B.
func TestSessionBoundary_Metadata(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ss := pg.NewPGSessionStore(db)
sessionA := "inv-sess-meta-a-" + uuid.New().String()[:8]
sessionB := "inv-sess-meta-b-" + uuid.New().String()[:8]
// Set metadata in session A
ss.GetOrCreate(ctx, sessionA)
ss.SetSessionMetadata(ctx, sessionA, map[string]string{
"secret_key": "secret_value",
"user_data": "sensitive",
})
if err := ss.Save(ctx, sessionA); err != nil {
t.Fatalf("Save A: %v", err)
}
// Create session B with different metadata
ss.GetOrCreate(ctx, sessionB)
ss.SetSessionMetadata(ctx, sessionB, map[string]string{
"channel": "telegram",
})
if err := ss.Save(ctx, sessionB); err != nil {
t.Fatalf("Save B: %v", err)
}
// INVARIANT: Session B MUST NOT see session A's metadata
metaB := ss.GetSessionMetadata(ctx, sessionB)
if metaB["secret_key"] != "" {
t.Errorf("INVARIANT VIOLATION: session A's metadata leaked to session B: secret_key=%q", metaB["secret_key"])
}
if metaB["user_data"] != "" {
t.Errorf("INVARIANT VIOLATION: session A's metadata leaked to session B: user_data=%q", metaB["user_data"])
}
// Verify session B has its own metadata
if metaB["channel"] != "telegram" {
t.Errorf("session B should have its own metadata, got channel=%q", metaB["channel"])
}
// Verify session A still has its metadata
metaA := ss.GetSessionMetadata(ctx, sessionA)
if metaA["secret_key"] != "secret_value" {
t.Errorf("session A's metadata should persist, got secret_key=%q", metaA["secret_key"])
}
}
// INVARIANT: Session A's label MUST NOT leak to Session B.
func TestSessionBoundary_Label(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ss := pg.NewPGSessionStore(db)
sessionA := "inv-sess-label-a-" + uuid.New().String()[:8]
sessionB := "inv-sess-label-b-" + uuid.New().String()[:8]
// Set label in session A
ss.GetOrCreate(ctx, sessionA)
ss.SetLabel(ctx, sessionA, "Private Conversation")
if err := ss.Save(ctx, sessionA); err != nil {
t.Fatalf("Save A: %v", err)
}
// Create session B without label
ss.GetOrCreate(ctx, sessionB)
if err := ss.Save(ctx, sessionB); err != nil {
t.Fatalf("Save B: %v", err)
}
// INVARIANT: Session B MUST NOT see session A's label
labelB := ss.GetLabel(ctx, sessionB)
if labelB == "Private Conversation" {
t.Errorf("INVARIANT VIOLATION: session A's label leaked to session B")
}
}
// INVARIANT: Session A's token counts MUST NOT affect Session B.
func TestSessionBoundary_TokenCounts(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ss := pg.NewPGSessionStore(db)
sessionA := "inv-sess-tokens-a-" + uuid.New().String()[:8]
sessionB := "inv-sess-tokens-b-" + uuid.New().String()[:8]
// Accumulate tokens in session A
ss.GetOrCreate(ctx, sessionA)
ss.AccumulateTokens(ctx, sessionA, 1000, 500)
ss.AccumulateTokens(ctx, sessionA, 2000, 1000)
if err := ss.Save(ctx, sessionA); err != nil {
t.Fatalf("Save A: %v", err)
}
// Create session B
ss.GetOrCreate(ctx, sessionB)
if err := ss.Save(ctx, sessionB); err != nil {
t.Fatalf("Save B: %v", err)
}
// Verify session A has accumulated tokens
dataA := ss.Get(ctx, sessionA)
if dataA.InputTokens != 3000 || dataA.OutputTokens != 1500 {
t.Errorf("session A tokens: expected 3000/1500, got %d/%d", dataA.InputTokens, dataA.OutputTokens)
}
// INVARIANT: Session B MUST NOT have session A's token counts
dataB := ss.Get(ctx, sessionB)
if dataB.InputTokens != 0 || dataB.OutputTokens != 0 {
t.Errorf("INVARIANT VIOLATION: session B has tokens %d/%d, should be 0/0",
dataB.InputTokens, dataB.OutputTokens)
}
}
// INVARIANT: Resetting Session A MUST NOT affect Session B.
func TestSessionBoundary_ResetIsolation(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
ss := pg.NewPGSessionStore(db)
sessionA := "inv-sess-reset-a-" + uuid.New().String()[:8]
sessionB := "inv-sess-reset-b-" + uuid.New().String()[:8]
// Set up both sessions with data
ss.GetOrCreate(ctx, sessionA)
ss.AddMessage(ctx, sessionA, providers.Message{Role: "user", Content: "message A"})
ss.SetSummary(ctx, sessionA, "summary A")
if err := ss.Save(ctx, sessionA); err != nil {
t.Fatalf("Save A: %v", err)
}
ss.GetOrCreate(ctx, sessionB)
ss.AddMessage(ctx, sessionB, providers.Message{Role: "user", Content: "message B"})
ss.SetSummary(ctx, sessionB, "summary B")
if err := ss.Save(ctx, sessionB); err != nil {
t.Fatalf("Save B: %v", err)
}
// Reset session A
ss.Reset(ctx, sessionA)
if err := ss.Save(ctx, sessionA); err != nil {
t.Fatalf("Save after reset: %v", err)
}
// INVARIANT: Session B MUST NOT be affected by session A's reset
histB := ss.GetHistory(ctx, sessionB)
if len(histB) != 1 {
t.Errorf("INVARIANT VIOLATION: session B lost messages after session A reset, got %d", len(histB))
}
if histB[0].Content != "message B" {
t.Errorf("session B should keep its message, got %q", histB[0].Content)
}
summaryB := ss.GetSummary(ctx, sessionB)
if summaryB != "summary B" {
t.Errorf("INVARIANT VIOLATION: session B lost summary after session A reset, got %q", summaryB)
}
// Verify session A was actually reset
histA := ss.GetHistory(ctx, sessionA)
if len(histA) != 0 {
t.Errorf("session A should have 0 messages after reset, got %d", len(histA))
}
}
+372
View File
@@ -0,0 +1,372 @@
//go:build integration
// Package invariants tests critical system invariants that must never be violated.
// These tests verify security boundaries, not functional behavior.
package invariants
import (
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// INVARIANT: Tenant A cannot access Tenant B's sessions.
func TestTenantIsolation_SessionStore(t *testing.T) {
db := testDB(t)
tenantA, _, tenantB, _ := seedTwoTenants(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
ss := pg.NewPGSessionStore(db)
sessionKey := "inv-sess-" + uuid.New().String()[:8]
// Create session in tenant A
ss.GetOrCreate(ctxA, sessionKey)
ss.AddMessage(ctxA, sessionKey, providers.Message{Role: "user", Content: "secret data"})
if err := ss.Save(ctxA, sessionKey); err != nil {
t.Fatalf("Save: %v", err)
}
// Verify tenant A can access
if got := ss.Get(ctxA, sessionKey); got == nil {
t.Fatal("tenant A should access own session")
}
// INVARIANT: Tenant B MUST NOT access tenant A's session
ss2 := pg.NewPGSessionStore(db) // new store instance to bypass cache
got := ss2.Get(ctxB, sessionKey)
assertAccessDenied(t, got, nil, "tenant B accessing tenant A's session")
// INVARIANT: Tenant B MUST NOT see tenant A's messages
hist := ss2.GetHistory(ctxB, sessionKey)
if len(hist) > 0 {
t.Errorf("INVARIANT VIOLATION: tenant B sees %d messages from tenant A", len(hist))
}
}
// INVARIANT: Tenant A cannot access Tenant B's agents.
func TestTenantIsolation_AgentStore(t *testing.T) {
db := testDB(t)
tenantA, agentA, tenantB, _ := seedTwoTenants(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
as := pg.NewPGAgentStore(db)
// Verify tenant A can access own agent
agent, err := as.GetByID(ctxA, agentA)
if err != nil || agent == nil {
t.Fatal("tenant A should access own agent")
}
// INVARIANT: Tenant B MUST NOT access tenant A's agent
got, err := as.GetByID(ctxB, agentA)
assertAccessDenied(t, got, err, "tenant B accessing tenant A's agent by ID")
// INVARIANT: Tenant B's agent list MUST NOT include tenant A's agents
agents, err := as.List(ctxB, "") // empty ownerID = list all
if err != nil {
t.Fatalf("List: %v", err)
}
for _, a := range agents {
if a.ID == agentA {
t.Errorf("INVARIANT VIOLATION: tenant B's list includes tenant A's agent")
}
}
}
// INVARIANT: Tenant A cannot access Tenant B's memory documents.
func TestTenantIsolation_MemoryStore(t *testing.T) {
db := testDB(t)
tenantA, agentA, tenantB, agentB := seedTwoTenants(t, db)
// Create memory document in tenant A via direct insert
docID := uuid.New()
_, err := db.Exec(
`INSERT INTO memory_documents (id, tenant_id, agent_id, user_id, path, content, hash, created_at, updated_at)
VALUES ($1, $2, $3, 'user-a', '/test/doc.md', 'secret memory content', 'abc123', NOW(), NOW())`,
docID, tenantA, agentA)
if err != nil {
t.Fatalf("create memory doc: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM memory_chunks WHERE document_id = $1", docID)
db.Exec("DELETE FROM memory_documents WHERE id = $1", docID)
})
// Verify tenant A's document exists
var count int
db.QueryRow("SELECT COUNT(*) FROM memory_documents WHERE id = $1 AND tenant_id = $2", docID, tenantA).Scan(&count)
if count != 1 {
t.Fatal("tenant A should have memory document")
}
// INVARIANT: Tenant B MUST NOT see tenant A's memory via tenant-scoped query
db.QueryRow("SELECT COUNT(*) FROM memory_documents WHERE id = $1 AND tenant_id = $2", docID, tenantB).Scan(&count)
if count != 0 {
t.Errorf("INVARIANT VIOLATION: tenant B can see tenant A's memory document")
}
// Test store-level isolation
ms := pg.NewPGMemoryStore(db, pg.PGMemoryConfig{})
ctxA := agentCtx(tenantA, agentA, "predefined")
ctxB := agentCtx(tenantB, agentB, "predefined")
// GetDocument uses agentID+userID+path, so we test with a unique path
contentA, err := ms.GetDocument(ctxA, agentA.String(), "user-a", "test-path")
// It's OK if this returns empty - the point is tenant B shouldn't see A's data
_ = contentA
_ = err
contentB, err := ms.GetDocument(ctxB, agentA.String(), "user-a", "test-path")
if contentB != "" {
t.Errorf("INVARIANT VIOLATION: tenant B got content from tenant A's agent: %s", contentB)
}
}
// INVARIANT: Tenant A cannot access Tenant B's teams.
func TestTenantIsolation_TeamStore(t *testing.T) {
db := testDB(t)
tenantA, agentA, tenantB, _ := seedTwoTenants(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
ts := pg.NewPGTeamStore(db)
// Create team in tenant A
teamID := uuid.New()
_, err := db.Exec(
`INSERT INTO agent_teams (id, tenant_id, name, lead_agent_id, status, settings, created_by)
VALUES ($1, $2, 'test-team', $3, 'active', '{"version": 2}', 'test')`,
teamID, tenantA, agentA)
if err != nil {
t.Fatalf("create team: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM agent_team_members WHERE team_id = $1", teamID)
db.Exec("DELETE FROM agent_teams WHERE id = $1", teamID)
})
// Verify tenant A can access
team, err := ts.GetTeam(ctxA, teamID)
if err != nil || team == nil {
t.Fatal("tenant A should access own team")
}
// INVARIANT: Tenant B MUST NOT access tenant A's team
got, err := ts.GetTeam(ctxB, teamID)
assertAccessDenied(t, got, err, "tenant B accessing tenant A's team")
// INVARIANT: Tenant B's team list MUST NOT include tenant A's teams
teams, err := ts.ListTeams(ctxB)
if err != nil {
t.Fatalf("List: %v", err)
}
for _, tm := range teams {
if tm.ID == teamID {
t.Errorf("INVARIANT VIOLATION: tenant B's list includes tenant A's team")
}
}
}
// INVARIANT: Tenant A cannot access Tenant B's skills.
func TestTenantIsolation_SkillStore(t *testing.T) {
db := testDB(t)
tenantA, _, tenantB, _ := seedTwoTenants(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
ss := pg.NewPGSkillStore(db, t.TempDir())
// Create skill in tenant A
desc := "test skill"
slug := "inv-skill-" + tenantA.String()[:8]
skillID, err := ss.CreateSkillManaged(ctxA, store.SkillCreateParams{
Name: slug, Slug: slug, Description: &desc, OwnerID: "test-owner",
Visibility: "private", Status: "active", Version: 1, FilePath: "/tmp/" + slug,
})
if err != nil {
t.Fatalf("CreateSkill: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM skill_agent_grants WHERE skill_id = $1", skillID)
db.Exec("DELETE FROM skill_user_grants WHERE skill_id = $1", skillID)
db.Exec("DELETE FROM skills WHERE id = $1", skillID)
})
// Verify tenant A can access
skill, ok := ss.GetSkillByID(ctxA, skillID)
if !ok {
t.Fatal("tenant A should access own skill")
}
_ = skill
// INVARIANT: Tenant B MUST NOT access tenant A's skill
gotSkill, gotOK := ss.GetSkillByID(ctxB, skillID)
if gotOK {
t.Errorf("INVARIANT VIOLATION: tenant B can access tenant A's skill: %s", gotSkill.Slug)
}
}
// INVARIANT: Tenant A cannot access Tenant B's cron jobs.
func TestTenantIsolation_CronStore(t *testing.T) {
db := testDB(t)
tenantA, agentA, tenantB, _ := seedTwoTenants(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
cs := pg.NewPGCronStore(db)
// Create cron job in tenant A using AddJob
job, err := cs.AddJob(ctxA, "test-cron",
store.CronSchedule{Kind: "cron", Expr: "0 * * * *"},
"test prompt", false, "", "", agentA.String(), "test-user")
if err != nil {
t.Fatalf("AddJob: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM cron_run_logs WHERE job_id = $1", job.ID)
db.Exec("DELETE FROM cron_jobs WHERE id = $1", job.ID)
})
// Verify tenant A can access
gotJob, ok := cs.GetJob(ctxA, job.ID) // ID is already a string
if !ok || gotJob == nil {
t.Fatal("tenant A should access own cron job")
}
// INVARIANT: Tenant B MUST NOT access tenant A's cron job
gotJobB, okB := cs.GetJob(ctxB, job.ID) // ID is already a string
if okB && gotJobB != nil {
t.Errorf("INVARIANT VIOLATION: tenant B can access tenant A's cron job")
}
}
// INVARIANT: Tenant A cannot access Tenant B's API keys.
func TestTenantIsolation_APIKeyStore(t *testing.T) {
db := testDB(t)
tenantA, _, tenantB, _ := seedTwoTenants(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
ks := pg.NewPGAPIKeyStore(db)
// Create API key in tenant A
keyID := uuid.New()
_, err := db.Exec(
`INSERT INTO api_keys (id, tenant_id, name, prefix, key_hash, scopes, created_by)
VALUES ($1, $2, 'test-key', 'gclw_inv', 'hash123', '{}', 'test-user')`,
keyID, tenantA)
if err != nil {
t.Fatalf("create api key: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM api_keys WHERE id = $1", keyID)
})
// Verify tenant A can access
keys, err := ks.List(ctxA, "") // empty ownerID = all keys
if err != nil {
t.Fatalf("List A: %v", err)
}
found := false
for _, k := range keys {
if k.ID == keyID {
found = true
break
}
}
if !found {
t.Fatal("tenant A should see own API key")
}
// INVARIANT: Tenant B's key list MUST NOT include tenant A's keys
keysB, err := ks.List(ctxB, "") // empty ownerID = all keys
if err != nil {
t.Fatalf("List B: %v", err)
}
for _, k := range keysB {
if k.ID == keyID {
t.Errorf("INVARIANT VIOLATION: tenant B's list includes tenant A's API key")
}
}
}
// INVARIANT: Tenant A cannot access Tenant B's MCP servers.
func TestTenantIsolation_MCPServerStore(t *testing.T) {
db := testDB(t)
tenantA, _, tenantB, _ := seedTwoTenants(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
ms := pg.NewPGMCPServerStore(db, "0123456789abcdef0123456789abcdef") // test encryption key
// Create MCP server in tenant A
serverID := uuid.New()
_, err := db.Exec(
`INSERT INTO mcp_servers (id, tenant_id, name, display_name, transport, enabled, created_by)
VALUES ($1, $2, 'test-mcp', 'Test MCP', 'stdio', true, 'test-user')`,
serverID, tenantA)
if err != nil {
t.Fatalf("create mcp server: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM mcp_user_credentials WHERE server_id = $1", serverID)
db.Exec("DELETE FROM mcp_access_requests WHERE server_id = $1", serverID)
db.Exec("DELETE FROM mcp_user_grants WHERE server_id = $1", serverID)
db.Exec("DELETE FROM mcp_agent_grants WHERE server_id = $1", serverID)
db.Exec("DELETE FROM mcp_servers WHERE id = $1", serverID)
})
// Verify tenant A can access
server, err := ms.GetServer(ctxA, serverID)
if err != nil || server == nil {
t.Fatal("tenant A should access own MCP server")
}
// INVARIANT: Tenant B MUST NOT access tenant A's MCP server
got, err := ms.GetServer(ctxB, serverID)
assertAccessDenied(t, got, err, "tenant B accessing tenant A's MCP server")
}
// INVARIANT: Tenant A cannot access Tenant B's vault documents.
func TestTenantIsolation_VaultStore(t *testing.T) {
db := testDB(t)
tenantA, _, tenantB, _ := seedTwoTenants(t, db)
// Create vault document in tenant A via direct SQL
docID := uuid.New()
_, err := db.Exec(
`INSERT INTO vault_documents (id, tenant_id, title, path, doc_type, content_hash, scope, created_at, updated_at)
VALUES ($1, $2, 'secret-doc', '/tmp/secret.md', 'document', 'abc123', 'personal', NOW(), NOW())`,
docID, tenantA)
if err != nil {
t.Fatalf("create vault doc: %v", err)
}
t.Cleanup(func() {
db.Exec("DELETE FROM vault_links WHERE tenant_id = $1", tenantA)
db.Exec("DELETE FROM vault_documents WHERE id = $1", docID)
})
// Verify tenant A can access via direct query
var count int
db.QueryRow("SELECT COUNT(*) FROM vault_documents WHERE id = $1 AND tenant_id = $2", docID, tenantA).Scan(&count)
if count != 1 {
t.Fatal("tenant A should have vault document")
}
// INVARIANT: Tenant B MUST NOT see tenant A's vault document via tenant-scoped query
db.QueryRow("SELECT COUNT(*) FROM vault_documents WHERE id = $1 AND tenant_id = $2", docID, tenantB).Scan(&count)
if count != 0 {
t.Errorf("INVARIANT VIOLATION: tenant B can see tenant A's vault document")
}
// INVARIANT: Any query scoped by tenant_id MUST exclude other tenants
db.QueryRow("SELECT COUNT(*) FROM vault_documents WHERE path = $1 AND tenant_id = $2", "/tmp/secret.md", tenantB).Scan(&count)
if count != 0 {
t.Errorf("INVARIANT VIOLATION: tenant B can see tenant A's vault document by path")
}
}
@@ -0,0 +1,86 @@
//go:build integration
package scenarios
import (
"strings"
"testing"
)
// SCENARIO: User has multi-turn conversation with context retention.
// Flow: User sends message → Agent responds → User follows up → Agent uses context
func TestScenario_MultiTurnConversation(t *testing.T) {
wsURL, _ := getTestServer(t)
client := connect(t, wsURL, "")
// Turn 1: Introduce a topic
resp1 := client.chat("My name is Alice and I like pizza.")
content1, _ := resp1["content"].(string)
if content1 == "" {
t.Error("expected non-empty response")
}
// Turn 2: Ask about previously mentioned topic
resp2 := client.chat("What is my name?")
content2, _ := resp2["content"].(string)
// Agent should remember the name from context
if !strings.Contains(strings.ToLower(content2), "alice") {
t.Logf("Response: %s", content2)
t.Log("SCENARIO NOTE: Agent may not have retained context - check memory settings")
}
}
// SCENARIO: Session reset clears conversation context.
// Flow: Chat → Reset → Chat → Verify no old context
func TestScenario_SessionReset(t *testing.T) {
wsURL, _ := getTestServer(t)
client := connect(t, wsURL, "")
// Establish context
client.chat("Remember the secret code: PHOENIX42")
// Reset session
client.send("sessions.reset", map[string]any{
"session_key": client.sessionKey,
})
// Check history is empty
history := client.getHistory()
if len(history) > 0 {
t.Errorf("expected empty history after reset, got %d messages", len(history))
}
}
// SCENARIO: Chat history persists across messages.
// Flow: Send 3 messages → Verify history contains all
func TestScenario_HistoryPersistence(t *testing.T) {
wsURL, _ := getTestServer(t)
client := connect(t, wsURL, "")
// Send multiple messages
messages := []string{
"Message one",
"Message two",
"Message three",
}
for _, msg := range messages {
client.chat(msg)
}
// Get history
history := client.getHistory()
// Should have at least user messages (may also have assistant responses)
userMsgCount := 0
for _, h := range history {
if role, _ := h["role"].(string); role == "user" {
userMsgCount++
}
}
if userMsgCount < len(messages) {
t.Errorf("expected at least %d user messages in history, got %d", len(messages), userMsgCount)
}
}
+199
View File
@@ -0,0 +1,199 @@
//go:build integration
// Package scenarios tests end-to-end user journeys.
// Scenario tests verify complete workflows, not individual API contracts.
package scenarios
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
var (
testWSURL string
testToken string
testUserID string
)
// getTestServer returns WS URL and token from environment.
func getTestServer(t *testing.T) (wsURL string, token string) {
t.Helper()
wsURL = os.Getenv("SCENARIO_TEST_WS_URL")
if wsURL == "" {
wsURL = os.Getenv("CONTRACT_TEST_WS_URL") // fallback
}
token = os.Getenv("SCENARIO_TEST_TOKEN")
if token == "" {
token = os.Getenv("CONTRACT_TEST_TOKEN")
}
if wsURL == "" {
t.Skip("SCENARIO_TEST_WS_URL not set - skipping scenario test")
}
if token == "" {
t.Skip("SCENARIO_TEST_TOKEN not set - skipping scenario test")
}
testWSURL = wsURL
testToken = token
testUserID = fmt.Sprintf("scenario-user-%d", time.Now().UnixNano())
return wsURL, token
}
// scenarioClient wraps a WebSocket connection for scenario testing.
type scenarioClient struct {
conn *websocket.Conn
nextID int
sessionKey string
t *testing.T
}
// connect creates a new scenario client with fresh session.
func connect(t *testing.T, wsURL string, userID string) *scenarioClient {
t.Helper()
if userID == "" {
userID = testUserID
}
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, _, err := dialer.DialContext(context.Background(), wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
c := &scenarioClient{conn: conn, nextID: 1, t: t}
t.Cleanup(func() { conn.Close() })
// Connect handshake
resp := c.send("connect", map[string]any{
"token": testToken,
"user_id": userID,
})
if sk, ok := resp["session_key"].(string); ok {
c.sessionKey = sk
}
return c
}
// reconnect creates a client that resumes an existing session.
func reconnect(t *testing.T, wsURL string, sessionKey string, userID string) *scenarioClient {
t.Helper()
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, _, err := dialer.DialContext(context.Background(), wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
c := &scenarioClient{conn: conn, nextID: 1, sessionKey: sessionKey, t: t}
t.Cleanup(func() { conn.Close() })
// Connect with existing session
c.send("connect", map[string]any{
"token": testToken,
"user_id": userID,
"session_key": sessionKey,
})
return c
}
// send sends a request and waits for response.
func (c *scenarioClient) send(method string, params any) map[string]any {
c.t.Helper()
reqID := fmt.Sprintf("req-%d", c.nextID)
c.nextID++
paramsJSON, _ := json.Marshal(params)
req := protocol.RequestFrame{
Type: protocol.FrameTypeRequest,
ID: reqID,
Method: method,
Params: paramsJSON,
}
if err := c.conn.WriteJSON(req); err != nil {
c.t.Fatalf("send %s: %v", method, err)
}
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
var resp protocol.ResponseFrame
if err := c.conn.ReadJSON(&resp); err != nil {
c.t.Fatalf("read %s response: %v", method, err)
}
if resp.ID != reqID {
c.t.Fatalf("response ID mismatch: got %s, want %s", resp.ID, reqID)
}
if !resp.OK {
c.t.Fatalf("%s failed: %s - %s", method, resp.Error.Code, resp.Error.Message)
}
payloadJSON, _ := json.Marshal(resp.Payload)
var result map[string]any
json.Unmarshal(payloadJSON, &result)
return result
}
// sendExpectError sends a request expecting an error response.
func (c *scenarioClient) sendExpectError(method string, params any) (code string, message string) {
c.t.Helper()
reqID := fmt.Sprintf("req-%d", c.nextID)
c.nextID++
paramsJSON, _ := json.Marshal(params)
req := protocol.RequestFrame{
Type: protocol.FrameTypeRequest,
ID: reqID,
Method: method,
Params: paramsJSON,
}
if err := c.conn.WriteJSON(req); err != nil {
c.t.Fatalf("send %s: %v", method, err)
}
c.conn.SetReadDeadline(time.Now().Add(10 * time.Second))
var resp protocol.ResponseFrame
if err := c.conn.ReadJSON(&resp); err != nil {
c.t.Fatalf("read %s response: %v", method, err)
}
if resp.OK {
c.t.Fatalf("expected error but got success for %s", method)
}
return resp.Error.Code, resp.Error.Message
}
// chat sends a chat message and returns the response.
func (c *scenarioClient) chat(message string) map[string]any {
return c.send("chat.send", map[string]any{
"message": message,
})
}
// getHistory retrieves chat history for current session.
func (c *scenarioClient) getHistory() []map[string]any {
resp := c.send("chat.history", map[string]any{})
messages, _ := resp["messages"].([]any)
result := make([]map[string]any, len(messages))
for i, m := range messages {
result[i], _ = m.(map[string]any)
}
return result
}
+76
View File
@@ -0,0 +1,76 @@
//go:build integration
package scenarios
import "testing"
// SCENARIO: User reconnects and resumes session with history.
// Flow: Connect → Chat → Disconnect → Reconnect with session_key → History preserved
func TestScenario_ReconnectWithHistory(t *testing.T) {
wsURL, _ := getTestServer(t)
userID := "reconnect-test-user"
// First connection
client1 := connect(t, wsURL, userID)
sessionKey := client1.sessionKey
if sessionKey == "" {
t.Skip("No session_key returned - reconnect test not applicable")
}
// Send a message
client1.chat("Remember this for reconnect test")
// Get history before disconnect
history1 := client1.getHistory()
msgCount1 := len(history1)
// Simulate disconnect (close connection)
client1.conn.Close()
// Reconnect with same session
client2 := reconnect(t, wsURL, sessionKey, userID)
// History should be preserved
history2 := client2.getHistory()
if len(history2) < msgCount1 {
t.Errorf("history not preserved: before=%d, after=%d", msgCount1, len(history2))
}
}
// SCENARIO: New session starts fresh without history.
// Flow: Connect with new user → No existing history
func TestScenario_FreshSession(t *testing.T) {
wsURL, _ := getTestServer(t)
userID := "fresh-session-test-user"
client := connect(t, wsURL, userID)
// Fresh session should have no history
history := client.getHistory()
if len(history) > 0 {
t.Logf("Fresh session has %d messages - may be resuming existing session", len(history))
}
}
// SCENARIO: Session preview returns summary without full history.
func TestScenario_SessionPreview(t *testing.T) {
wsURL, _ := getTestServer(t)
client := connect(t, wsURL, "")
// Send some messages
client.chat("First message for preview test")
client.chat("Second message for preview test")
// Get session preview
resp := client.send("sessions.preview", map[string]any{
"session_key": client.sessionKey,
})
// Should have some preview data
if resp["title"] == nil && resp["preview"] == nil && resp["message_count"] == nil {
t.Log("sessions.preview returned no standard fields - check implementation")
}
}
+128
View File
@@ -0,0 +1,128 @@
//go:build integration
package scenarios
import "testing"
// SCENARIO: Task happy path - create, claim, complete.
// Flow: Create task → Assign → Update progress → Complete
func TestScenario_TaskHappyPath(t *testing.T) {
wsURL, _ := getTestServer(t)
client := connect(t, wsURL, "")
// First check if teams exist
teamsResp := client.send("teams.list", map[string]any{})
teams, ok := teamsResp["teams"].([]any)
if !ok || len(teams) == 0 {
t.Skip("No teams available - skipping task lifecycle test")
}
team := teams[0].(map[string]any)
teamID, _ := team["id"].(string)
// Create a task
createResp := client.send("teams.tasks.create", map[string]any{
"team_id": teamID,
"title": "Test task for scenario",
"description": "Created by scenario test",
})
taskID, ok := createResp["id"].(string)
if !ok {
t.Fatalf("task creation failed, no id returned")
}
// Get task to verify
getResp := client.send("teams.tasks.get", map[string]any{
"team_id": teamID,
"task_id": taskID,
})
status, _ := getResp["status"].(string)
if status == "" {
t.Error("task should have a status")
}
// Clean up - delete task
client.send("teams.tasks.delete", map[string]any{
"team_id": teamID,
"task_id": taskID,
})
}
// SCENARIO: Task list returns tasks for team.
func TestScenario_TaskList(t *testing.T) {
wsURL, _ := getTestServer(t)
client := connect(t, wsURL, "")
// Check teams
teamsResp := client.send("teams.list", map[string]any{})
teams, ok := teamsResp["teams"].([]any)
if !ok || len(teams) == 0 {
t.Skip("No teams available - skipping task list test")
}
team := teams[0].(map[string]any)
teamID, _ := team["id"].(string)
// List tasks
listResp := client.send("teams.tasks.list", map[string]any{
"team_id": teamID,
})
// Should return array (may be empty)
if _, ok := listResp["tasks"].([]any); !ok {
t.Error("tasks field should be an array")
}
}
// SCENARIO: Task approval workflow.
// Flow: Create task → Approve → Verify status change
func TestScenario_TaskApproval(t *testing.T) {
wsURL, _ := getTestServer(t)
client := connect(t, wsURL, "")
// Check teams
teamsResp := client.send("teams.list", map[string]any{})
teams, ok := teamsResp["teams"].([]any)
if !ok || len(teams) == 0 {
t.Skip("No teams available - skipping task approval test")
}
team := teams[0].(map[string]any)
teamID, _ := team["id"].(string)
// Create task
createResp := client.send("teams.tasks.create", map[string]any{
"team_id": teamID,
"title": "Task for approval test",
"description": "Will be approved",
})
taskID, ok := createResp["id"].(string)
if !ok {
t.Fatalf("task creation failed")
}
// Approve task
client.send("teams.tasks.approve", map[string]any{
"team_id": teamID,
"task_id": taskID,
"comment": "Approved by scenario test",
})
// Verify status changed
getResp := client.send("teams.tasks.get", map[string]any{
"team_id": teamID,
"task_id": taskID,
})
status, _ := getResp["status"].(string)
t.Logf("Task status after approval: %s", status)
// Clean up
client.send("teams.tasks.delete", map[string]any{
"team_id": teamID,
"task_id": taskID,
})
}
+1
View File
@@ -27,6 +27,7 @@
"date-fns": "^4.1.0",
"framer-motion": "^12.38.0",
"graphology": "^0.26.0",
"graphology-communities-louvain": "^2.0.2",
"graphology-layout": "^0.6.1",
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
+27
View File
@@ -47,6 +47,9 @@ importers:
graphology:
specifier: ^0.26.0
version: 0.26.0(graphology-types@0.24.8)
graphology-communities-louvain:
specifier: ^2.0.2
version: 2.0.2(graphology-types@0.24.8)
graphology-layout:
specifier: ^0.6.1
version: 0.6.1(graphology-types@0.24.8)
@@ -2197,6 +2200,16 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphology-communities-louvain@2.0.2:
resolution: {integrity: sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==}
peerDependencies:
graphology-types: '>=0.19.0'
graphology-indices@0.17.0:
resolution: {integrity: sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==}
peerDependencies:
graphology-types: '>=0.20.0'
graphology-layout-forceatlas2@0.10.1:
resolution: {integrity: sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==}
peerDependencies:
@@ -5475,6 +5488,20 @@ snapshots:
graceful-fs@4.2.11: {}
graphology-communities-louvain@2.0.2(graphology-types@0.24.8):
dependencies:
graphology-indices: 0.17.0(graphology-types@0.24.8)
graphology-types: 0.24.8
graphology-utils: 2.5.2(graphology-types@0.24.8)
mnemonist: 0.39.8
pandemonium: 2.4.1
graphology-indices@0.17.0(graphology-types@0.24.8):
dependencies:
graphology-types: 0.24.8
graphology-utils: 2.5.2(graphology-types@0.24.8)
mnemonist: 0.39.8
graphology-layout-forceatlas2@0.10.1(graphology-types@0.24.8):
dependencies:
graphology-types: 0.24.8
+35 -1
View File
@@ -1,5 +1,6 @@
import Graph from "graphology";
import type { KGEntity, KGRelation } from "@/types/knowledge-graph";
import type { KGGraphNode, KGGraphEdge } from "@/types/graph-dto";
import { getNodeSize, truncateMiddle } from "@/components/graph/graph-utils";
// Solid colors per entity type
@@ -21,6 +22,39 @@ export function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]):
return deg;
}
/** Build KG graph from compact DTOs. */
export function buildKGGraphFromDTO(nodes: KGGraphNode[], edges: KGGraphEdge[]): Graph {
const graph = new Graph({ multi: false, type: "directed" });
const nodeIds = new Set(nodes.map((n) => n.id));
// Pre-compute degree from edges (compact DTO doesn't include it)
const deg = new Map<string, number>();
for (const e of edges) {
if (nodeIds.has(e.src)) deg.set(e.src, (deg.get(e.src) ?? 0) + 1);
if (nodeIds.has(e.tgt)) deg.set(e.tgt, (deg.get(e.tgt) ?? 0) + 1);
}
for (const n of nodes) {
graph.addNode(n.id, {
label: truncateMiddle(n.n, 28),
x: 0, y: 0,
size: getNodeSize(deg.get(n.id) ?? 0, nodes.length),
color: KG_TYPE_COLORS[n.t] ?? KG_DEFAULT_COLOR,
entityType: n.t,
});
}
for (const e of edges) {
if (nodeIds.has(e.src) && nodeIds.has(e.tgt) && !graph.hasEdge(e.src, e.tgt)) {
graph.addEdgeWithKey(e.id, e.src, e.tgt, {
label: e.type.replace(/_/g, " "), type: "curvedArrow",
});
}
}
return graph;
}
/** Build a Graphology graph from KG entities and relations. */
export function buildKGGraph(entities: KGEntity[], allRelations: KGRelation[]): Graph {
const graph = new Graph({ multi: false, type: "directed" });
@@ -35,7 +69,7 @@ export function buildKGGraph(entities: KGEntity[], allRelations: KGRelation[]):
label: truncateMiddle(e.name, 28),
x: 0,
y: 0,
size: getNodeSize(degree),
size: getNodeSize(degree, entities.length),
color: KG_TYPE_COLORS[e.entity_type] ?? KG_DEFAULT_COLOR,
entityType: e.entity_type,
});
+29 -1
View File
@@ -1,5 +1,6 @@
import Graph from "graphology";
import type { VaultDocument, VaultLink } from "@/types/vault";
import type { VaultGraphNode, VaultGraphEdge } from "@/types/graph-dto";
import { getNodeSize, truncateMiddle } from "@/components/graph/graph-utils";
// Colors per vault document type — matches DOC_TYPE_ICONS in vault-tree.tsx
@@ -49,6 +50,33 @@ export function limitVaultDocsByDegree(
return [...docs].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, nodeLimit);
}
/** Build graph from lightweight DTOs (degree pre-computed, no client loop). */
export function buildVaultGraphFromDTO(nodes: VaultGraphNode[], edges: VaultGraphEdge[]): Graph {
const graph = new Graph({ multi: false, type: "directed" });
const nodeIds = new Set(nodes.map((n) => n.id));
for (const n of nodes) {
graph.addNode(n.id, {
label: truncateMiddle(n.t || n.p.split("/").pop() || n.id.slice(0, 8), 28),
x: 0, y: 0,
size: getNodeSize(n.deg, nodes.length),
color: VAULT_TYPE_COLORS_LIGHT[n.dt] ?? DEFAULT_COLOR_LIGHT,
docType: n.dt,
});
}
for (const e of edges) {
if (nodeIds.has(e.from) && nodeIds.has(e.to) && !graph.hasEdge(e.from, e.to)) {
graph.addEdgeWithKey(e.id, e.from, e.to, {
label: e.type, type: "curvedArrow",
color: "#a1a1aa", size: 0.4,
});
}
}
return graph;
}
/** Build a Graphology graph from vault documents and their links. */
export function buildVaultGraph(
documents: VaultDocument[],
@@ -74,7 +102,7 @@ export function buildVaultGraph(
label: truncateMiddle(rawLabel, 28),
x: 0,
y: 0,
size: getNodeSize(degree),
size: getNodeSize(degree, documents.length),
// Color set by nodeReducer based on theme; use light as initial fallback
color: VAULT_TYPE_COLORS_LIGHT[doc.doc_type] ?? DEFAULT_COLOR_LIGHT,
docType: doc.doc_type,
+9 -2
View File
@@ -21,6 +21,7 @@ export class WsClient {
private reconnectAttempts = 0;
private authenticated = false;
private intentionalClose = false;
private pairingInProgress = false;
private connectGeneration = 0;
/** Server-assigned role from connect response. */
@@ -90,6 +91,7 @@ export class WsClient {
disconnect(): void {
this.intentionalClose = true;
this.pairingInProgress = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
@@ -221,10 +223,14 @@ export class WsClient {
// Browser pairing: server requires approval
if (res?.status === "pending_pairing" && res.pairing_code && res.sender_id) {
this.onPairingRequired?.(res.pairing_code, res.sender_id);
if (!this.pairingInProgress) {
this.pairingInProgress = true;
this.onPairingRequired?.(res.pairing_code, res.sender_id);
}
// Keep connection alive for polling browser.pairing.status
return;
}
this.pairingInProgress = false;
// Server accepted connection but assigned viewer role → token is invalid
if (this.getToken() && res?.role === "viewer") {
@@ -251,6 +257,7 @@ export class WsClient {
this.onAuthFailure?.();
return;
}
this.intentionalClose = true;
this.ws?.close();
}
}
@@ -329,11 +336,11 @@ export class WsClient {
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectAttempts++;
const delay = Math.min(
this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay,
);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
+88 -7
View File
@@ -1,7 +1,38 @@
/** Degree-based node sizing: 4-12px range. Logarithmic scale for dense hubs. */
export function getNodeSize(degree: number): number {
if (degree === 0) return 4;
return 4 + Math.min(Math.log2(degree + 1) * 2, 8);
import louvain from "graphology-communities-louvain";
import type Graph from "graphology";
/** 16-color palette for community detection — high contrast, distinct hues. */
const COMMUNITY_PALETTE = [
"#e6194B", "#3cb44b", "#4363d8", "#f58231",
"#42d4f4", "#f032e6", "#bfef45", "#fabed4",
"#469990", "#dcbeff", "#9A6324", "#ffe119",
"#800000", "#aaffc3", "#808000", "#000075",
] as const;
/** Run Louvain community detection and assign `community` + `color` attrs.
* Call AFTER graph is built (nodes + edges added). */
export function assignCommunityColors(graph: Graph): void {
if (graph.order === 0) return;
const communities = louvain(graph, { resolution: 1.0 });
graph.forEachNode((node) => {
const c = communities[node] ?? 0;
graph.setNodeAttribute(node, "community", c);
graph.setNodeAttribute(node, "color", COMMUNITY_PALETTE[c % COMMUNITY_PALETTE.length]);
});
}
/** Get community palette color by index (for legend). */
export function getCommunityColor(idx: number): string {
return COMMUNITY_PALETTE[idx % COMMUNITY_PALETTE.length]!;
}
/** Degree-based node sizing, scaled by graph density.
* Larger graphs → smaller nodes to avoid overlap. */
export function getNodeSize(degree: number, nodeCount = 200): number {
const s = nodeCount < 100 ? 1.0 : nodeCount < 500 ? 0.6 : nodeCount < 2000 ? 0.4 : 0.3;
const base = 3 * s;
if (degree === 0) return base;
return base + Math.min(Math.log2(degree + 1) * 1.2 * s, 5 * s);
}
/**
@@ -15,14 +46,64 @@ export function truncateMiddle(str: string, maxLength = 28): string {
return `${str.slice(0, keepStart)}…${str.slice(-keepEnd)}`;
}
/** Adaptive FA2 worker config based on graph size and orphan ratio.
* Key fix: NO strongGravityMode, NO linLogMode — eliminates ring artifacts. */
export function getFA2WorkerSettings(nodeCount: number, orphanRatio: number) {
// Scale up repulsion for larger graphs to prevent dense blobs
const baseScaling = nodeCount < 200 ? 5 : nodeCount < 1000 ? 10 : 20;
return {
settings: {
linLogMode: false,
outboundAttractionDistribution: true,
gravity: 0.5 + orphanRatio * 2.0,
scalingRatio: baseScaling + (1 - orphanRatio) * 5,
strongGravityMode: false,
slowDown: 5,
barnesHutOptimize: nodeCount > 50,
barnesHutTheta: 0.5,
edgeWeightInfluence: 0,
adjustSizes: true,
},
durationMs: nodeCount < 200 ? 2000 :
nodeCount < 1000 ? 3500 :
nodeCount < 5000 ? 5000 : 8000,
};
}
/** Semantic zoom tiers — cameraRatio thresholds (lower = more zoomed in).
* Zoom % ≈ 100/ratio. ratio 1.0=100%, 0.5=200%, 0.25=400%. */
export const ZOOM_TIERS = {
/** Fully zoomed out: nodes only, no edges, no labels */
FAR: 0.6,
/** Intermediate: hub edges only */
MID: 0.3,
/** Close: most edges visible */
NEAR: 0.12,
} as const;
/** Minimum degree for nodes to remain visible per tier */
export const TIER_MIN_DEGREE = {
FAR: 2,
MID: 1,
NEAR: 0,
} as const;
/** Minimum endpoint degree for edges per tier.
* Edges hidden when EITHER endpoint below threshold. */
export const TIER_EDGE_DEGREE = {
FAR: Infinity, // no edges at default view
MID: 6, // only hub-to-hub
NEAR: 2, // most edges
} as const;
/** Sigma settings constants for consistent look across both graph views */
export const SIGMA_SETTINGS = {
/** Labels hidden for nodes smaller than this on screen */
labelRenderedSizeThreshold: 8,
labelRenderedSizeThreshold: 14,
/** Lower = fewer labels shown (avoids overlap) */
labelDensity: 0.12,
labelDensity: 0.04,
/** Grid cell size for label collision avoidance */
labelGridCellSize: 120,
labelGridCellSize: 200,
/** Default edge color (overridden per-theme) */
defaultEdgeColor: "#334155",
/** Minimum camera ratio (most zoomed in) */
@@ -2,11 +2,12 @@ import { useRef, useEffect, useCallback, useState } from "react";
import Sigma from "sigma";
import { EdgeCurvedArrowProgram } from "@sigma/edge-curve";
import { EdgeArrowProgram } from "sigma/rendering";
import FA2Layout from "graphology-layout-forceatlas2/worker";
import forceAtlas2 from "graphology-layout-forceatlas2";
import noverlap from "graphology-layout-noverlap";
import type Graph from "graphology";
import { useUiStore } from "@/stores/use-ui-store";
import { SIGMA_SETTINGS } from "./graph-utils";
import { SIGMA_SETTINGS, getFA2WorkerSettings, ZOOM_TIERS, TIER_MIN_DEGREE, TIER_EDGE_DEGREE } from "./graph-utils";
import { getVaultNodeColor } from "@/adapters/vault-graph-adapter";
export type EdgeType = "curvedArrow" | "arrow";
@@ -19,28 +20,71 @@ export interface SigmaGraphContainerProps {
onNodeDoubleClick?: (nodeId: string) => void;
/** Called when Sigma instance is ready (or destroyed) */
onSigmaReady?: (sigma: Sigma | null) => void;
/** Called when FA2 layout starts/stops */
onLayoutStateChange?: (running: boolean) => void;
/** Compact mode for embedded mini-graphs (no layout, smaller labels) */
compact?: boolean;
/** Node types to hide (from filter component) */
hiddenTypes?: Set<string>;
}
/** Theme-aware colors - uses DOM class check for reliability */
/** Theme-aware colors — derived from store value, not DOM class.
* DOM class lags behind store update, causing stale reads on theme toggle. */
function useThemeColors() {
// Subscribe to store to trigger re-render when theme changes
useUiStore((s) => s.theme);
// Check DOM class for actual dark state (handles system mode correctly)
const isDark = typeof document !== "undefined" && document.documentElement.classList.contains("dark");
const theme = useUiStore((s) => s.theme);
const systemDark = typeof window !== "undefined"
&& window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDark = theme === "dark" || (theme === "system" && systemDark);
return {
isDark,
labelColor: isDark ? "#e2e8f0" : "#1e293b",
// Base edge color — subtle gray
edgeColor: isDark ? "#71717a" : "#d4d4d8", // zinc-500 dark, zinc-300 light
// Highlight for active neighborhood edges
edgeColor: isDark ? "#71717a" : "#d4d4d8",
highlightEdgeColor: isDark ? "#d4d4d8" : "#3f3f46",
};
}
/** Reposition orphan nodes (degree=0) into a compact ring around the connected cluster.
* Prevents FA2 from scattering them far away and forcing camera to zoom out. */
function compactOrphans(graph: Graph) {
const connected: { x: number; y: number }[] = [];
const orphans: string[] = [];
graph.forEachNode((node) => {
if (graph.degree(node) === 0) {
orphans.push(node);
} else {
connected.push({
x: graph.getNodeAttribute(node, "x") as number,
y: graph.getNodeAttribute(node, "y") as number,
});
}
});
if (orphans.length === 0 || connected.length === 0) return;
// Compute bounding circle of connected nodes
let cx = 0, cy = 0;
for (const p of connected) { cx += p.x; cy += p.y; }
cx /= connected.length;
cy /= connected.length;
let maxR = 0;
for (const p of connected) {
const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2);
if (d > maxR) maxR = d;
}
// Place orphans in a ring just outside the cluster (1.1-1.4x radius)
const innerR = maxR * 1.1;
const outerR = maxR * 1.4;
for (let i = 0; i < orphans.length; i++) {
const angle = (i / orphans.length) * Math.PI * 2 + Math.random() * 0.3;
const r = innerR + Math.random() * (outerR - innerR);
graph.setNodeAttribute(orphans[i], "x", cx + Math.cos(angle) * r);
graph.setNodeAttribute(orphans[i], "y", cy + Math.sin(angle) * r);
}
}
export function SigmaGraphContainer({
graph,
edgeType = "arrow",
@@ -48,11 +92,13 @@ export function SigmaGraphContainer({
onNodeSelect,
onNodeDoubleClick,
onSigmaReady,
onLayoutStateChange,
compact = false,
hiddenTypes,
}: SigmaGraphContainerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const internalSigmaRef = useRef<Sigma | null>(null);
const layoutRef = useRef<FA2Layout | null>(null);
// Incremented when sigma instance changes — used to trigger event handler registration.
const [sigmaVersion, setSigmaVersion] = useState(0);
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
@@ -60,6 +106,8 @@ export function SigmaGraphContainer({
const [pulsePhase, setPulsePhase] = useState(0);
// Camera ratio for edge density control (lower = more zoomed out)
const [cameraRatio, setCameraRatio] = useState(1);
// Layout running state
const [layoutRunning, setLayoutRunning] = useState(false);
const { isDark, labelColor, edgeColor, highlightEdgeColor } = useThemeColors();
const setSigmaRef = useCallback(
@@ -71,15 +119,30 @@ export function SigmaGraphContainer({
[onSigmaReady],
);
// --- Initialize Sigma with PRE-COMPUTED FA2 layout (no shake, no worker) ---
// Propagate layout state to parent
useEffect(() => {
onLayoutStateChange?.(layoutRunning);
}, [layoutRunning, onLayoutStateChange]);
/** Stop running layout worker — no post-processing to avoid visible flash.
* Noverlap + compactOrphans already ran after the sync pass (phase 1).
* The worker only does subtle refinement, so positions are already good. */
const stopLayout = useCallback((_sigma: Sigma, _orphanRatio: number) => {
const layout = layoutRef.current;
if (layout?.isRunning()) layout.stop();
layoutRef.current = null;
setLayoutRunning(false);
}, []);
// --- Initialize Sigma + FA2 worker layout (non-blocking) ---
useEffect(() => {
if (!containerRef.current || graph.order === 0) return;
// Unified adaptive FA2 layout — no density branching.
// Params scale with orphan ratio (disconnected nodes / total nodes).
// Random disc init — scale spread with canvas diagonal for responsive layout
if (graph.order > 1) {
// Random disc init — uniform area distribution for all graph types.
const spread = Math.sqrt(graph.order) * 20;
const rect = containerRef.current.getBoundingClientRect();
const canvasDiag = Math.sqrt(rect.width ** 2 + rect.height ** 2);
const spread = Math.sqrt(graph.order) * (canvasDiag / 25);
const nodes = graph.nodes();
for (let i = 0; i < nodes.length; i++) {
const angle = Math.random() * Math.PI * 2;
@@ -87,42 +150,6 @@ export function SigmaGraphContainer({
graph.setNodeAttribute(nodes[i], "x", Math.cos(angle) * r);
graph.setNodeAttribute(nodes[i], "y", Math.sin(angle) * r);
}
// Orphan ratio drives adaptive parameters:
// High orphan → stronger gravity (pull orphans to center), tighter layout
// Low orphan → link forces dominate, more spread for cluster readability
let orphanCount = 0;
for (const node of nodes) {
if (graph.degree(node) === 0) orphanCount++;
}
const orphanRatio = orphanCount / graph.order;
const gravity = 0.5 + orphanRatio * 2.0;
const scalingRatio = 4.0 - orphanRatio * 2.0;
const iterations = graph.order < 100 ? 500 : graph.order < 500 ? 300 : 200;
forceAtlas2.assign(graph, {
iterations,
settings: {
linLogMode: true,
outboundAttractionDistribution: true,
gravity,
scalingRatio,
adjustSizes: true,
strongGravityMode: true,
slowDown: 8,
barnesHutOptimize: graph.order > 100,
edgeWeightInfluence: 0,
},
});
// Noverlap only for connected-heavy graphs (prevents overlap in dense clusters).
// Skip for high-orphan graphs to avoid grid artifacts.
if (orphanRatio < 0.3) {
noverlap.assign(graph, {
maxIterations: 30,
settings: { margin: 2, ratio: 1.02, speed: 3, gridSize: 20 },
});
}
}
const edgePrograms: Record<string, typeof EdgeArrowProgram> = {
@@ -130,6 +157,7 @@ export function SigmaGraphContainer({
curvedArrow: EdgeCurvedArrowProgram as unknown as typeof EdgeArrowProgram,
};
// Create Sigma — shows nodes at random positions immediately
const sigma = new Sigma(graph, containerRef.current, {
allowInvalidContainer: true,
renderLabels: true,
@@ -145,28 +173,64 @@ export function SigmaGraphContainer({
labelFont: "Inter, system-ui, sans-serif",
labelSize: compact ? 10 : 12,
zoomingRatio: 1.3,
// Enable z-index sorting — required for edge/node `zIndex` attr to affect render order
zIndex: true,
// Disable default hover label box (white background) — we use forceLabel instead
defaultDrawNodeHover: () => undefined,
});
setSigmaRef(sigma);
// Fit viewport to graph on next frame (after initial render)
requestAnimationFrame(() => {
sigma.getCamera().animatedReset({ duration: 300 });
});
// FA2 layout: sync rough pass first, then optional worker refinement.
// This gives a reasonable layout immediately — no random-scatter animation.
let timer: ReturnType<typeof setTimeout> | undefined;
if (graph.order > 1) {
let orphanCount = 0;
const nodes = graph.nodes();
for (const node of nodes) {
if (graph.degree(node) === 0) orphanCount++;
}
const orphanRatio = orphanCount / graph.order;
const { settings } = getFA2WorkerSettings(graph.order, orphanRatio);
// Phase 1: quick sync pass — rough but instant layout
const syncIters = graph.order < 200 ? 300 : graph.order < 1000 ? 100 : 50;
forceAtlas2.assign(graph, { iterations: syncIters, settings });
compactOrphans(graph);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (compact || prefersReducedMotion) {
// Compact/reduced-motion: sync-only, no worker
if (orphanRatio < 0.3) {
noverlap.assign(graph, {
maxIterations: 30,
settings: { margin: 2, ratio: 1.02, speed: 3, gridSize: 20 },
});
}
} else {
// Phase 2: worker refinement — subtle position tweaks, short duration
const refineDuration = graph.order < 500 ? 1500 : graph.order < 2000 ? 2500 : 4000;
const layout = new FA2Layout(graph, { settings });
layoutRef.current = layout;
setLayoutRunning(true);
layout.start();
timer = setTimeout(() => {
stopLayout(sigma, orphanRatio);
}, refineDuration);
}
}
return () => {
if (timer) clearTimeout(timer);
if (layoutRef.current?.isRunning()) layoutRef.current.stop();
layoutRef.current = null;
setLayoutRunning(false);
sigma.kill();
// Only clear external ref if it still points to THIS sigma (concurrent mode safety)
if (internalSigmaRef.current === sigma) {
internalSigmaRef.current = null;
onSigmaReady?.(null);
}
};
}, [graph, edgeType, compact]);
}, [graph, edgeType, compact]);
// --- Update theme colors without re-init ---
useEffect(() => {
@@ -216,7 +280,6 @@ export function SigmaGraphContainer({
(attrs.docType || attrs.entityType || "other") as string;
sigma.setSetting("nodeReducer", (node, data) => {
// Apply theme-aware color based on docType
const docType = getNodeType(data);
const themedColor = getVaultNodeColor(docType, isDark);
const themedData = { ...data, color: themedColor };
@@ -226,21 +289,30 @@ export function SigmaGraphContainer({
return { ...themedData, hidden: true };
}
// Active node neighborhood always visible regardless of zoom tier
const activeNode = selectedNodeId || hoveredNode;
if (!activeNode) return themedData;
const hood = neighborhoodRef.current;
if (!hood) return themedData;
if (node === activeNode) {
// Active node: bring to top, show label
return { ...themedData, zIndex: 3, forceLabel: true };
if (activeNode && hood) {
if (node === activeNode) {
return { ...themedData, zIndex: 3, forceLabel: true };
}
if (hood.nodes.has(node)) {
return { ...themedData, zIndex: 2, forceLabel: true };
}
}
if (hood.nodes.has(node)) {
// Within 2-hop neighborhood: show label, slightly raised z-index
return { ...themedData, zIndex: 2, forceLabel: true };
// Semantic zoom: hide low-degree nodes when zoomed out
const degree = graph.degree(node);
if (cameraRatio > ZOOM_TIERS.FAR && degree < TIER_MIN_DEGREE.FAR) {
return { ...themedData, hidden: true };
}
// Outside neighborhood: keep color/size, no dimming — just lower z-index
if (cameraRatio > ZOOM_TIERS.MID && degree < TIER_MIN_DEGREE.MID) {
return { ...themedData, hidden: true };
}
if (activeNode && !hood) return themedData;
if (!activeNode) return themedData;
return { ...themedData, zIndex: 0 };
});
@@ -256,40 +328,48 @@ export function SigmaGraphContainer({
const activeNode = selectedNodeId || hoveredNode;
// Zoom-based edge density: when zoomed out, hide edges from low-degree nodes
// cameraRatio < 1 means zoomed out; < 0.5 means very zoomed out
if (!activeNode && cameraRatio < 0.6) {
const srcDegree = graph.degree(graph.source(edge));
const tgtDegree = graph.degree(graph.target(edge));
// Progressive threshold: more zoomed out → hide more edges
const minDegree = cameraRatio < 0.3 ? 8 : cameraRatio < 0.45 ? 5 : 3;
if (srcDegree < minDegree && tgtDegree < minDegree) {
// Active node neighborhood: always show related edges
if (activeNode) {
const hood = neighborhoodRef.current;
if (!hood) return data;
if (hood.edges.has(edge)) {
const alpha = Math.round(200 + Math.sin(pulsePhase * Math.PI * 2) * 40);
const alphaHex = Math.max(0, Math.min(255, alpha)).toString(16).padStart(2, "0");
return { ...data, color: `${highlightEdgeColor}${alphaHex}`, size: 1, zIndex: 2 };
}
return { ...data, hidden: true };
}
// Semantic zoom edge culling (no active node).
// Default/overview: NO edges. Edges appear progressively as user zooms in.
const srcDegree = graph.degree(graph.source(edge));
const tgtDegree = graph.degree(graph.target(edge));
if (cameraRatio > ZOOM_TIERS.FAR) {
// zoom ≤~170%: hide all edges
return { ...data, hidden: true };
}
if (cameraRatio > ZOOM_TIERS.MID) {
// zoom ~170-330%: only hub-to-hub
if (srcDegree < TIER_EDGE_DEGREE.MID || tgtDegree < TIER_EDGE_DEGREE.MID) {
return { ...data, hidden: true };
}
return { ...data, color: edgeColor, size: 0.3 };
}
// No active node: use default edge color from graph attributes
if (!activeNode) {
return { ...data, color: edgeColor, size: 0.5 };
if (cameraRatio > ZOOM_TIERS.NEAR) {
// zoom ~330-830%: most edges
if (srcDegree < TIER_EDGE_DEGREE.NEAR && tgtDegree < TIER_EDGE_DEGREE.NEAR) {
return { ...data, hidden: true };
}
return { ...data, color: edgeColor, size: 0.4 };
}
const hood = neighborhoodRef.current;
if (!hood) return data;
if (hood.edges.has(edge)) {
// Edges within neighborhood: thin + subtle OPACITY pulse (not size).
const alpha = Math.round(200 + Math.sin(pulsePhase * Math.PI * 2) * 40);
const alphaHex = Math.max(0, Math.min(255, alpha)).toString(16).padStart(2, "0");
const pulsedColor = `${highlightEdgeColor}${alphaHex}`;
return { ...data, color: pulsedColor, size: 1, zIndex: 2 };
}
// Non-related edges: HIDE entirely so active edges are clearly visible
return { ...data, hidden: true };
// zoom >830%: show all edges
return { ...data, color: edgeColor, size: 0.5 };
});
sigma.refresh();
// sigmaVersion ensures this runs after sigma is created
}, [selectedNodeId, hoveredNode, graph, highlightEdgeColor, edgeColor, hiddenTypes, isDark, cameraRatio, sigmaVersion]);
}, [selectedNodeId, hoveredNode, graph, highlightEdgeColor, edgeColor, hiddenTypes, cameraRatio, sigmaVersion, isDark]);
// --- Pulse animation for highlighted edges (only runs when a node is active) ---
// Respects prefers-reduced-motion — skips animation entirely for accessibility
@@ -357,10 +437,32 @@ export function SigmaGraphContainer({
onNodeSelectRef.current?.(null);
};
// Track camera ratio for edge density control
// rAF-debounced camera handler with adaptive label density
let pendingRatio = 1;
let rafId = 0;
const handleCameraUpdate = () => {
const ratio = sigma.getCamera().ratio;
setCameraRatio(ratio);
pendingRatio = sigma.getCamera().ratio;
if (!rafId) {
rafId = requestAnimationFrame(() => {
rafId = 0;
setCameraRatio(pendingRatio);
// Adaptive label density per zoom tier — fewer labels when zoomed out
if (pendingRatio > ZOOM_TIERS.FAR) {
// Default view: only biggest hubs get labels
sigma.setSetting("labelRenderedSizeThreshold", 18);
sigma.setSetting("labelDensity", 0.02);
} else if (pendingRatio > ZOOM_TIERS.MID) {
sigma.setSetting("labelRenderedSizeThreshold", 12);
sigma.setSetting("labelDensity", 0.06);
} else if (pendingRatio > ZOOM_TIERS.NEAR) {
sigma.setSetting("labelRenderedSizeThreshold", 8);
sigma.setSetting("labelDensity", 0.12);
} else {
sigma.setSetting("labelRenderedSizeThreshold", 5);
sigma.setSetting("labelDensity", 0.2);
}
});
}
};
sigma.on("enterNode", handleEnterNode);
@@ -371,6 +473,7 @@ export function SigmaGraphContainer({
sigma.getCamera().on("updated", handleCameraUpdate);
return () => {
if (rafId) cancelAnimationFrame(rafId);
sigma.off("enterNode", handleEnterNode);
sigma.off("leaveNode", handleLeaveNode);
sigma.off("clickNode", handleClickNode);
@@ -384,6 +487,17 @@ export function SigmaGraphContainer({
// Camera only animates for explicit user actions (search, fit-to-view, keyboard F).
// This matches the old force-graph behavior where clicking just highlights.
const handleStopLayout = useCallback(() => {
const sigma = internalSigmaRef.current;
if (!sigma) return;
let orphanCount = 0;
const nodes = graph.nodes();
for (const node of nodes) {
if (graph.degree(node) === 0) orphanCount++;
}
stopLayout(sigma, orphanCount / (graph.order || 1));
}, [graph, stopLayout]);
// No-data state
if (graph.order === 0) {
return (
@@ -394,10 +508,22 @@ export function SigmaGraphContainer({
}
return (
<div
ref={containerRef}
className="h-full w-full"
style={{ minHeight: compact ? 200 : 300 }}
/>
<div className="relative h-full w-full" style={{ minHeight: compact ? 200 : 300 }}>
<div ref={containerRef} className="h-full w-full" />
{layoutRunning && !compact && (
<div className="absolute top-2 left-2 z-10 flex items-center gap-2">
<div className="flex items-center gap-1.5 rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground backdrop-blur-sm">
<span className="h-2 w-2 animate-pulse rounded-full bg-blue-500" />
Laying out…
</div>
<button
onClick={handleStopLayout}
className="rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground backdrop-blur-sm hover:bg-muted cursor-pointer"
>
Stop
</button>
</div>
)}
</div>
);
}
@@ -3,7 +3,7 @@ import type Sigma from "sigma";
import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
const NODE_LIMIT_OPTIONS = [100, 200, 500, 1000, 5000] as const;
const NODE_LIMIT_OPTIONS = [100, 200, 500, 1000, 2000, 5000] as const;
interface SigmaGraphControlsProps {
sigma: Sigma | null;
@@ -36,13 +36,15 @@ export function SigmaGraphSearch({ sigma, graph, onNodeSelect, placeholder = "Se
}
const lower = q.toLowerCase();
const matches: SearchResult[] = [];
graph.forEachNode((node, attrs) => {
if (matches.length >= 10) return;
const nodes = graph.nodes();
for (let i = 0; i < nodes.length && matches.length < 10; i++) {
const node = nodes[i]!;
const attrs = graph.getNodeAttributes(node);
const label = (attrs.label as string) || "";
if (label.toLowerCase().includes(lower)) {
matches.push({ id: node, label, color: attrs.color as string });
}
});
}
setResults(matches);
setOpen(matches.length > 0);
setActiveIndex(0);
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { Outlet, useLocation } from "react-router";
import { WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -29,6 +30,13 @@ export function AppLayout() {
const connected = useAuthStore((s) => s.connected);
const isMobile = useIsTablet();
// Close mobile sidebar on route change (e.g. programmatic navigation, back button)
useEffect(() => {
if (isMobile && mobileSidebarOpen) {
setMobileSidebarOpen(false);
}
}, [location.pathname]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex h-dvh overflow-hidden safe-top">
{isMobile ? (
+1 -1
View File
@@ -84,7 +84,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
<nav className="flex-1 space-y-4 overflow-y-auto px-2 py-4">
<SidebarGroup label={t("groups.core")} collapsed={collapsed}>
<SidebarItem to={ROUTES.OVERVIEW} icon={LayoutDashboard} label={t("nav.overview")} collapsed={collapsed} />
<SidebarItem to="/chat" icon={MessageSquare} label={t("nav.chat")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CHAT} icon={MessageSquare} label={t("nav.chat")} collapsed={collapsed} />
<SidebarItem to={ROUTES.AGENTS} icon={Bot} label={t("nav.agents")} collapsed={collapsed} />
<SidebarItem to={ROUTES.TEAMS} icon={Users} label={t("nav.agentTeams")} collapsed={collapsed} />
</SidebarGroup>
@@ -76,6 +76,7 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
});
}
if (state === "disconnected") {
store.setRole("");
store.setTenant("", "", "", false);
store.setAvailableTenants([]);
store.setTenantSelected(false);
@@ -2,7 +2,7 @@ import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { useDebounce } from "@/hooks/use-debounce";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
interface SearchInputProps {
value: string;
@@ -22,10 +22,12 @@ export function SearchInput({
const { t } = useTranslation("common");
const [local, setLocal] = useState(value);
const debounced = useDebounce(local, delay);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
onChange(debounced);
}, [debounced, onChange]);
onChangeRef.current(debounced);
}, [debounced]);
useEffect(() => {
setLocal(value);
+35
View File
@@ -0,0 +1,35 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import type { KGGraphCompactResponse } from "@/types/graph-dto";
const KG_GRAPH_KEY = "kg-graph-compact";
/** Fetches KG graph from dedicated compact endpoint. */
export function useKGGraphCompact(agentId: string, opts?: { userId?: string; limit?: number }) {
const http = useHttp();
const limit = opts?.limit ?? 2000;
const params = useMemo(() => {
const p: Record<string, string> = { limit: String(limit) };
if (opts?.userId) p.user_id = opts.userId;
return p;
}, [opts?.userId, limit]);
const { data, isLoading } = useQuery({
queryKey: [KG_GRAPH_KEY, agentId, params],
queryFn: () => http.get<KGGraphCompactResponse>(
`/v1/agents/${agentId}/kg/graph/compact`, params,
),
staleTime: 60_000,
enabled: !!agentId,
});
return {
nodes: data?.nodes ?? [],
edges: data?.edges ?? [],
totalNodes: data?.total_nodes ?? 0,
totalEdges: data?.total_edges ?? 0,
loading: isLoading,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import type { VaultGraphResponse } from "@/types/graph-dto";
const VAULT_GRAPH_KEY = "vault-graph";
interface VaultGraphOpts {
teamId?: string;
limit?: number;
}
/** Fetches vault graph data from dedicated lightweight endpoint.
* Single request returns nodes (with degree) + edges. */
export function useVaultGraphData(agentId: string, opts?: VaultGraphOpts) {
const http = useHttp();
const limit = opts?.limit ?? 2000;
const params = useMemo(() => {
const p: Record<string, string> = { limit: String(limit) };
if (agentId) p.agent_id = agentId;
if (opts?.teamId) p.team_id = opts.teamId;
return p;
}, [agentId, opts?.teamId, limit]);
const { data, isLoading } = useQuery({
queryKey: [VAULT_GRAPH_KEY, params],
queryFn: () => http.get<VaultGraphResponse>("/v1/vault/graph", params),
staleTime: 60_000,
});
return {
nodes: data?.nodes ?? [],
edges: data?.edges ?? [],
totalNodes: data?.total_nodes ?? 0,
totalEdges: data?.total_edges ?? 0,
loading: isLoading,
};
}
+4 -1
View File
@@ -778,7 +778,10 @@
"shareMemoryNote": "Toggling this does not migrate data — existing per-user memories become inaccessible in shared mode.",
"shareKG": "Shared Knowledge Graph",
"shareKGTip": "All users access the same knowledge graph. When off, each user has their own isolated KG with global canonical fallback for read operations.",
"shareKGNote": "Toggling this does not migrate data — existing per-user KG entities become inaccessible in shared mode."
"shareKGNote": "Toggling this does not migrate data — existing per-user KG entities become inaccessible in shared mode.",
"shareSessions": "Share Sessions",
"shareSessionsTip": "Allow agent to access conversation sessions across all groups. When off, each user's sessions remain isolated.",
"shareSessionsNote": "Toggling this does not migrate data — existing per-user sessions become inaccessible in shared mode."
},
"compaction": {
"title": "Compaction",
+4 -1
View File
@@ -762,7 +762,10 @@
"shareMemoryNote": "Bật/tắt không di chuyển dữ liệu — memory per-user trước đó sẽ không truy cập được trong chế độ shared.",
"shareKG": "Chia sẻ Knowledge Graph",
"shareKGTip": "Tất cả người dùng truy cập chung Knowledge Graph. Khi tắt, mỗi người dùng có KG riêng với fallback đọc từ global canonical.",
"shareKGNote": "Bật/tắt không di chuyển dữ liệu — KG per-user trước đó sẽ không truy cập được trong chế độ shared."
"shareKGNote": "Bật/tắt không di chuyển dữ liệu — KG per-user trước đó sẽ không truy cập được trong chế độ shared.",
"shareSessions": "Chia sẻ phiên hội thoại",
"shareSessionsTip": "Cho phép agent truy cập các phiên hội thoại trên tất cả các nhóm. Khi tắt, phiên của mỗi người dùng được cô lập riêng.",
"shareSessionsNote": "Bật/tắt không di chuyển dữ liệu — phiên per-user trước đó sẽ không truy cập được trong chế độ shared."
},
"compaction": {
"title": "Nén ngữ cảnh",
+4 -1
View File
@@ -762,7 +762,10 @@
"shareMemoryNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立记忆将无法访问。",
"shareKG": "共享知识图谱",
"shareKGTip": "所有用户访问相同的知识图谱。关闭时,每个用户拥有独立的知识图谱,读取操作会回退到全局规范数据。",
"shareKGNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立知识图谱实体将无法访问。"
"shareKGNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立知识图谱实体将无法访问。",
"shareSessions": "共享会话",
"shareSessionsTip": "允许 agent 访问所有群组的会话记录。关闭时,每个用户的会话保持隔离。",
"shareSessionsNote": "切换不会迁移数据——切换到共享模式后,现有的用户独立会话将无法访问。"
},
"compaction": {
"title": "压缩",
@@ -42,8 +42,8 @@ export function useActivity() {
if (filters?.action) params.action = filters.action;
if (filters?.entity_type) params.entity_type = filters.entity_type;
if (filters?.actor_id) params.actor_id = filters.actor_id;
if (filters?.limit) params.limit = String(filters.limit);
if (filters?.offset) params.offset = String(filters.offset);
if (filters?.limit !== undefined) params.limit = String(filters.limit);
if (filters?.offset !== undefined) params.offset = String(filters.offset);
const res = await http.get<ActivityListResponse>("/v1/activity", params);
setLogs(res.logs ?? []);
@@ -175,7 +175,9 @@ export function buildAdvancedUpdatePayload(
wsSharing.shared_dm ||
wsSharing.shared_group ||
(wsSharing.shared_users?.length ?? 0) > 0 ||
wsSharing.share_memory
wsSharing.share_memory ||
wsSharing.share_knowledge_graph ||
wsSharing.share_sessions
) {
updates.workspace_sharing = wsSharing;
} else {
@@ -86,6 +86,21 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec
</p>
)}
</div>
<div className={`rounded-lg border p-3 sm:p-4 ${value.share_sessions ? "border-orange-400/60 bg-orange-50/30 dark:border-orange-500/30 dark:bg-orange-950/10" : ""}`}>
<div className="flex items-center justify-between">
<InfoLabel tip={t(`${s}.shareSessionsTip`)}>{t(`${s}.shareSessions`)}</InfoLabel>
<Switch
checked={value.share_sessions ?? false}
onCheckedChange={(v) => onChange({ ...value, share_sessions: v })}
/>
</div>
{value.share_sessions && (
<p className="mt-2 text-xs text-orange-600 dark:text-orange-400">
{t(`${s}.shareSessionsNote`)}
</p>
)}
</div>
</div>
</section>
+2 -2
View File
@@ -47,9 +47,9 @@ export function useAgents() {
max_tool_iterations: 0,
workspace: "",
restrict_to_workspace: false,
agent_type: "open" as const,
agent_type: (a as unknown as { agentType?: string }).agentType === "predefined" ? "predefined" as const : "open" as const,
is_default: false,
status: a.isRunning ? "running" : "idle",
status: a.isRunning ? "active" : "inactive",
}));
},
staleTime: 60_000,
@@ -65,7 +65,7 @@ export function ChannelAdvancedDialog({
if (!open) return;
setValues(deriveInitialValues(instance));
}, [open]);
}, [open, instance]);
const handleChange = useCallback((key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }));
@@ -67,7 +67,7 @@ export function ChannelDetailDialogs({
confirmValue={instance.display_name || instance.name}
confirmLabel={t("delete.confirmLabel")}
onConfirm={async () => {
onDelete({
await onDelete({
id: instance.id,
name: instance.display_name || instance.name,
});
+1 -1
View File
@@ -125,7 +125,7 @@ export function ChatPage() {
navigate("/chat");
}
},
[navigate, sessionKey],
[navigate],
);
const handleSend = useCallback(
@@ -83,9 +83,13 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
(v: T) => { setter(v); setDirty(true); };
const handleSave = () => {
// Strip masked/secret values to avoid overwriting real secrets with "***"
const gwClean = Object.fromEntries(
Object.entries(gw).filter(([, v]) => typeof v !== "string" || v !== "***"),
);
onPatch({
gateway: {
...gw,
...gwClean,
tool_status: ux.tool_status,
block_reply: ux.block_reply,
max_message_chars: rate.max_message_chars,
@@ -87,7 +87,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
reset(deriveDefaults(job));
fetchTargets();
}, [open]);
}, [open, job, reset, fetchTargets]);
const handleSave = async () => {
if (!onUpdate) {
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more