mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 18:15:41 +00:00
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:
121 files changed
+5764
-242
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ./...
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+27
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
Reference in new issue
Block a user