feat: multi-tenant isolation — complete implementation (#359)

* feat(security): multi-tenant user data isolation (Plan 1)

Comprehensive user data isolation for non-owner system users:

- API key identity binding: owner_id column forces user_id on auth,
  prevents spoofing via X-GoClaw-User-Id header
- Sessions: ownership checks on list/preview/patch/delete/reset,
  non-admin users see only their own sessions
- Cron: user_id filtering on list, ownership checks on mutations
- Server-side WS event filtering: agent/chat/session/cron/team events
  scoped per-user instead of broadcast to all clients
- Web UI role guards: RequireAdmin on 15 admin-only pages, role
  propagated from WS connect response to auth store
- Tracing/activity: user_id enforcement for non-admin HTTP callers
- Teams: HasTeamAccess membership checks on get/delete/list
- Skills: fail-closed ownership check (deny non-admin if store
  doesn't support owner lookup)
- HTTP auth: requireAuthBearer now enforces owner_id + user context
  for file/media downloads (was missing)
- Dead code: removed delegation_history, handoff_routes tables and
  all related handlers/store code
- New: team_user_grants table for user-to-team access control

Migration 000026: api_keys.owner_id + team_user_grants + DROP legacy tables

* feat(security): multi-tenant foundation — tenants table, tenant_id propagation, permission cache (Plan 2)

Add tenant isolation infrastructure across the entire gateway:

Schema (migration 000027):
- Create tenants + tenant_users tables with master tenant seed
- Add tenant_id column to 30 user-scoped tables (NOT NULL DEFAULT master)
- api_keys.tenant_id nullable (NULL = system-level cross-tenant key)
- Create builtin_tool_tenant_configs + skill_tenant_configs for per-tenant overrides
- Drop custom_tools table (agent loop integration never wired)

Store layer:
- TenantStore interface + PGTenantStore (CRUD tenants + tenant_users)
- TenantID field on AgentData + APIKeyData
- tenant_id in agents/api_keys/skills SQL (Create, Get, List)

Context propagation:
- WithTenantID/TenantIDFromContext (uuid.Nil = fail-closed)
- WithCrossTenant/IsCrossTenant (owner/system admin flag)

Auth tenant resolution:
- HTTP: resolveAuthBearer sets TenantID/CrossTenant on all 5 auth paths
- WS: handleConnect sets tenantID/crossTenant on Client
- API key 2-tier: NULL = cross-tenant (system), set = tenant-scoped

Runtime isolation:
- Event bus: TenantID field on Event, fail-closed filter in event_filter.go
- Cron: tenant context injected in RunJob handler
- Subagent: tenant validation prevents cross-tenant spawn
- Security logging: tenant_id in auth resolution logs

Tenant management:
- WS RPC: 7 methods (tenants.list/get/create/update, tenants.users.*)
- HTTP: 7 endpoints (/v1/tenants/*)
- Slug validation + path traversal prevention
- Role validation (owner/admin/operator/member/viewer)

Infrastructure:
- PermissionCache: 4 sub-caches (tenant resolve, role, agent access, team access)
- tenant_paths.go: filesystem path helpers with master-tenant backward compat
- i18n: MsgInvalidRole key + translations (en/vi/zh)

Dead code removed: custom_tools store, HTTP handler, DynamicToolLoader (-828 lines)

* feat(security): tenant query filtering + workspace isolation (Plan 3)

Add WHERE tenant_id filtering to all 30+ tenant-scoped store queries,
wire workspace filesystem isolation, and harden restrict_to_workspace.

Store query filtering:
- Add tenantClauseN/tenantIDForInsert/requireTenantID helpers
- Filter all SELECT/INSERT/UPDATE/DELETE by tenant_id for non-cross-tenant
- Refactor SessionStore.GetOrCreate and CronStore.AddJob/ListJobs to
  accept context.Context for tenant propagation
- System skills (is_system=true) bypass tenant filter for all tenants
- Special cases: GetByKey (channels), GetByHash (auth) skip filter

Workspace isolation:
- Resolver computes tenant-scoped workspace + dataDir for non-master tenants
- Add WithTenantSlug/TenantSlugFromContext to context propagation
- Add TenantStore + Workspace to ResolverDeps
- Force effectiveRestrict() to always return true (multi-tenant security)
- Remove restrict_to_workspace from agentAllowedFields

UI cleanup:
- Remove custom-tools pages, types, routes, constants (backend removed in Plan 2)
- Clean tool-name-select component of custom tools references

* feat(security): session ctx propagation + execMapUpdate tenant guard (Plan 4)

Session store:
- Add ctx to AddMessage, SetSessionMetadata, SetAgentInfo, List, Save
- List now filters by tenant_id for non-cross-tenant callers
- Save uses ExecContext for cancellation support
- All ~15 callers updated to pass ctx

execMapUpdate tenant guard:
- Remove deleted_at IS NULL from execMapUpdateWhereTenant (only agents has soft-delete)
- Migrate 8 callers to execMapUpdateWhereTenant: agent_links, channel_instances,
  mcp_servers, secure_cli, tracing, teams, skills_crud, cron_update
- Add ctx to UpdateSkill, UpdateJob interfaces + all callers

Deferred: cron scheduler global cache (correct by design — system process),
browser per-tenant isolation (separate plan).

* refactor(store): add context.Context to all SessionStore interface methods

Complete ctx propagation across all 24 SessionStore methods for:
- Future tenant-aware DB operations
- Request cancellation/timeout support
- Distributed tracing capability

Updated ~15 files including all callers in agent loop, gateway methods,
heartbeat ticker, tools, and CLI commands.

* fix(security): remove context.Background() shadowing in gateway handlers

Critical fix from code review: gateway agent handlers (create, update,
delete, identity, files, links, teams) were creating ctx := context.Background()
which shadowed the handler's ctx that carries tenant_id. This breaks
tenant-scoped agent queries for non-master tenants.

- Remove ctx shadowing in 7 agent handler files
- Add ctx param to resolveAgentUUID/resolveAgentInfo helpers
- Use store.WithCrossTenant in resolver (system-level operation)

* feat(security): tenant-scoped UNIQUE constraints for multi-tenant isolation

Update UNIQUE indexes to include tenant_id, allowing same names across tenants:
- agents: (agent_key) → (tenant_id, agent_key) WHERE deleted_at IS NULL
- sessions: (session_key) → (tenant_id, session_key)
- skills: (slug) → (tenant_id, slug)
- mcp_servers: (name) → (tenant_id, name)
- channel_contacts: (channel_type, sender_id) → (tenant_id, channel_type, sender_id)

Code changes:
- GetByKey now filters by tenant_id (same pattern as GetByID)
- ON CONFLICT clauses updated for sessions and skills
- Channel consumer uses WithCrossTenant for agent resolution
- Down migration restores original constraints

* fix(security): close remaining tenant isolation gaps from final audit

Critical fixes:
- gateway_setup: WithCrossTenant for default agent lookup at startup (C6)
- channel_contacts: ON CONFLICT updated to (tenant_id, channel_type, sender_id) (Q15)
- agents.Delete: tenant filter on DELETE (Q1)

High priority fixes:
- agents: List, GetDefault, ShareAgent, RevokeShare, ListShares, CanAccess,
  ListAccessible, Update unset-default — all now tenant-scoped
- skills_crud: DeleteSkill now takes ctx, verifies tenant ownership
- mcp_servers, channel_instances, secure_cli: Delete methods tenant-scoped
- WithCrossTenant added to: gateway team notifications, team_tool_cache,
  pending_messages GetDefault

* fix(migration): add tenant_id to usage_snapshots unique index

Update idx_usage_snapshots_unique to include tenant_id, preventing
cross-tenant upsert collisions when different tenants have agents
with same provider/model/channel combination.

* feat(security): cron tenant guard + browser per-tenant isolation

Phase 3 — Cron API tenant guard:
- Add ctx to 5 CronStore methods (GetJob, RemoveJob, EnableJob, RunJob, GetRunLog)
- All API-facing cron ops now filter by tenant_id (prevents cross-tenant CRUD)
- RemoveJob/EnableJob return "not found" on tenant mismatch (no enumeration)
- GetRunLog JOINs cron_jobs for tenant filtering
- UpdateJob internal reads scoped by tenant (defense-in-depth)
- Scheduler-internal methods (GetDueJobs, refreshJobCache) unchanged (system-level)

Phase 4 — Browser per-tenant isolation:
- Per-tenant incognito browser contexts via rod Incognito() (separate cookie jars)
- All page access (Snapshot, Screenshot, Navigate, Click, Type, etc.) validated
  via getPageForTenant — blocks cross-tenant access by targetID
- OpenTab creates pages in tenant's incognito context
- ListTabs scoped to tenant's incognito context
- ConsoleMessages validates page ownership
- Stop/reconnect properly cleans up incognito contexts

* feat(security): isolation gaps + per-tenant config (Plan 5)

Part A — Isolation Gap Fixes:
- Merge migration 028 into 027: add tenant_id to llm_providers +
  config_secrets, fix UNIQUE constraints for paired_devices +
  channel_instances
- providers.go: tenant filtering on all CRUD queries
- config_secrets.go: ON CONFLICT (key, tenant_id)
- pairing_store: add ctx to all 7 interface methods, remove hardcoded
  MasterTenantID, update ~15 channel caller files
- Session cache: prefix keys with tenantID to prevent cross-tenant
  collision. DB queries (loadFromDB, Save, Delete, LastUsedChannel)
  add tenant filter
- config_permissions cache: prefix keys with tenantID
- Cron ListJobs: fail-closed when tenant context missing

Part B — Per-Tenant Configuration:
- Provider Registry: compound key tenantID/name with fallback to
  master tenant. GetForTenant/ListForTenant/RegisterForTenant
- Resolver: uses tenant-aware provider lookup + disabled tools query
- Agent loop: filter disabled tools from LLM tool definitions
- Builtin tool tenant configs: store interface + PG implementation +
  PUT/DELETE HTTP endpoints
- Skill tenant configs: store interface + PG + ListAccessible LEFT
  JOIN to exclude disabled skills per tenant
- OAuth: DBTokenSource with tenantID field for tenant-scoped token
  refresh
- All HTTP provider handlers use RegisterForTenant/UnregisterForTenant

* feat(security): channel tenant propagation + MCP per-user credentials (Plan 6)

- Propagate tenant_id from channel_instances through BaseChannel →
  InboundMessage → agent loop context (fixes 5-point break in tenant flow)
- Inject tenant context in WS router dispatch for all gateway methods
- Add MCP per-user credential overrides (api_key, headers, env) with
  AES-256-GCM encryption and HTTP API endpoints
- Rewrite MCP pool with tenant-scoped keys, slot semaphore, idle eviction,
  and credential rotation support (Evict per tenant+server)
- Bypass pool for users with custom credentials (separate connections)
- Fix MCP APIKey never passed to connections (inject as Authorization header)

* fix(security): close remaining tenant isolation gaps from Plan 1-6 audit

- Add tenant_id to 6 missing tables: agent_context_files,
  skill_agent_grants, mcp_agent_grants, team_tasks, spans,
  embedding_cache (migration 027)
- Fix tid==uuid.Nil fallback to fail-closed (return error) in 8 update
  methods: agent_links, teams, skills, channel_instances, secure_cli,
  cron, mcp_servers, tracing
- Add tenant filter to bare DELETEs: DeleteLink, DeleteTeam
- Add tenant filter to queries: ListChildTraces, GetMonthlyAgentCost,
  CountAgentGrantsByServer, ListAccessible (MCP), ReviewRequest,
  ResolveGroupTitles, buildTraceWhere
- Fix missing tenant_id in INSERTs: CreateSkill, GrantToUser,
  ReviewRequest grant INSERTs
- Add tenant filter to api_keys: List, Revoke, Delete
- Fix cron scanJob/RemoveJob/EnableJob fallthrough patterns

* fix(security): inject tenant context into channel handler entry points

Channel handlers used context.Background() which lost tenant context,
causing store operations to either fail-closed or default to master
tenant. Now all 10 handler entry points inject tenant from BaseChannel.

* fix(security): tenant filters for teams, tasks, skills (Plan 6b audit)

- Teams: add tenant filter to GetTeamForAgent, ListMembers,
  ListIdleMembers, KnownUserIDs (JOIN agent_teams for tenant check)
- Teams: add tenant_id to GrantTeamAccess INSERT, tenant filter to
  RevokeTeamAccess, ListTeamGrants, HasTeamAccess
- Team tasks: add tenant_id to CreateTask INSERT, fail-closed
  UpdateTask, tenant filter on all 7 query/delete methods
- Skills: add tenant filter to RevokeFromAgent, ListAgentGrants
- Skills: add ctx param + tenant filter to ToggleSkill
- History: annotate context.Background() locations with TODOs for
  future tenant injection (requires PendingHistory struct refactor)

* fix(security): add tenant_id to 4 missing team tables + fix INSERTs

Add tenant_id column to: agent_team_members, team_task_comments,
team_task_events, team_task_attachments (migration 027).

Fix INSERT statements to include tenant_id: AddMember,
AddTaskComment, RecordTaskEvent, AttachFileToTask.

* fix(migration): cast UUID literals in tenant_users seed + usage_snapshots index

PostgreSQL doesn't auto-cast string to UUID in SELECT and expression
index contexts. Add explicit ::uuid casts to prevent migration failure.

* docs: add multi-tenant architecture guide for integrators

Comprehensive solution doc covering auth model, WS protocol, event
system, data isolation, API reference, and integration patterns.
Target audience: developers building custom frontends or SaaS on GoClaw.

* feat(ui): multi-tenant awareness + tenant admin page (Plan 7)

Backend:
- Enrich WS connect response with tenant_name, tenant_slug, cross_tenant
- Add tenants.mine WS method (any user, returns own memberships)
- Parse tenant_hint in connect params for browser pairing multi-tenant
- Wire tenantStore to MethodRouter for connect-time tenant lookup

Frontend:
- Auth store: tenantId, tenantName, tenantSlug, isCrossTenant, availableTenants
- WS client: capture tenant fields from connect, send tenant_hint
- WS provider: auto-fetch tenants.mine on connect
- useTenants() shared hook for all tenant-aware components
- Tenant indicator in sidebar connection status
- Tenant admin page (/admin/tenants) with list + create dialog
- Tenants nav in sidebar (cross-tenant admin only)
- i18n: tenants namespace (en/vi/zh)
- Type updates: tenant_id on AgentData, ApiKeyData

* refactor(ui): move tenant selector into user menu dropdown in topbar

Replace simple logout button with a Radix Popover user menu showing:
- User ID display
- Tenant selector (when multi-tenant: list all tenants with check mark)
- Logout button

Remove tenant indicator from connection-status.tsx (now in topbar).
Tenant switch saves slug to localStorage and reloads for reconnect.

* feat(ui): add logout confirmation dialog

Show destructive confirm dialog before logout via ConfirmDialog
component. Added logoutConfirm i18n key for en/vi/zh.

* fix(ui): security hardening — hide admin nav, fix route guard, fix refresh

- Hide System nav group for non-admin roles in sidebar (was visible to all)
- Replace RequireAdmin with RequireCrossTenant guard on /admin/tenants route
- Add RequireCrossTenant component to require-role.tsx
- Fix refresh button animation: use isFetching instead of isLoading
- Clean up connection-status.tsx (remove tenant indicator, now in topbar)

* feat: cross-tenant admin tenant scope selector

Backend: add tenant_scope connect param. Cross-tenant clients can
narrow their scope to a specific tenant (slug). applyTenantScope()
sets client.tenantID and clears crossTenant flag.

UI: user menu shows "All Tenants" option for cross-tenant admins.
Selecting a tenant saves slug to localStorage as tenant_scope,
reload reconnects with narrowed scope. "All Tenants" clears scope.

* feat: provisioning API key scope + tenant detail page (Plan 8)

Backend:
- Add operator.provision scope for limited tenant management
- Add HasScope() method to gateway Client
- Allow provision-scoped keys to create tenants + add users
- Allow provision-scoped keys to create tenant-bound API keys

Frontend:
- Tenant detail page with user management (list, add, remove)
- Clickable tenant list rows navigate to detail
- i18n: tenant detail keys (en/vi/zh)
- Route /admin/tenants/:id with RequireCrossTenant guard

* fix: tenant scope keeps admin privileges + UI pattern fixes

Backend:
- applyTenantScope keeps crossTenant=true (retains admin features)
- Router: scoped cross-tenant injects WithTenantID (filters data)
  while keeping admin role for method access

UI:
- Fix "All Tenants" check mark (compare against nil UUID string)
- Fix tenant label when scope active (show selected tenant name)
- Use ConfirmDialog for user removal (was hand-rolled)
- Add DialogDescription to add-user dialog (Radix a11y)
- Fix table min-w-[600px] consistency
- Fix column header mismatch (was "role", should be "created")

* fix(ui): clean up tenant detail header — remove redundant info panel

Remove duplicate slug/status/created panel. Info now shown in
PageHeader description (slug + date). Status badge removed (redundant
with description). Cleaner, consistent with other admin pages.

* fix(ui): redesign tenant detail with info cards + user cards

* feat(ui): tenant selection gate — require tenant before app access

- Add tenantSelected flag to auth store (persisted via localStorage)
- WS provider auto-selects: single-tenant user auto, cross-tenant
  admin defaults to "All Tenants", zero-tenant user blocked
- RequireAuth gate: redirect to /select-tenant when connected but
  no tenant selected
- New TenantSelectorPage: centered card layout matching login page,
  "All Tenants" amber card for cross-tenant admin, per-tenant cards
  with role badges, no-access state with logout button
- i18n: selectTenant, noAccess keys (en/vi/zh)

* fix(security): scope events for cross-tenant admin with tenant_scope

Event filter was checking !crossTenant before filtering — scoped
cross-tenant admins (crossTenant=true + tenantID set) bypassed
tenant event filtering. Now checks tenantID != Nil regardless of
crossTenant flag, ensuring scoped admins only see their chosen
tenant's events.

* fix(security): HTTP API now respects tenant_scope for gateway token

Root cause: UI uses HTTP API (/v1/agents, /v1/mcp/servers, etc.)
for data fetching. HTTP auth middleware with gateway token always
set CrossTenant=true with no tenant filtering. tenant_scope only
worked for WS connection, not HTTP requests.

Fix:
- HTTP client sends X-GoClaw-Tenant-Scope header from localStorage
- HTTP auth resolves header slug → tenant UUID via tenantStore
- requireAuth: CrossTenant + TenantID → WithTenantID (scoped)
- Wire InitTenantStore(pgStores.Tenants) in gateway startup

* feat(security): tenant-aware provider registry, event filter, and membership validation

- Refactor providers.Registry: Get(ctx, name) / List(ctx) extract tenant
  from context via injected TenantFromCtx func (avoids circular import)
- Event filter: fail-closed 3-mode tenant filtering
  Mode 1: unscoped admin sees all
  Mode 2: scoped admin sees tenant events + system events
  Mode 3: regular user sees only own tenant (fail-closed)
- WS connect: resolveTenantHint validates membership via GetUserRole
  with PermissionCache (30s TTL, bus invalidation)
- BroadcastForTenant helper for tenant-scoped event emission
- Session list: add TenantID to SessionListOpts from context
- Cron handleRun: preserve tenant in background goroutine context
- GOCLAW_LOG_LEVEL env var (debug|info|warn|error) for Docker/K8s
- Cache debug logging: tenant_cache, permission_cache, api_key_cache
- Friendly verify error: timeout → user-readable message
- Verify timeout: 15s → 30s

* feat(ui): setup wizard improvements + agent preset enrichment

- Setup: skip link with confirm dialog, language selector (en/vi/zh)
- Setup: card padding fix (py-0 gap-0 on Card, py-5 on CardContent)
- Setup: remove duplicate skip link from layout
- Step Model: verify countdown timer (30s), stops on result
- Step Agent: default Fox Spirit preset, selected state styling,
  hide agent key/name inputs, auto-derive from preset, emoji in config
- Summoning modal: elapsed timer (m:ss format)
- Agent presets: enriched prompts with human-like quirks
  Fox Spirit: playful personality, care reminders
  Artisan: portrait/banner/ads/logo expertise
  Astrologer: reference sites (astro.com, cafeastrology, labyrinthos)
- i18n: "triệu hồi linh hồn" fix, all 3 locales updated

* feat(ui): API Key tenant support + card layout + provider chain fix

- API Key create: tenant selector for cross-tenant admin, provision scope
- API Key create: redesigned dialog with scope cards, Radix Select, icons
- API Key list: card layout with badges (status, tenant, scopes)
- API Key: shortcut in user menu (topbar)
- API Key: keep "API Key" untranslated across all locales
- Provider chain: empty state fix — skip legacy entry when provider
  not found in current tenant
- i18n: form.cancel key added to all 3 locales

* fix(ui): add bottom padding to all page layouts + misc improvements

- Add pb-10 to all 24 page containers to prevent content touching
  bottom edge of viewport
- Various UI polish from user modifications (summoning colors,
  layout icon, agent cards, sidebar adjustments)

* feat(ui): MCP user credentials dialog + builtin tool tenant toggle

- MCP: per-user credentials dialog (api_key, headers, env KV editor)
  with status badges, delete all, save
- MCP: "My Credentials" button on each server row
- Builtin Tools: per-tenant enable/disable override toggle
  with "Using default" / "Enabled/Disabled for tenant" badges
  and reset-to-default button
- Setup: larger logo (h-16) and bolder title (text-4xl font-bold)
- i18n: all keys added to en/vi/zh for both features

* fix(ui): API key card spacing + remove pagination border

- Card padding: px-4 py-3.5 (was px-3 py-2), rows spaced with gap-2
- Scopes on separate row from dates for readability
- Card gap: space-y-2.5 between cards
- Pagination: add className prop, remove border-t on API keys page
- Badge/icon sizes bumped to text-xs / h-3.5 (was text-[10px] / h-3)

* fix(security): comprehensive tenant isolation audit — SQL, events, cache, skills, files

Defense-in-depth hardening across 12 audit phases:

- SQL: add tenant_id WHERE to teams_tasks lifecycle/activity/followup/progress/embedding (~30 functions)
- Events: broadcastTeamEvent + task_ticker + subagent announce now carry TenantID
- Cache: agentKeyCache scoped by tenant (agent keys per-tenant, not globally unique)
- Skills: SkillStore interface accepts ctx, SQL filter (is_system OR tenant_id=$N), per-tenant list cache, GrantToAgent includes tenant_id, tenant-scoped file storage
- Files: StorageHandler/FilesHandler/TeamAttachments/teamWorkspaceDir use config.TenantDataDir/TenantTeamDir
- Security: HMAC signed file tokens (file_token.go) replace gateway token in URLs
- Audit: AuditEventPayload carries TenantID for async subscriber tenant scoping
- InboundMessage: subagent/dispatch/validation/session_send propagate TenantID
- Pending messages: DeleteStale scoped by tenant

* fix(security): skip gateway token in URLs with signed file tokens

toFileUrl() now skips appending ?token=GATEWAY_TOKEN when the URL
already contains ?ft= (HMAC signed file token). Prevents gateway
token exposure via browser history, logs, and referrer headers.

* fix(security): stop persisting auth tokens in session media URLs

mediaToMarkdown() now stores clean paths (/v1/files/path) without
any auth tokens. Previously embedded ?token=GATEWAY_TOKEN (or ?ft=)
into markdown which gets persisted in session messages DB.

Frontend toFileUrl() adds auth at render time — tokens never stored.

* fix(security): migration 027 strips leaked gateway tokens from session URLs

Adds cleanup step to tenant foundation migration: removes ?token=xxx
from persisted media URLs in session messages. Old code embedded the
gateway token; new code stores clean paths only.

* fix(security): sign file URLs at delivery time, not persist time

Add SignFileURLs() utility that finds /v1/files/ and /v1/media/ URLs
in content and appends HMAC signed ?ft= tokens before delivery.

Applied at 4 delivery points:
- WS agent events (OnEvent callback in gateway_managed.go)
- WS chat.history response
- WS sessions.preview response
- HTTP /v1/chat/completions response

Sessions store clean paths only. Tokens are generated per-delivery
with 1h TTL — never persisted in DB. Frontend toFileUrl() skips
appending gateway token when ?ft= is already present.

* fix: file token verify path must match signed path (/v1/files/ prefix)

SignFileURLs() signs the full URL path "/v1/files/{path}" but the
verify in files.go auth() was using "/{path}" (without prefix).
HMAC mismatch caused all signed file tokens to return 401.

* fix(security): scope storage size cache per-tenant

sizeCache was a single global entry — all tenants shared one cached
size. Changed to sync.Map keyed by tenantBaseDir so each tenant gets
its own cached size calculation.

* feat(ui): redesign API keys page — table layout + code snippet dialog

Replace card-based API keys list with table layout matching MCP Servers
pattern. Add "API Key Usage" dialog with tabbed code snippets (cURL,
TypeScript, Go) showing gateway connection examples with syntax
highlighting and copy-to-clipboard.

* fix(builtin-tools): seed media tools disabled, fix tenant toggle, add unconfigured warning

- Seed media tools with Enabled=false and no default provider settings
  (user must configure provider chain before enabling)
- Fix provider chain form ghost entries: validate provider exists in
  tenant before showing (parseInitialEntries new-format path)
- Fix double toggle: show only tenant override OR global toggle, not both
- Fix list API: merge tenant_enabled from builtin_tool_tenant_configs
  into response when tenant-scoped (was always null)
- Add ListAll() to BuiltinToolTenantConfigStore for full override map
- Add amber warning banner for enabled media tools missing provider config

* feat(mcp): require_user_credentials setting + KeyValueEditor for user creds

- Add require_user_credentials setting in mcp_servers.settings JSONB
- Backend: skip MCP server in LoadForAgent when user lacks credentials
- Frontend: toggle in MCP form dialog, persisted in settings field
- Redesign MCP user credentials dialog: replace raw Textarea with
  KeyValueEditor (sensitive key masking for auth/token/secret fields)
- Add settings to mcpServerAllowedFields for HTTP update

* fix(security): restrict cross-tenant to owner IDs, config to owners only

- Gateway token + non-owner user ID: admin role but tenant-scoped
  (no cross-tenant access). Fallback: only "system" is owner when
  GOCLAW_OWNER_IDS not configured (fail-closed).
- Config page (WS config.* methods): wrapped with requireCrossTenant
  middleware — non-owner admins get permission denied
- Config sidebar link: hidden for non-cross-tenant users
- Logout: clear tenant_id and tenant_hint from localStorage
  (prevents tenant scope leak to next user session)
- Refactor: LOCAL_STORAGE_KEYS.TENANT_ID/TENANT_HINT constants

* fix(ui): chat bubble contrast, login logo, tenant no-access UX

- Chat bubble: use --chat-bubble-user CSS var (darker orange, L=0.50/0.52)
  with text-white for WCAG AA contrast (~5.5:1)
- Login page: logo h-20 w-20, title text-3xl font-bold
- Tenant selector no-access: shield icon + hint text explaining
  user needs admin to add them to a tenant
- Sidebar: GoClaw text uses text-sidebar-primary (brand color)

* feat(contacts): merge/unmerge contacts to tenant users

Add API and UI for linking channel contacts to tenant_users identity,
enabling cross-channel user identification within a tenant.

Backend:
- POST /v1/contacts/merge — link contacts to existing or new tenant_user
- POST /v1/contacts/unmerge — remove merged_id from contacts
- GET /v1/contacts/merged/{id} — list contacts by tenant_user
- GET /v1/tenant-users — list users for current tenant
- Add display_name + metadata columns to tenant_users (migration 27)
- All endpoints enforce tenant isolation via context tenant_id

Frontend:
- Checkbox multi-select on contacts table
- Selection toolbar with Merge/Unmerge buttons
- Merge dialog: link to existing user or create new
- Link2 icon indicator for merged contacts
- i18n: en/vi/zh translations for merge section

* fix(security): add tenant_id to span and embedding_cache inserts

SpanData struct was missing TenantID field — all span inserts failed
with NOT NULL constraint violation after migration 027 dropped defaults.

Fix captures tenant_id from context at emit time (6 call sites in
loop_tracing.go + subagent_tracing.go), then includes it in both
CreateSpan() and BatchCreateSpans() SQL (25→26 columns).

Also fixes embedding_cache writeEmbeddingCache() which was missing
tenant_id in its batch INSERT — same class of bug.

Both use MasterTenantID fallback for backward compatibility.

* feat: Introduce tenant switcher UI and enhance multi-tenant architecture documentation.

* fix(security): enforce tenant scoping, fix session isolation and UI cleanup

- Force cross-tenant admins to always have a concrete tenant_id (default
  MasterTenantID) instead of unscoped WithCrossTenant — prevents mismatch
  between session listing (no filter) and writes (MasterTenantID fallback)
- Make agent router tenant-aware: Get(ctx, agentID) resolves agent for
  the caller's tenant, preventing cross-tenant agent cache collisions
- Fix context.Background() in title goroutine and summarization — now
  uses tenant-aware context (WithoutCancel) so titles and compaction
  persist to the correct tenant
- Add read-only SessionStore.Get() method; replace GetOrCreate in auth
  checks (preview/patch/delete/reset) to prevent phantom session creation
- Inject tenant from channel instance into inbound message processing
- Remove "All Tenants" option from tenant selector, topbar switcher,
  and ws-provider auto-select — admin must always operate within a tenant
- Fix contacts page selection toolbar layout shift (always rendered)
- Widen MCP credentials sensitive header regex to catch API_KEY etc.

* fix(security): propagate tenant_id in consumer handlers and background ops

- InjectTeamDispatch: use context.WithoutCancel instead of context.Background
  to preserve tenant_id while avoiding cancel propagation from HTTP/WS handlers
- handleTeammateMessage/handleSubagentAnnounce: inject tenant_id from msg
- Add nil guard for outcome.Result to prevent panic on agent-not-found
- Use BroadcastForTenant for EventTeamTaskFailed/Completed/LeaderProcessing
- Remove unnecessary WithCrossTenant in autoSetFollowup (ctx already scoped)
- resolveAgentByKey: accept ctx param for tenant-scoped agent lookup
- pending_messages: use request ctx instead of cross-tenant for GetDefault

* fix(security): tenant-scope EnsureContact and PendingHistory DB operations

- All channel EnsureContact calls now use tenant-scoped ctx instead of
  context.Background (whatsapp, slack, discord, telegram, feishu, zalo)
- PendingHistory: add tenantID field, thread through constructors
- All PendingHistory DB ops (load, flush, compact, delete) use tenantCtx()
- Normalize timeouts: 10s for simple queries, 15s for batch writes

* feat(teams): auto-attach media, retry completed tasks, improve tool messages

- Auto-attach workspace media from any tool (create_image/audio/video) to
  team tasks via loop-level hook, not just write_file interceptor
- Store absolute paths in team_task_attachments instead of relative
- Extend retry action to support completed tasks (reopen for follow-up)
- Context-aware comment result messages with next-action guidance for
  leader vs member roles and task status
- All tool results include task_id for agent follow-up actions
- Use #N "subject" format instead of raw UUIDs in tool messages

* feat(multi-tenant): tenant isolation for media, events, providers and UI

- Tenant-scoped media store, event filter, provider registry
- Tenant header propagation in WS/HTTP clients
- UI: tenant-aware chat messages, markdown renderer improvements
- Protocol: tenant error codes and event definitions

* docs: add multi-tenant architecture documentation

* fix(teams): store absolute paths in team_task_attachments

- AutoAttachWorkspaceFile: use cleanPath consistently instead of raw absPath
- executeAttach: resolve relative paths to absolute via team workspace
- AfterWrite interceptor already uses filepath.Clean (verified)

* fix(teams): attachment download handles both absolute and relative paths

filepath.Join with an absolute att.Path discards the teamBase prefix,
causing path traversal check to fail and download to serve wrong file.
Now checks IsAbs first — uses path directly for new absolute entries,
falls back to legacy join for old relative entries.

* fix(teams): attachment download validates against workspace root not tenant dir

Absolute paths stored in DB don't match TenantTeamDir structure
(master tenant has no tenants/ prefix). Now validates absolute paths
against dataDir (workspace root) instead. Legacy relative paths still
resolve via TenantTeamDir as before. IDOR check on att.TeamID ensures
cross-team isolation.

* fix(teams): attachment download uses workspace root, not data dir

Files are stored under GOCLAW_WORKSPACE/teams/ but handler was passed
dataDir (GOCLAW_DATA_DIR) — completely different directory. Now passes
workspace. Legacy relative paths resolve via {workspace}/teams/{teamID}/{chatID}/{path}.

* fix(security): use HMAC-signed file tokens for attachment downloads

Replace gateway token exposure (?token=) with HMAC-signed short-lived
file tokens (?ft=) for team task attachment downloads — same mechanism
used by chat file URLs.

Backend:
- team_attachments auth: accept ?ft= signed token (priority 1), Bearer (priority 2)
- teams_tasks RPC: sign download_url with HMAC at delivery time
- Add fileTokenSecret to TeamsMethods, thread through wireChannelRPCMethods

Frontend:
- Use server-signed download_url from attachment data instead of ?token=
- Remove useAuthStore dependency from task-detail-dialog

* fix(security): decouple file token signing from gateway token

- Generate random 256-bit HMAC key at startup (crypto/rand, memory-only)
- All file signing/verification uses FileSigningKey() instead of gateway token
- Remove ?token= query param fallback from /v1/files/, /v1/media/, attachments
- Only ?ft= signed tokens and Bearer header accepted for file access
- Reduce file token TTL from 1h to 5min
- Frontend: remove gateway token from all file URLs and imports
- Note: tokens invalidate on restart (acceptable for 5min TTL + WS reconnect)

* fix(ui): use signed download_url for task attachments

- Add download_url to TeamTaskAttachment type
- Use a.download_url (server-signed ?ft=) instead of bare URL

* fix(security): tenant-scope team workspace paths + show user/tenant in topbar

- WorkspaceDir callers now use config.TenantWorkspace() to resolve
  tenant-scoped base dir (non-master tenants get workspace/tenants/{slug}/)
- Fixes: all tenants previously wrote to global /app/workspace/teams/
  without filesystem-level isolation
- Affected: loop.go (agent run), team_tasks_mutations.go (task creation)
- teams_workspace.go already correct (uses TenantTeamDir)
- UI topbar: show "userId (tenantName)" in user menu

* feat(ui): redesign task detail dialog with improved UX

- Split monolithic 343-line component into 5 focused files
- New header: subject as title, identifier + status badges above
- Metadata grid with soft bg-muted/30 background, priority icons
- Attachments: card-style with mime-type icons + proper download Button
- Description/Result: markdown rendering via MarkdownRenderer
- Comments: avatar circles + markdown rendering for content
- All sections collapsible with chevron + count badge
- Timeline: vertical dot-line pattern, collapsed by default
- Fix kanban card hover layout shift (opacity instead of display toggle)

* fix(security): tenant-scoped workspace paths and tool cache isolation

- Scope team workspace paths to tenant directory
- Add tenant isolation to tool cache and task reads
- Shell deny pattern improvements
- Agent resolver and context file tenant scoping
- Sidebar tenant/user display fix
- Add tests for workspace, boundary, and context file interceptor

* fix(ui): tenant visibility fallback, merge coming-soon, task detail tweaks

- Tenants page: try tenants.list (owner), fall back to tenants.mine
  for regular users; hide create button for non-owners
- Merge contacts dialog: add coming-soon banner (i18n en/vi/zh),
  disable form and submit button
- Task detail: collapse attachments/comments by default,
  guard download_url before rendering
This commit is contained in:
Viet Tran authored and GitHub committed 2026-03-23 08:08:23 +07:00
1 parent f8571dec88
commit cd022699f6
417 files changed
+13582 -4484

No files matched your search

+2 -4
View File
@@ -1,10 +1,8 @@
<p align="center">
<img src="_statics/goclaw.png" alt="GoClaw" />
<img src="_statics/goclaw-logo.svg" alt="GoClaw" height="200" />
</p>
<h1 align="center">GoClaw</h1>
<p align="center"><strong>Enterprise AI Agent Platform</strong></p>
<p align="center"><strong>Multi-Tenant AI Agent Platform</strong></p>
<p align="center">
Multi-agent AI gateway built in Go. 20+ LLM providers. 7 channels. Multi-tenant PostgreSQL.<br/>
+74
View File
@@ -0,0 +1,74 @@
<svg width="718" height="480" viewBox="0 0 718 480" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2002_138)">
<path d="M475.975 111.14C479.22 108.995 482.125 107.284 485.505 105.343C494.22 140.39 496.545 174.152 483.055 208.656C477.735 222.262 471.185 233.521 465.68 246.319C473.555 260.847 480.845 275.458 490.435 289.009C492.05 291.291 500.855 302.539 501.4 304.235C500.21 304.54 499.38 304.82 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C335.67 399.565 331.109 393.6 326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345L214.593 338.51C222.06 327.945 229.275 317.2 236.235 306.29C230.543 306.06 225.064 305.675 219.438 304.77C217.635 304.42 217.579 304.75 216.815 303.65C231.937 284.12 238.287 273.55 249.888 251.212L252.646 246.608C247.281 234.241 240.568 223.097 235.317 209.993C221.694 175.992 223.402 140.277 232.644 105.53L237.439 108.304C262.959 122.947 292.157 143.265 311.9 165.499C317.746 172.083 322.162 180.227 327.393 187.017C349.08 186.699 369.595 187.143 391.025 187.081C395.85 179.083 404.47 166.614 411.075 159.903C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#F08223"/>
<path d="M249.888 251.211C251.835 250.214 253.172 249.412 255.36 250.387C270.46 257.119 284.97 265.137 300.152 271.747C304.538 273.656 310.84 276.006 314.378 279.05L314.531 279.461C318.081 280.559 325.666 283.327 329.094 284.895C330.719 286.329 333.366 290.049 335.057 291.98C343.514 301.633 351.07 313.295 359.44 322.825C366.445 313.69 373.515 304.37 380.815 295.483C382.3 293.674 386.195 288.894 387.81 287.395C387.35 292.673 381.975 307.9 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01L429.51 303.01C430.935 302.198 433.095 302.38 434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C335.67 399.565 331.109 393.6 326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345L214.593 338.51C222.06 327.945 229.275 317.2 236.235 306.29C230.543 306.06 225.064 305.675 219.438 304.77C217.635 304.42 217.579 304.75 216.815 303.65C231.937 284.119 238.287 273.549 249.888 251.211Z" fill="#DA4525"/>
<path d="M434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455C391.715 386.09 391.415 385.555 391.285 385.125C387.405 372.375 383.51 359.05 380.835 346.01C380.65 345.115 381.09 344.14 381.445 343.42L382.22 343.615L382.225 343.04C384.98 341.915 386.945 337.445 389.505 334.9C390.545 333.34 391.54 331.825 392.65 330.32C398.095 320.98 399.355 312.075 409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01L429.51 303.01C430.935 302.199 433.095 302.38 434.83 302.353Z" fill="#F4972E"/>
<path d="M446.265 336.37C462.965 336.705 479.35 338.475 496.13 338.66C497.595 338.86 498.825 339.04 500.315 339.105L502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455C394.31 381.31 396.755 375.905 399.435 370.925C400.44 367.285 440.07 340.3 446.265 336.37Z" fill="white"/>
<path d="M500.315 339.105L502.38 339.145C498.995 342.1 475.905 352.86 469.955 356.015C446.68 368.37 430.11 378.295 409.91 394.9C408.84 394.365 407.62 393.95 406.49 393.52C413.69 383.865 445.835 349.87 455.93 347.36C465.845 344.89 476.66 343.405 486.77 341.93C491.05 341.305 495.905 341.175 500.015 339.885L500.315 339.105Z" fill="#FACA71"/>
<path d="M391.915 386.455C394.31 381.31 396.755 375.905 399.435 370.925C399.98 375.735 400.255 380.575 400.26 385.42C400.27 387.73 399.935 398.565 400.32 400.06L400.525 400.49C398.615 403.055 396.885 407.465 395.53 410.515C393.89 404.415 394.78 392.36 391.3 387.51L391.915 386.455Z" fill="#FACA71"/>
<path d="M406.49 393.52C407.62 393.95 408.84 394.365 409.91 394.9C409.08 395.73 403.185 401.9 402.89 402.05C402.735 400.975 402.965 400.88 403.325 399.83L402.92 399.505L400.525 400.49L400.32 400.06L406.49 393.52Z" fill="#F08223"/>
<path d="M389.505 334.9C393 337.065 417.915 332.855 421.125 334.505L420.16 336.995L420.67 337.15L422.305 334.91C426.325 333.77 453.35 326.36 455.235 327.165C452.62 329.84 448.625 333.655 446.265 336.37C440.07 340.3 400.44 367.285 399.435 370.925C396.755 375.905 394.31 381.31 391.915 386.455C391.715 386.09 391.415 385.555 391.285 385.125C387.405 372.375 383.51 359.05 380.835 346.01C380.65 345.115 381.09 344.14 381.445 343.42L382.22 343.615L382.225 343.04C384.98 341.915 386.945 337.445 389.505 334.9Z" fill="#F08223"/>
<path d="M446.555 305.29L447.405 305.83C452.245 304.96 478.275 305.085 482.49 306.535C489.165 317.605 495.08 326.745 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145L500.315 339.105C498.825 339.04 497.595 338.86 496.13 338.66C494.09 335.885 462.545 326.815 457.36 325.215C452.42 319.68 448.105 313.665 443.7 307.7L446.555 305.29Z" fill="#F69723"/>
<path d="M446.555 305.29L447.405 305.83C454.18 309.655 499.56 337.28 502.305 337.49L503.29 338.78C502.99 338.91 502.705 339.105 502.38 339.145L500.315 339.105C498.825 339.04 497.595 338.86 496.13 338.66C494.09 335.885 462.545 326.815 457.36 325.215C452.42 319.68 448.105 313.665 443.7 307.7L446.555 305.29Z" fill="#EC6D25"/>
<path d="M443.7 307.7C448.105 313.665 452.42 319.68 457.36 325.215C462.545 326.815 494.09 335.885 496.13 338.66C479.35 338.475 462.965 336.705 446.265 336.37C448.625 333.655 452.62 329.84 455.235 327.165L456.005 326.755L456.1 326.12C453.035 325.54 426.825 332.905 421.63 334.265C426.095 327.89 437.16 315.91 440.08 310.76C441.3 309.755 442.51 308.735 443.7 307.7Z" fill="#F69723"/>
<path d="M409.11 306.775L410.7 306.315C417.56 304.28 421.82 304.295 428.905 304.01C425.445 312.065 424.365 315.4 417.625 321.425C408.89 326.975 402.5 325.235 393.965 329.86C393.13 330.315 393.5 330.19 392.65 330.32C398.095 320.98 399.355 312.075 409.11 306.775Z" fill="white"/>
<path d="M434.83 302.353C438.73 303.125 442.8 303.945 446.555 305.29L443.7 307.7C442.51 308.735 441.3 309.755 440.08 310.76C434.505 313.14 421.24 322.815 417.625 321.425C424.365 315.4 425.445 312.065 428.905 304.01L429.51 303.01C430.935 302.199 433.095 302.38 434.83 302.353Z" fill="#DA4525"/>
<path d="M314.531 279.461C318.081 280.559 325.666 283.327 329.094 284.895C330.719 286.329 333.366 290.05 335.057 291.98C343.514 301.634 351.07 313.295 359.44 322.825C366.445 313.69 373.515 304.37 380.815 295.483C382.3 293.675 386.195 288.894 387.81 287.395C387.35 292.673 381.975 307.9 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C381.09 344.14 380.65 345.115 380.835 346.01C383.51 359.05 387.405 372.375 391.285 385.125C391.415 385.555 391.715 386.09 391.915 386.455L391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.88 405.9 376.56 407.9 375.295 409.935C373.905 408.695 372.65 407.735 371.17 406.595C365.925 406.235 358.415 405.48 353.435 406.435C350.165 406.575 345.404 406.69 343.047 409.185C341.637 407.735 341.384 407.225 340.31 405.47C342.142 402.15 341.352 380.05 341.785 374.79C343.747 350.935 344.124 326.33 331.977 304.81C327.03 296.049 320.094 288.237 314.776 279.853L314.531 279.461Z" fill="#F4972E"/>
<path d="M393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C381.09 344.14 380.65 345.115 380.835 346.01C383.51 359.05 387.405 372.375 391.285 385.125C391.415 385.555 391.715 386.09 391.915 386.455L391.3 387.51C387.425 393.09 383.415 398.565 379.26 403.94C377.84 402.61 377.385 402.05 377.28 399.92C376.4 382.1 375.52 363.255 375.31 345.51C375.25 340.65 380.975 313.44 383.33 310.125L383.875 309.675C386.765 309.525 390 309.14 392.935 308.895L393.66 308.825Z" fill="#F69723"/>
<path d="M393.66 308.825C397.965 307.925 405.28 304.495 409.11 306.775C399.355 312.075 398.095 320.98 392.65 330.32C391.54 331.825 390.545 333.34 389.505 334.9C386.945 337.445 384.98 341.915 382.225 343.04L382.22 343.615L381.445 343.42C382.455 337.06 390.25 315.39 392.935 308.895L393.66 308.825Z" fill="#DA4525"/>
<path d="M249.887 251.211C251.835 250.214 253.171 249.412 255.36 250.387C270.46 257.119 284.969 265.137 300.152 271.747C304.538 273.656 310.84 276.006 314.377 279.05L314.53 279.461L314.775 279.853C320.093 288.237 327.03 296.049 331.976 304.81C344.123 326.33 343.747 350.935 341.784 374.79C341.351 380.05 342.142 402.15 340.31 405.47C335.67 399.565 331.109 393.6 326.627 387.57L326.236 386.595C324.674 384.21 318.966 372.275 318.379 369.495C321.8 364.125 328.789 344.45 330.96 337.87C328.616 334.9 327.292 333.125 325.309 329.925C321.013 322.865 317.299 310.865 311.052 307.7C314.105 306.01 319.586 308.205 323.587 309.05C309.233 290.833 288.257 288.808 266.548 286.637C259.493 285.931 246.877 293.012 239.771 295.59C234.71 297.426 223.085 301.584 219.437 304.77C217.634 304.42 217.578 304.75 216.814 303.65C231.937 284.119 238.286 273.549 249.887 251.211Z" fill="#E75F21"/>
<path d="M330.96 337.87C332.604 339.94 333.83 342.88 335.655 344.665L336.784 344.075C337.078 345.18 337.36 346.695 337.08 347.785C335.164 355.26 329.211 381.145 326.237 386.595C324.674 384.21 318.966 372.275 318.379 369.495C321.8 364.125 328.789 344.45 330.96 337.87Z" fill="#DA4525"/>
<path d="M311.052 307.7C314.105 306.01 319.586 308.205 323.587 309.05C327.743 311.415 335.05 338.29 336.784 344.075L335.655 344.665C333.829 342.88 332.604 339.94 330.96 337.87C328.616 334.9 327.292 333.125 325.309 329.925C321.013 322.865 317.299 310.865 311.052 307.7Z" fill="#DA4525"/>
<path d="M268.114 336.32C268.887 336.33 270.77 336.415 271.335 336.78C284.292 345.12 307.051 359.89 318.097 369.395L318.379 369.495C318.966 372.275 324.674 384.21 326.237 386.595L326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91L312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345C216.817 338.915 219.08 339.04 221.099 339.045C236.778 338.265 252.45 337.355 268.114 336.32Z" fill="white"/>
<path d="M214.873 339.345C216.817 338.915 219.08 339.04 221.099 339.045L218.947 339.795C219.863 340.57 219.246 340.26 220.501 340.405C235.123 342.075 249.675 345.575 264.308 347.335C277.918 358.375 290.889 369.745 302.72 382.69C305.29 385.505 310.663 391.055 312.706 394.09C311.019 395.015 311.11 395.2 310.295 396.915C290.334 379.59 272.747 368.9 249.053 356.57C239.038 351.36 224.767 345.095 215.441 339.68L214.873 339.345Z" fill="#F08223"/>
<path d="M318.097 369.395L318.379 369.495C318.966 372.275 324.674 384.21 326.237 386.595L326.628 387.57C323.986 392.97 323.355 404.335 322.883 410.61C321.285 407.76 319.351 403.11 317.784 399.91C317.969 398.145 317.684 390.63 317.676 388.3C317.682 381.995 317.823 375.695 318.097 369.395Z" fill="#F08223"/>
<path d="M268.113 336.32L266.913 335.82L267.786 335.11C267.128 331.325 261.513 329.03 259.178 325.915C261.93 325.08 272.277 311.12 275.076 307.855C283.457 311.925 290.08 317.29 298.126 321.73C299.054 322.24 299.62 321.74 300.413 321.33C308.443 326.055 315.108 325.51 323.212 329.145C324.045 329.52 324.41 329.795 325.309 329.925C327.292 333.125 328.616 334.9 330.96 337.87C328.789 344.45 321.8 364.125 318.379 369.495L318.097 369.395C307.051 359.89 284.292 345.12 271.334 336.78C270.77 336.415 268.886 336.33 268.113 336.32Z" fill="#E85922"/>
<path d="M287.781 303.575C297.485 304.045 301.879 304.265 311.053 307.7C317.3 310.865 321.014 322.865 325.309 329.925C324.411 329.795 324.046 329.52 323.212 329.145C315.109 325.51 308.444 326.055 300.413 321.33C294.081 315.365 292.334 311.07 287.781 303.575Z" fill="white"/>
<path d="M475.975 111.14C479.22 108.995 482.125 107.284 485.505 105.343C494.22 140.39 496.545 174.152 483.055 208.656C477.735 222.262 471.185 233.521 465.68 246.319C473.555 260.847 480.845 275.458 490.435 289.009C492.05 291.291 500.855 302.539 501.4 304.235C500.21 304.54 499.38 304.82 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C478.275 305.085 452.245 304.96 447.405 305.83L446.555 305.29C442.8 303.945 438.73 303.125 434.83 302.354C433.095 302.38 430.935 302.199 429.51 303.01L428.905 304.01C421.82 304.295 417.56 304.28 410.7 306.315L409.11 306.775C405.28 304.495 397.965 307.925 393.66 308.825L392.935 308.895C390 309.14 386.765 309.525 383.875 309.675L383.33 310.125C381.975 307.9 387.35 292.674 387.81 287.395L388.675 285.191C390.86 276.813 392.71 265.406 394.29 256.695C396.565 243.899 399.02 231.136 401.65 218.408C402.865 212.441 404.09 205.534 405.76 199.734C408.57 201.681 411.785 204.312 414.53 206.447C419.4 193.235 427.11 181.252 434.905 169.619C429.295 171.723 414.485 204.218 413.525 204.599C411.235 202.926 408.93 201.274 406.61 199.641C410.015 189.8 416.955 178.363 422.98 169.762C425.32 166.43 429.985 162.012 431.42 158.457C421.49 167.084 410.295 187.99 405.56 200.062C402.89 197.768 392.865 189.414 391.025 187.081C395.85 179.083 404.47 166.614 411.075 159.903C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#F7A32A"/>
<path d="M434.905 169.618C437.66 167.012 443.08 159.28 446.455 155.702C457.205 144.317 468.31 132.77 480.515 122.928L481.275 123.109L482.24 123.35C483.5 126.867 484.245 135.769 484.56 139.633C486.62 165.019 481.185 184.667 470.58 207.316C465.555 218.054 459.865 226.927 454.66 237.048L454.88 237.673L454.345 237.792C441.635 228.163 427.165 215.247 414.53 206.446C419.4 193.235 427.11 181.252 434.905 169.618Z" fill="#E86123"/>
<path d="M434.905 169.618C437.66 167.012 443.08 159.28 446.455 155.702C457.205 144.317 468.31 132.77 480.515 122.928L481.275 123.109C467.74 141.984 449.24 167.332 438.465 187.451C445.905 185.56 453.31 183.772 460.595 181.335C453.93 188.829 445.545 198.546 439.515 206.538C444.51 207.501 451.155 209.118 455.92 209.873C450.755 213.598 445.955 217.047 441 221.061C445.38 226.021 450.615 231.842 454.66 237.048L454.88 237.673L454.345 237.792C441.635 228.163 427.165 215.247 414.53 206.446C419.4 193.235 427.11 181.252 434.905 169.618Z" fill="white"/>
<path d="M475.975 111.14C477.15 110.878 478.335 110.631 479.52 110.398C479.195 111.261 479.29 111.554 478.475 112.028C472.55 115.474 434.835 152.825 431.42 158.457C421.49 167.084 410.295 187.99 405.56 200.062C402.89 197.768 392.865 189.413 391.025 187.081C395.85 179.082 404.47 166.614 411.075 159.902C425.385 145.359 458.04 119.627 475.975 111.14Z" fill="#EC6D25"/>
<path d="M453.14 286.783C460.12 285.369 483.665 298.451 491.255 300.772C492.935 301.286 496.96 303.47 498.14 304.85C492.925 305.455 487.71 306.015 482.49 306.535C478.275 305.085 452.245 304.96 447.405 305.83L446.555 305.29C442.8 303.945 438.73 303.125 434.83 302.353C437.745 297.95 452.2 291.317 454.275 287.336L453.14 286.783Z" fill="#F08223"/>
<path d="M393.66 308.825C394.06 308.075 394.18 307.74 394.745 307.115C409.915 290.309 432.69 288.779 453.14 286.783L454.275 287.336C452.2 291.317 437.745 297.95 434.83 302.353C433.095 302.38 430.935 302.198 429.51 303.01L428.905 304.01C421.82 304.295 417.56 304.28 410.7 306.315L409.11 306.775C405.28 304.495 397.965 307.925 393.66 308.825Z" fill="#EC6D25"/>
<path d="M388.675 285.191L406.465 278.65C399.885 284.628 386.8 301.284 383.875 309.675L383.33 310.125C381.975 307.9 387.35 292.674 387.81 287.395L388.675 285.191Z" fill="#FACA71"/>
<path d="M232.644 105.53L237.438 108.304C240.228 113.372 250.138 121.211 254.514 125.613C278.743 149.989 299.494 166.835 312.917 199.525C311.366 200.7 304.995 205.971 303.838 206.04C300.248 209.239 264.434 237.583 262.412 238.139C259.443 240.534 255.073 244.83 252.646 246.608C247.28 234.241 240.567 223.097 235.317 209.993C221.693 175.992 223.401 140.277 232.644 105.53Z" fill="#DA4525"/>
<path d="M232.644 105.53L237.438 108.304C240.228 113.372 250.138 121.211 254.514 125.613C278.743 149.989 299.494 166.835 312.917 199.525C311.366 200.7 304.995 205.971 303.838 206.04C292.202 176.579 269.233 151.382 246.013 130.29C243.454 127.965 240.506 125.001 237.649 123.202L236.68 123.214C233.512 127.179 233.319 157.573 234.522 167.705C237.069 189.174 251.852 217.614 262.359 237.084C262.468 237.162 262.578 237.239 262.687 237.317L262.412 238.139C259.443 240.534 255.073 244.83 252.646 246.608C247.28 234.241 240.567 223.097 235.317 209.993C221.693 175.992 223.401 140.277 232.644 105.53Z" fill="#E75F21"/>
<path d="M237.649 123.202C240.506 125.001 243.454 127.965 246.013 130.29C269.233 151.382 292.202 176.579 303.838 206.04C300.248 209.239 264.434 237.583 262.412 238.139L262.687 237.317C267.458 232.339 272.612 226.212 277.221 220.982C272.507 217.272 267.82 213.514 262.958 210.002C268.747 208.621 273.289 207.607 279.187 206.655C272.583 198.399 265.111 189.932 258.254 181.779C264.544 183.94 273.329 186.081 280.072 188.117C265.558 163.876 253.103 146.274 237.649 123.202Z" fill="white"/>
<path d="M312.917 199.525C313.842 208.746 329.501 283.62 329.093 284.895C325.665 283.328 318.08 280.56 314.53 279.462L314.377 279.051C310.84 276.007 304.538 273.657 300.152 271.747C284.969 265.137 270.46 257.12 255.36 250.388C253.171 249.412 251.835 250.214 249.887 251.212L252.646 246.608C255.073 244.83 259.443 240.534 262.412 238.139C264.434 237.583 300.248 209.239 303.838 206.04C304.995 205.971 311.366 200.7 312.917 199.525Z" fill="#EC6D25"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C621.565 367.67 618.1 372.775 615.505 376.62C603.8 393.985 590.755 407.065 573.195 418.535C558.59 427.585 542.505 433.99 525.68 437.455C474.11 448.47 409.665 436.945 367.58 474.325L362.02 479.92C364.675 471.09 369.22 460.1 373.15 451.81C385.24 447.205 396.37 434.76 402.66 423.43C404.7 419.76 406.64 409.95 409.285 407C421.92 392.885 440.665 380.49 458.075 373.04C463.07 370.96 472.755 369.86 478.055 368.64C491.315 365.58 503.22 362.38 515.38 356.08C554.775 335.245 570.47 308.165 583.605 268.238C594.21 261.405 609.565 253.776 621.56 250.225Z" fill="#E75F21"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C621.565 367.67 618.1 372.775 615.505 376.62C603.8 393.985 590.755 407.065 573.195 418.535C571.44 414.48 545.975 385.205 542.01 381.33C545.73 379.58 548.56 376.67 551.55 373.895C553.47 369.34 558.85 361.395 561.64 356.86C565.865 349.91 569.865 342.82 573.625 335.605C579.675 324.1 585.435 312.17 590.735 300.303C593.075 295.052 595.375 289.951 598.375 285.025L598.865 284.239L598.58 283.248L596.69 282.524C595.035 283.259 592.67 284.079 590.925 284.746C598.69 274.913 607.395 265.636 616.01 256.539C617.96 254.482 619.98 252.581 621.56 250.225Z" fill="#F69723"/>
<path d="M621.56 250.225C649.19 241.166 690.17 245.625 717.205 256.44C709.7 259.501 703.805 261.849 696.72 265.928C667.78 282.589 653.15 309.35 637.985 337.57C635.265 342.625 626.38 359.54 623.34 363.51C623.565 360.315 625.67 352.15 626.47 348.71L634.525 314.865C623.65 320.42 611.72 325.83 600.605 331.08C607.7 312.03 616.01 292.932 623.54 273.98C621.255 274.578 619.18 275.355 616.955 276.14C611.51 277.698 601.165 281.561 596.69 282.524C595.035 283.259 592.67 284.079 590.925 284.746C598.69 274.913 607.395 265.636 616.01 256.539C617.96 254.482 619.98 252.581 621.56 250.225Z" fill="#FACA71"/>
<path d="M616.955 276.139C618.865 276.349 619.025 276.37 620.78 275.62L621.765 275.983C621.55 276.499 620.36 279.537 620.27 279.703C618.645 282.815 616.2 286.393 614.46 289.437C602.325 310.605 571.635 360.585 551.55 373.895C553.47 369.34 558.85 361.395 561.64 356.86C565.865 349.91 569.865 342.82 573.625 335.605C579.675 324.1 585.435 312.17 590.735 300.302C593.075 295.051 595.375 289.951 598.375 285.024L598.865 284.238L598.58 283.248L596.69 282.524C601.165 281.56 611.51 277.697 616.955 276.139Z" fill="#F08223"/>
<path d="M367.58 474.325C369.175 468.785 376.94 462.325 380.92 458.04C402.63 434.675 432.38 421.595 462.36 412.475C487.43 404.85 512.63 399.29 535.565 386.03C537.88 384.695 539.795 382.84 542.01 381.33C545.975 385.205 571.44 414.48 573.195 418.535C558.59 427.585 542.505 433.99 525.68 437.455C474.11 448.47 409.665 436.945 367.58 474.325Z" fill="#EC6D25"/>
<path d="M0.801025 256.56C25.0185 247.36 54.1575 243.004 79.764 246.599C84.7195 247.295 91.8315 248.471 96.5195 250.184C107.353 252.657 125.73 261.55 134.908 268.389C151.273 321.035 182.319 357.225 239.866 368.71C259.977 372.725 273.389 377.2 289.764 390.215C305.109 401.825 309.611 404.04 315.084 422.56C317.848 431.91 335.17 447.435 344.74 451.765C348.92 460.1 353.64 470.705 356.47 479.625C353.74 476.995 350.935 474.185 348 471.815C341.661 467.07 335.043 462.49 327.807 459.22C279.619 437.72 224.65 448.955 175.055 432.73C142.825 422.185 109.913 394.42 94.6255 363.56C93.1065 361.485 91.6015 358.645 90.351 356.335C72.476 323.335 56.065 285.647 22.0795 265.999C15.0615 261.942 8.32553 259.394 0.801025 256.56Z" fill="#E25424"/>
<path d="M96.5196 250.183C107.353 252.656 125.73 261.55 134.908 268.389C151.273 321.035 182.319 357.225 239.866 368.71C259.977 372.725 273.389 377.2 289.764 390.215C305.109 401.825 309.611 404.04 315.084 422.56C317.848 431.91 335.17 447.435 344.74 451.765C348.92 460.1 353.64 470.705 356.47 479.625C353.74 476.995 350.935 474.185 348 471.815C346.347 465.985 337.908 458.67 333.511 454.355C300.489 421.36 253.876 412.4 211.091 398.675C161.126 382.655 136.715 345.09 111.239 302.361C107.851 296.679 100.658 280.062 96.1736 276.477C95.9266 276.2 95.6466 275.431 95.4761 275.041C98.1241 274.429 122.162 282.64 126.602 284.128C121.639 277.875 116.417 271.832 110.95 266.014C107.991 262.893 98.3261 253.311 96.5196 250.183Z" fill="#DA4525"/>
<path d="M0.801025 256.56C25.0185 247.36 54.1575 243.004 79.764 246.599C84.7195 247.295 91.8315 248.471 96.5195 250.184C98.326 253.311 107.991 262.893 110.95 266.015C116.417 271.833 121.639 277.876 126.602 284.128C122.162 282.641 98.124 274.43 95.476 275.041C95.6465 275.431 95.9265 276.201 96.1735 276.477C98.524 284.568 102.091 292.191 105.352 299.903C109.836 310.48 114.246 321.085 118.583 331.725C112.441 327.625 90.966 318.075 83.428 315.2C85.304 322.65 95.0385 358.885 94.6255 363.56C93.1065 361.485 91.6015 358.645 90.351 356.335C72.476 323.335 56.065 285.647 22.0795 265.999C15.0615 261.942 8.32553 259.394 0.801025 256.56Z" fill="#EC6D25"/>
<path d="M36.5449 144.41C39.9939 142.362 43.5309 140.466 47.1459 138.728C71.5904 126.965 101.828 120.272 128.784 122.526C133.934 122.956 142.546 124.004 147.441 125.707C179.429 133.995 200.18 152.645 216.845 180.687C220.918 187.54 221.171 191.645 223.036 199.172C227.129 215.7 236.727 231.113 243.669 246.443C238.264 256.707 231.491 269.571 224.834 279.118C218.987 288.737 209.926 299.947 203.02 309.135C209.213 310.84 215.524 312.075 221.813 313.395C216.298 322.785 208.814 332.995 202.483 341.975C209.473 346.055 217.54 349.635 224.721 353.415C227.875 355.075 231.937 356.795 234.787 358.805C186.018 347.505 155.923 311.75 142.857 265.626C140.49 257.27 135.466 248.348 133.181 239.672C130.294 234.691 126.858 225.314 124.571 219.9C106.437 176.964 83.9889 152.799 36.5449 144.41Z" fill="#DA4525"/>
<path d="M147.441 125.708C179.429 133.996 200.18 152.646 216.845 180.688C220.918 187.54 221.171 191.646 223.036 199.172C227.129 215.701 236.727 231.114 243.669 246.444C238.264 256.708 231.491 269.571 224.834 279.119C219.383 273.408 210.999 265.446 206.282 259.418C189.485 237.952 176.496 211.879 163.029 188.069L149.288 164.041C148.577 162.77 142.818 152.392 142.458 152.068L141.922 153.256C140.917 151.391 139.8 148.753 138.889 146.775C152.344 148.295 166.196 150.78 179.569 153.061C173.076 147.624 166.488 142.3 159.809 137.094C156.534 134.519 149.974 129.652 147.218 126.9L147.441 125.708Z" fill="#E75F21"/>
<path d="M36.5449 144.41C39.9939 142.362 43.5309 140.466 47.1459 138.728C71.5904 126.965 101.828 120.272 128.784 122.526C133.934 122.956 142.546 124.004 147.441 125.707L147.218 126.899C149.974 129.651 156.534 134.519 159.809 137.093C166.488 142.3 173.076 147.623 179.569 153.061C166.196 150.779 152.344 148.294 138.889 146.775C139.8 148.752 140.917 151.39 141.922 153.255C148.71 169.117 156.304 188.254 163.529 203.623C158.986 200.799 151.773 197.847 146.733 195.59L119.912 183.552C121.851 190.84 134.038 235.869 133.181 239.672C130.294 234.691 126.858 225.314 124.571 219.9C106.437 176.964 83.9889 152.799 36.5449 144.41Z" fill="#ED8337"/>
<path d="M572.35 125.308C598.135 118.311 634.385 123.835 659.05 133.56C667.205 136.776 674.455 140.479 682.24 144.456C650.39 150.413 629.28 160.685 610.45 188.3C604.515 197.12 599.365 206.448 595.07 216.173C592.01 223.087 589.27 230.474 585.895 237.176C583.285 245.531 579.88 253.913 576.95 262.181C569.995 281.821 561.8 304.92 547.345 320.385C545.005 313.765 539.525 302.994 536.43 296.405C529.665 282.164 523.095 267.832 516.71 253.414C515.515 254.523 512.315 258.123 511.51 259.438C507.95 265.256 496.115 273.944 493.63 279.473C488.625 273.581 478.345 253.189 474.55 245.828C481.17 232.857 487.5 220.572 492.62 206.947C496.135 197.588 496.09 188.852 501.115 180.584C518.81 151.447 539.23 134.098 572.35 125.308Z" fill="#F4972E"/>
<path d="M572.35 125.308C571.9 126.426 541.25 151.291 538.465 153.402C551.965 150.871 565.5 148.554 579.07 146.452C577.7 149.784 575.985 154.186 574.265 157.334C571.2 160.404 569.28 162.849 567.125 166.646C554.035 189.76 540.79 212.81 527.875 236.027C526.28 238.898 517.22 251.854 516.71 253.413C515.515 254.522 512.315 258.123 511.51 259.438C507.95 265.255 496.115 273.944 493.63 279.473C488.625 273.58 478.345 253.189 474.55 245.828C481.17 232.857 487.5 220.571 492.62 206.947C496.135 197.587 496.09 188.852 501.115 180.583C518.81 151.447 539.23 134.098 572.35 125.308Z" fill="#F08223"/>
<path d="M572.35 125.308C598.135 118.311 634.385 123.835 659.05 133.56C667.205 136.776 674.455 140.479 682.24 144.456C650.39 150.413 629.28 160.685 610.45 188.3C604.515 197.12 599.365 206.448 595.07 216.173C592.01 223.087 589.27 230.474 585.895 237.176C585.96 233.281 588.37 224.496 589.285 220.119C591.865 207.775 594.93 195.414 597.98 183.173C584.925 189.586 568.445 197.745 555.145 203.218C561.26 188.217 568.495 172.179 574.265 157.335C575.985 154.187 577.7 149.784 579.07 146.453C565.5 148.555 551.965 150.872 538.465 153.403C541.25 151.292 571.9 126.426 572.35 125.308Z" fill="#FACA71"/>
<path d="M277.512 2.78054L275.877 1.43053L275.913 0.699528C278.475 0.131028 286.904 0.450029 289.772 0.663029C314.831 2.52053 340.798 9.97953 361.985 23.594C364.84 25.4275 372.805 30.894 375.17 33.285C394.42 48.914 406.245 70.157 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.86 402.795 156.754 400.035 160.209C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.561 355.41 178.558L330.998 179.014C319.783 160.43 308.678 149.399 292.3 135.801C295.777 131.081 298.905 124.877 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78054Z" fill="#E75F21"/>
<path d="M277.512 2.78054L275.877 1.43053L275.913 0.699528C278.475 0.131028 286.904 0.450029 289.772 0.663029C314.831 2.52053 340.798 9.97953 361.985 23.594C364.84 25.4275 372.805 30.894 375.17 33.285C375.615 36.569 391.19 64.5035 393.845 70.404C384.445 65.329 371.97 57.674 362.595 53.7195L362.61 68.776C362.32 77.4535 361.005 102.597 359.305 111.152C358.17 110.762 358.705 111.024 357.815 109.978C348.595 101.215 339.085 90.766 329.741 81.58C323.576 90.476 317.497 99.4315 311.506 108.446C310.513 109.957 306.463 115.948 305.891 117.308C304.136 118.823 303.897 118.982 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78054Z" fill="#FACA71"/>
<path d="M277.512 2.78052C284.638 2.42802 307.217 19.9145 312.684 24.762C332.687 42.5 342.664 66.495 351.76 90.9685C353.605 95.937 357.215 105.145 357.815 109.978C348.595 101.215 339.085 90.766 329.741 81.58C323.576 90.476 317.497 99.4315 311.506 108.446C310.513 109.957 306.463 115.948 305.891 117.308C304.136 118.823 303.897 118.982 301.681 119.644C307.198 105.851 311.598 93.812 311.65 78.6955C311.762 46.352 299.06 25.0135 277.512 2.78052Z" fill="#F4972E"/>
<path d="M375.17 33.285C394.42 48.914 406.245 70.157 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.86 402.795 156.754 400.035 160.209C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.561 355.41 178.558C356.92 172.869 365.44 163.225 364.575 159.044L364.835 158.695C368.895 132.116 371.065 103.652 367.4 76.9855C366.535 70.721 365.485 62.97 363.125 57.1175C363.105 60.4655 363.26 65.67 362.61 68.776L362.595 53.7195C371.97 57.674 384.445 65.329 393.845 70.404C391.19 64.5035 375.615 36.569 375.17 33.285Z" fill="#F08223"/>
<path d="M364.835 158.695L365.335 159.336C366.41 159.804 366.925 158.388 367.76 157.297C382.32 138.281 395.435 117.849 407.13 96.9625C408 95.4115 408.71 95.0415 410.285 94.5105C412.52 113.337 410.665 130.468 405.575 148.655C404.4 152.859 402.795 156.753 400.035 160.208C395.065 166.337 390.785 172.476 386.63 179.2C376.23 178.775 365.82 178.56 355.41 178.557C356.92 172.869 365.44 163.225 364.575 159.044L364.835 158.695Z" fill="#EC6D25"/>
<path d="M305.891 117.307C315.324 124.841 327.596 132.809 337.444 140.221C343.616 144.868 358.225 155.846 364.575 159.044C365.44 163.225 356.92 172.869 355.41 178.557L330.998 179.013C319.784 160.429 308.678 149.398 292.301 135.801C295.778 131.081 298.905 124.877 301.682 119.644C303.898 118.982 304.137 118.822 305.891 117.307Z" fill="#DA4525"/>
<path d="M391.3 387.51C394.78 392.36 393.89 404.415 395.53 410.515C396.885 407.465 398.615 403.055 400.525 400.49L402.92 399.505L403.325 399.83C402.965 400.88 402.735 400.975 402.89 402.05L402.535 402.7C399.655 408.015 398.105 413.93 395.85 418.79C392.56 425.88 380.88 439.145 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2C338.889 439.77 326.289 427.235 323.247 420.585C318.345 409.865 319.43 405.595 310.295 396.915C311.11 395.2 311.019 395.015 312.706 394.09L317.784 399.91C319.351 403.11 321.285 407.76 322.883 410.61C323.355 404.335 323.986 392.97 326.628 387.57C331.109 393.6 335.67 399.565 340.31 405.47C341.384 407.225 341.637 407.735 343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C376.56 407.9 377.88 405.9 379.26 403.94C383.415 398.565 387.425 393.09 391.3 387.51Z" fill="white"/>
<path d="M353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C377.705 414.175 380.255 416.815 377.215 421.29C371.795 426.65 367.335 429.325 361.405 433.885C360.82 434.335 360.445 435.205 360.31 435.96L360.045 435.97L358.725 434.39C353.57 431.78 343.19 425.065 340.459 419.45C339.283 417.03 341.936 411.78 343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435Z" fill="#DA4525"/>
<path d="M343.047 409.185C345.404 406.69 350.165 406.575 353.435 406.435C355.745 406.565 356 406.215 357.685 407.275C358.015 409.36 357.065 410.69 356.205 412.93C354.165 413.615 352.455 414.045 350.65 415.21L350.685 415.725C352.32 416.065 352.57 416.035 354.185 416.05C357.665 419.43 358.67 429.75 358.725 434.39C353.57 431.78 343.19 425.065 340.459 419.45C339.283 417.03 341.936 411.78 343.047 409.185Z" fill="#DA4525"/>
<path d="M353.435 406.435C358.415 405.48 365.925 406.235 371.17 406.595C372.65 407.735 373.905 408.695 375.295 409.935C377.705 414.175 380.255 416.815 377.215 421.29C375.29 419.28 373.645 418.815 371.065 417.705C369.065 417.25 366.495 416.96 364.42 416.665C361.13 416.565 357.5 416.26 354.185 416.05C352.57 416.035 352.32 416.065 350.685 415.725L350.65 415.21C352.455 414.045 354.165 413.615 356.205 412.93C357.065 410.69 358.015 409.36 357.685 407.275C356 406.215 355.745 406.565 353.435 406.435Z" fill="#E75F21"/>
<path d="M356.205 412.93C358.905 412.9 361.175 412.78 363.855 413.19L364.42 416.665C361.13 416.565 357.5 416.26 354.185 416.05C352.57 416.035 352.32 416.065 350.685 415.725L350.65 415.21C352.455 414.045 354.165 413.615 356.205 412.93Z" fill="#F69723"/>
<path d="M363.855 413.19C367.75 413.97 368.875 414.365 371.065 417.705C369.065 417.25 366.495 416.96 364.42 416.665L363.855 413.19Z" fill="#FACA71"/>
<path d="M391.3 387.51C394.78 392.36 393.89 404.415 395.53 410.515C393.755 419.015 392.145 422.945 386.235 429.465C378.375 438.13 371.005 438.17 360.31 435.96C360.445 435.205 360.82 434.335 361.405 433.885C367.335 429.325 371.795 426.65 377.215 421.29C380.255 416.815 377.705 414.175 375.295 409.935C376.56 407.9 377.88 405.9 379.26 403.94C383.415 398.565 387.425 393.09 391.3 387.51Z" fill="white"/>
<path d="M310.295 396.915C311.11 395.2 311.019 395.015 312.706 394.09L317.784 399.91C319.351 403.11 321.285 407.76 322.883 410.61C325.461 429.71 340.655 441.145 360.045 435.97L360.31 435.96C371.005 438.17 378.375 438.13 386.235 429.465C392.145 422.945 393.755 419.015 395.53 410.515C396.885 407.465 398.615 403.055 400.525 400.49L402.92 399.505L403.325 399.83C402.965 400.88 402.735 400.975 402.89 402.05L402.535 402.7C399.655 408.015 398.105 413.93 395.85 418.79C392.56 425.88 380.88 439.145 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2C338.889 439.77 326.289 427.235 323.247 420.585C318.345 409.865 319.43 405.595 310.295 396.915Z" fill="#DA4525"/>
<path d="M345.152 443.2C348.635 441.88 367.265 440.27 371.56 441.395C372.025 441.515 373.65 442.595 374.165 442.94C373.72 443.15 373.27 443.35 372.815 443.54C362.92 447.725 354.82 447.155 345.152 443.2Z" fill="#F08223"/>
<path d="M493.63 279.473C496.115 273.944 507.95 265.255 511.51 259.438C512.315 258.123 515.515 254.523 516.71 253.414C523.095 267.832 529.665 282.164 536.43 296.405C539.525 302.994 545.005 313.765 547.345 320.385C531.46 338.03 505.54 355.425 481.965 358.905C493.275 353.14 504.665 347.535 516.13 342.095C508.71 332.79 502.46 323.37 496.09 313.35C502.59 312.415 508.96 310.875 515.345 309.365C508.2 299.179 500.235 290.169 493.63 279.473Z" fill="#EC6D25"/>
</g>
<defs>
<clipPath id="clip0_2002_138">
<rect width="718" height="480" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

+104
View File
@@ -0,0 +1,104 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_7)">
<path d="M642.66 275.875C646.285 273.484 649.53 271.577 653.305 269.414C663.04 308.476 665.637 346.107 650.568 384.565C644.626 399.73 637.31 412.278 631.161 426.543C639.957 442.735 648.1 459.021 658.812 474.125C660.616 476.668 670.451 489.204 671.06 491.095C669.73 491.435 668.803 491.747 667.418 491.781C661.593 492.455 655.768 493.079 649.937 493.659C657.393 505.997 664 516.185 672.071 528.161L673.171 529.599C672.836 529.743 672.517 529.961 672.154 530.005C668.373 533.299 642.582 545.292 635.936 548.808C609.938 562.579 591.429 573.641 568.866 592.149C567.939 593.074 561.354 599.951 561.025 600.118C560.852 598.92 561.109 598.814 561.511 597.644L561.058 597.282L558.383 598.379C556.25 601.238 554.317 606.154 552.804 609.553C550.972 602.754 551.966 589.318 548.079 583.912C543.751 590.132 539.271 596.234 534.63 602.225C533.089 604.409 531.614 606.639 530.201 608.907C528.649 607.525 527.247 606.455 525.594 605.184C519.735 604.783 511.347 603.941 505.784 605.006C502.131 605.162 496.813 605.29 494.181 608.071C492.605 606.455 492.323 605.886 491.123 603.93C485.941 597.348 480.846 590.7 475.84 583.979C472.89 589.998 472.184 602.665 471.658 609.659C469.873 606.482 467.712 601.3 465.962 597.733L460.289 591.246C458.405 592.277 458.507 592.483 457.597 594.395C435.301 575.085 415.656 563.17 389.19 549.427C378.003 543.62 362.062 536.637 351.646 530.602L351.011 530.228L350.699 529.298C359.039 517.522 367.098 505.546 374.872 493.386C368.514 493.129 362.394 492.7 356.11 491.692C354.096 491.301 354.033 491.669 353.18 490.443C370.072 468.675 377.164 456.894 390.122 431.996L393.204 426.865C387.21 413.081 379.712 400.66 373.847 386.055C358.63 348.158 360.538 308.35 370.861 269.622L376.217 272.714C404.723 289.034 437.337 311.681 459.389 336.462C465.92 343.801 470.852 352.878 476.695 360.445C500.919 360.091 523.835 360.586 547.772 360.517C553.161 351.602 562.79 337.705 570.167 330.225C586.152 314.015 622.627 285.334 642.66 275.875Z" fill="#F08223"/>
<path d="M390.122 431.996C392.298 430.884 393.79 429.99 396.235 431.078C413.102 438.581 429.309 447.517 446.267 454.885C451.166 457.013 458.206 459.632 462.157 463.025L462.328 463.483C466.293 464.707 474.766 467.792 478.595 469.539C480.41 471.138 483.367 475.284 485.256 477.436C494.702 488.196 503.142 501.193 512.492 511.815C520.316 501.634 528.213 491.246 536.367 481.34C538.026 479.325 542.377 473.996 544.181 472.326C543.667 478.209 537.663 495.18 539.176 497.66L539.785 497.159C543.013 496.991 546.627 496.562 549.905 496.289L550.715 496.211C555.524 495.208 563.694 491.385 567.972 493.926L569.749 493.414C577.411 491.145 582.169 491.162 590.083 490.844L590.759 489.73C592.351 488.825 594.764 489.028 596.702 488.998C601.058 489.858 605.604 490.772 609.798 492.271L610.748 492.873C616.154 491.903 645.229 492.043 649.937 493.659C657.393 505.997 664 516.185 672.071 528.161L673.171 529.599C672.836 529.743 672.517 529.961 672.154 530.005C668.373 533.299 642.582 545.292 635.936 548.808C609.938 562.579 591.429 573.641 568.866 592.149C567.939 593.074 561.354 599.951 561.025 600.118C560.852 598.92 561.109 598.814 561.511 597.644L561.058 597.282L558.383 598.379C556.25 601.238 554.317 606.154 552.804 609.553C550.972 602.754 551.966 589.318 548.079 583.912C543.751 590.132 539.271 596.234 534.63 602.225C533.089 604.409 531.614 606.639 530.201 608.907C528.649 607.525 527.247 606.455 525.594 605.184C519.735 604.783 511.347 603.941 505.784 605.006C502.131 605.162 496.813 605.29 494.181 608.071C492.605 606.455 492.323 605.886 491.123 603.93C485.941 597.348 480.846 590.7 475.84 583.979C472.89 589.998 472.184 602.665 471.658 609.659C469.873 606.482 467.712 601.3 465.962 597.733L460.289 591.246C458.405 592.277 458.507 592.483 457.597 594.395C435.301 575.085 415.656 563.17 389.19 549.427C378.003 543.62 362.062 536.637 351.646 530.602L351.011 530.228L350.699 529.298C359.039 517.522 367.098 505.546 374.872 493.386C368.514 493.129 362.394 492.7 356.11 491.692C354.096 491.301 354.033 491.669 353.18 490.443C370.072 468.675 377.164 456.894 390.122 431.996Z" fill="#DA4525"/>
<path d="M596.701 488.998C601.058 489.858 605.604 490.772 609.798 492.271L610.748 492.873C616.154 491.903 645.229 492.043 649.937 493.659C657.393 505.997 664 516.185 672.07 528.161L673.171 529.599C672.836 529.743 672.517 529.961 672.154 530.005C668.373 533.299 642.582 545.292 635.936 548.808C609.938 562.579 591.429 573.641 568.866 592.149C567.939 593.074 561.354 599.951 561.025 600.118C560.852 598.92 561.109 598.814 561.511 597.644L561.058 597.282L558.383 598.379C556.25 601.238 554.317 606.154 552.804 609.553C550.972 602.754 551.966 589.318 548.079 583.912L548.766 582.736C548.542 582.33 548.207 581.733 548.062 581.254C543.728 567.043 539.377 552.191 536.39 537.657C536.183 536.659 536.674 535.573 537.071 534.77L537.937 534.988L537.942 534.347C541.019 533.093 543.214 528.111 546.074 525.274C547.236 523.535 548.347 521.847 549.587 520.169C555.669 509.759 557.076 499.834 567.972 493.926L569.748 493.414C577.411 491.145 582.169 491.162 590.083 490.844L590.759 489.73C592.351 488.825 594.764 489.028 596.701 488.998Z" fill="#F4972E"/>
<path d="M609.474 526.912C628.128 527.286 646.43 529.259 665.173 529.465C666.81 529.688 668.183 529.888 669.848 529.961L672.154 530.005C668.373 533.299 642.582 545.292 635.936 548.808C609.938 562.579 591.429 573.641 568.866 592.149C567.939 593.074 561.354 599.951 561.025 600.118C560.852 598.92 561.109 598.814 561.511 597.644L561.058 597.282L558.383 598.379C556.25 601.238 554.317 606.154 552.804 609.553C550.972 602.754 551.966 589.318 548.079 583.912L548.766 582.736C551.441 577.002 554.172 570.977 557.166 565.427C558.288 561.37 602.555 531.293 609.474 526.912Z" fill="white"/>
<path d="M669.848 529.961L672.154 530.005C668.373 533.299 642.582 545.292 635.936 548.808C609.938 562.579 591.429 573.641 568.866 592.149C567.671 591.553 566.308 591.09 565.046 590.611C573.088 579.85 608.994 541.959 620.27 539.162C631.345 536.409 643.425 534.753 654.718 533.109C659.499 532.413 664.922 532.268 669.513 530.83L669.848 529.961Z" fill="#FACA71"/>
<path d="M548.766 582.736C551.441 577.002 554.172 570.977 557.166 565.427C557.774 570.788 558.082 576.183 558.087 581.583C558.098 584.157 557.724 596.234 558.154 597.9L558.383 598.379C556.25 601.238 554.317 606.154 552.804 609.553C550.972 602.754 551.966 589.318 548.079 583.912L548.766 582.736Z" fill="#FACA71"/>
<path d="M565.046 590.611C566.308 591.09 567.671 591.553 568.866 592.149C567.939 593.074 561.354 599.951 561.025 600.118C560.852 598.92 561.109 598.814 561.511 597.644L561.058 597.282L558.383 598.379L558.154 597.9L565.046 590.611Z" fill="#F08223"/>
<path d="M546.074 525.274C549.978 527.687 577.808 522.995 581.393 524.834L580.315 527.609L580.885 527.782L582.711 525.285C587.201 524.014 617.388 515.755 619.494 516.653C616.573 519.634 612.11 523.886 609.474 526.912C602.555 531.293 558.288 561.37 557.166 565.427C554.172 570.977 551.441 577.002 548.766 582.736C548.542 582.329 548.207 581.733 548.062 581.254C543.728 567.043 539.377 552.191 536.39 537.657C536.183 536.659 536.674 535.573 537.071 534.77L537.937 534.988L537.942 534.347C541.019 533.093 543.214 528.111 546.074 525.274Z" fill="#F08223"/>
<path d="M609.798 492.271L610.748 492.873C616.154 491.903 645.229 492.043 649.937 493.659C657.393 505.997 664 516.185 672.07 528.161L673.171 529.599C672.836 529.743 672.517 529.961 672.154 530.005L669.848 529.961C668.183 529.888 666.809 529.688 665.173 529.465C662.894 526.372 627.659 516.263 621.867 514.479C616.349 508.31 611.53 501.606 606.609 494.957L609.798 492.271Z" fill="#F69723"/>
<path d="M609.798 492.271L610.748 492.873C618.315 497.136 669.004 527.927 672.07 528.161L673.171 529.599C672.836 529.743 672.517 529.961 672.154 530.005L669.848 529.961C668.183 529.888 666.809 529.688 665.173 529.465C662.894 526.372 627.659 516.263 621.867 514.479C616.349 508.31 611.53 501.606 606.609 494.957L609.798 492.271Z" fill="#EC6D25"/>
<path d="M606.609 494.957C611.53 501.606 616.349 508.31 621.867 514.479C627.659 516.263 662.894 526.372 665.173 529.465C646.43 529.259 628.128 527.286 609.474 526.912C612.11 523.886 616.573 519.634 619.494 516.653L620.354 516.196L620.46 515.488C617.036 514.841 587.76 523.05 581.957 524.566C586.945 517.461 599.304 504.108 602.566 498.368C603.928 497.248 605.28 496.111 606.609 494.957Z" fill="#F69723"/>
<path d="M567.972 493.926L569.748 493.414C577.411 491.145 582.169 491.162 590.083 490.844C586.219 499.822 585.012 503.54 577.484 510.255C567.727 516.441 560.589 514.501 551.056 519.656C550.123 520.164 550.536 520.024 549.587 520.169C555.669 509.759 557.076 499.834 567.972 493.926Z" fill="white"/>
<path d="M596.702 488.998C601.058 489.858 605.604 490.772 609.798 492.271L606.609 494.957C605.28 496.111 603.928 497.248 602.566 498.368C596.338 501.021 581.522 511.804 577.484 510.255C585.012 503.54 586.219 499.822 590.083 490.844L590.759 489.73C592.351 488.825 594.764 489.028 596.702 488.998Z" fill="#DA4525"/>
<path d="M462.328 463.483C466.293 464.707 474.766 467.792 478.595 469.539C480.41 471.138 483.367 475.284 485.256 477.436C494.702 488.196 503.142 501.193 512.491 511.815C520.316 501.634 528.213 491.246 536.367 481.34C538.026 479.325 542.377 473.996 544.18 472.326C543.667 478.209 537.663 495.18 539.176 497.66L539.785 497.159C543.013 496.991 546.627 496.562 549.905 496.289L550.715 496.211C555.524 495.208 563.694 491.385 567.972 493.926C557.076 499.834 555.669 509.759 549.587 520.169C548.347 521.847 547.235 523.535 546.074 525.274C543.214 528.111 541.019 533.093 537.942 534.347L537.936 534.988L537.071 534.77C536.674 535.573 536.183 536.659 536.389 537.657C539.377 552.191 543.728 567.043 548.062 581.254C548.207 581.733 548.542 582.33 548.766 582.736L548.079 583.912C543.75 590.132 539.271 596.234 534.63 602.225C533.089 604.409 531.614 606.639 530.201 608.907C528.649 607.525 527.247 606.455 525.594 605.184C519.735 604.783 511.347 603.941 505.784 605.006C502.131 605.162 496.813 605.29 494.181 608.071C492.605 606.455 492.322 605.886 491.123 603.93C493.17 600.23 492.287 575.597 492.77 569.735C494.962 543.146 495.383 515.722 481.815 491.736C476.29 481.971 468.542 473.265 462.602 463.919L462.328 463.483Z" fill="#F4972E"/>
<path d="M550.715 496.211C555.524 495.208 563.694 491.385 567.972 493.926C557.076 499.834 555.669 509.759 549.587 520.169C548.347 521.847 547.235 523.535 546.074 525.274C543.214 528.111 541.019 533.093 537.942 534.347L537.936 534.988L537.071 534.77C536.674 535.573 536.183 536.659 536.39 537.657C539.377 552.191 543.728 567.043 548.062 581.254C548.207 581.733 548.542 582.33 548.766 582.736L548.079 583.912C543.75 590.132 539.271 596.234 534.63 602.225C533.044 600.742 532.536 600.118 532.419 597.744C531.436 577.882 530.453 556.878 530.218 537.1C530.151 531.683 536.546 501.355 539.176 497.66L539.785 497.159C543.013 496.991 546.627 496.562 549.905 496.289L550.715 496.211Z" fill="#F69723"/>
<path d="M550.715 496.211C555.524 495.208 563.694 491.385 567.972 493.926C557.076 499.834 555.669 509.759 549.587 520.169C548.347 521.847 547.236 523.535 546.074 525.274C543.214 528.111 541.019 533.093 537.942 534.347L537.937 534.988L537.071 534.77C538.199 527.681 546.906 503.528 549.905 496.289L550.715 496.211Z" fill="#DA4525"/>
<path d="M390.122 431.996C392.298 430.884 393.79 429.99 396.235 431.078C413.102 438.581 429.309 447.517 446.267 454.885C451.166 457.013 458.206 459.632 462.157 463.025L462.328 463.483L462.602 463.919C468.542 473.265 476.29 481.971 481.815 491.736C495.383 515.722 494.963 543.146 492.77 569.735C492.287 575.597 493.17 600.23 491.123 603.93C485.941 597.348 480.846 590.7 475.84 583.979L475.403 582.892C473.658 580.234 467.282 566.931 466.627 563.833C470.448 557.848 478.255 535.918 480.68 528.584C478.062 525.274 476.582 523.296 474.367 519.729C469.569 511.86 465.421 498.485 458.443 494.957C461.853 493.074 467.975 495.52 472.444 496.462C456.411 476.158 432.981 473.901 408.732 471.481C400.851 470.694 386.76 478.587 378.823 481.46C373.169 483.507 360.184 488.14 356.11 491.692C354.096 491.301 354.033 491.669 353.18 490.443C370.072 468.675 377.164 456.894 390.122 431.996Z" fill="#E75F21"/>
<path d="M480.68 528.584C482.516 530.891 483.885 534.168 485.924 536.158L487.184 535.5C487.513 536.732 487.828 538.42 487.516 539.635C485.375 547.967 478.725 576.818 475.403 582.892C473.658 580.234 467.282 566.931 466.627 563.833C470.448 557.848 478.255 535.918 480.68 528.584Z" fill="#DA4525"/>
<path d="M458.443 494.957C461.853 493.074 467.975 495.52 472.444 496.462C477.086 499.098 485.247 529.052 487.184 535.5L485.924 536.158C483.885 534.168 482.516 530.891 480.68 528.584C478.062 525.274 476.582 523.296 474.367 519.729C469.569 511.86 465.421 498.485 458.443 494.957Z" fill="#DA4525"/>
<path d="M410.481 526.857C411.344 526.868 413.448 526.963 414.078 527.369C428.552 536.665 453.973 553.127 466.312 563.722L466.627 563.833C467.282 566.932 473.658 580.234 475.403 582.892L475.84 583.979C472.89 589.998 472.184 602.665 471.658 609.659C469.873 606.482 467.712 601.3 465.962 597.733L460.289 591.246C458.405 592.277 458.507 592.483 457.597 594.395C435.301 575.085 415.656 563.17 389.19 549.427C378.003 543.62 362.062 536.637 351.646 530.602L351.011 530.228C353.183 529.749 355.71 529.888 357.966 529.894C375.479 529.025 392.984 528.01 410.481 526.857Z" fill="white"/>
<path d="M351.011 530.228C353.183 529.749 355.71 529.888 357.966 529.894L355.561 530.73C356.585 531.594 355.896 531.248 357.298 531.41C373.63 533.271 389.885 537.172 406.229 539.134C421.432 551.439 435.921 564.112 449.136 578.54C452.006 581.677 458.008 587.863 460.289 591.246C458.405 592.277 458.507 592.483 457.597 594.395C435.301 575.085 415.656 563.17 389.19 549.427C378.003 543.62 362.062 536.637 351.646 530.602L351.011 530.228Z" fill="#F08223"/>
<path d="M466.312 563.722L466.627 563.833C467.282 566.931 473.658 580.234 475.403 582.892L475.84 583.979C472.89 589.998 472.184 602.665 471.658 609.659C469.873 606.482 467.712 601.3 465.962 597.733C466.169 595.766 465.85 587.39 465.841 584.793C465.848 577.765 466.005 570.743 466.312 563.722Z" fill="#F08223"/>
<path d="M410.481 526.857L409.14 526.299L410.115 525.508C409.38 521.289 403.108 518.731 400.5 515.259C403.574 514.329 415.131 498.769 418.258 495.13C427.62 499.666 435.018 505.646 444.004 510.595C445.041 511.163 445.673 510.606 446.559 510.149C455.529 515.415 462.974 514.808 472.025 518.86C472.956 519.278 473.364 519.584 474.367 519.729C476.582 523.296 478.062 525.274 480.68 528.584C478.255 535.918 470.448 557.848 466.627 563.833L466.312 563.722C453.973 553.127 428.552 536.665 414.078 527.369C413.448 526.963 411.344 526.868 410.481 526.857Z" fill="#E85922"/>
<path d="M432.448 490.36C443.288 490.884 448.196 491.129 458.443 494.957C465.421 498.485 469.569 511.86 474.367 519.729C473.364 519.584 472.956 519.278 472.025 518.86C462.974 514.808 455.529 515.415 446.559 510.149C439.486 503.501 437.534 498.713 432.448 490.36Z" fill="white"/>
<path d="M642.66 275.875C646.285 273.484 649.53 271.577 653.305 269.414C663.04 308.476 665.637 346.107 650.568 384.564C644.626 399.73 637.31 412.278 631.161 426.543C639.957 442.735 648.1 459.021 658.812 474.125C660.616 476.668 670.451 489.204 671.06 491.095C669.73 491.435 668.803 491.747 667.418 491.781C661.593 492.455 655.768 493.079 649.937 493.659C645.229 492.043 616.154 491.903 610.748 492.873L609.798 492.271C605.604 490.772 601.058 489.858 596.702 488.998C594.764 489.028 592.351 488.825 590.759 489.73L590.083 490.844C582.169 491.162 577.411 491.145 569.748 493.414L567.972 493.926C563.694 491.385 555.524 495.208 550.715 496.211L549.905 496.289C546.627 496.562 543.013 496.991 539.785 497.159L539.176 497.66C537.663 495.18 543.667 478.209 544.181 472.326L545.147 469.869C547.587 460.531 549.654 447.817 551.419 438.107C553.96 423.846 556.702 409.62 559.64 395.434C560.997 388.783 562.365 381.084 564.231 374.62C567.369 376.79 570.96 379.723 574.027 382.102C579.466 367.377 588.078 354.02 596.785 341.054C590.519 343.399 573.976 379.617 572.904 380.042C570.346 378.178 567.771 376.336 565.18 374.517C568.983 363.548 576.735 350.8 583.465 341.214C586.079 337.5 591.29 332.575 592.893 328.614C581.801 338.228 569.296 361.53 564.007 374.986C561.025 372.429 549.827 363.117 547.772 360.517C553.161 351.602 562.79 337.705 570.167 330.225C586.152 314.015 622.627 285.334 642.66 275.875Z" fill="#F7A32A"/>
<path d="M596.785 341.054C599.863 338.149 605.917 329.531 609.687 325.543C621.694 312.853 634.098 299.983 647.731 289.014L648.58 289.215L649.658 289.484C651.065 293.404 651.898 303.326 652.25 307.633C654.551 335.927 648.48 357.827 636.634 383.071C631.021 395.04 624.665 404.93 618.851 416.21L619.097 416.906L618.5 417.039C604.303 406.307 588.14 391.911 574.027 382.102C579.466 367.377 588.078 354.02 596.785 341.054Z" fill="#E86123"/>
<path d="M596.785 341.054C599.863 338.149 605.917 329.531 609.687 325.543C621.694 312.853 634.098 299.983 647.731 289.014L648.58 289.215C633.462 310.253 612.797 338.506 600.762 360.93C609.072 358.823 617.343 356.83 625.481 354.113C618.036 362.466 608.67 373.297 601.935 382.204C607.514 383.278 614.936 385.08 620.259 385.921C614.49 390.073 609.128 393.918 603.593 398.391C608.486 403.919 614.333 410.407 618.851 416.21L619.097 416.906L618.5 417.039C604.303 406.307 588.14 391.911 574.027 382.102C579.466 367.377 588.078 354.02 596.785 341.054Z" fill="white"/>
<path d="M642.66 275.875C643.973 275.583 645.296 275.307 646.62 275.048C646.257 276.01 646.363 276.336 645.453 276.865C638.834 280.705 596.707 322.336 592.893 328.614C581.801 338.228 569.296 361.53 564.007 374.986C561.025 372.429 549.827 363.117 547.772 360.517C553.161 351.602 562.79 337.705 570.167 330.225C586.152 314.015 622.627 285.334 642.66 275.875Z" fill="#EC6D25"/>
<path d="M617.154 471.644C624.95 470.068 651.25 484.649 659.728 487.235C661.604 487.809 666.1 490.243 667.418 491.781C661.593 492.455 655.768 493.079 649.937 493.659C645.229 492.043 616.154 491.903 610.748 492.873L609.798 492.271C605.604 490.772 601.058 489.858 596.702 488.998C599.958 484.091 616.104 476.698 618.421 472.26L617.154 471.644Z" fill="#F08223"/>
<path d="M550.715 496.211C551.162 495.375 551.296 495.002 551.927 494.305C568.872 475.574 594.311 473.868 617.154 471.644L618.421 472.26C616.104 476.698 599.958 484.091 596.701 488.998C594.764 489.028 592.351 488.825 590.759 489.73L590.083 490.845C582.169 491.162 577.411 491.145 569.748 493.414L567.972 493.926C563.694 491.385 555.524 495.208 550.715 496.211Z" fill="#EC6D25"/>
<path d="M545.147 469.869L565.018 462.578C557.668 469.242 543.052 487.806 539.785 497.159L539.176 497.66C537.663 495.18 543.667 478.209 544.181 472.326L545.147 469.869Z" fill="#FACA71"/>
<path d="M370.861 269.622L376.217 272.714C379.333 278.362 390.403 287.099 395.291 292.006C422.354 319.175 445.533 337.951 460.526 374.387C458.794 375.697 451.677 381.572 450.385 381.649C446.375 385.214 406.371 416.805 404.113 417.425C400.796 420.095 395.915 424.883 393.203 426.865C387.21 413.081 379.712 400.66 373.847 386.055C358.63 348.158 360.538 308.35 370.861 269.622Z" fill="#DA4525"/>
<path d="M370.861 269.622L376.217 272.714C379.333 278.362 390.403 287.099 395.291 292.006C422.354 319.175 445.533 337.951 460.526 374.387C458.794 375.697 451.677 381.572 450.385 381.649C437.388 348.812 411.732 320.727 385.794 297.218C382.936 294.628 379.643 291.324 376.453 289.319L375.37 289.332C371.831 293.751 371.616 327.628 372.959 338.921C375.805 362.85 392.317 394.548 404.053 416.25C404.175 416.336 404.297 416.423 404.42 416.509L404.113 417.425C400.796 420.095 395.915 424.883 393.203 426.865C387.21 413.081 379.712 400.66 373.847 386.055C358.63 348.158 360.538 308.35 370.861 269.622Z" fill="#E75F21"/>
<path d="M376.453 289.319C379.643 291.324 382.936 294.628 385.794 297.218C411.732 320.727 437.388 348.812 450.385 381.649C446.375 385.214 406.371 416.805 404.113 417.425L404.42 416.509C409.749 410.961 415.505 404.132 420.654 398.303C415.388 394.167 410.153 389.979 404.722 386.064C411.189 384.525 416.262 383.395 422.85 382.334C415.474 373.132 407.127 363.694 399.468 354.608C406.494 357.016 416.306 359.402 423.839 361.672C407.627 334.653 393.715 315.034 376.453 289.319Z" fill="white"/>
<path d="M460.526 374.387C461.559 384.664 479.05 468.118 478.595 469.539C474.766 467.792 466.293 464.707 462.328 463.483L462.157 463.025C458.206 459.632 451.166 457.013 446.267 454.885C429.309 447.517 413.102 438.581 396.235 431.078C393.79 429.99 392.298 430.884 390.122 431.996L393.203 426.865C395.915 424.883 400.796 420.095 404.113 417.425C406.371 416.805 446.375 385.214 450.385 381.649C451.677 381.572 458.794 375.697 460.526 374.387Z" fill="#EC6D25"/>
<path d="M805.277 430.896C836.14 420.799 881.914 425.77 912.112 437.823C903.729 441.235 897.144 443.853 889.23 448.398C856.905 466.968 840.563 496.796 823.624 528.25C820.586 533.884 810.661 552.737 807.266 557.162C805.283 561.799 801.412 567.489 798.514 571.774C785.44 591.129 770.868 605.708 751.254 618.492C734.94 628.579 716.973 635.718 698.18 639.58C640.577 651.857 568.592 639.012 521.584 680.675L515.373 686.911C518.339 677.069 523.416 664.82 527.805 655.58C541.31 650.447 553.742 636.576 560.768 623.948C563.046 619.858 565.213 608.923 568.168 605.635C582.281 589.903 603.219 576.088 622.666 567.784C628.245 565.466 639.063 564.24 644.983 562.88C659.795 559.469 673.092 555.903 686.675 548.881C730.679 525.659 748.21 495.476 762.882 450.973C774.728 443.357 791.879 434.855 805.277 430.896Z" fill="#E75F21"/>
<path d="M805.277 430.896C836.14 420.799 881.914 425.77 912.112 437.823C903.729 441.235 897.144 443.853 889.23 448.398C856.905 466.968 840.563 496.796 823.624 528.25C820.586 533.884 810.661 552.737 807.266 557.162C805.283 561.799 801.413 567.489 798.514 571.774C785.44 591.129 770.868 605.708 751.254 618.492C749.294 613.972 720.85 581.343 716.421 577.024C720.576 575.074 723.737 571.83 727.077 568.737C729.221 563.66 735.231 554.805 738.347 549.75C743.066 542.004 747.534 534.101 751.734 526.06C758.492 513.236 764.926 499.939 770.846 486.712C773.46 480.859 776.029 475.175 779.38 469.684L779.927 468.808L779.609 467.703L777.498 466.897C775.649 467.716 773.007 468.63 771.058 469.373C779.732 458.413 789.455 448.073 799.078 437.934C801.256 435.641 803.512 433.523 805.277 430.896Z" fill="#F69723"/>
<path d="M805.277 430.896C836.14 420.799 881.914 425.77 912.112 437.823C903.729 441.235 897.144 443.853 889.23 448.398C856.905 466.968 840.563 496.796 823.624 528.25C820.586 533.884 810.661 552.737 807.266 557.162C807.517 553.601 809.868 544.501 810.762 540.666L819.759 502.943C807.612 509.135 794.286 515.165 781.871 521.016C789.796 499.783 799.078 478.497 807.489 457.373C804.937 458.04 802.619 458.906 800.134 459.78C794.052 461.517 782.496 465.823 777.498 466.897C775.649 467.716 773.007 468.63 771.058 469.373C779.732 458.413 789.455 448.073 799.078 437.934C801.256 435.641 803.512 433.523 805.277 430.896Z" fill="#FACA71"/>
<path d="M800.134 459.78C802.267 460.014 802.446 460.038 804.406 459.202L805.506 459.607C805.266 460.181 803.937 463.567 803.836 463.752C802.021 467.221 799.29 471.209 797.347 474.602C783.792 498.195 749.511 553.902 727.077 568.737C729.221 563.66 735.231 554.805 738.347 549.75C743.066 542.004 747.534 534.101 751.734 526.06C758.492 513.236 764.926 499.939 770.846 486.712C773.46 480.859 776.029 475.175 779.38 469.684L779.927 468.807L779.609 467.703L777.498 466.897C782.496 465.823 794.052 461.517 800.134 459.78Z" fill="#F08223"/>
<path d="M521.584 680.675C523.365 674.5 532.039 667.3 536.484 662.524C560.734 636.481 593.965 621.903 627.452 611.738C655.455 603.239 683.603 597.042 709.222 582.263C711.807 580.775 713.947 578.707 716.421 577.024C720.85 581.343 749.294 613.972 751.254 618.492C734.94 628.579 716.974 635.718 698.18 639.58C640.577 651.857 568.592 639.012 521.584 680.675Z" fill="#EC6D25"/>
<path d="M111.895 437.957C138.945 427.703 171.493 422.848 200.096 426.855C205.631 427.631 213.575 428.941 218.811 430.85C230.912 433.607 251.439 443.519 261.69 451.142C279.97 509.82 314.648 550.157 378.928 562.958C401.392 567.433 416.373 572.421 434.664 586.927C451.804 599.867 456.832 602.336 462.946 622.978C466.034 633.4 485.381 650.704 496.071 655.53C500.741 664.82 506.013 676.64 509.174 686.582C506.125 683.651 502.991 680.519 499.713 677.877C492.632 672.588 485.24 667.484 477.157 663.839C423.332 639.875 361.932 652.398 306.535 634.314C270.534 622.56 233.771 591.614 216.696 557.218C214.999 554.905 213.318 551.74 211.921 549.165C191.955 512.384 173.624 470.377 135.663 448.478C127.824 443.956 120.299 441.116 111.895 437.957Z" fill="#E25424"/>
<path d="M218.811 430.85C230.912 433.607 251.439 443.519 261.691 451.142C279.97 509.82 314.648 550.157 378.928 562.958C401.392 567.433 416.373 572.421 434.664 586.927C451.804 599.867 456.832 602.336 462.946 622.978C466.034 633.4 485.382 650.704 496.071 655.53C500.741 664.82 506.013 676.64 509.174 686.582C506.125 683.651 502.991 680.519 499.713 677.877C497.867 671.379 488.44 663.226 483.529 658.417C446.643 621.641 394.577 611.654 346.786 596.357C290.976 578.501 263.709 536.632 235.253 489.007C231.469 482.673 223.434 464.153 218.425 460.157C218.149 459.848 217.836 458.991 217.646 458.556C220.604 457.875 247.453 467.026 252.413 468.684C246.869 461.715 241.037 454.98 234.93 448.495C231.625 445.016 220.829 434.336 218.811 430.85Z" fill="#DA4525"/>
<path d="M111.895 437.957C138.945 427.703 171.493 422.848 200.096 426.855C205.631 427.631 213.575 428.941 218.811 430.85C220.829 434.336 231.625 445.016 234.93 448.495C241.037 454.98 246.869 461.715 252.413 468.684C247.453 467.026 220.604 457.875 217.646 458.556C217.836 458.991 218.149 459.848 218.425 460.157C221.05 469.175 225.035 477.671 228.677 486.267C233.685 498.056 238.612 509.876 243.456 521.735C236.596 517.165 212.608 506.521 204.188 503.317C206.284 511.62 217.157 552.007 216.696 557.218C214.999 554.905 213.318 551.74 211.921 549.165C191.955 512.384 173.624 470.377 135.663 448.478C127.824 443.956 120.299 441.116 111.895 437.957Z" fill="#EC6D25"/>
<path d="M151.82 312.957C155.673 310.674 159.624 308.561 163.662 306.624C190.966 293.514 224.742 286.053 254.851 288.565C260.603 289.045 270.223 290.213 275.691 292.111C311.421 301.349 334.599 322.136 353.215 353.391C357.764 361.029 358.047 365.605 360.129 373.994C364.702 392.416 375.422 409.595 383.176 426.682C377.139 438.122 369.574 452.459 362.138 463.101C355.607 473.821 345.486 486.316 337.772 496.557C344.69 498.457 351.738 499.834 358.763 501.305C352.604 511.771 344.244 523.151 337.172 533.16C344.98 537.707 353.99 541.697 362.012 545.91C365.535 547.761 370.072 549.678 373.255 551.918C318.781 539.323 285.165 499.471 270.57 448.062C267.926 438.749 262.315 428.805 259.762 419.135C256.538 413.583 252.699 403.131 250.145 397.097C229.889 349.241 204.815 322.307 151.82 312.957Z" fill="#DA4525"/>
<path d="M275.691 292.111C311.421 301.349 334.599 322.136 353.215 353.391C357.764 361.029 358.047 365.605 360.129 373.994C364.702 392.416 375.422 409.595 383.176 426.682C377.139 438.122 369.574 452.459 362.138 463.101C356.05 456.736 346.685 447.861 341.416 441.142C322.654 417.217 308.145 388.156 293.102 361.618L277.754 334.837C276.959 333.42 270.527 321.854 270.124 321.492L269.526 322.816C268.403 320.737 267.155 317.797 266.138 315.593C281.168 317.287 296.64 320.056 311.577 322.599C304.325 316.539 296.966 310.605 289.505 304.802C285.847 301.933 278.52 296.507 275.442 293.44L275.691 292.111Z" fill="#E75F21"/>
<path d="M151.82 312.957C155.673 310.674 159.624 308.561 163.662 306.624C190.966 293.514 224.742 286.053 254.851 288.565C260.603 289.045 270.223 290.213 275.691 292.111L275.442 293.44C278.52 296.507 285.847 301.933 289.505 304.802C296.966 310.605 304.325 316.539 311.577 322.599C296.64 320.056 281.168 317.287 266.138 315.593C267.155 317.797 268.403 320.737 269.526 322.816C277.108 340.495 285.59 361.825 293.66 378.955C288.586 375.808 280.529 372.518 274.9 370.002L244.941 356.585C247.107 364.708 260.72 414.896 259.762 419.135C256.538 413.583 252.699 403.131 250.145 397.097C229.889 349.241 204.815 322.307 151.82 312.957Z" fill="#ED8337"/>
<path d="M750.31 291.666C779.112 283.867 819.603 290.024 847.153 300.864C856.262 304.448 864.361 308.575 873.056 313.008C837.48 319.648 813.901 331.097 792.868 361.875C786.238 371.707 780.486 382.103 775.688 392.942C772.27 400.648 769.21 408.882 765.44 416.352C762.524 425.664 758.721 435.007 755.448 444.223C747.68 466.113 738.526 491.859 722.38 509.096C719.766 501.717 713.645 489.712 710.188 482.368C702.631 466.495 695.293 450.521 688.161 434.45C686.826 435.687 683.252 439.7 682.352 441.165C678.376 447.649 665.156 457.333 662.381 463.496C656.79 456.928 645.307 434.2 641.068 425.996C648.463 411.539 655.533 397.845 661.252 382.66C665.179 372.228 665.128 362.491 670.741 353.275C690.506 320.8 713.315 301.463 750.31 291.666Z" fill="#F4972E"/>
<path d="M750.31 291.666C749.807 292.912 715.572 320.627 712.461 322.98C727.54 320.159 742.659 317.576 757.816 315.234C756.286 318.947 754.37 323.854 752.449 327.362C749.026 330.784 746.881 333.509 744.474 337.741C729.852 363.503 715.058 389.194 700.632 415.072C698.85 418.272 688.73 432.712 688.161 434.45C686.826 435.687 683.252 439.7 682.352 441.165C678.376 447.649 665.156 457.333 662.381 463.496C656.79 456.928 645.307 434.2 641.068 425.996C648.463 411.539 655.533 397.845 661.252 382.66C665.179 372.228 665.128 362.491 670.741 353.275C690.506 320.8 713.315 301.463 750.31 291.666Z" fill="#F08223"/>
<path d="M750.31 291.666C779.112 283.867 819.603 290.024 847.153 300.864C856.262 304.448 864.361 308.575 873.056 313.008C837.48 319.648 813.901 331.097 792.868 361.876C786.238 371.707 780.486 382.103 775.688 392.942C772.27 400.648 769.21 408.882 765.44 416.352C765.512 412.011 768.204 402.22 769.226 397.34C772.108 383.583 775.532 369.805 778.939 356.161C764.356 363.309 745.948 372.403 731.092 378.503C737.923 361.783 746.004 343.907 752.449 327.362C754.37 323.854 756.286 318.947 757.816 315.234C742.659 317.576 727.54 320.159 712.461 322.98C715.572 320.627 749.807 292.912 750.31 291.666Z" fill="#FACA71"/>
<path d="M420.979 155.099L419.153 153.594L419.193 152.78C422.055 152.146 431.469 152.502 434.673 152.739C462.664 154.809 491.668 163.123 515.334 178.297C518.523 180.341 527.42 186.434 530.062 189.099C551.564 206.519 564.772 230.196 569.285 257.34C571.781 278.324 569.709 297.417 564.024 317.688C562.711 322.375 560.919 326.715 557.836 330.566C552.284 337.396 547.504 344.239 542.862 351.733C531.246 351.259 519.618 351.021 507.99 351.017L480.722 351.525C468.195 330.812 455.791 318.517 437.497 303.362C441.381 298.101 444.874 291.186 447.976 285.353C454.138 269.979 459.053 256.561 459.111 239.713C459.236 203.663 445.048 179.88 420.979 155.099Z" fill="#E75F21"/>
<path d="M420.979 155.099L419.153 153.594L419.193 152.78C422.055 152.146 431.469 152.502 434.673 152.739C462.664 154.809 491.668 163.123 515.334 178.297C518.523 180.341 527.42 186.434 530.062 189.099C530.559 192.759 547.956 223.895 550.922 230.471C540.422 224.815 526.487 216.282 516.016 211.875L516.032 228.657C515.708 238.328 514.24 266.352 512.341 275.888C511.073 275.453 511.67 275.745 510.676 274.58C500.378 264.813 489.755 253.166 479.318 242.928C472.432 252.843 465.642 262.825 458.95 272.872C457.841 274.556 453.317 281.233 452.678 282.749C450.718 284.438 450.451 284.615 447.976 285.353C454.138 269.979 459.053 256.561 459.111 239.713C459.236 203.663 445.048 179.88 420.979 155.099Z" fill="#FACA71"/>
<path d="M420.979 155.099C428.939 154.706 454.159 174.196 460.265 179.599C482.609 199.37 493.753 226.114 503.913 253.392C505.974 258.93 510.006 269.192 510.676 274.58C500.378 264.813 489.755 253.166 479.318 242.928C472.432 252.843 465.642 262.825 458.95 272.872C457.841 274.556 453.317 281.233 452.678 282.749C450.718 284.438 450.451 284.615 447.976 285.353C454.138 269.979 459.053 256.561 459.111 239.713C459.236 203.663 445.048 179.88 420.979 155.099Z" fill="#F4972E"/>
<path d="M530.062 189.099C551.564 206.519 564.772 230.196 569.285 257.34C571.781 278.324 569.709 297.417 564.024 317.688C562.711 322.375 560.919 326.715 557.836 330.566C552.284 337.396 547.504 344.239 542.862 351.733C531.246 351.259 519.618 351.021 507.99 351.017C509.677 344.677 519.193 333.928 518.227 329.268L518.518 328.879C523.053 299.254 525.477 267.529 521.383 237.807C520.417 230.824 519.244 222.185 516.608 215.662C516.585 219.394 516.758 225.195 516.032 228.657L516.016 211.875C526.487 216.282 540.422 224.815 550.922 230.471C547.956 223.895 530.559 192.759 530.062 189.099Z" fill="#F08223"/>
<path d="M518.518 328.879L519.076 329.593C520.277 330.115 520.852 328.537 521.785 327.321C538.048 306.126 552.698 283.353 565.761 260.073C566.733 258.344 567.526 257.932 569.285 257.34C571.781 278.324 569.709 297.417 564.024 317.688C562.711 322.375 560.919 326.715 557.836 330.566C552.284 337.396 547.504 344.239 542.862 351.733C531.246 351.259 519.618 351.021 507.99 351.017C509.677 344.677 519.193 333.928 518.227 329.268L518.518 328.879Z" fill="#EC6D25"/>
<path d="M452.678 282.749C463.214 291.146 476.922 300.027 487.922 308.289C494.816 313.467 511.134 325.703 518.227 329.268C519.193 333.928 509.677 344.677 507.99 351.017L480.722 351.525C468.195 330.812 455.791 318.517 437.497 303.362C441.381 298.101 444.874 291.186 447.976 285.353C450.451 284.615 450.718 284.438 452.678 282.749Z" fill="#DA4525"/>
<path d="M548.079 583.912C551.966 589.318 550.972 602.754 552.804 609.553C554.317 606.154 556.25 601.238 558.383 598.379L561.058 597.282L561.511 597.644C561.109 598.814 560.852 598.92 561.025 600.118L560.628 600.843C557.411 606.767 555.68 613.359 553.161 618.776C549.486 626.679 536.44 641.464 528.939 645.694C528.442 645.928 527.939 646.15 527.431 646.362C516.379 651.027 507.331 650.392 496.532 645.983C489.536 642.16 475.462 628.189 472.064 620.777C466.589 608.829 467.8 604.069 457.597 594.395C458.507 592.483 458.405 592.277 460.289 591.246L465.962 597.733C467.712 601.3 469.873 606.482 471.658 609.659C472.184 602.665 472.89 589.998 475.84 583.979C480.846 590.7 485.941 597.348 491.123 603.93C492.323 605.886 492.605 606.455 494.181 608.071C496.813 605.29 502.131 605.162 505.784 605.006C511.347 603.941 519.735 604.783 525.594 605.184C527.247 606.455 528.649 607.525 530.201 608.907C531.614 606.639 533.089 604.409 534.63 602.225C539.271 596.234 543.75 590.132 548.079 583.912Z" fill="white"/>
<path d="M505.784 605.006C511.347 603.941 519.735 604.783 525.594 605.184C527.247 606.455 528.649 607.525 530.201 608.907C532.893 613.633 535.742 616.575 532.346 621.563C526.292 627.537 521.31 630.519 514.686 635.601C514.033 636.103 513.614 637.072 513.463 637.914L513.167 637.925L511.693 636.164C505.935 633.255 494.341 625.77 491.29 619.512C489.977 616.815 492.94 610.963 494.181 608.071C496.813 605.29 502.131 605.162 505.784 605.006Z" fill="#DA4525"/>
<path d="M494.181 608.071C496.813 605.29 502.131 605.162 505.784 605.006C508.364 605.151 508.649 604.76 510.531 605.942C510.9 608.266 509.839 609.748 508.878 612.245C506.599 613.008 504.689 613.488 502.673 614.786L502.712 615.36C504.538 615.739 504.818 615.706 506.622 615.722C510.509 619.49 511.631 630.992 511.693 636.164C505.935 633.255 494.341 625.77 491.29 619.512C489.977 616.815 492.94 610.963 494.181 608.071Z" fill="#DA4525"/>
<path d="M505.784 605.006C511.347 603.941 519.735 604.783 525.594 605.184C527.247 606.455 528.649 607.525 530.201 608.907C532.893 613.633 535.742 616.575 532.346 621.563C530.196 619.322 528.358 618.804 525.476 617.567C523.243 617.06 520.372 616.737 518.054 616.408C514.379 616.296 510.324 615.956 506.622 615.722C504.818 615.706 504.538 615.739 502.712 615.36L502.673 614.786C504.689 613.488 506.599 613.008 508.878 612.245C509.839 609.748 510.9 608.266 510.531 605.942C508.649 604.76 508.364 605.151 505.784 605.006Z" fill="#E75F21"/>
<path d="M508.878 612.245C511.894 612.211 514.429 612.078 517.423 612.535L518.054 616.408C514.379 616.296 510.324 615.956 506.622 615.722C504.818 615.706 504.538 615.739 502.712 615.36L502.673 614.786C504.689 613.488 506.599 613.008 508.878 612.245Z" fill="#F69723"/>
<path d="M517.423 612.535C521.774 613.404 523.03 613.844 525.477 617.567C523.243 617.06 520.372 616.737 518.054 616.408L517.423 612.535Z" fill="#FACA71"/>
<path d="M548.079 583.912C551.966 589.318 550.972 602.754 552.804 609.553C550.821 619.027 549.023 623.407 542.421 630.675C533.642 640.332 525.409 640.377 513.463 637.914C513.614 637.072 514.033 636.103 514.686 635.601C521.31 630.519 526.292 627.537 532.346 621.563C535.742 616.575 532.893 613.633 530.201 608.907C531.614 606.639 533.089 604.409 534.63 602.225C539.271 596.234 543.75 590.132 548.079 583.912Z" fill="white"/>
<path d="M457.597 594.395C458.507 592.483 458.405 592.277 460.289 591.246L465.962 597.733C467.712 601.3 469.873 606.482 471.658 609.659C474.537 630.948 491.508 643.693 513.167 637.925L513.463 637.914C525.409 640.377 533.642 640.332 542.421 630.675C549.023 623.407 550.821 619.027 552.804 609.553C554.317 606.154 556.25 601.238 558.383 598.379L561.058 597.282L561.511 597.644C561.109 598.814 560.852 598.92 561.025 600.118L560.628 600.843C557.411 606.767 555.68 613.359 553.161 618.776C549.486 626.679 536.44 641.464 528.939 645.694C528.442 645.928 527.939 646.151 527.431 646.362C516.379 651.027 507.331 650.392 496.532 645.983C489.536 642.16 475.462 628.189 472.064 620.777C466.589 608.829 467.8 604.069 457.597 594.395Z" fill="#DA4525"/>
<path d="M496.532 645.983C500.422 644.512 521.232 642.718 526.029 643.972C526.549 644.105 528.364 645.309 528.939 645.694C528.442 645.928 527.939 646.15 527.431 646.362C516.379 651.027 507.331 650.392 496.532 645.983Z" fill="#F08223"/>
<path d="M662.381 463.496C665.156 457.333 678.376 447.649 682.352 441.165C683.252 439.7 686.826 435.687 688.161 434.45C695.293 450.521 702.631 466.495 710.188 482.368C713.645 489.712 719.766 501.717 722.38 509.096C704.636 528.763 675.684 548.151 649.351 552.03C661.984 545.604 674.707 539.357 687.513 533.293C679.225 522.922 672.244 512.423 665.128 501.255C672.389 500.213 679.504 498.496 686.636 496.813C678.655 485.46 669.758 475.418 662.381 463.496Z" fill="#EC6D25"/>
</g>
<path d="M293.26 837C277.009 837 263.39 831.644 252.403 820.933C241.468 810.222 236 796.77 236 780.576C236 764.179 241.468 750.651 252.403 739.991C263.339 729.33 277.034 724 293.488 724C299.462 724 304.753 724.558 309.36 725.675C314.018 726.792 318.245 728.315 322.042 730.244C325.84 732.173 329.105 734.432 331.839 737.021C334.623 739.61 336.016 740.904 336.016 740.904L313.917 766.794C313.917 766.794 313.056 766.032 311.335 764.509C309.664 762.987 307.968 761.768 306.246 760.854C304.525 759.89 302.627 759.154 300.551 758.646C298.526 758.088 296.323 757.809 293.944 757.809C287.514 757.809 282.122 759.915 277.768 764.129C273.414 768.342 271.237 774.256 271.237 781.871C271.237 788.622 273.389 794.155 277.692 798.47C281.996 802.735 287.59 804.867 294.475 804.867C300.601 804.867 305.259 803.674 308.449 801.288C311.638 798.902 313.638 795.907 314.448 792.303V794.358H291.514V770.068H348.166V781.338C348.166 799.054 342.977 812.76 332.598 822.456C322.22 832.152 309.107 837 293.26 837Z" fill="url(#paint0_linear_1_7)"/>
<path d="M394.973 837C382.417 837 372.013 833.015 363.761 825.045C355.559 817.024 351.458 806.999 351.458 794.968C351.458 782.886 355.559 772.86 363.761 764.89C372.013 756.869 382.417 752.859 394.973 752.859C407.478 752.859 417.857 756.869 426.109 764.89C434.362 772.86 438.488 782.886 438.488 794.968C438.488 806.999 434.362 817.024 426.109 825.045C417.907 833.015 407.529 837 394.973 837ZM394.973 807.456C398.416 807.456 401.327 806.288 403.706 803.953C406.136 801.567 407.351 798.572 407.351 794.968C407.351 791.313 406.136 788.292 403.706 785.906C401.327 783.52 398.416 782.327 394.973 782.327C391.48 782.327 388.543 783.52 386.164 785.906C383.784 788.292 382.594 791.287 382.594 794.892C382.594 798.546 383.784 801.567 386.164 803.953C388.543 806.288 391.48 807.456 394.973 807.456Z" fill="url(#paint1_linear_1_7)"/>
<path d="M501.022 837C484.011 837 469.962 831.721 458.875 821.162C447.838 810.552 442.319 797.024 442.319 780.576C442.319 764.027 447.888 750.473 459.027 739.914C470.165 729.305 484.138 724 500.947 724C506.06 724 510.92 724.533 515.527 725.599C520.135 726.614 524.413 728.163 528.362 730.244C532.311 732.274 535.829 734.61 538.918 737.249C542.057 739.889 543.626 741.209 543.626 741.209L520.16 768.85C520.16 768.85 519.476 768.139 518.109 766.718C516.793 765.246 515.3 764.002 513.629 762.987C511.958 761.92 510.161 761.108 508.237 760.55C506.364 759.941 504.237 759.636 501.858 759.636C495.985 759.636 491.049 761.565 487.049 765.423C483.05 769.281 481.05 774.332 481.05 780.576C481.05 786.668 483.024 791.668 486.973 795.577C490.922 799.435 495.884 801.364 501.858 801.364C504.237 801.364 506.414 801.059 508.389 800.45C510.414 799.841 512.262 799.003 513.933 797.937C515.603 796.821 517.097 795.551 518.413 794.13C519.73 792.658 520.388 791.922 520.388 791.922L545.069 816.821C545.069 816.821 543.601 818.37 540.664 821.466C537.778 824.563 534.31 827.304 530.26 829.69C526.21 832.076 521.729 833.878 516.818 835.096C511.908 836.365 506.642 837 501.022 837Z" fill="url(#paint2_linear_1_7)"/>
<path d="M551.455 835.02V725.98H584.718V835.02H551.455Z" fill="url(#paint3_linear_1_7)"/>
<path d="M622.606 837C614.202 837 607.19 834.766 601.57 830.299C596.001 825.781 593.216 819.842 593.216 812.481C593.216 804.41 596.456 798.013 602.937 793.292C609.468 788.571 617.999 786.211 628.529 786.211C632.174 786.211 635.389 786.49 638.174 787.049C641.009 787.556 643.87 788.343 646.755 789.409V786.211C646.755 783.216 645.667 780.906 643.49 779.282C641.313 777.657 638.503 776.845 635.06 776.845C633.086 776.845 631.137 777.099 629.213 777.606C627.289 778.114 625.365 778.901 623.441 779.967C621.568 781.033 619.669 782.327 617.745 783.85C615.822 785.373 614.86 786.135 614.86 786.135L595.57 769.002C595.57 769.002 597.292 767.708 600.734 765.119C604.177 762.479 607.772 760.245 611.518 758.418C615.315 756.59 619.517 755.22 624.125 754.306C628.732 753.341 633.719 752.859 639.085 752.859C651.489 752.859 661.362 755.6 668.703 761.083C676.094 766.515 679.79 774.662 679.79 785.526V835.02H648.426V820.248L653.135 825.578H648.35C645.515 829.284 642.072 832.127 638.022 834.106C633.972 836.035 628.833 837 622.606 837ZM634.529 816.441C638.478 816.441 641.49 815.324 643.566 813.09C645.692 810.806 646.755 807.887 646.755 804.334V803.724C645.591 803.064 644.072 802.531 642.199 802.125C640.376 801.668 638.174 801.44 635.592 801.44C632.605 801.44 630.023 802.1 627.846 803.42C625.669 804.74 624.58 806.643 624.58 809.131C624.58 811.517 625.542 813.344 627.466 814.613C629.441 815.832 631.795 816.441 634.529 816.441Z" fill="url(#paint4_linear_1_7)"/>
<path d="M707.681 835.173L679.734 754.763H713.073L722.338 790.627L723.857 797.252H724.009L725.527 790.704L734.413 754.763H761.752L770.789 790.704L772.536 797.1H772.688L774.282 790.704L782.636 754.763H814L785.142 835.173H757.879L748.234 803.42L746.639 797.633H746.488L744.893 803.42L735.4 835.173H707.681Z" fill="url(#paint5_linear_1_7)"/>
<defs>
<linearGradient id="paint0_linear_1_7" x1="236" y1="780.5" x2="814" y2="780.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#DB4024"/>
<stop offset="1" stop-color="#F28B1B"/>
</linearGradient>
<linearGradient id="paint1_linear_1_7" x1="236" y1="780.5" x2="814" y2="780.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#DB4024"/>
<stop offset="1" stop-color="#F28B1B"/>
</linearGradient>
<linearGradient id="paint2_linear_1_7" x1="236" y1="780.5" x2="814" y2="780.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#DB4024"/>
<stop offset="1" stop-color="#F28B1B"/>
</linearGradient>
<linearGradient id="paint3_linear_1_7" x1="236" y1="780.5" x2="814" y2="780.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#DB4024"/>
<stop offset="1" stop-color="#F28B1B"/>
</linearGradient>
<linearGradient id="paint4_linear_1_7" x1="236" y1="780.5" x2="814" y2="780.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#DB4024"/>
<stop offset="1" stop-color="#F28B1B"/>
</linearGradient>
<linearGradient id="paint5_linear_1_7" x1="236" y1="780.5" x2="814" y2="780.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#DB4024"/>
<stop offset="1" stop-color="#F28B1B"/>
</linearGradient>
<clipPath id="clip0_1_7">
<rect width="802" height="535" fill="white" transform="translate(111 152)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 43 KiB

+57 -33
View File
@@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"github.com/google/uuid"
@@ -45,6 +46,21 @@ func runGateway() {
if verbose {
logLevel = slog.LevelDebug
}
// Env override (docker/K8s friendly, default: info): GOCLAW_LOG_LEVEL=debug|info|warn|error
if lvl := os.Getenv("GOCLAW_LOG_LEVEL"); lvl != "" {
switch strings.ToLower(lvl) {
case "debug":
logLevel = slog.LevelDebug
case "info":
logLevel = slog.LevelInfo
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
default:
fmt.Fprintf(os.Stderr, "warning: unknown GOCLAW_LOG_LEVEL=%q, using info\n", lvl)
}
}
textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
@@ -64,7 +80,7 @@ func runGateway() {
msgBus := bus.New()
// Create provider registry
providerRegistry := providers.NewRegistry()
providerRegistry := providers.NewRegistry(store.TenantIDFromContext)
registerProviders(providerRegistry, cfg)
// Resolve workspace (must be absolute for system prompt + file tool path resolution)
@@ -145,12 +161,9 @@ func runGateway() {
}
// Notify clients that leader is processing team results
// (bridges UI gap between last task.completed and announce run.started).
msgBus.Broadcast(bus.Event{
Name: protocol.EventTeamLeaderProcessing,
Payload: map[string]any{
"agentId": meta.ParentAgent,
"tasks": len(items),
},
bus.BroadcastForTenant(msgBus, protocol.EventTeamLeaderProcessing, meta.OriginTenantID, map[string]any{
"agentId": meta.ParentAgent,
"tasks": len(items),
})
msgBus.PublishInbound(bus.InboundMessage{
@@ -159,6 +172,7 @@ func runGateway() {
ChatID: meta.OriginChatID,
Content: content,
UserID: meta.OriginUserID,
TenantID: meta.OriginTenantID,
Metadata: batchMeta,
Media: batchMedia,
})
@@ -170,7 +184,7 @@ func runGateway() {
slog.Info("subagent system enabled", "tools", []string{"spawn"})
}
skillsLoader, skillSearchTool, globalSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus)
skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus)
_ = skillSearchTool // used via wireExtras → skillsLoader; kept for type clarity
// DateTime tool (precise time for cron scheduling, memory timestamps, etc.)
@@ -282,19 +296,10 @@ func runGateway() {
server.SetAgentStore(pgStores.Agents)
}
// Dynamic custom tools: load global tools from DB before resolver
var dynamicLoader *tools.DynamicToolLoader
if pgStores.CustomTools != nil {
dynamicLoader = tools.NewDynamicToolLoader(pgStores.CustomTools, workspace)
if err := dynamicLoader.LoadGlobal(context.Background(), toolsReg); err != nil {
slog.Warn("failed to load global custom tools", "error", err)
}
}
var mcpPool *mcpbridge.Pool
var mediaStore *media.Store
var postTurn tools.PostTurnProcessor
contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader, redisClient)
contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, redisClient)
if mcpPool != nil {
defer mcpPool.Stop()
}
@@ -303,7 +308,7 @@ func runGateway() {
if mcpMgr != nil {
mcpToolLister = mcpMgr
}
agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH := wireHTTP(pgStores, cfg.Gateway.Token, cfg.Agents.Defaults.Workspace, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH := wireHTTP(pgStores, cfg.Gateway.Token, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
if providersH != nil {
providersH.SetAPIBaseFallback(cfg.Providers.APIBaseForType)
}
@@ -323,10 +328,13 @@ func runGateway() {
}
server.SetWakeHandler(wakeH)
if mcpH != nil {
if mcpPool != nil {
mcpH.SetPoolEvictor(mcpPool)
}
server.SetMCPHandler(mcpH)
}
if customToolsH != nil {
server.SetCustomToolsHandler(customToolsH)
if mcpUserCredsH != nil {
server.SetMCPUserCredentialsHandler(mcpUserCredsH)
}
if channelInstancesH != nil {
server.SetChannelInstancesHandler(channelInstancesH)
@@ -334,14 +342,11 @@ func runGateway() {
if providersH != nil {
server.SetProvidersHandler(providersH)
}
if delegationsH != nil {
server.SetDelegationsHandler(delegationsH)
}
if teamEventsH != nil {
server.SetTeamEventsHandler(teamEventsH)
}
if pgStores != nil && pgStores.Teams != nil {
server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, cfg.Gateway.Token, dataDir))
server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, cfg.Gateway.Token, workspace))
}
if builtinToolsH != nil {
server.SetBuiltinToolsHandler(builtinToolsH)
@@ -399,7 +404,7 @@ func runGateway() {
// Workspace file serving endpoint — serves files by absolute path, auth-token protected.
// Supports media from any agent workspace (each agent has its own workspace from DB).
server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace))
server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace, dataDir))
// Storage file management — browse/delete files under the resolved workspace directory.
// Uses GOCLAW_WORKSPACE (or default ~/.goclaw/workspace) so it works correctly
@@ -472,7 +477,7 @@ func runGateway() {
instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages))
instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory)
instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages))
if err := instanceLoader.LoadAll(context.Background()); err != nil {
if err := instanceLoader.LoadAll(store.WithCrossTenant(context.Background())); err != nil {
slog.Error("failed to load channel instances from DB", "error", err)
}
}
@@ -507,7 +512,8 @@ func runGateway() {
})
go func() {
for payload := range auditCh {
if err := pgStores.Activity.Log(context.Background(), &store.ActivityLog{
auditCtx := store.WithTenantID(context.Background(), payload.TenantID)
if err := pgStores.Activity.Log(auditCtx, &store.ActivityLog{
ActorType: payload.ActorType,
ActorID: payload.ActorID,
Action: payload.Action,
@@ -612,7 +618,7 @@ func runGateway() {
if err != nil {
return
}
team, err := notifyTeamStore.GetTeam(context.Background(), teamUUID)
team, err := notifyTeamStore.GetTeam(store.WithCrossTenant(context.Background()), teamUUID)
if err != nil || team == nil {
return
}
@@ -654,7 +660,7 @@ func runGateway() {
// Resolve lead agent key (needed for leader mode routing + completed-by-leader skip).
var leadAgentKey string
if notifyAgentStore != nil {
if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil {
if la, err := notifyAgentStore.GetByID(store.WithCrossTenant(context.Background()), team.LeadAgentID); err == nil {
leadAgentKey = la.AgentKey
}
}
@@ -793,10 +799,11 @@ func runGateway() {
// Uses calibrated token estimation (actual prompt tokens from last LLM call)
// and the agent's real context window (cached on session by the Loop).
sched.SetTokenEstimateFunc(func(sessionKey string) (int, int) {
history := pgStores.Sessions.GetHistory(sessionKey)
lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(sessionKey)
bctx := context.Background()
history := pgStores.Sessions.GetHistory(bctx, sessionKey)
lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(bctx, sessionKey)
tokens := agent.EstimateTokensWithCalibration(history, lastPT, lastMC)
cw := pgStores.Sessions.GetContextWindow(sessionKey)
cw := pgStores.Sessions.GetContextWindow(bctx, sessionKey)
if cw <= 0 {
cw = config.DefaultContextWindow
}
@@ -870,6 +877,23 @@ func runGateway() {
methods.NewAPIKeysMethods(pgStores.APIKeys).Register(server.Router())
}
// Tenant management RPC + HTTP
if pgStores.Tenants != nil {
methods.NewTenantsMethods(pgStores.Tenants, msgBus, workspace).Register(server.Router())
server.SetTenantsHandler(httpapi.NewTenantsHandler(pgStores.Tenants, cfg.Gateway.Token, msgBus, workspace))
server.Router().SetTenantStore(pgStores.Tenants)
// Permission cache for tenant membership checks (tenant role, agent access, etc.)
permCache := cache.NewPermissionCache()
msgBus.Subscribe("permission-cache", func(e bus.Event) {
if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok {
permCache.HandleInvalidation(p)
}
})
server.Router().SetPermissionCache(permCache)
httpapi.InitTenantStore(pgStores.Tenants, msgBus)
httpapi.InitOwnerIDs(cfg.Gateway.OwnerIDs)
}
// Reload quota config on config changes via pub/sub.
if quotaChecker != nil {
msgBus.Subscribe("quota-config-reload", func(evt bus.Event) {
+5 -4
View File
@@ -1,6 +1,7 @@
package cmd
import (
"context"
"log/slog"
"strings"
@@ -86,7 +87,7 @@ func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.Mem
// Fallback: resolve from provider registry (DB-stored providers like "openai-embedding").
// Any OpenAI-compatible provider in the registry can serve embeddings.
if providerReg != nil {
if regProv, err := providerReg.Get(name); err == nil {
if regProv, err := providerReg.Get(context.Background(), name); err == nil {
if op, ok := regProv.(*providers.OpenAIProvider); ok {
embBase := op.APIBase()
if apiBase != "" {
@@ -102,15 +103,15 @@ func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.Mem
}
func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus *bus.MessageBus, toolsReg *tools.Registry, workspace string, sandboxMgr sandbox.Manager) *tools.SubagentManager {
names := providerReg.List()
names := providerReg.List(context.Background())
if len(names) == 0 {
return nil
}
agentCfg := cfg.ResolveAgent("default")
provider, err := providerReg.Get(agentCfg.Provider)
provider, err := providerReg.Get(context.Background(), agentCfg.Provider)
if err != nil {
provider, _ = providerReg.Get(names[0])
provider, _ = providerReg.Get(context.Background(), names[0])
}
if provider == nil {
return nil
+8 -15
View File
@@ -44,33 +44,26 @@ func builtinToolSeedData() []store.BuiltinToolDef {
Requires: []string{"knowledge_graph"},
},
// media — seed uses chain format: {"providers":[...]}
{Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true,
Settings: json.RawMessage(`{"providers":[{"provider":"openrouter","model":"google/gemini-2.5-flash-image","enabled":true,"timeout":120,"max_retries":2}]}`),
// media — user must configure provider chain via UI before use
{Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: false,
Requires: []string{"vision_provider"},
},
{Name: "read_document", DisplayName: "Read Document", Description: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider", Category: "media", Enabled: true,
Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`),
{Name: "read_document", DisplayName: "Read Document", Description: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider", Category: "media", Enabled: false,
Requires: []string{"document_provider"},
},
{Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: true,
Settings: json.RawMessage(`{"providers":[{"provider":"openrouter","model":"google/gemini-2.5-flash-image","enabled":true,"timeout":120,"max_retries":2}]}`),
{Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: false,
Requires: []string{"image_gen_provider"},
},
{Name: "read_audio", DisplayName: "Read Audio", Description: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider", Category: "media", Enabled: true,
Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`),
{Name: "read_audio", DisplayName: "Read Audio", Description: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider", Category: "media", Enabled: false,
Requires: []string{"audio_provider"},
},
{Name: "read_video", DisplayName: "Read Video", Description: "Analyze video files using a video-capable LLM provider", Category: "media", Enabled: true,
Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`),
{Name: "read_video", DisplayName: "Read Video", Description: "Analyze video files using a video-capable LLM provider", Category: "media", Enabled: false,
Requires: []string{"video_provider"},
},
{Name: "create_video", DisplayName: "Create Video", Description: "Generate videos from text descriptions using AI", Category: "media", Enabled: true,
Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"veo-3.0-generate-preview","enabled":true,"timeout":120,"max_retries":2}]}`),
{Name: "create_video", DisplayName: "Create Video", Description: "Generate videos from text descriptions using AI", Category: "media", Enabled: false,
Requires: []string{"video_gen_provider"},
},
{Name: "create_audio", DisplayName: "Create Audio", Description: "Generate music or sound effects from text descriptions using AI", Category: "media", Enabled: true,
Settings: json.RawMessage(`{"providers":[{"provider":"minimax","model":"music-2.5+","enabled":true,"timeout":120,"max_retries":2}]}`),
{Name: "create_audio", DisplayName: "Create Audio", Description: "Generate music or sound effects from text descriptions using AI", Category: "media", Enabled: false,
Requires: []string{"audio_gen_provider"},
},
{Name: "tts", DisplayName: "Text to Speech", Description: "Convert text to natural-sounding speech audio", Category: "media", Enabled: true,
+3 -3
View File
@@ -143,7 +143,7 @@ func wireChannelEventSubscribers(
if !ok || payload.Kind != bus.CacheKindChannelInstances {
return
}
go instanceLoader.Reload(context.Background())
go instanceLoader.Reload(store.WithCrossTenant(context.Background()))
})
}
@@ -197,7 +197,7 @@ func wireChannelEventSubscribers(
if err != nil {
return
}
all, err := ciStore.ListAll(context.Background())
all, err := ciStore.ListAll(store.WithCrossTenant(context.Background()))
if err != nil {
slog.Warn("cascade disable: failed to list channel instances", "error", err)
return
@@ -205,7 +205,7 @@ func wireChannelEventSubscribers(
disabled := 0
for _, inst := range all {
if inst.AgentID == agentID && inst.Enabled {
if err := ciStore.Update(context.Background(), inst.ID, map[string]any{"enabled": false}); err != nil {
if err := ciStore.Update(store.WithCrossTenant(context.Background()), inst.ID, map[string]any{"enabled": false}); err != nil {
slog.Warn("cascade disable: failed to disable channel instance", "name", inst.Name, "error", err)
} else {
disabled++
+1
View File
@@ -120,6 +120,7 @@ func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore
if agentStore == nil {
return
}
// Caller (processNormalMessage) already injected tenant_id into ctx.
// agentKey may be a slug ("default") or a UUID string (from WS clients).
var ag *store.AgentData
var err error
+41 -29
View File
@@ -39,6 +39,13 @@ func handleSubagentAnnounce(
return false
}
// Inject tenant scope — same as processNormalMessage.
if msg.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, msg.TenantID)
} else {
ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
@@ -190,6 +197,13 @@ func handleTeammateMessage(
return false
}
// Inject tenant scope — same as processNormalMessage.
if msg.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, msg.TenantID)
} else {
ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
origChannel := msg.Metadata["origin_channel"]
origPeerKind := msg.Metadata["origin_peer_kind"]
origLocalKey := msg.Metadata["origin_local_key"]
@@ -346,24 +360,21 @@ func handleTeammateMessage(
if err := teamStore.FailTask(ctx, teamTaskID, teamID, outcome.Err.Error()); err != nil {
slog.Warn("auto-complete: FailTask error", "task_id", teamTaskID, "error", err)
} else {
msgBus.Broadcast(bus.Event{
Name: protocol.EventTeamTaskFailed,
Payload: protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusFailed,
Reason: outcome.Err.Error(),
Channel: taskChannel,
ChatID: taskChatID,
Timestamp: now,
ActorType: "agent",
ActorID: toAgent,
},
bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusFailed,
Reason: outcome.Err.Error(),
Channel: taskChannel,
ChatID: taskChatID,
Timestamp: now,
ActorType: "agent",
ActorID: toAgent,
})
}
} else {
} else if outcome.Result != nil {
result := outcome.Result.Content
if len(outcome.Result.Deliverables) > 0 {
result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n")
@@ -374,21 +385,18 @@ func handleTeammateMessage(
if err := teamStore.CompleteTask(ctx, teamTaskID, teamID, result); err != nil {
slog.Warn("auto-complete: CompleteTask error", "task_id", teamTaskID, "error", err)
} else {
msgBus.Broadcast(bus.Event{
Name: protocol.EventTeamTaskCompleted,
Payload: protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusCompleted,
OwnerAgentKey: toAgent,
Channel: taskChannel,
bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskCompleted, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{
TeamID: teamID.String(),
TaskID: teamTaskID.String(),
TaskNumber: taskNumber,
Subject: taskSubject,
Status: store.TeamTaskStatusCompleted,
OwnerAgentKey: toAgent,
Channel: taskChannel,
ChatID: taskChatID,
Timestamp: now,
ActorType: "agent",
ActorID: toAgent,
},
})
}
}
@@ -413,6 +421,9 @@ func handleTeammateMessage(
errMsg = errMsg[:500] + "..."
}
announceContent = fmt.Sprintf("[FAILED] %s", errMsg)
} else if outcome.Result == nil {
slog.Warn("teammate message: nil result without error", "from", senderID)
return
} else if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) {
slog.Info("teammate message: suppressed silent/empty reply", "from", senderID)
return
@@ -560,8 +571,9 @@ func handleResetCommand(
sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
}
}
sessStore.Reset(sessionKey)
sessStore.Save(sessionKey)
ctx := store.WithTenantID(context.Background(), msg.TenantID)
sessStore.Reset(ctx, sessionKey)
sessStore.Save(ctx, sessionKey)
providers.ResetCLISession("", sessionKey)
slog.Info("inbound: /reset command", "session", sessionKey)
+3 -6
View File
@@ -116,11 +116,6 @@ func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string {
return ""
}
tokenQuery := ""
if cfg.Gateway.Token != "" {
tokenQuery = "?token=" + cfg.Gateway.Token
}
var parts []string
for _, mr := range media {
cleanPath := filepath.Clean(mr.Path)
@@ -129,7 +124,9 @@ func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string {
if urlPath == "" {
continue
}
fileURL := "/v1/files/" + urlPath + tokenQuery
// Store clean path only — no auth tokens in persisted session messages.
// Frontend adds auth (Bearer header or ?ft= signed token) at render time.
fileURL := "/v1/files/" + urlPath
if strings.HasPrefix(mr.ContentType, "image/") {
parts = append(parts, fmt.Sprintf("![image](%s)", fileURL))
} else {
+15 -2
View File
@@ -38,13 +38,21 @@ func processNormalMessage(
postTurn tools.PostTurnProcessor,
msgBus *bus.MessageBus,
) {
// Inject tenant from channel instance into context so all store operations
// (agent lookup, session creation, etc.) are tenant-scoped.
if msg.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, msg.TenantID)
} else {
ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
// Determine target agent via bindings or explicit AgentID
agentID := msg.AgentID
if agentID == "" {
agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
}
agentLoop, err := agents.Get(agentID)
agentLoop, err := agents.Get(ctx, agentID)
if err != nil {
slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
return
@@ -96,7 +104,7 @@ func processNormalMessage(
// Persist friendly names from channel metadata into session + user profile.
sessionMeta := extractSessionMetadata(msg, peerKind)
if len(sessionMeta) > 0 {
sessStore.SetSessionMetadata(sessionKey, sessionMeta)
sessStore.SetSessionMetadata(ctx, sessionKey, sessionMeta)
if agentStore != nil {
if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil {
_ = agentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta)
@@ -298,6 +306,11 @@ func processNormalMessage(
}
}
// Inject tenant context from channel instance so all store queries are tenant-scoped.
if msg.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, msg.TenantID)
}
// Inject post-turn dispatch tracker so team task creates are deferred.
ptd := tools.NewPendingTeamDispatch()
schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)
+1 -1
View File
@@ -30,7 +30,7 @@ func makeSchedulerRunFunc(agents *agent.Router, cfg *config.Config) scheduler.Ru
}
}
loop, err := agents.Get(agentID)
loop, err := agents.Get(ctx, agentID)
if err != nil {
return nil, fmt.Errorf("agent %s not found: %w", agentID, err)
}
+4 -1
View File
@@ -60,8 +60,11 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg
)
}
// Build context with tenant scope so agent loop events are scoped correctly.
cronCtx := store.WithTenantID(context.Background(), job.TenantID)
// Schedule through cron lane — scheduler handles agent resolution and concurrency
outCh := sched.Schedule(context.Background(), scheduler.LaneCron, agent.RunRequest{
outCh := sched.Schedule(cronCtx, scheduler.LaneCron, agent.RunRequest{
SessionKey: sessionKey,
Message: job.Payload.Message,
Channel: channel,
+9 -12
View File
@@ -9,16 +9,14 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools + pending messages).
func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler) {
// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages).
func wireHTTP(stores *store.Stores, token, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.MCPUserCredentialsHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
var mcpH *httpapi.MCPHandler
var customToolsH *httpapi.CustomToolsHandler
var channelInstancesH *httpapi.ChannelInstancesHandler
var providersH *httpapi.ProvidersHandler
var delegationsH *httpapi.DelegationsHandler
var builtinToolsH *httpapi.BuiltinToolsHandler
var pendingMessagesH *httpapi.PendingMessagesHandler
var secureCLIH *httpapi.SecureCLIHandler
@@ -35,7 +33,7 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
if pgSkills, ok := stores.Skills.(*pg.PGSkillStore); ok {
dirs := pgSkills.Dirs()
if len(dirs) > 0 {
skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], token, msgBus)
skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], dataDir, bundledSkillsDir, token, msgBus)
}
}
}
@@ -47,13 +45,13 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
if stores != nil && stores.MCP != nil {
mcpH = httpapi.NewMCPHandler(stores.MCP, token, msgBus, mcpToolLister)
}
if stores != nil && stores.CustomTools != nil {
customToolsH = httpapi.NewCustomToolsHandler(stores.CustomTools, token, msgBus, toolsReg)
var mcpUserCredsH *httpapi.MCPUserCredentialsHandler
if stores != nil && stores.MCP != nil {
mcpUserCredsH = httpapi.NewMCPUserCredentialsHandler(stores.MCP, token)
}
if stores != nil && stores.ChannelInstances != nil {
channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, token, msgBus)
channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, stores.Tenants, token, msgBus)
}
if stores != nil && stores.Providers != nil {
@@ -67,12 +65,11 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
var teamEventsH *httpapi.TeamEventsHandler
if stores != nil && stores.Teams != nil {
delegationsH = httpapi.NewDelegationsHandler(stores.Teams, token)
teamEventsH = httpapi.NewTeamEventsHandler(stores.Teams, token)
}
if stores != nil && stores.BuiltinTools != nil {
builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, token, msgBus)
builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, stores.BuiltinToolTenantCfgs, token, msgBus)
}
if stores != nil && stores.PendingMessages != nil {
@@ -83,5 +80,5 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, token, msgBus)
}
return agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH
return agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH
}
+19 -22
View File
@@ -8,9 +8,12 @@ import (
"github.com/google/uuid"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/media"
@@ -45,7 +48,6 @@ func wireExtras(
injectionAction string,
appCfg *config.Config,
sandboxMgr sandbox.Manager,
dynamicLoader *tools.DynamicToolLoader,
redisClient any, // nil when built without -tags redis or when Redis is unconfigured
) (*tools.ContextFileInterceptor, *mcpbridge.Pool, *media.Store, tools.PostTurnProcessor) {
// 1. Build cache instances (in-memory or Redis depending on build tags)
@@ -115,7 +117,7 @@ func wireExtras(
// 5. Shared MCP connection pool (eliminates duplicate connections across agents)
var mcpPool *mcpbridge.Pool
if stores.MCP != nil {
mcpPool = mcpbridge.NewPool()
mcpPool = mcpbridge.NewPool(mcpbridge.DefaultPoolConfig())
}
// 6. Set up agent resolver: lazy-creates Loops from DB
@@ -145,7 +147,6 @@ func wireExtras(
SandboxEnabled: sandboxEnabled,
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
DynamicLoader: dynamicLoader,
AgentLinkStore: stores.AgentLinks,
TeamStore: stores.Teams,
DataDir: workspace,
@@ -158,10 +159,22 @@ func wireExtras(
ModelPricing: appCfg.Telemetry.ModelPricing,
TracingStore: stores.Tracing,
MemoryStore: stores.Memory,
TenantStore: stores.Tenants,
BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs,
SkillTenantCfgs: stores.SkillTenantCfgs,
Workspace: workspace,
OnEvent: func(event agent.AgentEvent) {
// Sign /v1/files/ and /v1/media/ URLs in content before delivery.
// Sessions store clean paths; signing happens only at delivery time.
if m, ok := event.Payload.(map[string]string); ok {
if c, has := m["content"]; has && strings.Contains(c, "/v1/") {
m["content"] = httpapi.SignFileURLs(c, httpapi.FileSigningKey())
}
}
msgBus.Broadcast(bus.Event{
Name: protocol.EventAgent,
Payload: event,
Name: protocol.EventAgent,
Payload: event,
TenantID: event.TenantID,
})
},
})
@@ -381,22 +394,6 @@ func wireExtras(
})
}
// Custom tools cache: reload global tools on create/update/delete
if dynamicLoader != nil {
msgBus.Subscribe(bus.TopicCacheCustomTools, func(event bus.Event) {
if event.Name != protocol.EventCacheInvalidate {
return
}
payload, ok := event.Payload.(bus.CacheInvalidatePayload)
if !ok || payload.Kind != bus.CacheKindCustomTools {
return
}
dynamicLoader.ReloadGlobal(context.Background(), toolsReg)
// Invalidate all agent caches so they re-resolve with updated tools
agentRouter.InvalidateAll()
})
}
// Builtin tools cache: re-apply disables on settings/enabled changes
if stores.BuiltinTools != nil {
msgBus.Subscribe(bus.TopicCacheBuiltinTools, func(event bus.Event) {
@@ -530,7 +527,7 @@ func buildKGExtractFunc(kgStore store.KnowledgeGraphStore, bts store.BuiltinTool
return
}
p, err := providerReg.Get(settings.ExtractionProvider)
p, err := providerReg.Get(ctx, settings.ExtractionProvider)
if err != nil {
slog.Warn("kg extract: provider not found", "provider", settings.ExtractionProvider, "error", err)
return
+2 -7
View File
@@ -19,14 +19,14 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
chatMethods := methods.NewChatMethods(agents, sessStore, server.RateLimiter(), msgBus)
chatMethods.Register(router)
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router)
methods.NewSessionsMethods(sessStore, msgBus).Register(router)
methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router)
methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus).Register(router)
// Phase 2: Skills (uses SkillStore interface — PG or File)
methods.NewSkillsMethods(skillStore).Register(router)
// Phase 2: Cron (store created externally, shared with gateway)
methods.NewCronMethods(cronStore, msgBus).Register(router)
methods.NewCronMethods(cronStore, msgBus, cfg).Register(router)
// Phase 2: Heartbeat
heartbeatMethods := methods.NewHeartbeatMethods(heartbeatStore, msgBus)
@@ -52,11 +52,6 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
// Phase 3: Live log tailing
methods.NewLogsMethods(logTee).Register(router)
// Phase 4: Delegation history
if teamStore != nil {
methods.NewDelegationsMethods(teamStore).Register(router)
}
slog.Info("registered all RPC methods",
"phase1", []string{"chat", "agents", "sessions", "config"},
"phase2", []string{"skills", "cron", "heartbeat", "pairing", "usage", "exec_approval", "send"},
+14 -14
View File
@@ -233,7 +233,7 @@ func jsonToStringMap(data json.RawMessage) map[string]string {
// mcpStore is optional; when provided, per-agent MCP servers are injected into CLI config.
// cfg provides fallback api_base values from config/env when DB providers have none set.
func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config) {
ctx := context.Background()
ctx := store.WithCrossTenant(context.Background())
dbProviders, err := provStore.ListProviders(ctx)
if err != nil {
slog.Warn("failed to load providers from DB", "error", err)
@@ -265,7 +265,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore)
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
}
registry.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...))
registry.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...))
slog.Info("registered provider from DB", "name", p.Name)
continue
}
@@ -280,7 +280,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
if host == "" {
host = "http://localhost:11434"
}
registry.Register(providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3"))
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3"))
slog.Info("registered provider from DB", "name", p.Name)
continue
}
@@ -297,37 +297,37 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
}
switch p.ProviderType {
case store.ProviderChatGPTOAuth:
ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name)
registry.Register(providers.NewCodexProvider(p.Name, ts, p.APIBase, ""))
ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name).WithTenantID(p.TenantID)
registry.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, p.APIBase, ""))
case store.ProviderAnthropicNative:
registry.Register(providers.NewAnthropicProvider(p.APIKey,
registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey,
providers.WithAnthropicBaseURL(p.APIBase)))
case store.ProviderDashScope:
registry.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, ""))
registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, ""))
case store.ProviderBailian:
base := p.APIBase
if base == "" {
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
}
registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
case store.ProviderZai:
base := p.APIBase
if base == "" {
base = "https://api.z.ai/api/paas/v4"
}
registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
case store.ProviderZaiCoding:
base := p.APIBase
if base == "" {
base = "https://api.z.ai/api/coding/paas/v4"
}
registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5"))
case store.ProviderOllamaCloud:
base := p.APIBase
if base == "" {
base = "https://ollama.com/v1"
}
registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3"))
registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3"))
case store.ProviderSuno:
// Suno is a media-only provider (music gen). Register as OpenAI-compat
// so credentialProvider interface works for API key/base extraction.
@@ -337,14 +337,14 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
}
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, "")
prov.WithProviderType(p.ProviderType)
registry.Register(prov)
registry.RegisterForTenant(p.TenantID, prov)
default:
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "")
prov.WithProviderType(p.ProviderType)
if p.ProviderType == store.ProviderMiniMax {
prov.WithChatPath("/text/chatcompletion_v2")
}
registry.Register(prov)
registry.RegisterForTenant(p.TenantID, prov)
}
slog.Info("registered provider from DB", "name", p.Name)
}
@@ -416,7 +416,7 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) {
if workDir == "" {
workDir = defaultACPWorkDir()
}
registry.Register(providers.NewACPProvider(
registry.RegisterForTenant(p.TenantID, providers.NewACPProvider(
binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(),
providers.WithACPModel(p.Name),
))
+25 -8
View File
@@ -201,6 +201,15 @@ func setupToolRegistry(
if et, ok := execTool.(*tools.ExecTool); ok {
et.DenyPaths(dataDir, ".goclaw/")
et.AllowPathExemptions(".goclaw/skills-store/")
// Harden: block access to internal workspace files via shell commands.
// Prevents `cat ../config.json`, `cat memory.db` etc. from user workspaces.
et.DenyPaths(
filepath.Join(workspace, "memory.db"),
filepath.Join(workspace, "memory.db-wal"),
filepath.Join(workspace, "memory.db-shm"),
filepath.Join(workspace, "config.json"),
filepath.Join(workspace, "delegate"),
)
if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" {
et.DenyPaths(cfgPath)
}
@@ -214,11 +223,17 @@ func setupToolRegistry(
// deny paths add defense-in-depth.
internalDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
"memory/", ".media/", "delegate/",
"memory/", ".media/", ".uploads/", "delegate/",
}
// read_file: allow .media/ access (uploaded documents accessed via AllowPaths
// for backward compat; new uploads go to per-user .uploads/ within workspace).
readFileDenyPaths := []string{
"config.json", "memory.db", "memory.db-wal", "memory.db-shm",
"memory/", "delegate/",
}
if rf, ok := toolsReg.Get("read_file"); ok {
if t, ok := rf.(*tools.ReadFileTool); ok {
t.DenyPaths(internalDenyPaths...)
t.DenyPaths(readFileDenyPaths...)
}
}
if wf, ok := toolsReg.Get("write_file"); ok {
@@ -338,7 +353,7 @@ func setupMemoryEmbeddings(
if pgStores.Memory != nil {
memCfg := cfg.Agents.Defaults.Memory
if pgStores.Agents != nil {
if defaultAgent, agErr := pgStores.Agents.GetByKey(context.Background(), "default"); agErr == nil {
if defaultAgent, agErr := pgStores.Agents.GetByKey(store.WithCrossTenant(context.Background()), "default"); agErr == nil {
if agentMemCfg := defaultAgent.ParseMemoryConfig(); agentMemCfg != nil {
memCfg = agentMemCfg
slog.Debug("using per-agent memory config from DB", "agent", defaultAgent.AgentKey)
@@ -452,7 +467,9 @@ func setupSkillsSystem(
toolsReg *tools.Registry,
providerRegistry *providers.Registry,
msgBus *bus.MessageBus,
) (*skills.Loader, *tools.SkillSearchTool, string) {
) (*skills.Loader, *tools.SkillSearchTool, string, string) {
var bundledSkillsDir string // resolved later; returned for HTTP handler fallback
// Skills loader + search tool
// Global skills live under ~/.goclaw/skills/ (user-managed), not data/skills/.
globalSkillsDir := os.Getenv("GOCLAW_SKILLS_DIR")
@@ -469,7 +486,7 @@ func setupSkillsSystem(
skillSearchTool := tools.NewSkillSearchTool(skillsLoader)
toolsReg.Register(skillSearchTool)
toolsReg.Register(tools.NewUseSkillTool())
slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills()))
slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills(store.WithCrossTenant(context.Background()))))
// Wire skills-store directory into filesystem loader so agents
// can discover uploaded skills in their system prompt and BM25 search index.
@@ -480,7 +497,7 @@ func setupSkillsSystem(
slog.Info("skills-store directory wired into loader", "dir", storeDirs[0])
// Seed system/bundled skills into DB
bundledSkillsDir := os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR")
bundledSkillsDir = os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR")
if bundledSkillsDir == "" {
// Check common locations: Docker default, then local dev
for _, candidate := range []string{"bundled-skills", "/app/bundled-skills", "skills"} {
@@ -518,7 +535,7 @@ func setupSkillsSystem(
if len(storeDirs) > 0 {
toolsReg.Register(tools.NewPublishSkillTool(pgSkills, storeDirs[0], skillsLoader))
slog.Info("publish_skill tool registered")
toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], skillsLoader))
toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], dataDir, skillsLoader))
slog.Info("skill_manage tool registered")
}
}
@@ -549,6 +566,6 @@ func setupSkillsSystem(
}
}
return skillsLoader, skillSearchTool, globalSkillsDir
return skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir
}
+4 -3
View File
@@ -1,6 +1,7 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -30,7 +31,7 @@ func skillsListCmd() *cobra.Command {
Short: "List all available skills",
Run: func(cmd *cobra.Command, args []string) {
loader := loadSkillsLoader()
allSkills := loader.ListSkills()
allSkills := loader.ListSkills(context.Background())
if jsonOutput {
data, _ := json.MarshalIndent(allSkills, "", " ")
@@ -66,7 +67,7 @@ func skillsShowCmd() *cobra.Command {
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
loader := loadSkillsLoader()
info, ok := loader.GetSkill(args[0])
info, ok := loader.GetSkill(context.Background(), args[0])
if !ok {
fmt.Fprintf(os.Stderr, "Skill not found: %s\n", args[0])
os.Exit(1)
@@ -77,7 +78,7 @@ func skillsShowCmd() *cobra.Command {
fmt.Printf("Location: %s\n", info.Path)
fmt.Println()
content, ok := loader.LoadSkill(args[0])
content, ok := loader.LoadSkill(context.Background(), args[0])
if ok {
fmt.Println("--- Content ---")
fmt.Println(content)
+1
View File
@@ -32,6 +32,7 @@ API keys are hashed with SHA-256 before lookup — the raw key is never stored.
| `Authorization` | Bearer token for authentication |
| `X-GoClaw-User-Id` | External user ID for multi-tenant context |
| `X-GoClaw-Agent-Id` | Agent identifier for scoped operations |
| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) |
| `Accept-Language` | Locale (`en`, `vi`, `zh`) for i18n error messages |
| `Content-Type` | `application/json` for request bodies |
+387
View File
@@ -0,0 +1,387 @@
# Multi-Tenant Integration Guide
GoClaw is an **AI agent gateway** — it handles agents, chat, sessions, tools, MCP servers, and memory. It supports two deployment modes:
1. **Personal / Single-tenant** — Use GoClaw directly as your AI backend. Built-in dashboard included.
2. **SaaS / Multi-tenant** — Integrate GoClaw behind your application. API keys bridge the two systems.
---
## Deployment Modes
### Mode 1: Personal Use (Single-Tenant)
Use GoClaw as a standalone AI backend with its built-in web dashboard. No separate frontend or backend needed.
```mermaid
graph LR
U[You] -->|browser| GC[GoClaw Dashboard<br/>+ Gateway]
GC --> AG[Agents / Chat / Tools]
AG --> DB[(PostgreSQL)]
AG -->|LLM calls| LLM[Anthropic / OpenAI / Gemini / ...]
```
**How it works:**
- Log in with the gateway token via the built-in web dashboard
- Create agents, configure LLM providers, chat — all from the dashboard
- Connect chat channels (Telegram, Discord, etc.) for messaging
- All data lives under the default "master" tenant — no tenant config needed
**Setup:**
```bash
# 1. Build and onboard
go build -o goclaw . && ./goclaw onboard
# 2. Start the gateway
source .env.local && ./goclaw
# 3. Open dashboard at http://localhost:3777
# Log in with your gateway token + user ID "system"
```
**When to use:** Personal AI assistant, small team, self-hosted AI tools, development/testing.
**Scaling up:** When you need multiple isolated environments (clients, departments, projects), create additional tenants. Multi-tenant features activate automatically — no migration needed.
---
### Mode 2: SaaS Integration (Multi-Tenant)
Integrate GoClaw as the AI engine behind your SaaS application. Your app handles auth, billing, and UI. GoClaw handles AI. Each tenant is fully isolated — agents, sessions, memory, teams, providers, and files.
```mermaid
graph TB
subgraph "Tenant A"
FEa[Frontend A]
BEa[Backend A]
TGa[Telegram Bot A]
end
subgraph "Tenant B"
FEb[Frontend B]
BEb[Backend B]
DCb[Discord Bot B]
end
subgraph "GoClaw Gateway"
subgraph "Entry Points"
HTTP[HTTP API]
WS[WebSocket]
CH[Channel Manager]
end
TI{Tenant Isolation<br/>Layer}
subgraph "Tenant-Scoped Engine"
AG[Agent Loop]
TOOLS[Tools / Skills / MCP]
MEM[Memory / KG]
end
DB[(PostgreSQL<br/>WHERE tenant_id = $N)]
end
FEa -->|authenticated| BEa
BEa -->|API Key A + user_id| HTTP
TGa -->|webhook| CH
FEb -->|authenticated| BEb
BEb -->|API Key B + user_id| HTTP
DCb -->|webhook| CH
HTTP --> TI
WS --> TI
CH --> TI
TI -->|ctx with tenant_id| AG
AG --> TOOLS
AG --> MEM
AG --> DB
TOOLS --> DB
MEM --> DB
```
**How it works:**
- Each tenant's **backend connects via a tenant-bound API key** — GoClaw auto-scopes all data
- **Chat channels** (Telegram, Discord, etc.) connect directly — tenant resolved from channel instance config
- The **Tenant Isolation Layer** resolves tenant_id from credentials and injects it into Go context
- Every SQL query enforces `WHERE tenant_id = $N` — fail-closed, no cross-tenant leakage
**When to use:** SaaS products with AI features, multi-client platforms, white-label AI solutions.
### Connection Types Summary
All connections go through the **Tenant Isolation Layer** before reaching the agent engine:
| Connection | Auth Method | Tenant Resolution | Isolation |
|------------|-------------|-------------------|-----------|
| **HTTP API** | `Bearer` token (API key or gateway token) | Auto from API key's `tenant_id` | Per-request |
| **WebSocket** | Token on `connect` (API key or gateway token) | Auto from API key's `tenant_id` | Per-session |
| **Chat Channels** | None (direct webhook/WS) | Baked into channel instance DB config | Per-instance |
| **Dashboard** | Gateway token or browser pairing | User's tenant membership | Per-session |
**Tenant Isolation Layer** — resolves credentials → injects `tenant_id` into Go `context.Context` → all downstream SQL queries enforce `WHERE tenant_id = $N`. Fail-closed: missing tenant = error, never unfiltered data.
---
## Tenant Setup (Multi-Tenant Only)
```mermaid
sequenceDiagram
participant Admin as System Admin
participant GC as GoClaw API
Admin->>GC: tenants.create {name: "Acme Corp", slug: "acme"}
GC-->>Admin: {id: "tenant-uuid", slug: "acme"}
Admin->>GC: tenants.users.add {tenant_id, user_id: "user-123", role: "admin"}
Admin->>GC: api_keys.create {tenant_id, scopes: ["operator.read", "operator.write"]}
GC-->>Admin: {key: "goclaw_sk_abc123..."}
Note over Admin: Store API key in your backend's config/secrets
```
Each tenant gets isolated: **agents, sessions, teams, memory, LLM providers, MCP servers, skills**. A tenant-bound API key automatically scopes every request — no extra headers needed.
---
## Tenant Resolution
GoClaw determines the tenant from the credentials used to connect:
| Credential | Tenant Resolution | Use Case |
|------------|-------------------|----------|
| **Gateway token** + owner user ID | All tenants (cross-tenant) | System administration |
| **Gateway token** + non-owner user ID | User's tenant membership | Dashboard users |
| **API key** (tenant-bound) | Auto from key's `tenant_id` | Normal SaaS integration |
| **API key** (system-level) + `X-GoClaw-Tenant-Id` | Header value (UUID or slug) | Cross-tenant admin tools |
| **Browser pairing** | Paired tenant | Dashboard operators |
| **No credentials** | Master tenant | Dev/single-user mode |
**Owner IDs:** Configured via `GOCLAW_OWNER_IDS` env var (comma-separated). Only owners get cross-tenant access with the gateway token. Default: `system`.
**Recommended for SaaS**: Use **tenant-bound API keys**. The tenant is resolved automatically from the key — your backend doesn't need to send any tenant header.
---
## HTTP API
All HTTP endpoints accept standard headers:
| Header | Required | Description |
|--------|:---:|-------------|
| `Authorization` | Yes | `Bearer <api-key-or-gateway-token>` |
| `X-GoClaw-User-Id` | Yes | Your app's user ID (max 255 chars). Scopes sessions and per-user data |
| `X-GoClaw-Tenant-Id` | No | Tenant UUID or slug. Only needed for system-level keys |
| `X-GoClaw-Agent-Id` | No | Target agent ID (alternative to `model` field) |
| `Accept-Language` | No | Locale for error messages: `en`, `vi`, `zh` |
### Chat (OpenAI-Compatible)
```bash
curl -X POST https://goclaw.example.com/v1/chat/completions \
-H "Authorization: Bearer goclaw_sk_abc123..." \
-H "X-GoClaw-User-Id: user-456" \
-H "Content-Type: application/json" \
-d '{
"model": "agent:my-agent",
"messages": [{"role": "user", "content": "Hello"}]
}'
```
The `model` field uses `agent:<agent-key>` format. The API key is bound to tenant "Acme Corp" — the response only includes data from that tenant.
### List Resources
```bash
# List agents
curl https://goclaw.example.com/v1/agents \
-H "Authorization: Bearer goclaw_sk_abc123..." \
-H "X-GoClaw-User-Id: user-456"
# List sessions
curl https://goclaw.example.com/v1/sessions \
-H "Authorization: Bearer goclaw_sk_abc123..." \
-H "X-GoClaw-User-Id: user-456"
```
### System Admin (Cross-Tenant)
```bash
# List agents for a specific tenant (requires gateway token + owner user ID)
curl https://goclaw.example.com/v1/agents \
-H "Authorization: Bearer $GATEWAY_TOKEN" \
-H "X-GoClaw-Tenant-Id: acme" \
-H "X-GoClaw-User-Id: system"
```
---
## WebSocket Integration
For real-time features (streaming chat, live events), connect via WebSocket:
```mermaid
sequenceDiagram
participant FE as Your Frontend
participant BE as Your Backend
participant GC as GoClaw Gateway
FE->>BE: User sends message (authenticated)
BE->>GC: WS connect {token: "goclaw_sk_abc...", user_id: "user-456"}
GC-->>BE: {role: "operator", tenant_id, tenant_name, tenant_slug}
BE->>GC: chat.send {agent_key: "my-agent", message: "Hello"}
GC-->>BE: event: agent {type: "chunk", content: "Hi..."}
BE-->>FE: Stream response to user
GC-->>BE: event: agent {type: "run.completed"}
```
After `connect`, **all methods are auto-scoped** to the API key's tenant. Events are server-side filtered — your backend only receives events belonging to its tenant.
**Protocol**: Frame types `req` (client→server), `res` (server→client), `event` (async push). Protocol version 3.
---
## Chat Channels
Chat channels (Telegram, Discord, Zalo, Slack, WhatsApp, Feishu) connect **directly** to GoClaw — no API key needed. Tenant isolation is baked into the channel instance at registration time.
```mermaid
sequenceDiagram
participant TG as Telegram
participant CH as Channel Manager
participant TI as Tenant Isolation
participant AG as Agent Loop
participant DB as PostgreSQL
Note over CH: Channel instance loaded from DB<br/>with tenant_id baked in
TG->>CH: Incoming message (webhook)
CH->>TI: Resolve tenant from instance config
TI->>AG: ctx with tenant_id + user_id
AG->>DB: Query with WHERE tenant_id = $N
AG-->>CH: Agent response
CH-->>TG: Reply to user
```
Each channel instance stores its `tenant_id` in the `channel_instances` table. When a message arrives, the Channel Manager looks up the instance config and injects the tenant context — no headers or tokens required.
| Channel | Protocol | Tenant Source |
|---------|----------|---------------|
| Telegram | Webhook | Instance config |
| Discord | WebSocket | Instance config |
| Zalo | Webhook | Instance config |
| Slack | Events API | Instance config |
| WhatsApp | Webhook | Instance config |
| Feishu/Lark | Webhook | Instance config |
---
## API Key Scopes
API keys use scopes to control access level:
| Scope | Role | Permissions |
|-------|------|-------------|
| `operator.admin` | admin | Full access — agents, config, API keys, tenants |
| `operator.read` | viewer | Read-only — list agents, sessions, configs |
| `operator.write` | operator | Read + write — chat, create sessions, manage agents |
| `operator.approvals` | operator | Approve/reject execution requests |
| `operator.provision` | operator | Create tenants + manage tenant users |
| `operator.pairing` | operator | Manage device pairing |
A key with `["operator.read", "operator.write"]` gets `operator` role. A key with `["operator.admin"]` gets `admin` role.
---
## Per-Tenant Overrides
Tenants can customize their environment without affecting other tenants:
| Feature | Scope | How |
|---------|-------|-----|
| **LLM Providers** | Per-tenant provider configs | Each tenant registers own API keys + models |
| **Builtin Tools** | Enable/disable per tenant | `builtin_tool_tenant_configs` table |
| **Skills** | Enable/disable per tenant | `skill_tenant_configs` table |
| **MCP Servers** | Per-tenant + per-user credentials | Server-level shared, user-level overrides |
| **MCP Require User Credentials** | Per-server setting | `settings.require_user_credentials` — forces per-user API keys |
MCP servers support two credential tiers:
- **Server-level** (shared): configured in the MCP server form, used by all users
- **User-level** (overrides): configured via "My Credentials", per-user API keys merged at runtime (user wins on key collision)
When `require_user_credentials` is enabled, users without personal credentials cannot use that MCP server.
---
## Security
| Concern | How GoClaw Handles It |
|---------|-----------------------|
| API key exposure | Keys stay in your backend — never sent to browser |
| Cross-tenant data access | All SQL queries include `WHERE tenant_id = $N` (fail-closed) |
| Event leakage | Server-side 3-mode filter: unscoped admin, scoped admin, regular user |
| Missing tenant context | Fail-closed: returns error, never unfiltered data |
| API key storage | Keys hashed with SHA-256 at rest; only prefix shown in UI |
| Tenant impersonation | Tenant resolved from API key binding, not client headers |
| Privilege escalation | Role derived from key scopes, not client claims |
| Gateway token abuse | Only configured owner IDs get cross-tenant; others are tenant-scoped |
| System config access | Config page restricted to cross-tenant owners only |
| Logout isolation | Tenant scope cleared from localStorage on logout |
| Tenant access revocation | Proactive WS event + `TENANT_ACCESS_REVOKED` error forces immediate UI logout |
| File URL security | HMAC-signed file tokens (`?ft=`) — no gateway token in URLs |
---
## Tenant Data Model
```mermaid
erDiagram
TENANTS ||--o{ TENANT_USERS : "members"
TENANTS ||--o{ API_KEYS : "keys"
TENANTS ||--o{ AGENTS : "owns"
TENANTS ||--o{ SESSIONS : "owns"
TENANTS ||--o{ TEAMS : "owns"
TENANTS ||--o{ LLM_PROVIDERS : "configures"
TENANTS ||--o{ MCP_SERVERS : "registers"
TENANTS ||--o{ SKILLS : "manages"
TENANTS {
uuid id PK
string name
string slug UK
string status
jsonb settings
}
TENANT_USERS {
uuid tenant_id FK
string user_id
string role
}
API_KEYS {
uuid id PK
uuid tenant_id FK "NULL = system key"
string owner_id
text[] scopes
boolean revoked
}
```
40+ tables carry `tenant_id` with NOT NULL constraint. Exception: `api_keys.tenant_id` is nullable — NULL means system-level cross-tenant key.
**Master tenant** (UUID `0193a5b0-7000-7000-8000-000000000001`): All legacy/default data. Single-tenant deployments use this exclusively.
---
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `GOCLAW_OWNER_IDS` | `system` | Comma-separated user IDs with cross-tenant access |
| `GOCLAW_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `GOCLAW_CONFIG` | `config.json5` | Path to gateway config file |
+52 -21
View File
@@ -43,6 +43,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if l.agentUUID != uuid.Nil {
ctx = store.WithAgentID(ctx, l.agentUUID)
}
// Inject tenant into context for tool-level tenant scoping (spawn, MCP, etc.)
if l.tenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, l.tenantID)
}
// Inject user ID into context for per-user scoping (memory, context files, etc.)
if req.UserID != "" {
ctx = store.WithUserID(ctx, req.UserID)
@@ -182,7 +186,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if tools.IsSharedWorkspace(team.Settings) {
wsChat = ""
}
if wsDir, err := tools.WorkspaceDir(l.dataDir, team.ID, wsChat); err == nil {
tenantBase := config.TenantWorkspace(l.dataDir, store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx))
if wsDir, err := tools.WorkspaceDir(tenantBase, team.ID, wsChat); err == nil {
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
if team.LeadAgentID == l.agentUUID {
ctx = tools.WithToolWorkspace(ctx, wsDir)
@@ -196,7 +201,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// Persist agent UUID + user ID on the session (for querying/tracing)
if l.agentUUID != uuid.Nil || req.UserID != "" {
l.sessions.SetAgentInfo(req.SessionKey, l.agentUUID, req.UserID)
l.sessions.SetAgentInfo(ctx, req.SessionKey, l.agentUUID, req.UserID)
}
// Security: scan user message for injection patterns.
@@ -247,19 +252,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// 0. Cache agent's context window on the session (first run only).
// Enables scheduler's adaptive throttle to use the real value instead of hardcoded 200K.
if l.sessions.GetContextWindow(req.SessionKey) <= 0 {
l.sessions.SetContextWindow(req.SessionKey, l.contextWindow)
if l.sessions.GetContextWindow(ctx, req.SessionKey) <= 0 {
l.sessions.SetContextWindow(ctx, req.SessionKey, l.contextWindow)
}
// 0b. Load adaptive tool timing from session metadata.
toolTiming := ParseToolTiming(l.sessions.GetSessionMetadata(req.SessionKey))
toolTiming := ParseToolTiming(l.sessions.GetSessionMetadata(ctx, req.SessionKey))
// Resolve slow_tool notification config from already-loaded team settings (no extra DB query).
slowToolEnabled := tools.ParseTeamNotifyConfig(resolvedTeamSettings).SlowTool
// 1. Build messages from session history
history := l.sessions.GetHistory(req.SessionKey)
summary := l.sessions.GetSummary(req.SessionKey)
history := l.sessions.GetHistory(ctx, req.SessionKey)
summary := l.sessions.GetSummary(ctx, req.SessionKey)
// buildMessages resolves context files once and also detects BOOTSTRAP.md presence
// (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection.
@@ -279,14 +284,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// 2. Process media: sanitize images, persist to media store.
var mediaRefs []providers.MediaRef
if len(req.Media) > 0 {
mediaRefs = l.persistMedia(req.SessionKey, req.Media)
// Load current-turn images from persisted refs.
mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx))
// Load current-turn images from persisted refs (Path is always set for new uploads).
var imageFiles []bus.MediaFile
for _, ref := range mediaRefs {
if ref.Kind == "image" {
if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
imageFiles = append(imageFiles, bus.MediaFile{Path: p, MimeType: ref.MimeType})
}
if ref.Kind == "image" && ref.Path != "" {
imageFiles = append(imageFiles, bus.MediaFile{Path: ref.Path, MimeType: ref.MimeType})
}
}
if images := loadImages(imageFiles); len(images) > 0 {
@@ -384,7 +387,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if len(mediaRefs) > 0 && l.mediaStore != nil {
var mediaPaths []string
for _, ref := range mediaRefs {
if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
// Prefer workspace-local path (.uploads/) over canonical .media/ path.
if ref.Path != "" {
mediaPaths = append(mediaPaths, ref.Path)
} else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
mediaPaths = append(mediaPaths, p)
}
}
@@ -636,6 +642,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
toolDefs = l.tools.ProviderDefs()
}
// Per-tenant tool exclusions: remove tools disabled for this agent's tenant.
if len(l.disabledTools) > 0 {
filtered := toolDefs[:0]
for _, td := range toolDefs {
if !l.disabledTools[td.Function.Name] {
filtered = append(filtered, td)
} else {
delete(allowedTools, td.Function.Name)
}
}
toolDefs = filtered
}
// Bootstrap mode: restrict API tool definitions to write_file only (open agents).
// Predefined agents keep all tools — BOOTSTRAP.md guides behavior.
if hadBootstrap && l.agentType != store.AgentTypePredefined {
@@ -874,7 +893,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if sanitized != "" && !IsSilentReply(sanitized) {
blockReplies++
lastBlockReply = sanitized
l.emit(AgentEvent{
emitRun(AgentEvent{
Type: protocol.AgentEventBlockReply,
AgentID: l.id,
RunID: req.RunID,
@@ -1032,6 +1051,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
} else if mr := parseMediaResult(result.ForLLM); mr != nil {
mediaResults = append(mediaResults, *mr)
}
// Auto-attach workspace media to task (covers create_image/audio/video).
if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" {
for _, mf := range result.Media {
tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path)
}
}
if result.Deliverable != "" {
deliverables = append(deliverables, result.Deliverable)
}
@@ -1229,6 +1254,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
} else if mr := parseMediaResult(r.result.ForLLM); mr != nil {
mediaResults = append(mediaResults, *mr)
}
// Auto-attach workspace media to task (covers create_image/audio/video).
if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" {
for _, mf := range r.result.Media {
tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path)
}
}
if r.result.Deliverable != "" {
deliverables = append(deliverables, r.result.Deliverable)
}
@@ -1403,27 +1434,27 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// Flush all buffered messages to session atomically.
// This ensures concurrent runs never see each other's in-progress messages.
for _, msg := range pendingMsgs {
l.sessions.AddMessage(req.SessionKey, msg)
l.sessions.AddMessage(ctx, req.SessionKey, msg)
}
// Persist adaptive tool timing to session metadata.
if serialized := toolTiming.Serialize(); serialized != "" {
l.sessions.SetSessionMetadata(req.SessionKey, map[string]string{"tool_timing": serialized})
l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{"tool_timing": serialized})
}
// Write session metadata (matching TS session entry updates)
l.sessions.UpdateMetadata(req.SessionKey, l.model, l.provider.Name(), req.Channel)
l.sessions.AccumulateTokens(req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens))
l.sessions.UpdateMetadata(ctx, req.SessionKey, l.model, l.provider.Name(), req.Channel)
l.sessions.AccumulateTokens(ctx, req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens))
// Calibrate token estimation: store actual prompt tokens + message count.
// Next time EstimateTokensWithCalibration() is called, it uses this as a base
// instead of the chars/3 heuristic (more accurate for multilingual content).
if totalUsage.PromptTokens > 0 {
msgCount := len(history) + len(pendingMsgs)
l.sessions.SetLastPromptTokens(req.SessionKey, totalUsage.PromptTokens, msgCount)
l.sessions.SetLastPromptTokens(ctx, req.SessionKey, totalUsage.PromptTokens, msgCount)
}
l.sessions.Save(req.SessionKey)
l.sessions.Save(ctx, req.SessionKey)
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md
// as a safety net in case the LLM didn't clear it itself.
+18 -18
View File
@@ -179,7 +179,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
OwnerIDs: l.ownerIDs,
Mode: mode,
ToolNames: toolNames,
SkillsSummary: l.resolveSkillsSummary(skillFilter),
SkillsSummary: l.resolveSkillsSummary(ctx, skillFilter),
HasMemory: l.hasMemory,
HasSpawn: l.tools != nil && hasSpawn,
HasTeam: hasTeamTools,
@@ -231,8 +231,8 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
slog.Info("sanitizeHistory: cleaned session history",
"session", sessionKey, "dropped", droppedCount)
cleanedHistory, _ := sanitizeHistory(history)
l.sessions.SetHistory(sessionKey, cleanedHistory)
l.sessions.Save(sessionKey)
l.sessions.SetHistory(ctx, sessionKey, cleanedHistory)
l.sessions.Save(ctx, sessionKey)
}
// Current user message
@@ -305,7 +305,7 @@ const (
// Returns (summary XML, useInline) — useInline=true means skills are inlined and
// the system prompt should use TS-style "scan <available_skills>" instructions
// instead of "use skill_search".
func (l *Loop) resolveSkillsSummary(skillFilter []string) string {
func (l *Loop) resolveSkillsSummary(ctx context.Context, skillFilter []string) string {
if l.skillsLoader == nil {
return ""
}
@@ -316,7 +316,7 @@ func (l *Loop) resolveSkillsSummary(skillFilter []string) string {
allowList = skillFilter
}
filtered := l.skillsLoader.FilterSkills(allowList)
filtered := l.skillsLoader.FilterSkills(ctx, allowList)
if len(filtered) == 0 {
return ""
}
@@ -330,7 +330,7 @@ func (l *Loop) resolveSkillsSummary(skillFilter []string) string {
if len(filtered) <= skillInlineMaxCount && estimatedTokens <= skillInlineMaxTokens {
// Inline mode: build full XML summary
return l.skillsLoader.BuildSummary(allowList)
return l.skillsLoader.BuildSummary(ctx, allowList)
}
// Search mode: no XML in prompt, agent uses skill_search tool
@@ -471,10 +471,10 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) {
}
func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
history := l.sessions.GetHistory(sessionKey)
history := l.sessions.GetHistory(ctx, sessionKey)
// Use calibrated token estimation when available.
lastPT, lastMC := l.sessions.GetLastPromptTokens(sessionKey)
lastPT, lastMC := l.sessions.GetLastPromptTokens(ctx, sessionKey)
tokenEstimate := EstimateTokensWithCalibration(history, lastPT, lastMC)
// Resolve compaction thresholds from config with sensible defaults.
@@ -505,7 +505,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
// Memory flush runs synchronously INSIDE the guard
// (so concurrent runs don't both trigger flush for the same compaction cycle).
flushSettings := ResolveMemoryFlushSettings(l.compactionCfg)
if l.shouldRunMemoryFlush(sessionKey, tokenEstimate, flushSettings) {
if l.shouldRunMemoryFlush(ctx, sessionKey, tokenEstimate, flushSettings) {
l.runMemoryFlush(ctx, sessionKey, flushSettings)
}
@@ -521,15 +521,15 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
// Re-check: history may have been truncated by a concurrent summarize
// that finished between our threshold check and acquiring the lock.
history := l.sessions.GetHistory(sessionKey)
sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 120*time.Second)
defer cancel()
history := l.sessions.GetHistory(sctx, sessionKey)
if len(history) <= keepLast {
return
}
sctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
summary := l.sessions.GetSummary(sessionKey)
summary := l.sessions.GetSummary(sctx, sessionKey)
toSummarize := history[:len(history)-keepLast]
var sb strings.Builder
@@ -579,10 +579,10 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
return
}
l.sessions.SetSummary(sessionKey, SanitizeAssistantContent(resp.Content))
l.sessions.TruncateHistory(sessionKey, keepLast)
l.sessions.IncrementCompaction(sessionKey)
l.sessions.Save(sessionKey)
l.sessions.SetSummary(sctx, sessionKey, SanitizeAssistantContent(resp.Content))
l.sessions.TruncateHistory(sctx, sessionKey, keepLast)
l.sessions.IncrementCompaction(sctx, sessionKey)
l.sessions.Save(sctx, sessionKey)
}()
}
+2 -1
View File
@@ -29,6 +29,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
event.UserID = req.UserID
event.Channel = req.Channel
event.ChatID = req.ChatID
event.TenantID = store.TenantIDFromContext(ctx)
l.emit(event)
}
@@ -160,7 +161,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
traceCtx := ctx
traceStatus := store.TraceStatusError
if ctx.Err() != nil {
traceCtx = context.Background()
traceCtx = context.WithoutCancel(ctx)
traceStatus = store.TraceStatusCancelled
}
l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, err.Error(), "")
+12
View File
@@ -65,6 +65,10 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration
span.AgentID = &l.agentUUID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
span.TenantID = store.TenantIDFromContext(ctx)
if span.TenantID == uuid.Nil {
span.TenantID = store.MasterTenantID
}
// Verbose mode: include input messages (same stripping as emitLLMSpan).
if collector.Verbose() && len(messages) > 0 {
@@ -195,6 +199,10 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName,
span.AgentID = &l.agentUUID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
span.TenantID = store.TenantIDFromContext(ctx)
if span.TenantID == uuid.Nil {
span.TenantID = store.MasterTenantID
}
collector.EmitSpan(span)
return spanID
@@ -302,6 +310,10 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st
span.AgentID = &l.agentUUID
}
span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx)
span.TenantID = store.TenantIDFromContext(ctx)
if span.TenantID == uuid.Nil {
span.TenantID = store.MasterTenantID
}
collector.EmitSpan(span)
}
+15 -2
View File
@@ -40,6 +40,7 @@ type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID st
type Loop struct {
id string
agentUUID uuid.UUID // set for context propagation
tenantID uuid.UUID // agent's owning tenant
agentType string // "open" or "predefined"
provider providers.Provider
model string
@@ -108,6 +109,9 @@ type Loop struct {
// Global builtin tool settings (from builtin_tools table)
builtinToolSettings tools.BuiltinToolSettings
// Per-tenant disabled tools (tool name → true means excluded from LLM)
disabledTools map[string]bool
// Thinking level for extended thinking support
thinkingLevel string
@@ -160,6 +164,9 @@ type AgentEvent struct {
UserID string `json:"userId,omitempty"`
Channel string `json:"channel,omitempty"`
ChatID string `json:"chatId,omitempty"`
// TenantID scopes this event to a specific tenant for filtering (not serialized).
TenantID uuid.UUID `json:"-"`
}
// LoopConfig configures a new Loop.
@@ -209,9 +216,10 @@ type LoopConfig struct {
// Shell deny group overrides (nil = all defaults)
ShellDenyGroups map[string]bool
// Agent UUID for context propagation to tools
// Agent UUID + tenant for context propagation to tools
AgentUUID uuid.UUID
AgentType string // "open" or "predefined"
TenantID uuid.UUID // agent's owning tenant — injected into execution context
AgentType string // "open" or "predefined"
// Per-user file seeding + dynamic context loading
EnsureUserFiles EnsureUserFilesFunc
@@ -229,6 +237,9 @@ type LoopConfig struct {
// Global builtin tool settings (from builtin_tools table)
BuiltinToolSettings tools.BuiltinToolSettings
// Per-tenant disabled tools (tool name → true means excluded)
DisabledTools map[string]bool
// Thinking level: "off", "low", "medium", "high" (from agent other_config)
ThinkingLevel string
@@ -298,6 +309,7 @@ func NewLoop(cfg LoopConfig) *Loop {
return &Loop{
id: cfg.ID,
agentUUID: cfg.AgentUUID,
tenantID: cfg.TenantID,
agentType: cfg.AgentType,
provider: cfg.Provider,
model: cfg.Model,
@@ -337,6 +349,7 @@ func NewLoop(cfg LoopConfig) *Loop {
injectionAction: action,
maxMessageChars: cfg.MaxMessageChars,
builtinToolSettings: cfg.BuiltinToolSettings,
disabledTools: cfg.DisabledTools,
thinkingLevel: cfg.ThinkingLevel,
selfEvolve: cfg.SelfEvolve,
skillEvolve: cfg.SkillEvolve,
+81 -30
View File
@@ -3,12 +3,16 @@ package agent
import (
"encoding/base64"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/media"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
@@ -50,19 +54,20 @@ func loadImages(files []bus.MediaFile) []providers.ImageContent {
return images
}
// persistMedia sanitizes images, saves all media files to persistent storage,
// and returns lightweight MediaRefs. Non-image files are saved as-is.
// If mediaStore is nil, falls back to legacy behavior (no persistence, delete temp files).
func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile) []providers.MediaRef {
if l.mediaStore == nil {
// Fallback: no persistent storage configured.
// slog.Warn("media: no media store configured, temp files will be deleted", "agent", l.id)
// Keep workspace files — don't delete originals.
// defer func() {
// for _, f := range files {
// _ = os.Remove(f.Path)
// }
// }()
// persistMedia sanitizes images, saves all media files to the per-user workspace
// .uploads/ directory, and returns lightweight MediaRefs with persisted paths.
// All media types (images, documents, audio, video) are stored within the user's
// workspace for filesystem-level tenant isolation.
// workspace is the per-user workspace path from ToolWorkspaceFromCtx(ctx).
func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile, workspace string) []providers.MediaRef {
if workspace == "" {
slog.Warn("media: no workspace, cannot persist media")
return nil
}
uploadsDir := filepath.Join(workspace, ".uploads")
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
slog.Warn("media: failed to create .uploads dir", "dir", uploadsDir, "error", err)
return nil
}
@@ -70,49 +75,80 @@ func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile) []provider
for _, f := range files {
mime := f.MimeType
if mime == "" {
mime = mimeFromExt(filepath.Ext(f.Path)) // fallback for legacy callers
mime = mimeFromExt(filepath.Ext(f.Path))
}
kind := mediaKindFromMime(mime)
// Sanitize images before persistent storage.
srcPath := f.Path
var sanitizedTemp string // track temp file for cleanup
if kind == "image" {
sanitized, err := SanitizeImage(f.Path)
if err != nil {
slog.Warn("media: sanitize image failed, using original", "path", f.Path, "error", err)
} else {
srcPath = sanitized
sanitizedTemp = sanitized
mime = "image/jpeg" // sanitized output is always JPEG
// Keep workspace files — don't delete originals after sanitization.
// if sanitized != f.Path {
// _ = os.Remove(f.Path)
// }
}
}
id, _, err := l.mediaStore.SaveFile(sessionKey, srcPath, mime)
if err != nil {
id := uuid.New().String()
ext := media.ExtFromMime(mime)
if ext == "" {
ext = filepath.Ext(srcPath) // fallback to source extension
}
dstPath := filepath.Join(uploadsDir, id+ext)
if err := copyMediaFile(srcPath, dstPath); err != nil {
slog.Warn("media: failed to persist file", "path", f.Path, "error", err)
// Keep workspace files — don't delete on persist failure.
// _ = os.Remove(srcPath)
if sanitizedTemp != "" {
os.Remove(sanitizedTemp)
}
continue
}
if sanitizedTemp != "" {
os.Remove(sanitizedTemp) // cleanup sanitized temp file
}
refs = append(refs, providers.MediaRef{
ID: id,
MimeType: mime,
Kind: kind,
Path: dstPath,
})
slog.Debug("media: persisted file", "id", id, "kind", kind, "mime", mime, "agent", l.id)
slog.Debug("media: persisted file", "id", id, "kind", kind, "path", dstPath, "agent", l.id)
}
return refs
}
// copyMediaFile copies src to dst using buffered I/O.
// Removes partial dst file on failure.
func copyMediaFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst) // cleanup partial file
return err
}
return out.Close()
}
// enrichDocumentPaths updates the last user message to include persisted file paths
// in <media:document> tags. This allows skills (e.g. pdf skill via exec) to access
// the file directly, matching how Claude Code skills work with file paths.
func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []providers.MediaRef) {
if l.mediaStore == nil || len(messages) == 0 {
if len(messages) == 0 {
return
}
// Find last user message
@@ -132,8 +168,16 @@ func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []provider
if ref.Kind != "document" {
continue
}
p, err := l.mediaStore.LoadPath(ref.ID)
if err != nil {
// Use persisted workspace path; fall back to legacy .media/ lookup.
p := ref.Path
if p == "" && l.mediaStore != nil {
var err error
p, err = l.mediaStore.LoadPath(ref.ID)
if err != nil {
continue
}
}
if p == "" {
continue
}
// Replace <media:document> or <media:document name="X"> with version that includes path.
@@ -307,7 +351,7 @@ const maxMediaReloadMessages = 5
// reloadMediaForMessages populates Images on historical messages that have image MediaRefs.
// Only reloads the last maxMessages messages with image refs (newest first) to limit context usage.
func (l *Loop) reloadMediaForMessages(msgs []providers.Message, maxMessages int) {
if l.mediaStore == nil || maxMessages <= 0 {
if maxMessages <= 0 {
return
}
@@ -324,9 +368,16 @@ func (l *Loop) reloadMediaForMessages(msgs []providers.Message, maxMessages int)
continue
}
hasImageRef = true
p, err := l.mediaStore.LoadPath(ref.ID)
if err != nil {
slog.Debug("media: reload skip missing file", "id", ref.ID, "error", err)
p := ref.Path
if p == "" && l.mediaStore != nil {
var err error
p, err = l.mediaStore.LoadPath(ref.ID)
if err != nil {
slog.Debug("media: reload skip missing file", "id", ref.ID, "error", err)
continue
}
}
if p == "" {
continue
}
imageFiles = append(imageFiles, bus.MediaFile{Path: p, MimeType: ref.MimeType})
+9 -3
View File
@@ -7,7 +7,6 @@ import (
"image/jpeg"
"image/png"
"os"
"path/filepath"
"github.com/disintegration/imaging"
)
@@ -51,10 +50,17 @@ func SanitizeImage(inputPath string) (string, error) {
return "", fmt.Errorf("encode jpeg (q=%d): %w", quality, err)
}
if buf.Len() <= imageSanitizeMaxBytes {
outPath := filepath.Join(os.TempDir(), fmt.Sprintf("goclaw_sanitized_%d.jpg", os.Getpid()))
if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil {
f, err := os.CreateTemp("", "goclaw_sanitized_*.jpg")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
outPath := f.Name()
if _, err := f.Write(buf.Bytes()); err != nil {
f.Close()
os.Remove(outPath)
return "", fmt.Errorf("write sanitized image: %w", err)
}
f.Close()
return outPath, nil
}
}
+5 -2
View File
@@ -69,8 +69,11 @@ func (l *Loop) loadHistoricalImagesForTool(ctx context.Context, currentRefs []pr
continue
}
hasImage = true
p, err := l.mediaStore.LoadPath(ref.ID)
if err != nil {
p := ref.Path
if p == "" && l.mediaStore != nil {
p, _ = l.mediaStore.LoadPath(ref.ID)
}
if p == "" {
continue
}
histPaths = append(histPaths, bus.MediaFile{Path: p, MimeType: ref.MimeType})
+7 -7
View File
@@ -67,7 +67,7 @@ func ResolveMemoryFlushSettings(compaction *config.CompactionConfig) *MemoryFlus
// shouldRunMemoryFlush checks whether a memory flush should run before compaction.
// Flush always runs when compaction triggers (called inside maybeSummarize),
// gated only by enabled/memory checks and a dedup guard per compaction cycle.
func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool {
func (l *Loop) shouldRunMemoryFlush(ctx context.Context, sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool {
if settings == nil || !settings.Enabled || !l.hasMemory {
return false
}
@@ -77,8 +77,8 @@ func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings
}
// Deduplication: skip if already flushed in this compaction cycle.
compactionCount := l.sessions.GetCompactionCount(sessionKey)
lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(sessionKey)
compactionCount := l.sessions.GetCompactionCount(ctx, sessionKey)
lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(ctx, sessionKey)
if lastFlushAt >= 0 && lastFlushAt == compactionCount {
return false
}
@@ -95,8 +95,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *
defer cancel()
// Build messages: system prompt + history summary + flush prompt
history := l.sessions.GetHistory(sessionKey)
summary := l.sessions.GetSummary(sessionKey)
history := l.sessions.GetHistory(ctx, sessionKey)
summary := l.sessions.GetSummary(ctx, sessionKey)
var messages []providers.Message
@@ -203,8 +203,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings *
}
// Mark flush as done
l.sessions.SetMemoryFlushDone(sessionKey)
l.sessions.Save(sessionKey)
l.sessions.SetMemoryFlushDone(ctx, sessionKey)
l.sessions.Save(ctx, sessionKey)
slog.Info("memory flush: completed", "session", sessionKey)
}
+65 -20
View File
@@ -50,9 +50,6 @@ type ResolverDeps struct {
SandboxContainerDir string
SandboxWorkspaceAccess string
// Dynamic custom tools
DynamicLoader *tools.DynamicToolLoader
// Inter-agent delegation
AgentLinkStore store.AgentLinkStore
@@ -89,13 +86,22 @@ type ResolverDeps struct {
// Memory store for extractive memory fallback
MemoryStore store.MemoryStore
// Tenant store for workspace path resolution
TenantStore store.TenantStore
// Per-tenant tool/skill config overrides
BuiltinToolTenantCfgs store.BuiltinToolTenantConfigStore
SkillTenantCfgs store.SkillTenantConfigStore
// Global workspace root (GOCLAW_WORKSPACE)
Workspace string
}
// NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data.
// Agents are defined in Postgres, not config.json.
func NewManagedResolver(deps ResolverDeps) ResolverFunc {
return func(agentKey string) (Agent, error) {
ctx := context.Background()
return func(ctx context.Context, agentKey string) (Agent, error) {
// Support lookup by UUID (e.g. from cron jobs that store agent_id as UUID)
var ag *store.AgentData
@@ -113,15 +119,15 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
return nil, fmt.Errorf("agent %s is inactive", agentKey)
}
// Resolve provider
provider, err := deps.ProviderReg.Get(ag.Provider)
// Resolve provider (tenant-aware: tries tenant-specific first, falls back to master)
provider, err := deps.ProviderReg.GetForTenant(ag.TenantID, ag.Provider)
if err != nil {
// Fallback to any available provider
names := deps.ProviderReg.List()
// Fallback to any available provider for this tenant
names := deps.ProviderReg.ListForTenant(ag.TenantID)
if len(names) == 0 {
return nil, fmt.Errorf("no providers configured for agent %s", agentKey)
}
provider, _ = deps.ProviderReg.Get(names[0])
provider, _ = deps.ProviderReg.GetForTenant(ag.TenantID, names[0])
slog.Warn("agent provider not found, using fallback",
"agent", agentKey, "wanted", ag.Provider, "using", names[0])
if tl := ag.ParseThinkingLevel(); tl != "" && tl != "off" {
@@ -210,27 +216,33 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
sandboxCfgOverride = &resolved
}
// Expand ~ in workspace path and ensure directory exists
// Resolve tenant slug once for workspace + dataDir scoping.
var tenantSlug string
if ag.TenantID != store.MasterTenantID && ag.TenantID != uuid.Nil {
tenantSlug = resolveTenantSlug(deps.TenantStore, ag.TenantID)
}
// Expand ~ in workspace path and ensure directory exists.
// For non-master tenants, prefix workspace with tenant slug directory.
workspace := ag.Workspace
if workspace != "" {
workspace = config.ExpandHome(workspace)
if !filepath.IsAbs(workspace) {
workspace, _ = filepath.Abs(workspace)
}
}
if tenantSlug != "" {
if deps.Workspace != "" {
workspace = config.TenantWorkspace(deps.Workspace, ag.TenantID, tenantSlug)
}
}
if workspace != "" {
if err := os.MkdirAll(workspace, 0755); err != nil {
slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err)
}
}
// Per-agent custom tools (clone registry if agent has custom tools)
toolsReg := deps.Tools
if deps.DynamicLoader != nil {
if agentReg, err := deps.DynamicLoader.LoadForAgent(ctx, deps.Tools, ag.ID); err != nil {
slog.Warn("failed to load custom tools", "agent", agentKey, "error", err)
} else if agentReg != nil {
toolsReg = agentReg
}
}
// Per-agent MCP servers: connect to granted MCP servers and register their tools.
// Uses a per-agent MCP Manager that queries the MCPServerStore for accessible servers.
@@ -292,6 +304,18 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
}
}
// Load per-tenant tool exclusions (disabled tools for this agent's tenant)
var disabledTools map[string]bool
if deps.BuiltinToolTenantCfgs != nil && ag.TenantID != uuid.Nil {
if disabled, err := deps.BuiltinToolTenantCfgs.ListDisabled(ctx, ag.TenantID); err == nil && len(disabled) > 0 {
disabledTools = make(map[string]bool, len(disabled))
for _, name := range disabled {
disabledTools[name] = true
}
slog.Debug("tenant tool exclusions", "agent", agentKey, "tenant", ag.TenantID, "disabled", len(disabled))
}
}
// Filter skills by visibility + agent grants.
// Only public skills and explicitly granted internal skills appear in the system prompt.
var skillAllowList []string
@@ -308,10 +332,17 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
}
}
// Resolve tenant-scoped DataDir for team workspace resolution.
dataDir := deps.DataDir
if tenantSlug != "" {
dataDir = config.TenantDataDir(deps.DataDir, ag.TenantID, tenantSlug)
}
restrictVal := true // always restrict agents to their workspace
loop := NewLoop(LoopConfig{
ID: ag.AgentKey,
AgentUUID: ag.ID,
TenantID: ag.TenantID,
AgentType: ag.AgentType,
Provider: provider,
Model: ag.Model,
@@ -319,7 +350,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
MaxTokens: ag.ParseMaxTokens(),
MaxIterations: maxIter,
Workspace: workspace,
DataDir: deps.DataDir,
DataDir: dataDir,
RestrictToWs: &restrictVal,
SubagentsCfg: ag.ParseSubagentsConfig(),
MemoryCfg: ag.ParseMemoryConfig(),
@@ -346,6 +377,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
BuiltinToolSettings: builtinSettings,
DisabledTools: disabledTools,
ThinkingLevel: ag.ParseThinkingLevel(),
SelfEvolve: ag.ParseSelfEvolve(),
SkillEvolve: ag.AgentType == store.AgentTypePredefined && ag.ParseSkillEvolve(),
@@ -385,6 +417,19 @@ func (r *Router) InvalidateAll() {
slog.Debug("invalidated all agent caches")
}
// resolveTenantSlug looks up the tenant slug for workspace path resolution.
// Returns the tenant ID string as fallback if lookup fails.
func resolveTenantSlug(ts store.TenantStore, tenantID uuid.UUID) string {
if ts == nil {
return tenantID.String()
}
tenant, err := ts.GetTenant(store.WithCrossTenant(context.Background()), tenantID)
if err != nil || tenant == nil {
return tenantID.String()
}
return tenant.Slug
}
func derefInt(p *int) int {
if p == nil {
return 0
+24 -8
View File
@@ -5,11 +5,15 @@ import (
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ResolverFunc is called when an agent isn't found in the cache.
// Used to lazy-create agents from DB.
type ResolverFunc func(agentKey string) (Agent, error)
// Used to lazy-create agents from DB. Context carries tenant scope.
type ResolverFunc func(ctx context.Context, agentKey string) (Agent, error)
const defaultRouterTTL = 10 * time.Minute
@@ -64,9 +68,12 @@ func (r *Router) Register(ag Agent) {
// Get returns an agent by ID. Lazy-creates from DB via resolver if needed.
// Cached entries expire after TTL as a safety net for multi-instance deployments.
func (r *Router) Get(agentID string) (Agent, error) {
// Cache key includes tenant so the same agent_key in different tenants resolves independently.
func (r *Router) Get(ctx context.Context, agentID string) (Agent, error) {
cacheKey := agentCacheKey(ctx, agentID)
r.mu.RLock()
entry, ok := r.agents[agentID]
entry, ok := r.agents[cacheKey]
resolver := r.resolver
r.mu.RUnlock()
@@ -77,23 +84,23 @@ func (r *Router) Get(agentID string) (Agent, error) {
// TTL expired → remove stale entry so resolver re-creates
if ok {
r.mu.Lock()
delete(r.agents, agentID)
delete(r.agents, cacheKey)
r.mu.Unlock()
}
// Try resolver (create from DB)
if resolver != nil {
ag, err := resolver(agentID)
ag, err := resolver(ctx, agentID)
if err != nil {
return nil, err
}
r.mu.Lock()
// Double-check: another goroutine might have created it
if existing, ok := r.agents[agentID]; ok {
if existing, ok := r.agents[cacheKey]; ok {
r.mu.Unlock()
return existing.agent, nil
}
r.agents[agentID] = &agentEntry{agent: ag, cachedAt: time.Now()}
r.agents[cacheKey] = &agentEntry{agent: ag, cachedAt: time.Now()}
r.mu.Unlock()
return ag, nil
}
@@ -101,6 +108,15 @@ func (r *Router) Get(agentID string) (Agent, error) {
return nil, fmt.Errorf("agent not found: %s", agentID)
}
// agentCacheKey builds a tenant-scoped cache key for the agent router.
func agentCacheKey(ctx context.Context, agentID string) string {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
return agentID
}
return tid.String() + ":" + agentID
}
// Remove removes an agent from the router.
func (r *Router) Remove(agentID string) {
r.mu.Lock()
+6
View File
@@ -63,6 +63,12 @@ func (s *seedStubStore) GetByKey(_ context.Context, _ string) (*store.AgentData,
func (s *seedStubStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
return nil, nil
}
func (s *seedStubStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) {
return nil, nil
}
func (s *seedStubStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) {
return nil, nil
}
func (s *seedStubStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil }
func (s *seedStubStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
func (s *seedStubStore) List(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil }
+10
View File
@@ -0,0 +1,10 @@
package bus
import "github.com/google/uuid"
// BroadcastForTenant broadcasts an event with explicit tenant scoping.
// Use this for events that carry tenant-specific data to ensure proper
// isolation in the WS event filter (event_filter.go).
func BroadcastForTenant(pub EventPublisher, name string, tenantID uuid.UUID, payload any) {
pub.Broadcast(Event{Name: name, TenantID: tenantID, Payload: payload})
}
+11 -4
View File
@@ -3,6 +3,8 @@ package bus
import (
"context"
"encoding/json"
"github.com/google/uuid"
)
// MediaFile represents an inbound media file with its MIME type.
@@ -21,6 +23,7 @@ type InboundMessage struct {
Media []MediaFile `json:"media,omitempty"`
SessionKey string `json:"session_key"` // deprecated: gateway builds canonical key
PeerKind string `json:"peer_kind,omitempty"` // "direct" or "group" (used for session key)
TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope from channel instance
AgentID string `json:"agent_id,omitempty"` // target agent (for multi-agent routing)
UserID string `json:"user_id,omitempty"` // external user ID for per-user scoping (memory, bootstrap)
HistoryLimit int `json:"history_limit,omitempty"` // max turns to keep in context (0=unlimited, from channel config)
@@ -46,8 +49,9 @@ type MediaAttachment struct {
// Event represents a server-side event to broadcast to WebSocket clients.
type Event struct {
Name string `json:"name"` // event name (e.g. "agent", "chat", "health")
Payload any `json:"payload,omitempty"`
Name string `json:"name"` // event name (e.g. "agent", "chat", "health")
Payload any `json:"payload,omitempty"`
TenantID uuid.UUID `json:"-"` // tenant scope for event filtering (not serialized to clients)
}
// Cache invalidation kind constants.
@@ -56,7 +60,6 @@ const (
CacheKindBootstrap = "bootstrap"
CacheKindSkills = "skills"
CacheKindCron = "cron"
CacheKindCustomTools = "custom_tools"
CacheKindChannelInstances = "channel_instances"
CacheKindBuiltinTools = "builtin_tools"
CacheKindTeam = "team"
@@ -67,6 +70,10 @@ const (
CacheKindAPIKeys = "api_keys"
CacheKindHeartbeat = "heartbeat"
CacheKindConfigPerms = "config_perms"
CacheKindTenantUsers = "tenant_users"
CacheKindAgentAccess = "agent_access"
CacheKindTeamAccess = "team_access"
CacheKindTenants = "tenants"
)
// Topic constants for msgBus.Subscribe() / Broadcast().
@@ -75,7 +82,6 @@ const (
TopicCacheAgent = "cache:agent"
TopicCacheSkills = "cache:skills"
TopicCacheCron = "cache:cron"
TopicCacheCustomTools = "cache:custom_tools"
TopicCacheBuiltinTools = "cache:builtin_tools"
TopicCacheTeam = "cache:team"
TopicCacheUserWorkspace = "cache:user_workspace"
@@ -122,6 +128,7 @@ type AuditEventPayload struct {
EntityID string `json:"entity_id"`
IPAddress string `json:"ip_address,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
TenantID uuid.UUID `json:"tenant_id,omitempty"` // for async subscriber tenant scoping
}
// CacheInvalidatePayload signals cache layers to evict stale entries.
+121
View File
@@ -0,0 +1,121 @@
package cache
import (
"context"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
// agentAccessEntry caches agent access check results.
type agentAccessEntry struct {
Allowed bool
Role string
}
// PermissionCache provides short-TTL caching for hot permission lookups.
// Uses InMemoryCache[V] caches with pubsub invalidation.
type PermissionCache struct {
tenantResolve *InMemoryCache[uuid.UUID]
tenantRole *InMemoryCache[string]
agentAccess *InMemoryCache[agentAccessEntry]
teamAccess *InMemoryCache[bool]
}
// NewPermissionCache creates a new permission cache.
func NewPermissionCache() *PermissionCache {
return &PermissionCache{
tenantResolve: NewInMemoryCache[uuid.UUID](),
tenantRole: NewInMemoryCache[string](),
agentAccess: NewInMemoryCache[agentAccessEntry](),
teamAccess: NewInMemoryCache[bool](),
}
}
const (
tenantResolveTTL = 60 * time.Second
tenantRoleTTL = 30 * time.Second
agentAccessTTL = 30 * time.Second
teamAccessTTL = 30 * time.Second
)
// --- Tenant Resolution ---
func (pc *PermissionCache) GetTenantResolve(ctx context.Context, userID string) (uuid.UUID, bool) {
return pc.tenantResolve.Get(ctx, userID)
}
func (pc *PermissionCache) SetTenantResolve(ctx context.Context, userID string, tenantID uuid.UUID) {
pc.tenantResolve.Set(ctx, userID, tenantID, tenantResolveTTL)
}
// --- Tenant Role ---
func (pc *PermissionCache) GetTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, bool) {
return pc.tenantRole.Get(ctx, tenantID.String()+":"+userID)
}
func (pc *PermissionCache) SetTenantRole(ctx context.Context, tenantID uuid.UUID, userID, role string) {
pc.tenantRole.Set(ctx, tenantID.String()+":"+userID, role, tenantRoleTTL)
}
// --- Agent Access ---
func (pc *PermissionCache) GetAgentAccess(ctx context.Context, agentID uuid.UUID, userID string) (bool, string, bool) {
entry, ok := pc.agentAccess.Get(ctx, agentID.String()+":"+userID)
if !ok {
return false, "", false
}
return entry.Allowed, entry.Role, true
}
func (pc *PermissionCache) SetAgentAccess(ctx context.Context, agentID uuid.UUID, userID string, allowed bool, role string) {
pc.agentAccess.Set(ctx, agentID.String()+":"+userID, agentAccessEntry{Allowed: allowed, Role: role}, agentAccessTTL)
}
// --- Team Access ---
func (pc *PermissionCache) GetTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, bool) {
return pc.teamAccess.Get(ctx, teamID.String()+":"+userID)
}
func (pc *PermissionCache) SetTeamAccess(ctx context.Context, teamID uuid.UUID, userID string, allowed bool) {
pc.teamAccess.Set(ctx, teamID.String()+":"+userID, allowed, teamAccessTTL)
}
// --- Invalidation ---
// HandleInvalidation processes a cache invalidation event from the bus.
func (pc *PermissionCache) HandleInvalidation(p bus.CacheInvalidatePayload) {
slog.Debug("perm_cache.invalidated", "kind", string(p.Kind), "key", p.Key)
ctx := context.Background()
switch p.Kind {
case bus.CacheKindTenantUsers:
// Key is userID — invalidate resolve + all tenant roles for this user.
// Can't efficiently delete all tenantRole entries for a user by prefix,
// so clear all tenant roles (short TTL makes this acceptable).
if p.Key != "" {
pc.tenantResolve.Delete(ctx, p.Key)
pc.tenantRole.Clear(ctx)
} else {
pc.tenantResolve.Clear(ctx)
pc.tenantRole.Clear(ctx)
}
case bus.CacheKindAgentAccess:
// Key is agentID — delete all access entries for this agent.
if p.Key != "" {
pc.agentAccess.DeleteByPrefix(ctx, p.Key+":")
} else {
pc.agentAccess.Clear(ctx)
}
case bus.CacheKindTeamAccess:
// Key is teamID — delete all access entries for this team.
if p.Key != "" {
pc.teamAccess.DeleteByPrefix(ctx, p.Key+":")
} else {
pc.teamAccess.Clear(ctx)
}
}
}
+10
View File
@@ -14,6 +14,8 @@ import (
"net/http"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -156,6 +158,7 @@ type BaseChannel struct {
running bool
allowList []string
agentID string // for DB instances: routes to specific agent (empty = use resolveAgentRoute)
tenantID uuid.UUID // for DB instances: tenant scope (zero = master tenant fallback)
contactCollector *store.ContactCollector // optional: auto-collect contacts from channel messages
}
@@ -191,6 +194,12 @@ func (c *BaseChannel) AgentID() string { return c.agentID }
// SetAgentID sets the explicit agent ID for routing (used by InstanceLoader for DB instances).
func (c *BaseChannel) SetAgentID(id string) { c.agentID = id }
// TenantID returns the tenant UUID for this channel (zero = master tenant fallback).
func (c *BaseChannel) TenantID() uuid.UUID { return c.tenantID }
// SetTenantID sets the tenant scope (used by InstanceLoader for DB instances).
func (c *BaseChannel) SetTenantID(id uuid.UUID) { c.tenantID = id }
// SetContactCollector sets the contact collector for auto-collecting contacts from messages.
func (c *BaseChannel) SetContactCollector(cc *store.ContactCollector) { c.contactCollector = cc }
@@ -325,6 +334,7 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
PeerKind: peerKind,
UserID: userID,
Metadata: metadata,
TenantID: c.tenantID,
AgentID: c.agentID,
}
+1 -1
View File
@@ -70,7 +70,7 @@ func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.Pair
config: cfg,
requireMention: requireMention,
pairingService: pairingSvc,
groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore),
groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore, base.TenantID()),
historyLimit: historyLimit,
agentStore: agentStore,
configPermStore: configPermStore,
+15 -12
View File
@@ -13,10 +13,13 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/channels/typing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// handleMessage processes incoming Discord messages.
func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
// Ignore bot's own messages
if m.Author == nil || m.Author.ID == c.botUserID {
return
@@ -40,11 +43,11 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
}
if isDM {
if !c.checkDMPolicy(senderID, channelID) {
if !c.checkDMPolicy(ctx, senderID, channelID) {
return
}
} else {
if !c.checkGroupPolicy(senderID, channelID) {
if !c.checkGroupPolicy(ctx, senderID, channelID) {
slog.Debug("discord group message rejected by policy",
"user_id", senderID,
"username", senderName,
@@ -192,7 +195,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
// Collect contact even when bot is not mentioned (cache prevents DB spam).
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group")
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group")
}
slog.Debug("discord group message recorded (no mention)",
@@ -284,7 +287,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
// Collect contact for processed messages (DM + group-mentioned).
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind)
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind)
}
// Publish directly to bus (to preserve MediaFile MIME types)
@@ -307,7 +310,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
}
// checkGroupPolicy evaluates the group policy for a sender, with pairing support.
func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
@@ -327,7 +330,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
}
groupSenderID := fmt.Sprintf("group:%s", channelID)
if c.pairingService != nil {
paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -338,7 +341,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
return true
}
}
c.sendPairingReply(groupSenderID, channelID)
c.sendPairingReply(ctx, groupSenderID, channelID)
return false
default: // "open"
return true
@@ -346,7 +349,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
}
// checkDMPolicy evaluates the DM policy for a sender, handling pairing flow.
func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
@@ -367,7 +370,7 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
default: // "pairing"
paired := false
if c.pairingService != nil {
p, err := c.pairingService.IsPaired(senderID, c.Name())
p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -382,13 +385,13 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
return true
}
c.sendPairingReply(senderID, channelID)
c.sendPairingReply(ctx, senderID, channelID)
return false
}
}
// sendPairingReply sends a pairing code to the user via DM.
func (c *Channel) sendPairingReply(senderID, channelID string) {
func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) {
if c.pairingService == nil {
return
}
@@ -400,7 +403,7 @@ func (c *Channel) sendPairingReply(senderID, channelID string) {
}
}
code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil)
code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil)
if err != nil {
slog.Debug("discord pairing request failed", "sender_id", senderID, "error", err)
return
+6
View File
@@ -6,7 +6,10 @@ import (
"log/slog"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -28,6 +31,9 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
}
ctx := context.Background()
if ta, ok := ch.(interface{ TenantID() uuid.UUID }); ok {
ctx = store.WithTenantID(ctx, ta.TenantID())
}
// Forward to StreamingChannel
if sc, ok := ch.(StreamingChannel); ok {
+2 -2
View File
@@ -62,7 +62,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
// 4. Group policy
if mc.ChatType == "group" {
if !c.checkGroupPolicy(mc.SenderID, mc.ChatID) {
if !c.checkGroupPolicy(ctx, mc.SenderID, mc.ChatID) {
slog.Debug("feishu group message rejected by policy", "sender_id", mc.SenderID, "chat_id", mc.ChatID)
return
}
@@ -99,7 +99,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) {
// 6. DM policy (pairing flow)
if mc.ChatType == "p2p" {
if !c.checkDMPolicy(mc.SenderID, mc.ChatID) {
if !c.checkDMPolicy(ctx, mc.SenderID, mc.ChatID) {
return
}
}
+8 -8
View File
@@ -46,7 +46,7 @@ func (c *Channel) fetchSenderName(ctx context.Context, openID string) string {
// --- Policy checks ---
func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool {
groupPolicy := c.cfg.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
@@ -85,7 +85,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
}
groupSenderID := fmt.Sprintf("group:%s", chatID)
if c.pairingService != nil {
paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -96,14 +96,14 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
return true
}
}
c.sendPairingReply(groupSenderID, chatID)
c.sendPairingReply(ctx, groupSenderID, chatID)
return false
default: // "open"
return true
}
}
func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
dmPolicy := c.cfg.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
@@ -124,7 +124,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
default: // "pairing"
paired := false
if c.pairingService != nil {
p, err := c.pairingService.IsPaired(senderID, c.Name())
p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -139,12 +139,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
return true
}
c.sendPairingReply(senderID, chatID)
c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
func (c *Channel) sendPairingReply(senderID, chatID string) {
func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
if c.pairingService == nil {
return
}
@@ -156,7 +156,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
}
}
code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil)
code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("feishu pairing request failed", "sender_id", senderID, "error", err)
return
+5 -3
View File
@@ -86,7 +86,7 @@ func New(cfg config.FeishuConfig, msgBus *bus.MessageBus, pairingSvc store.Pairi
client: client,
pairingService: pairingSvc,
groupAllowList: cfg.GroupAllowFrom,
groupHistory: channels.MakeHistory(channels.TypeFeishu, pendingStore),
groupHistory: channels.MakeHistory(channels.TypeFeishu, pendingStore, base.TenantID()),
historyLimit: historyLimit,
stopCh: make(chan struct{}),
}, nil
@@ -255,7 +255,8 @@ func (c *Channel) WebhookHandler() (string, http.Handler) {
}
handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) {
c.handleMessageEvent(context.Background(), event)
ctx := store.WithTenantID(context.Background(), c.TenantID())
c.handleMessageEvent(ctx, event)
})
return path, http.HandlerFunc(handler)
@@ -275,7 +276,8 @@ func (c *Channel) startWebhook(ctx context.Context) error {
slog.Info("feishu: starting Webhook server", "port", port, "path", path)
handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) {
c.handleMessageEvent(context.Background(), event)
ctx := store.WithTenantID(context.Background(), c.TenantID())
c.handleMessageEvent(ctx, event)
})
mux := http.NewServeMux()
+19 -5
View File
@@ -16,6 +16,7 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -56,6 +57,9 @@ type PendingHistory struct {
stopCh chan struct{}
stopped chan struct{}
// Tenant isolation for DB operations.
tenantID uuid.UUID
// Compaction (optional — nil means no auto-compaction)
compactionCfg *CompactionConfig
@@ -70,11 +74,12 @@ func NewPendingHistory() *PendingHistory {
// NewPersistentHistory creates a persistent history tracker with batched DB flush.
// Call StartFlusher() after creation and StopFlusher() on shutdown.
func NewPersistentHistory(channelName string, s store.PendingMessageStore) *PendingHistory {
func NewPersistentHistory(channelName string, s store.PendingMessageStore, tenantID uuid.UUID) *PendingHistory {
return &PendingHistory{
entries: make(map[string][]HistoryEntry),
channelName: channelName,
store: s,
tenantID: tenantID,
flushSignal: make(chan struct{}, 1),
stopCh: make(chan struct{}),
stopped: make(chan struct{}),
@@ -103,13 +108,22 @@ func (ph *PendingHistory) LoadFromDB(ctx context.Context) {
}
// MakeHistory creates a PendingHistory — persistent if store is non-nil, RAM-only otherwise.
func MakeHistory(channelName string, s store.PendingMessageStore) *PendingHistory {
func MakeHistory(channelName string, s store.PendingMessageStore, tenantID uuid.UUID) *PendingHistory {
if s != nil {
return NewPersistentHistory(channelName, s)
return NewPersistentHistory(channelName, s, tenantID)
}
return NewPendingHistory()
}
// tenantCtx returns a context with the tenant ID set for DB operations.
func (ph *PendingHistory) tenantCtx() context.Context {
ctx := context.Background()
if ph.tenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, ph.tenantID)
}
return ctx
}
// Record adds a message to the pending history for a group.
// If limit ≤ 0, recording is disabled.
func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit int) {
@@ -155,7 +169,7 @@ func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit in
// populates RAM cache, and returns converted entries.
// Called when RAM has no entries but DB store is available.
func (ph *PendingHistory) loadFromDB(historyKey string) []HistoryEntry {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second)
defer cancel()
msgs, err := ph.store.ListByKey(ctx, ph.channelName, historyKey)
@@ -270,7 +284,7 @@ func (ph *PendingHistory) Clear(historyKey string) {
// Remove pending flushes for this key
ph.removeFromFlushBuf(historyKey)
// Delete from DB
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second)
defer cancel()
if err := ph.store.DeleteByKey(ctx, ph.channelName, historyKey); err != nil {
slog.Warn("pending_history.clear_db_failed", "channel", ph.channelName, "key", historyKey, "error", err)
+2 -2
View File
@@ -37,7 +37,7 @@ func (ph *PendingHistory) MaybeCompact(historyKey string, currentCount int, cfg
// RAM count may be stale after restart (LoadFromDB doesn't warm full history).
// If RAM says below threshold, ask DB for the real count.
if currentCount <= threshold {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second)
defer cancel()
dbCount, err := ph.store.CountByKey(ctx, ph.channelName, historyKey)
if err != nil || dbCount <= threshold {
@@ -137,7 +137,7 @@ func (ph *PendingHistory) runCompaction(historyKey string, cfg *CompactionConfig
// Force-flush buffer to ensure DB is consistent
ph.flushNow()
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
ctx, cancel := context.WithTimeout(ph.tenantCtx(), 180*time.Second)
defer cancel()
// Check threshold from DB (may have been cleared since trigger)
+1 -1
View File
@@ -93,7 +93,7 @@ func (ph *PendingHistory) flushLoop() {
// writeBatch writes a batch of messages to the DB store.
func (ph *PendingHistory) writeBatch(batch []store.PendingMessage) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(ph.tenantCtx(), 15*time.Second)
defer cancel()
if err := ph.store.AppendBatch(ctx, batch); err != nil {
+10 -3
View File
@@ -9,6 +9,8 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -23,7 +25,7 @@ type ChannelFactory func(name string, creds json.RawMessage, cfg json.RawMessage
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (Channel, error)
// InstanceLoader loads channel instances from the database and registers them with the Manager.
// Follows the DynamicToolLoader pattern: LoadAll at startup, Reload on cache invalidation.
// Follows a load-all-at-startup pattern with cache invalidation for reload.
type InstanceLoader struct {
store store.ChannelInstanceStore
agentStore store.AgentStore
@@ -234,6 +236,10 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
if base, ok := ch.(interface{ SetType(string) }); ok {
base.SetType(inst.ChannelType)
}
// Propagate tenant_id from DB instance to channel for tenant-scoped message handling.
if base, ok := ch.(interface{ SetTenantID(uuid.UUID) }); ok {
base.SetTenantID(inst.TenantID)
}
// Wire pending message auto-compaction.
// Priority: config provider/model > agent's provider/model > fallback.
@@ -242,8 +248,9 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
var model string
// Try config-level provider/model first.
tctx := store.WithTenantID(ctx, inst.TenantID)
if l.pendingCompactCfg != nil && l.pendingCompactCfg.Provider != "" {
if cp, err := l.providerReg.Get(l.pendingCompactCfg.Provider); err == nil {
if cp, err := l.providerReg.Get(tctx, l.pendingCompactCfg.Provider); err == nil {
p = cp
model = l.pendingCompactCfg.Model
if model == "" {
@@ -253,7 +260,7 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
}
// Fallback: agent's provider/model.
if p == nil && ag != nil && ag.Provider != "" {
if ap, err := l.providerReg.Get(ag.Provider); err == nil {
if ap, err := l.providerReg.Get(tctx, ag.Provider); err == nil {
p = ap
model = ag.Model
if model == "" {
+30 -8
View File
@@ -3,11 +3,14 @@ package channels
import (
"context"
"database/sql"
"fmt"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// QuotaResult is returned by QuotaChecker.Check.
@@ -222,20 +225,23 @@ func (qc *QuotaChecker) Usage(ctx context.Context) QuotaUsageResult {
dayAgo := now.Add(-24 * time.Hour)
weekAgo := now.Add(-7 * 24 * time.Hour)
// Query per-user counts for the past week
rows, err := qc.db.QueryContext(ctx, `
// Query per-user counts for the past week (tenant-scoped)
tenantFilter, tenantArgs, nextIdx := tenantWhereClause(ctx, 4)
args := append([]any{hourAgo, dayAgo, weekAgo}, tenantArgs...)
rows, err := qc.db.QueryContext(ctx, fmt.Sprintf(`
SELECT
user_id,
COUNT(*) FILTER (WHERE created_at >= $1) AS hour_count,
COUNT(*) FILTER (WHERE created_at >= $2) AS day_count,
COUNT(*) AS week_count
FROM traces
WHERE parent_trace_id IS NULL AND created_at >= $3
WHERE parent_trace_id IS NULL AND created_at >= $3%s
GROUP BY user_id
ORDER BY week_count DESC
LIMIT 50`,
hourAgo, dayAgo, weekAgo,
LIMIT 50`, tenantFilter),
args...,
)
_ = nextIdx
if err != nil {
slog.Warn("quota.usage: failed to query user counts", "error", err)
QueryTodaySummary(ctx, qc.db, &result)
@@ -272,7 +278,9 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult
now := time.Now().UTC()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
err := db.QueryRowContext(ctx, `
tenantFilter, tenantArgs, _ := tenantWhereClause(ctx, 2)
args := append([]any{startOfDay}, tenantArgs...)
err := db.QueryRowContext(ctx, fmt.Sprintf(`
SELECT
COUNT(*),
COALESCE(SUM(total_input_tokens), 0),
@@ -280,14 +288,28 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult
COALESCE(SUM(total_cost), 0),
COUNT(DISTINCT user_id)
FROM traces
WHERE parent_trace_id IS NULL AND created_at >= $1`,
startOfDay,
WHERE parent_trace_id IS NULL AND created_at >= $1%s`, tenantFilter),
args...,
).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.CostToday, &result.UniqueUsersToday)
if err != nil {
slog.Warn("quota.usage: failed to query today summary", "error", err)
}
}
// tenantWhereClause returns a SQL fragment " AND tenant_id = $N" with the tenant UUID arg,
// or empty string if the caller has cross-tenant access. startIdx is the next $N placeholder.
func tenantWhereClause(ctx context.Context, startIdx int) (string, []any, int) {
if store.IsCrossTenant(ctx) {
return "", nil, startIdx
}
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
// Fail-closed: no tenant = filter to impossible value
return " AND tenant_id = '00000000-0000-0000-0000-000000000000'", nil, startIdx
}
return fmt.Sprintf(" AND tenant_id = $%d", startIdx), []any{tid}, startIdx + 1
}
// cleanupLoop periodically evicts stale cache entries.
func (qc *QuotaChecker) cleanupLoop() {
ticker := time.NewTicker(2 * time.Minute)
+1 -1
View File
@@ -122,7 +122,7 @@ func New(cfg config.SlackConfig, msgBus *bus.MessageBus, pairingSvc store.Pairin
config: cfg,
requireMention: requireMention,
pairingService: pairingSvc,
groupHistory: channels.MakeHistory(channels.TypeSlack, pendingStore),
groupHistory: channels.MakeHistory(channels.TypeSlack, pendingStore, base.TenantID()),
historyLimit: historyLimit,
debounceDelay: debounceDelay,
threadTTL: threadTTL,
+6 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/slack-go/slack/socketmode"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (c *Channel) handleEventsAPI(evt socketmode.Event) {
@@ -32,6 +33,8 @@ func (c *Channel) handleEventsAPI(evt socketmode.Event) {
}
func (c *Channel) handleMessage(ev *slackevents.MessageEvent) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
// For message_changed: extract user/text from the nested Message field.
// Only process if the edit introduces a new @bot mention.
if ev.SubType == "message_changed" {
@@ -89,11 +92,11 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) {
// Policy check
if isDM {
if !c.checkDMPolicy(senderID, channelID) {
if !c.checkDMPolicy(ctx, senderID, channelID) {
return
}
} else {
if !c.checkGroupPolicy(senderID, channelID) {
if !c.checkGroupPolicy(ctx, senderID, channelID) {
return
}
}
@@ -196,7 +199,7 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) {
// Collect contact even when bot is not mentioned (cache prevents DB spam).
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, displayName, "", "group")
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, "", "group")
}
slog.Debug("slack group message recorded (no mention)",
+13 -9
View File
@@ -1,6 +1,7 @@
package slack
import (
"context"
"fmt"
"log/slog"
"strings"
@@ -10,9 +11,12 @@ import (
"github.com/slack-go/slack/slackevents"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
if ev.User == c.botUserID || ev.User == "" {
return
}
@@ -35,7 +39,7 @@ func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) {
displayName := strings.ReplaceAll(c.resolveDisplayName(senderID), "|", "_")
compoundSenderID := fmt.Sprintf("%s|%s", senderID, displayName)
if !c.checkGroupPolicy(senderID, channelID) {
if !c.checkGroupPolicy(ctx, senderID, channelID) {
return
}
@@ -115,7 +119,7 @@ func (c *Channel) stripBotMention(text string) string {
// --- Policy checks ---
func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
@@ -130,7 +134,7 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
return c.HasAllowList() && c.IsAllowed(senderID)
default: // "pairing"
if c.pairingService != nil {
paired, err := c.pairingService.IsPaired(senderID, c.Name())
paired, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -143,12 +147,12 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool {
if c.HasAllowList() && c.IsAllowed(senderID) {
return true
}
c.sendPairingReply(senderID, channelID)
c.sendPairingReply(ctx, senderID, channelID)
return false
}
}
func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
@@ -172,7 +176,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
}
groupSenderID := fmt.Sprintf("group:%s", channelID)
if c.pairingService != nil {
paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -183,14 +187,14 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool {
return true
}
}
c.sendPairingReply(groupSenderID, channelID)
c.sendPairingReply(ctx, groupSenderID, channelID)
return false
default: // "open"
return true
}
}
func (c *Channel) sendPairingReply(senderID, channelID string) {
func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) {
if c.pairingService == nil {
return
}
@@ -201,7 +205,7 @@ func (c *Channel) sendPairingReply(senderID, channelID string) {
}
}
code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil)
code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil)
if err != nil {
slog.Warn("slack: failed to request pairing code", "error", err)
return
+3 -1
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// HandleMessage overrides BaseChannel to allow messages when the chatID (Slack channel)
@@ -32,7 +33,8 @@ func (c *Channel) HandleMessage(senderID, chatID, content string, mediaPaths []s
// Collect contact for processed messages (DM + group-mentioned).
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(context.Background(), c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind)
ctx := store.WithTenantID(context.Background(), c.TenantID())
cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind)
}
c.Bus().PublishInbound(bus.InboundMessage{
+1 -1
View File
@@ -122,7 +122,7 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai
agentStore: agentStore,
configPermStore: configPermStore,
teamStore: teamStore,
groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore),
groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore, base.TenantID()),
historyLimit: historyLimit,
requireMention: requireMention,
}, nil
@@ -36,7 +36,7 @@ func (c *Channel) sendPairingReply(ctx context.Context, chatID int64, userID, us
}
meta := map[string]string{"username": username}
code, err := c.pairingService.RequestPairing(userID, c.Name(), fmt.Sprintf("%d", chatID), "default", meta)
code, err := c.pairingService.RequestPairing(ctx, userID, c.Name(), fmt.Sprintf("%d", chatID), "default", meta)
if err != nil {
slog.Debug("pairing request failed", "user_id", userID, "error", err)
return
@@ -70,7 +70,7 @@ func (c *Channel) sendGroupPairingReply(ctx context.Context, chatID int64, chatI
if chatTitle != "" {
meta = map[string]string{"chat_title": chatTitle}
}
code, err := c.pairingService.RequestPairing(groupSenderID, c.Name(), localKey, "default", meta)
code, err := c.pairingService.RequestPairing(ctx, groupSenderID, c.Name(), localKey, "default", meta)
if err != nil {
slog.Debug("group pairing request failed", "chat_id", chatIDStr, "error", err)
return
+4 -4
View File
@@ -148,8 +148,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
default: // "pairing" or unknown → secure default
paired := false
if c.pairingService != nil {
p1, err1 := c.pairingService.IsPaired(userID, c.Name())
p2, err2 := c.pairingService.IsPaired(senderID, c.Name())
p1, err1 := c.pairingService.IsPaired(ctx, userID, c.Name())
p2, err2 := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err1 != nil || err2 != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"user_id", userID, "channel", c.Name(), "err1", err1, "err2", err2)
@@ -256,7 +256,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
if topicCfg.groupPolicy == "pairing" && c.pairingService != nil {
if _, cached := c.approvedGroups.Load(chatIDStr); !cached {
groupSenderID := fmt.Sprintf("group:%d", chatID)
paired, pairErr := c.pairingService.IsPaired(groupSenderID, c.Name())
paired, pairErr := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if pairErr != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", pairErr)
@@ -295,7 +295,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
if isGroup && topicCfg.groupPolicy == "pairing" && c.pairingService != nil {
if _, cached := c.approvedGroups.Load(chatIDStr); !cached {
groupSenderID := fmt.Sprintf("group:%d", chatID)
paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
+13 -11
View File
@@ -204,6 +204,8 @@ func (c *Channel) listenLoop() {
// handleIncomingMessage processes a message received from the bridge.
// Expected format: {"type":"message","from":"...","chat":"...","content":"...","id":"...","from_name":"...","media":[...]}
func (c *Channel) handleIncomingMessage(msg map[string]any) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
senderID, ok := msg["from"].(string)
if !ok || senderID == "" {
return
@@ -222,11 +224,11 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) {
// DM/Group policy check
if peerKind == "direct" {
if !c.checkDMPolicy(senderID, chatID) {
if !c.checkDMPolicy(ctx, senderID, chatID) {
return
}
} else {
if !c.checkGroupPolicy(senderID, chatID) {
if !c.checkGroupPolicy(ctx, senderID, chatID) {
slog.Debug("whatsapp group message rejected by policy", "sender_id", senderID)
return
}
@@ -269,14 +271,14 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) {
// Collect contact for processed messages.
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind)
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind)
}
c.HandleMessage(senderID, chatID, content, media, metadata, peerKind)
}
// checkGroupPolicy evaluates the group policy for a sender, with pairing support.
func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "open"
@@ -296,7 +298,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
}
groupSenderID := fmt.Sprintf("group:%s", chatID)
if c.pairingService != nil {
paired, err := c.pairingService.IsPaired(groupSenderID, c.Name())
paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -307,7 +309,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
return true
}
}
c.sendPairingReply(groupSenderID, chatID)
c.sendPairingReply(ctx, groupSenderID, chatID)
return false
default: // "open"
return true
@@ -315,7 +317,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool {
}
// checkDMPolicy evaluates the DM policy for a sender, handling pairing flow.
func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "pairing"
@@ -336,7 +338,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
default: // "pairing"
paired := false
if c.pairingService != nil {
p, err := c.pairingService.IsPaired(senderID, c.Name())
p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -351,13 +353,13 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
return true
}
c.sendPairingReply(senderID, chatID)
c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
// sendPairingReply sends a pairing code to the user via the WS bridge.
func (c *Channel) sendPairingReply(senderID, chatID string) {
func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
if c.pairingService == nil {
return
}
@@ -369,7 +371,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
}
}
code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil)
code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("whatsapp pairing request failed", "sender_id", senderID, "error", err)
return
+1 -1
View File
@@ -64,7 +64,7 @@ func New(cfg config.ZaloPersonalConfig, msgBus *bus.MessageBus, pairingSvc store
BaseChannel: base,
config: cfg,
pairingService: pairingSvc,
groupHistory: channels.MakeHistory(channels.TypeZaloPersonal, pendingStore),
groupHistory: channels.MakeHistory(channels.TypeZaloPersonal, pendingStore, base.TenantID()),
historyLimit: historyLimit,
requireMention: requireMention,
stopCh: make(chan struct{}),
+10 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/channels/typing"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
@@ -32,6 +33,8 @@ func (c *Channel) handleMessage(msg protocol.Message) {
}
func (c *Channel) handleDM(msg protocol.UserMessage) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
senderID := msg.Data.UIDFrom
threadID := msg.ThreadID()
@@ -40,7 +43,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) {
return
}
if !c.checkDMPolicy(senderID, threadID) {
if !c.checkDMPolicy(ctx, senderID, threadID) {
return
}
@@ -61,7 +64,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) {
// Collect contact for DM messages.
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "direct")
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "direct")
}
metadata := map[string]string{
@@ -72,6 +75,8 @@ func (c *Channel) handleDM(msg protocol.UserMessage) {
}
func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
senderID := msg.Data.UIDFrom
threadID := msg.ThreadID()
@@ -81,7 +86,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
}
// Step 1: enforce access policy (allowlist/pairing). Hard reject — don't record history.
if !c.checkGroupPolicy(senderID, threadID) {
if !c.checkGroupPolicy(ctx, senderID, threadID) {
return
}
@@ -105,7 +110,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
// Collect contact even when bot is not mentioned (cache prevents DB spam).
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "group")
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group")
}
slog.Debug("zalo_personal group message recorded (no mention)",
@@ -138,7 +143,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
// Collect contact for group-mentioned messages.
if cc := c.ContactCollector(); cc != nil {
cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "group")
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group")
}
metadata := map[string]string{
+8 -8
View File
@@ -13,7 +13,7 @@ import (
const pairingDebounce = 60 * time.Second
// checkDMPolicy enforces DM policy for incoming messages.
func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "allowlist"
@@ -37,7 +37,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
default: // "pairing"
paired := false
if c.pairingService != nil {
p, err := c.pairingService.IsPaired(senderID, c.Name())
p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -52,12 +52,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
return true
}
c.sendPairingReply(senderID, chatID)
c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
func (c *Channel) sendPairingReply(senderID, chatID string) {
func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
sess := c.session()
if c.pairingService == nil || sess == nil {
return
@@ -70,7 +70,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
}
}
code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil)
code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("zalo_personal pairing request failed", "sender_id", senderID, "error", err)
return
@@ -98,7 +98,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
// checkGroupPolicy enforces group access policy (allowlist/pairing).
// Returns false if the group is blocked by policy; does NOT check @mention gating.
func (c *Channel) checkGroupPolicy(senderID, groupID string) bool {
func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, groupID string) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "allowlist"
@@ -124,7 +124,7 @@ func (c *Channel) checkGroupPolicy(senderID, groupID string) bool {
groupSenderID := fmt.Sprintf("group:%s", groupID)
paired := false
if c.pairingService != nil {
p, err := c.pairingService.IsPaired(groupSenderID, c.Name())
p, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"group_sender", groupSenderID, "channel", c.Name(), "error", err)
@@ -135,7 +135,7 @@ func (c *Channel) checkGroupPolicy(senderID, groupID string) bool {
if paired {
c.approvedGroups.Store(groupID, true)
} else {
c.sendPairingReply(groupSenderID, groupID)
c.sendPairingReply(ctx, groupSenderID, groupID)
return false
}
}
@@ -110,7 +110,7 @@ func (m *QRMethods) runQRFlow(ctx context.Context, client *gateway.Client, insta
return
}
if err := m.instanceStore.Update(context.Background(), instanceID, map[string]any{
if err := m.instanceStore.Update(ctx, instanceID, map[string]any{
"credentials": string(credsJSON),
}); err != nil {
slog.Error("Zalo Personal QR: save credentials failed", "instance", instanceIDStr, "error", err)
+11 -7
View File
@@ -186,6 +186,8 @@ func (c *Channel) processUpdate(update zaloUpdate) {
}
func (c *Channel) handleTextMessage(msg *zaloMessage) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
senderID := msg.From.ID
if senderID == "" {
slog.Warn("zalo: dropping text message with empty sender ID", "message_id", msg.MessageID)
@@ -197,7 +199,7 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) {
}
// DM policy enforcement (Zalo is DM-only)
if !c.checkDMPolicy(senderID, chatID) {
if !c.checkDMPolicy(ctx, senderID, chatID) {
return
}
@@ -221,6 +223,8 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) {
}
func (c *Channel) handleImageMessage(msg *zaloMessage) {
ctx := context.Background()
ctx = store.WithTenantID(ctx, c.TenantID())
senderID := msg.From.ID
if senderID == "" {
slog.Warn("zalo: dropping image message with empty sender ID", "message_id", msg.MessageID)
@@ -231,7 +235,7 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) {
chatID = senderID
}
if !c.checkDMPolicy(senderID, chatID) {
if !c.checkDMPolicy(ctx, senderID, chatID) {
return
}
@@ -278,7 +282,7 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) {
// --- DM Policy ---
func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool {
switch c.dmPolicy {
case "disabled":
slog.Debug("zalo message rejected: DMs disabled", "sender_id", senderID)
@@ -298,7 +302,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
// Check if already paired or in allowlist
paired := false
if c.pairingService != nil {
p, err := c.pairingService.IsPaired(senderID, c.Name())
p, err := c.pairingService.IsPaired(ctx, senderID, c.Name())
if err != nil {
slog.Warn("security.pairing_check_failed, assuming paired (fail-open)",
"sender_id", senderID, "channel", c.Name(), "error", err)
@@ -314,12 +318,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
}
// Send pairing reply (debounced)
c.sendPairingReply(senderID, chatID)
c.sendPairingReply(ctx, senderID, chatID)
return false
}
}
func (c *Channel) sendPairingReply(senderID, chatID string) {
func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) {
if c.pairingService == nil {
return
}
@@ -331,7 +335,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) {
}
}
code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil)
code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil)
if err != nil {
slog.Debug("zalo pairing request failed", "sender_id", senderID, "error", err)
return
+58
View File
@@ -0,0 +1,58 @@
package config
import (
"path/filepath"
"strings"
"github.com/google/uuid"
)
// masterTenantID mirrors store.MasterTenantID.
// Duplicated here to avoid an import cycle (store imports config).
var masterTenantID = uuid.MustParse("0193a5b0-7000-7000-8000-000000000001")
// TenantDataDir returns the data directory root for a tenant.
// Master tenant returns dataDir unchanged (backward compat).
// Other tenants return dataDir/tenants/{slug}/.
func TenantDataDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string {
if tenantID == masterTenantID {
return dataDir
}
result := filepath.Join(dataDir, "tenants", tenantSlug)
// Defense-in-depth: prevent path traversal via malicious slug.
tenantsBase := filepath.Join(dataDir, "tenants") + string(filepath.Separator)
if !strings.HasPrefix(result+string(filepath.Separator), tenantsBase) {
return filepath.Join(dataDir, "tenants", tenantID.String())
}
return result
}
// TenantWorkspace returns the workspace root for a tenant.
// Master tenant returns workspace unchanged (backward compat).
// Other tenants return workspace/tenants/{slug}/.
func TenantWorkspace(workspace string, tenantID uuid.UUID, tenantSlug string) string {
if tenantID == masterTenantID {
return workspace
}
result := filepath.Join(workspace, "tenants", tenantSlug)
tenantsBase := filepath.Join(workspace, "tenants") + string(filepath.Separator)
if !strings.HasPrefix(result+string(filepath.Separator), tenantsBase) {
return filepath.Join(workspace, "tenants", tenantID.String())
}
return result
}
// TenantTeamDir returns the team workspace directory for a tenant.
func TenantTeamDir(dataDir string, tenantID uuid.UUID, tenantSlug string, teamID uuid.UUID) string {
return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "teams", teamID.String())
}
// TenantSkillsStoreDir returns the managed skills directory for a tenant.
func TenantSkillsStoreDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string {
return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "skills-store")
}
// TenantMediaDir returns the media storage directory for a tenant.
func TenantMediaDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string {
return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "media")
}
+45
View File
@@ -34,6 +34,14 @@ type Client struct {
pairingPending bool // true while waiting for admin approval
pairedSenderID string // senderID used for browser pairing auth (for revocation lookup)
pairedChannel string // channel used for pairing auth (e.g., "browser")
// Team access cache for event filtering (lazily populated).
teamIDs map[string]bool
tenantID uuid.UUID // resolved tenant (uuid.Nil = cross-tenant)
crossTenant bool // true for owner/system admin
tenantName string // resolved tenant display name (set during connect)
tenantSlug string // resolved tenant URL slug (set during connect)
}
func NewClient(conn *websocket.Conn, server *Server, remoteIP string) *Client {
@@ -192,6 +200,43 @@ func (c *Client) ConnectedAt() time.Time { return c.connectedAt }
// RemoteAddr returns the peer IP:port.
func (c *Client) RemoteAddr() string { return c.remoteAddr }
// TenantID returns the resolved tenant UUID (uuid.Nil means cross-tenant).
func (c *Client) TenantID() uuid.UUID { return c.tenantID }
// IsCrossTenant returns true if the client has cross-tenant (owner/system admin) access.
func (c *Client) IsCrossTenant() bool { return c.crossTenant }
// HasScope reports whether the client has the given scope.
func (c *Client) HasScope(scope permissions.Scope) bool {
for _, s := range c.scopes {
if s == scope {
return true
}
}
return false
}
// hasTeamAccess checks if the client has access to a team (for event filtering).
// Returns true for admin role. For others, checks the lazily-populated teamIDs cache.
// TODO: populate teamIDs from team_user_grants on connect or first team event.
func (c *Client) hasTeamAccess(teamID string) bool {
if permissions.HasMinRole(c.role, permissions.RoleAdmin) {
return true
}
if c.teamIDs == nil {
return false
}
return c.teamIDs[teamID]
}
// SetTeamAccess sets the team access cache for this client.
func (c *Client) SetTeamAccess(teamIDs []string) {
c.teamIDs = make(map[string]bool, len(teamIDs))
for _, id := range teamIDs {
c.teamIDs[id] = true
}
}
// Close shuts down the client connection.
func (c *Client) Close() {
close(c.send)
+208
View File
@@ -0,0 +1,208 @@
package gateway
import (
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// clientCanReceiveEvent checks whether a WS client should receive a given bus event.
// Admin clients receive all events. Non-admin clients are filtered by user/team scope.
func clientCanReceiveEvent(c *Client, event bus.Event) bool {
// Internal events are never forwarded.
if strings.HasPrefix(event.Name, "cache.") || event.Name == protocol.EventAuditLog {
return false
}
// System-wide events go to everyone.
if isSystemEvent(event.Name) {
return true
}
// Tenant isolation: fail-closed, 3-mode filtering.
//
// Mode 1: Unscoped admin (crossTenant=true, tenantID=Nil) → see everything
// Mode 2: Scoped admin (crossTenant=true, tenantID=X) → tenant X events + unscoped system events
// Mode 3: Regular user (crossTenant=false, tenantID=X) → ONLY tenant X events (fail-closed)
if !c.crossTenant && c.tenantID == uuid.Nil {
return false // fail-closed: no tenant assigned to non-admin client
} else if c.crossTenant && c.tenantID == uuid.Nil {
// Mode 1: unscoped cross-tenant admin → no tenant filtering
} else if c.tenantID != uuid.Nil {
// Event has explicit tenant → must match client's tenant
if event.TenantID != uuid.Nil && event.TenantID != c.tenantID {
return false
}
// Event has no tenant → only cross-tenant admin (scoped) can see unscoped events
if event.TenantID == uuid.Nil && !c.crossTenant {
return false // fail-closed: regular users blocked from unscoped events
}
}
// Admin sees everything (when not tenant-scoped, handled above).
if permissions.HasMinRole(c.role, permissions.RoleAdmin) {
return true
}
// Agent / chat events: filter by UserID.
if event.Name == protocol.EventAgent || event.Name == protocol.EventChat {
if uid := extractEventUserID(event); uid != "" {
return uid == c.userID
}
return true // no routing context → broadcast (legacy)
}
// Session events: filter by UserID in payload.
if event.Name == protocol.EventSessionUpdated {
if uid := extractMapField(event.Payload, "userId"); uid != "" {
return uid == c.userID
}
return true
}
// Cron events: filter by UserID in payload.
if event.Name == protocol.EventCron {
if ce, ok := event.Payload.(store.CronEvent); ok && ce.UserID != "" {
return ce.UserID == c.userID
}
if uid := extractMapField(event.Payload, "userId"); uid != "" {
return uid == c.userID
}
return true
}
// Trace events: filter by UserID.
if event.Name == protocol.EventTraceUpdated {
if uid := extractMapField(event.Payload, "userId"); uid != "" {
return uid == c.userID
}
return true
}
// Team events: filter by TeamID.
if strings.HasPrefix(event.Name, "team.") || strings.HasPrefix(event.Name, "delegation.") {
if tid := extractTeamID(event); tid != "" {
return c.hasTeamAccess(tid)
}
return true // no team context → broadcast
}
// Tenant access revocation: deliver to the affected user only.
if event.Name == protocol.EventTenantAccessRevoked {
if uid := extractMapField(event.Payload, "user_id"); uid != "" {
return uid == c.userID
}
return false
}
// Admin-only events: pairing, node, agent links.
if isAdminOnlyEvent(event.Name) {
return false // non-admin clients don't receive these
}
// Exec approval events: scoped to the requesting user.
if strings.HasPrefix(event.Name, "exec.approval.") {
if uid := extractMapField(event.Payload, "userId"); uid != "" {
return uid == c.userID
}
return true
}
// Zalo personal QR events: admin-only (channel management).
if strings.HasPrefix(event.Name, "zalo.personal.") {
return false
}
// Skill dep events → broadcast (non-sensitive, skill names only).
if strings.HasPrefix(event.Name, "skill.") {
return true
}
// Default: deny unknown events to non-admin (fail-closed).
return false
}
// isSystemEvent returns true for events that should be broadcast to all clients.
func isSystemEvent(name string) bool {
switch name {
case protocol.EventHealth, protocol.EventPresence, protocol.EventVoicewakeChanged,
protocol.EventTick, protocol.EventShutdown, protocol.EventConnectChallenge,
protocol.EventTalkMode, protocol.EventHeartbeat:
return true
}
return false
}
// isAdminOnlyEvent returns true for events that only admin clients should receive.
func isAdminOnlyEvent(name string) bool {
switch name {
case protocol.EventNodePairRequested, protocol.EventNodePairResolved,
protocol.EventDevicePairReq, protocol.EventDevicePairRes,
protocol.EventAgentLinkCreated, protocol.EventAgentLinkUpdated, protocol.EventAgentLinkDeleted,
protocol.EventWorkspaceFileChanged:
return true
}
return false
}
// extractEventUserID extracts UserID from agent.AgentEvent payload.
func extractEventUserID(event bus.Event) string {
switch ae := event.Payload.(type) {
case agent.AgentEvent:
return ae.UserID
case *agent.AgentEvent:
return ae.UserID
}
return extractMapField(event.Payload, "userId")
}
// extractTeamID extracts TeamID from team event payloads.
func extractTeamID(event bus.Event) string {
switch te := event.Payload.(type) {
case protocol.TeamTaskEventPayload:
return te.TeamID
case *protocol.TeamTaskEventPayload:
return te.TeamID
}
// Fallback: check map payloads.
if tid := extractMapField(event.Payload, "team_id"); tid != "" {
return tid
}
return extractMapField(event.Payload, "teamId")
}
// extractMapField extracts a string field from a map[string]any or map[string]string payload.
// Falls back to JSON unmarshaling for struct payloads.
func extractMapField(payload any, key string) string {
switch m := payload.(type) {
case map[string]any:
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
case map[string]string:
return m[key]
default:
// Try JSON round-trip for struct payloads (lazy, used rarely).
data, err := json.Marshal(payload)
if err != nil {
return ""
}
var parsed map[string]any
if json.Unmarshal(data, &parsed) == nil {
if v, ok := parsed[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
}
}
return ""
}
+18
View File
@@ -0,0 +1,18 @@
package methods
import (
"slices"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
)
// canSeeAll checks if user has full data visibility (admin role OR owner user).
func canSeeAll(role permissions.Role, ownerIDs []string, userID string) bool {
if permissions.HasMinRole(role, permissions.RoleAdmin) {
return true
}
if userID != "" && slices.Contains(ownerIDs, userID) {
return true
}
return false
}
+9 -9
View File
@@ -59,7 +59,7 @@ func (m *AgentLinksMethods) handleList(ctx context.Context, client *gateway.Clie
return
}
agentID, err := resolveAgentUUID(m.agentStore, params.AgentID)
agentID, err := resolveAgentUUID(ctx, m.agentStore, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
@@ -133,12 +133,12 @@ func (m *AgentLinksMethods) handleCreate(ctx context.Context, client *gateway.Cl
return
}
sourceAgent, err := resolveAgentInfo(m.agentStore, params.SourceAgent)
sourceAgent, err := resolveAgentInfo(ctx, m.agentStore, params.SourceAgent)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "source agent: "+err.Error()))
return
}
targetAgent, err := resolveAgentInfo(m.agentStore, params.TargetAgent)
targetAgent, err := resolveAgentInfo(ctx, m.agentStore, params.TargetAgent)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "target agent: "+err.Error()))
return
@@ -408,15 +408,15 @@ func (m *AgentLinksMethods) invalidateLinkAgentsByID(ctx context.Context, source
// --- helpers ---
func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) {
func resolveAgentUUID(ctx context.Context, agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) {
if id, err := uuid.Parse(keyOrID); err == nil {
ag, err := agentStore.GetByID(context.Background(), id)
ag, err := agentStore.GetByID(ctx, id)
if err != nil {
return uuid.Nil, err
}
return ag.ID, nil
}
ag, err := agentStore.GetByKey(context.Background(), keyOrID)
ag, err := agentStore.GetByKey(ctx, keyOrID)
if err != nil {
return uuid.Nil, err
}
@@ -424,9 +424,9 @@ func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, e
}
// resolveAgentInfo returns full agent data for validation and cache invalidation.
func resolveAgentInfo(agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) {
func resolveAgentInfo(ctx context.Context, agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) {
if id, err := uuid.Parse(keyOrID); err == nil {
return agentStore.GetByID(context.Background(), id)
return agentStore.GetByID(ctx, id)
}
return agentStore.GetByKey(context.Background(), keyOrID)
return agentStore.GetByKey(ctx, keyOrID)
}
+6 -9
View File
@@ -4,13 +4,13 @@ import (
"context"
"encoding/json"
"log/slog"
"slices"
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -34,10 +34,7 @@ func NewAgentsMethods(agents *agent.Router, cfg *config.Config, cfgPath, workspa
// isOwnerUser checks if the given user ID is in the configured owner IDs.
func (m *AgentsMethods) isOwnerUser(userID string) bool {
if userID == "" {
return false
}
return slices.Contains(m.cfg.Gateway.OwnerIDs, userID)
return canSeeAll(permissions.RoleViewer, m.cfg.Gateway.OwnerIDs, userID)
}
func (m *AgentsMethods) Register(router *gateway.MethodRouter) {
@@ -57,7 +54,7 @@ type agentParams struct {
AgentID string `json:"agentId"`
}
func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleAgent(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params agentParams
if req.Params != nil {
json.Unmarshal(req.Params, &params)
@@ -66,7 +63,7 @@ func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, r
params.AgentID = "default"
}
loop, err := m.agents.Get(params.AgentID)
loop, err := m.agents.Get(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
@@ -78,7 +75,7 @@ func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, r
}))
}
func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleAgentWait(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params agentParams
if req.Params != nil {
json.Unmarshal(req.Params, &params)
@@ -87,7 +84,7 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien
params.AgentID = "default"
}
loop, err := m.agents.Get(params.AgentID)
loop, err := m.agents.Get(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
+21 -1
View File
@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
@@ -28,6 +30,7 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client
Avatar string `json:"avatar"`
AgentType string `json:"agent_type"` // "open" (default) or "predefined"
OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system"
TenantID string `json:"tenant_id"` // required for cross-tenant callers; ignored otherwise
// Per-agent config overrides
ToolsConfig json.RawMessage `json:"tools_config,omitempty"`
SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"`
@@ -67,7 +70,6 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client
if m.agentStore != nil {
// --- DB-backed: create agent in store ---
ctx := context.Background()
// Check if agent already exists in DB
if existing, _ := m.agentStore.GetByKey(ctx, agentID); existing != nil {
@@ -82,10 +84,28 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client
ownerID = params.OwnerIDs[0]
}
// Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant.
var tenantID uuid.UUID
if client.IsCrossTenant() {
if params.TenantID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "tenant_id")))
return
}
tid, err := uuid.Parse(params.TenantID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
return
}
tenantID = tid
} else {
tenantID = client.TenantID()
}
agentData := &store.AgentData{
AgentKey: agentID,
DisplayName: params.Name,
OwnerID: ownerID,
TenantID: tenantID,
AgentType: agentType,
Provider: m.cfg.Agents.Defaults.Provider,
Model: m.cfg.Agents.Defaults.Model,
@@ -31,6 +31,12 @@ func (s *createCaptureStore) GetByKey(_ context.Context, _ string) (*store.Agent
func (s *createCaptureStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
return nil, nil
}
func (s *createCaptureStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) {
return nil, nil
}
func (s *createCaptureStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) {
return nil, nil
}
func (s *createCaptureStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return nil
}
@@ -40,7 +40,6 @@ func (m *AgentsMethods) handleDelete(ctx context.Context, client *gateway.Client
if m.agentStore != nil {
// --- DB-backed: delete from store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
+3 -3
View File
@@ -38,7 +38,7 @@ func (m *AgentsMethods) handleFilesList(ctx context.Context, client *gateway.Cli
if m.agentStore != nil {
// --- DB-backed: list from store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
@@ -137,7 +137,7 @@ func (m *AgentsMethods) handleFilesGet(ctx context.Context, client *gateway.Clie
if m.agentStore != nil {
// --- DB-backed: read from store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
@@ -236,7 +236,7 @@ func (m *AgentsMethods) handleFilesSet(ctx context.Context, client *gateway.Clie
if m.agentStore != nil {
// --- DB-backed: write to store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
+1 -2
View File
@@ -15,7 +15,7 @@ import (
// --- agent.identity.get ---
// Matching TS src/gateway/server-methods/agent.ts:601-643
func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *AgentsMethods) handleIdentityGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
AgentID string `json:"agentId"`
SessionKey string `json:"sessionKey"`
@@ -42,7 +42,6 @@ func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Cli
if m.agentStore != nil {
// --- DB-backed: read identity from store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err == nil {
result["name"] = ag.DisplayName
@@ -46,7 +46,6 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client
if m.agentStore != nil {
// --- DB-backed: update agent in store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
+51 -2
View File
@@ -35,7 +35,12 @@ func (m *APIKeysMethods) Register(router *gateway.MethodRouter) {
func (m *APIKeysMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
keys, err := m.apiKeys.List(ctx)
// Non-admin callers only see their own keys.
ownerID := ""
if client.Role() != permissions.RoleAdmin {
ownerID = client.UserID()
}
keys, err := m.apiKeys.List(ctx, ownerID)
if err != nil {
slog.Error("api_keys.list failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "API keys")))
@@ -54,6 +59,8 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresIn *int `json:"expires_in"` // seconds; nil = never
OwnerID string `json:"owner_id"` // optional; non-admin callers always get their own user_id
TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key)
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
@@ -79,6 +86,12 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
}
}
// Non-admin callers always bind the key to their own user_id.
ownerID := params.OwnerID
if client.Role() != permissions.RoleAdmin {
ownerID = client.UserID()
}
raw, hash, prefix, err := crypto.GenerateAPIKey()
if err != nil {
slog.Error("api_keys.generate failed", "error", err)
@@ -86,6 +99,34 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
return
}
// Resolve tenant_id based on caller type.
var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB)
if client.IsCrossTenant() {
if params.TenantID != "" {
tid, err := uuid.Parse(params.TenantID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
return
}
tenantID = tid
}
// else: uuid.Nil stays → system-level key
} else if client.HasScope(permissions.ScopeProvision) {
// Provision-scoped callers may create tenant-bound keys only (not system-level).
if params.TenantID != "" {
tid, err := uuid.Parse(params.TenantID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
return
}
tenantID = tid
} else {
tenantID = client.TenantID()
}
} else {
tenantID = client.TenantID()
}
now := time.Now()
key := &store.APIKeyData{
ID: store.GenNewID(),
@@ -93,6 +134,8 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien
Prefix: prefix,
KeyHash: hash,
Scopes: params.Scopes,
OwnerID: ownerID,
TenantID: tenantID,
CreatedBy: store.UserIDFromContext(ctx),
CreatedAt: now,
UpdatedAt: now,
@@ -139,7 +182,13 @@ func (m *APIKeysMethods) handleRevoke(ctx context.Context, client *gateway.Clien
return
}
if err := m.apiKeys.Revoke(ctx, id); err != nil {
// Non-admin callers can only revoke their own keys.
ownerID := ""
if client.Role() != permissions.RoleAdmin {
ownerID = client.UserID()
}
if err := m.apiKeys.Revoke(ctx, id, ownerID); err != nil {
slog.Error("api_keys.revoke failed", "error", err, "id", params.ID)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", params.ID)))
return
+23 -16
View File
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
"github.com/nextlevelbuilder/goclaw/internal/bus"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
@@ -22,11 +23,11 @@ import (
// ChatMethods handles chat.send, chat.history, chat.abort, chat.inject.
type ChatMethods struct {
agents *agent.Router
sessions store.SessionStore
rateLimiter *gateway.RateLimiter
eventBus bus.EventPublisher
postTurn tools.PostTurnProcessor
agents *agent.Router
sessions store.SessionStore
rateLimiter *gateway.RateLimiter
eventBus bus.EventPublisher
postTurn tools.PostTurnProcessor
}
func NewChatMethods(agents *agent.Router, sess store.SessionStore, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods {
@@ -114,7 +115,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
}
}
loop, err := m.agents.Get(params.AgentID)
loop, err := m.agents.Get(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
@@ -222,24 +223,25 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
}
// Auto-generate conversation title on first message (label empty = never titled).
if label := m.sessions.GetLabel(sessionKey); label == "" {
if label := m.sessions.GetLabel(ctx, sessionKey); label == "" {
agentProvider := loop.Provider()
agentModel := loop.Model()
userMsg := params.Message
// Use runCtxBase (WithoutCancel + tenant-aware) so title save uses correct tenant.
titleCtx := runCtxBase
go func() {
title := agent.GenerateTitle(context.Background(), agentProvider, agentModel, userMsg)
title := agent.GenerateTitle(titleCtx, agentProvider, agentModel, userMsg)
if title == "" {
return
}
m.sessions.SetLabel(sessionKey, title)
if err := m.sessions.Save(sessionKey); err != nil {
m.sessions.SetLabel(titleCtx, sessionKey, title)
if err := m.sessions.Save(titleCtx, sessionKey); err != nil {
slog.Warn("failed to save session title", "sessionKey", sessionKey, "error", err)
return
}
m.eventBus.Broadcast(bus.Event{
Name: protocol.EventSessionUpdated,
Payload: map[string]string{"sessionKey": sessionKey, "label": title},
})
bus.BroadcastForTenant(m.eventBus, protocol.EventSessionUpdated,
client.TenantID(),
map[string]string{"sessionKey": sessionKey, "label": title, "userId": userID})
}()
}
@@ -277,7 +279,12 @@ func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client,
sessionKey = sessions.BuildWSSessionKey(params.AgentID, uuid.NewString())
}
history := m.sessions.GetHistory(sessionKey)
history := m.sessions.GetHistory(ctx, sessionKey)
// Sign file URLs before delivery — sessions store clean paths.
for i := range history {
history[i].Content = httpapi.SignFileURLs(history[i].Content, httpapi.FileSigningKey())
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"messages": history,
@@ -320,7 +327,7 @@ func (m *ChatMethods) handleInject(ctx context.Context, client *gateway.Client,
// Create an assistant message with gateway-injected metadata
messageID := uuid.NewString()
m.sessions.AddMessage(params.SessionKey, providers.Message{
m.sessions.AddMessage(ctx, params.SessionKey, providers.Message{
Role: "assistant",
Content: text,
})
+19 -4
View File
@@ -29,10 +29,25 @@ func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.Con
}
func (m *ConfigMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodConfigGet, m.handleGet)
router.Register(protocol.MethodConfigApply, m.handleApply)
router.Register(protocol.MethodConfigPatch, m.handlePatch)
router.Register(protocol.MethodConfigSchema, m.handleSchema)
router.Register(protocol.MethodConfigGet, m.requireCrossTenant(m.handleGet))
router.Register(protocol.MethodConfigApply, m.requireCrossTenant(m.handleApply))
router.Register(protocol.MethodConfigPatch, m.requireCrossTenant(m.handlePatch))
router.Register(protocol.MethodConfigSchema, m.requireCrossTenant(m.handleSchema))
}
// requireCrossTenant wraps a handler to only allow cross-tenant (owner/system) users.
func (m *ConfigMethods) requireCrossTenant(next gateway.MethodHandler) gateway.MethodHandler {
return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
if !client.IsCrossTenant() {
locale := store.LocaleFromContext(ctx)
client.SendResponse(protocol.NewErrorResponse(
req.ID, protocol.ErrUnauthorized,
i18n.T(locale, i18n.MsgPermissionDenied, req.Method),
))
return
}
next(ctx, client, req)
}
}
func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+52 -12
View File
@@ -7,6 +7,7 @@ import (
"regexp"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -19,10 +20,11 @@ var cronSlugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
type CronMethods struct {
service store.CronStore
eventBus bus.EventPublisher
cfg *config.Config
}
func NewCronMethods(service store.CronStore, eventBus bus.EventPublisher) *CronMethods {
return &CronMethods{service: service, eventBus: eventBus}
func NewCronMethods(service store.CronStore, eventBus bus.EventPublisher, cfg *config.Config) *CronMethods {
return &CronMethods{service: service, eventBus: eventBus, cfg: cfg}
}
func (m *CronMethods) Register(router *gateway.MethodRouter) {
@@ -36,7 +38,7 @@ func (m *CronMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodCronRuns, m.handleRuns)
}
func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *CronMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
IncludeDisabled bool `json:"includeDisabled"`
}
@@ -44,7 +46,11 @@ func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req
json.Unmarshal(req.Params, &params)
}
jobs := m.service.ListJobs(params.IncludeDisabled, "", "")
userID := ""
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
userID = client.UserID()
}
jobs := m.service.ListJobs(ctx, params.IncludeDisabled, "", userID)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"jobs": jobs,
@@ -80,7 +86,7 @@ func (m *CronMethods) handleCreate(ctx context.Context, client *gateway.Client,
return
}
job, err := m.service.AddJob(params.Name, params.Schedule, params.Message, params.Deliver, params.Channel, params.To, params.AgentID, client.UserID())
job, err := m.service.AddJob(ctx, params.Name, params.Schedule, params.Message, params.Deliver, params.Channel, params.To, params.AgentID, client.UserID())
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
@@ -106,7 +112,15 @@ func (m *CronMethods) handleDelete(ctx context.Context, client *gateway.Client,
return
}
if err := m.service.RemoveJob(params.JobID); err != nil {
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
job, ok := m.service.GetJob(ctx, params.JobID)
if !ok || job.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
return
}
}
if err := m.service.RemoveJob(ctx, params.JobID); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
@@ -132,7 +146,15 @@ func (m *CronMethods) handleToggle(ctx context.Context, client *gateway.Client,
return
}
if err := m.service.EnableJob(params.JobID, params.Enabled); err != nil {
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
job, ok := m.service.GetJob(ctx, params.JobID)
if !ok || job.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
return
}
}
if err := m.service.EnableJob(ctx, params.JobID, params.Enabled); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
@@ -168,7 +190,15 @@ func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client,
return
}
job, err := m.service.UpdateJob(jobID, params.Patch)
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
existing, ok := m.service.GetJob(ctx, jobID)
if !ok || existing.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
return
}
}
job, err := m.service.UpdateJob(ctx, jobID, params.Patch)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
@@ -203,12 +233,19 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req
force := params.Mode == "force"
// Validate job exists before responding
_, ok := m.service.GetJob(jobID)
job, ok := m.service.GetJob(ctx, jobID)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgJobNotFound)))
return
}
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
if job.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job")))
return
}
}
// Respond immediately — job execution happens in background
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
@@ -216,14 +253,17 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req
}))
emitAudit(m.eventBus, client, "cron.run", "cron", jobID)
// Preserve tenant scope for async execution.
tenantID := store.TenantIDFromContext(ctx)
go func() {
if _, _, err := m.service.RunJob(jobID, force); err != nil {
bgCtx := store.WithTenantID(context.Background(), tenantID)
if _, _, err := m.service.RunJob(bgCtx, jobID, force); err != nil {
slog.Warn("cron.run background error", "jobId", jobID, "error", err)
}
}()
}
func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *CronMethods) handleRuns(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
JobID string `json:"jobId"`
ID string `json:"id"`
@@ -239,7 +279,7 @@ func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req
jobID = params.ID
}
entries, total := m.service.GetRunLog(jobID, params.Limit, params.Offset)
entries, total := m.service.GetRunLog(ctx, jobID, params.Limit, params.Offset)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"entries": entries,
"total": total,
-137
View File
@@ -1,137 +0,0 @@
package methods
import (
"context"
"encoding/json"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// DelegationsMethods handles delegations.* RPC methods.
type DelegationsMethods struct {
teamStore store.TeamStore
}
func NewDelegationsMethods(teamStore store.TeamStore) *DelegationsMethods {
return &DelegationsMethods{teamStore: teamStore}
}
func (m *DelegationsMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodDelegationsList, m.handleList)
router.Register(protocol.MethodDelegationsGet, m.handleGet)
}
func (m *DelegationsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable)))
return
}
var params struct {
SourceAgentID string `json:"source_agent_id"`
TargetAgentID string `json:"target_agent_id"`
TeamID string `json:"team_id"`
UserID string `json:"user_id"`
Status string `json:"status"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
if req.Params != nil {
_ = json.Unmarshal(req.Params, &params)
}
opts := store.DelegationHistoryListOpts{
UserID: params.UserID,
Status: params.Status,
Limit: params.Limit,
Offset: params.Offset,
}
if params.SourceAgentID != "" {
if id, err := uuid.Parse(params.SourceAgentID); err == nil {
opts.SourceAgentID = &id
}
}
if params.TargetAgentID != "" {
if id, err := uuid.Parse(params.TargetAgentID); err == nil {
opts.TargetAgentID = &id
}
}
if params.TeamID != "" {
if id, err := uuid.Parse(params.TeamID); err == nil {
opts.TeamID = &id
}
}
records, total, err := m.teamStore.ListDelegationHistory(ctx, opts)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
// Truncate results for WS transport
const maxResultRunes = 500
for i := range records {
if records[i].Result != nil {
r := []rune(*records[i].Result)
if len(r) > maxResultRunes {
s := string(r[:maxResultRunes]) + "..."
records[i].Result = &s
}
}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"records": records,
"total": total,
}))
}
func (m *DelegationsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if m.teamStore == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable)))
return
}
var params struct {
ID string `json:"id"`
}
if req.Params != nil {
_ = json.Unmarshal(req.Params, &params)
}
if params.ID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id")))
return
}
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "id")))
return
}
record, err := m.teamStore.GetDelegationHistory(ctx, id)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
// Truncate result for WS transport
const maxResultRunes = 8000
if record.Result != nil {
r := []rune(*record.Result)
if len(r) > maxResultRunes {
s := string(r[:maxResultRunes]) + "..."
record.Result = &s
}
}
client.SendResponse(protocol.NewOKResponse(req.ID, record))
}
+9 -9
View File
@@ -70,7 +70,7 @@ func (m *PairingMethods) handleRequest(ctx context.Context, client *gateway.Clie
params.AccountID = "default"
}
code, err := m.service.RequestPairing(params.SenderID, params.Channel, params.ChatID, params.AccountID, nil)
code, err := m.service.RequestPairing(ctx, params.SenderID, params.Channel, params.ChatID, params.AccountID, nil)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error()))
return
@@ -99,7 +99,7 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie
params.ApprovedBy = "operator"
}
paired, err := m.service.ApprovePairing(params.Code, params.ApprovedBy)
paired, err := m.service.ApprovePairing(ctx, params.Code, params.ApprovedBy)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
@@ -135,7 +135,7 @@ func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client,
return
}
if err := m.service.DenyPairing(params.Code); err != nil {
if err := m.service.DenyPairing(ctx, params.Code); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
@@ -150,9 +150,9 @@ func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client,
}))
}
func (m *PairingMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
pending := m.service.ListPending()
paired := m.service.ListPaired()
func (m *PairingMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
pending := m.service.ListPending(ctx)
paired := m.service.ListPaired(ctx)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"pending": pending,
@@ -175,7 +175,7 @@ func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Clien
return
}
if err := m.service.RevokePairing(params.SenderID, params.Channel); err != nil {
if err := m.service.RevokePairing(ctx, params.SenderID, params.Channel); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error()))
return
}
@@ -222,7 +222,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client
return
}
paired, pairErr := m.service.IsPaired(params.SenderID, "browser")
paired, pairErr := m.service.IsPaired(ctx, params.SenderID, "browser")
if pairErr != nil {
slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr)
}
@@ -234,7 +234,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client
}
// Check if the pairing request still exists (not expired)
pending := m.service.ListPending()
pending := m.service.ListPending(ctx)
for _, p := range pending {
if p.SenderID == params.SenderID && p.Channel == "browser" {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
+77 -19
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -15,10 +17,11 @@ import (
type SessionsMethods struct {
sessions store.SessionStore
eventBus bus.EventPublisher
cfg *config.Config
}
func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher) *SessionsMethods {
return &SessionsMethods{sessions: sess, eventBus: eventBus}
func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher, cfg *config.Config) *SessionsMethods {
return &SessionsMethods{sessions: sess, eventBus: eventBus, cfg: cfg}
}
func (m *SessionsMethods) Register(router *gateway.MethodRouter) {
@@ -36,7 +39,7 @@ type sessionsListParams struct {
Offset int `json:"offset"`
}
func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *SessionsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params sessionsListParams
if req.Params != nil {
json.Unmarshal(req.Params, &params)
@@ -47,18 +50,19 @@ func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client,
}
opts := store.SessionListOpts{
AgentID: params.AgentID,
Channel: params.Channel,
Limit: params.Limit,
Offset: params.Offset,
AgentID: params.AgentID,
Channel: params.Channel,
Limit: params.Limit,
Offset: params.Offset,
TenantID: store.TenantIDFromContext(ctx),
}
// Only filter by UserID when a channel filter is specified (e.g. chat sidebar sends channel="ws").
// Sessions admin page omits channel → sees all sessions unfiltered.
if params.Channel != "" {
// Role-based filtering: admins/owners see all sessions; regular users see only their own.
// Tenant scope is always applied above — admin sees all sessions within the tenant.
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
opts.UserID = client.UserID()
}
result := m.sessions.ListPagedRich(opts)
result := m.sessions.ListPagedRich(ctx, opts)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"sessions": result.Sessions,
"total": result.Total,
@@ -79,8 +83,26 @@ func (m *SessionsMethods) handlePreview(ctx context.Context, client *gateway.Cli
return
}
history := m.sessions.GetHistory(params.Key)
summary := m.sessions.GetSummary(params.Key)
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
sess := m.sessions.Get(ctx, params.Key)
if sess == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
return
}
if sess.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
return
}
}
history := m.sessions.GetHistory(ctx, params.Key)
summary := m.sessions.GetSummary(ctx, params.Key)
// Sign file URLs before delivery — sessions store clean paths.
for i := range history {
history[i].Content = httpapi.SignFileURLs(history[i].Content, httpapi.FileSigningKey())
}
summary = httpapi.SignFileURLs(summary, httpapi.FileSigningKey())
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"key": params.Key,
@@ -109,23 +131,35 @@ func (m *SessionsMethods) handlePatch(ctx context.Context, client *gateway.Clien
return
}
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
sess := m.sessions.Get(ctx, params.Key)
if sess == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
return
}
if sess.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
return
}
}
// Apply label patch
if params.Label != nil {
m.sessions.SetLabel(params.Key, *params.Label)
m.sessions.SetLabel(ctx, params.Key, *params.Label)
}
// Apply model patch
if params.Model != nil {
m.sessions.UpdateMetadata(params.Key, *params.Model, "", "")
m.sessions.UpdateMetadata(ctx, params.Key, *params.Model, "", "")
}
// Apply metadata patch
if len(params.Metadata) > 0 {
m.sessions.SetSessionMetadata(params.Key, params.Metadata)
m.sessions.SetSessionMetadata(ctx, params.Key, params.Metadata)
}
// Save changes to DB
m.sessions.Save(params.Key)
m.sessions.Save(ctx, params.Key)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
@@ -142,7 +176,19 @@ func (m *SessionsMethods) handleDelete(ctx context.Context, client *gateway.Clie
return
}
if err := m.sessions.Delete(params.Key); err != nil {
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
sess := m.sessions.Get(ctx, params.Key)
if sess == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
return
}
if sess.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
return
}
}
if err := m.sessions.Delete(ctx, params.Key); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
@@ -161,7 +207,19 @@ func (m *SessionsMethods) handleReset(ctx context.Context, client *gateway.Clien
return
}
m.sessions.Reset(params.Key)
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
sess := m.sessions.Get(ctx, params.Key)
if sess == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
return
}
if sess.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
return
}
}
m.sessions.Reset(ctx, params.Key)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
+17 -13
View File
@@ -33,8 +33,8 @@ func (m *SkillsMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodSkillsUpdate, m.handleUpdate)
}
func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
allSkills := m.store.ListSkills()
func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
allSkills := m.store.ListSkills(ctx)
result := make([]map[string]any, 0, len(allSkills))
for _, s := range allSkills {
@@ -86,13 +86,13 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r
return
}
info, ok := m.store.GetSkill(params.Name)
info, ok := m.store.GetSkill(ctx, params.Name)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
return
}
content, _ := m.store.LoadSkill(params.Name)
content, _ := m.store.LoadSkill(ctx, params.Name)
resp := map[string]any{
"name": info.Name,
@@ -116,7 +116,7 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r
// skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore).
type skillUpdater interface {
UpdateSkill(id uuid.UUID, updates map[string]any) error
UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error
}
func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
@@ -153,7 +153,7 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client
} else {
// Look up by name — use GetSkill which returns path info, but we need DB ID
// For PGSkillStore, the name is the slug
info, exists := m.store.GetSkill(params.Name)
info, exists := m.store.GetSkill(ctx, params.Name)
if !exists {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name)))
return
@@ -172,17 +172,21 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client
return
}
// Ownership check: only skill owner or admin can update
// Ownership check: only skill owner or admin can update.
// Fail-closed: if store doesn't implement skillOwnerGetter, deny non-admin callers.
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
if ownerGetter, ok := m.store.(skillOwnerGetter); ok {
if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
return
}
ownerGetter, ok := m.store.(skillOwnerGetter)
if !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
return
}
if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update")))
return
}
}
if err := updater.UpdateSkill(skillID, params.Updates); err != nil {
if err := updater.UpdateSkill(ctx, skillID, params.Updates); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
}
+12 -4
View File
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -22,7 +23,7 @@ type TeamsMethods struct {
agentRouter *agent.Router // for cache invalidation
msgBus *bus.MessageBus // for pub/sub cache invalidation
eventBus bus.EventPublisher
dataDir string // workspace data directory for resolving file paths
dataDir string // workspace data directory for resolving file paths
}
func NewTeamsMethods(teamStore store.TeamStore, agentStore store.AgentStore, linkStore store.AgentLinkStore, agentRouter *agent.Router, msgBus *bus.MessageBus, eventBus bus.EventPublisher, dataDir string) *TeamsMethods {
@@ -73,7 +74,14 @@ func (m *TeamsMethods) handleList(ctx context.Context, client *gateway.Client, r
return
}
teams, err := m.teamStore.ListTeams(ctx)
var teams []store.TeamData
var err error
if permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
teams, err = m.teamStore.ListTeams(ctx)
} else {
callerID := store.UserIDFromContext(ctx)
teams, err = m.teamStore.ListUserTeams(ctx, callerID)
}
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
return
@@ -118,7 +126,7 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client,
}
// Resolve lead agent
leadAgent, err := resolveAgentInfo(m.agentStore, params.Lead)
leadAgent, err := resolveAgentInfo(ctx, m.agentStore, params.Lead)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead agent: "+err.Error()))
return
@@ -134,7 +142,7 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client,
// Resolve member agents
var memberAgents []*store.AgentData
for _, memberKey := range params.Members {
ag, err := resolveAgentInfo(m.agentStore, memberKey)
ag, err := resolveAgentInfo(ctx, m.agentStore, memberKey)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "member agent "+memberKey+": "+err.Error()))
return
+19
View File
@@ -10,6 +10,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
@@ -50,6 +51,15 @@ func (m *TeamsMethods) handleGet(ctx context.Context, client *gateway.Client, re
return
}
// Non-admin callers must be a member of the team to view it.
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
callerID := store.UserIDFromContext(ctx)
if ok, err := m.teamStore.HasTeamAccess(ctx, teamID, callerID); err != nil || !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", params.TeamID)))
return
}
}
members, err := m.teamStore.ListMembers(ctx, teamID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
@@ -92,6 +102,15 @@ func (m *TeamsMethods) handleDelete(ctx context.Context, client *gateway.Client,
return
}
// Non-admin callers must have team access to delete.
if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) {
callerID := store.UserIDFromContext(ctx)
if ok, err := m.teamStore.HasTeamAccess(ctx, teamID, callerID); err != nil || !ok {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", params.TeamID)))
return
}
}
// Fetch team and members before deleting for event + cache invalidation
team, _ := m.teamStore.GetTeam(ctx, teamID)
members, _ := m.teamStore.ListMembers(ctx, teamID)
+1 -1
View File
@@ -53,7 +53,7 @@ func (m *TeamsMethods) handleAddMember(ctx context.Context, client *gateway.Clie
}
// Resolve agent
ag, err := resolveAgentInfo(m.agentStore, params.Agent)
ag, err := resolveAgentInfo(ctx, m.agentStore, params.Agent)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent: "+err.Error()))
return
+8
View File
@@ -12,6 +12,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -104,6 +105,13 @@ func (m *TeamsMethods) handleTaskGet(ctx context.Context, client *gateway.Client
events, _ := m.teamStore.ListTaskEvents(ctx, taskID)
attachments, _ := m.teamStore.ListTaskAttachments(ctx, taskID)
// Sign download URLs at delivery time (same pattern as chat file URLs).
for i := range attachments {
dlPath := fmt.Sprintf("/v1/teams/%s/attachments/%s/download", teamID, attachments[i].ID)
ft := httpapi.SignFileToken(dlPath, httpapi.FileSigningKey(), httpapi.FileTokenTTL)
attachments[i].DownloadURL = dlPath + "?ft=" + ft
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"task": task,
"comments": comments,
+59 -19
View File
@@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
@@ -24,14 +26,54 @@ func (m *TeamsMethods) RegisterWorkspace(router *gateway.MethodRouter) {
router.Register(protocol.MethodTeamsWorkspaceDelete, m.handleWorkspaceDelete)
}
// teamWorkspaceDir returns the base directory for a team's workspace files.
// Pattern: {dataDir}/teams/{teamID}/
// If chatID is provided, scopes to {dataDir}/teams/{teamID}/{chatID}/
func teamWorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) string {
if chatID != "" {
return filepath.Join(dataDir, "teams", teamID.String(), chatID)
// resolveWorkspacePath resolves fileName within scopeDir and validates that the
// canonical path (after symlink resolution) stays inside the scope directory.
// Returns the resolved disk path or an error if the path escapes the boundary.
func resolveWorkspacePath(scopeDir, fileName string) (string, error) {
diskPath := filepath.Clean(filepath.Join(scopeDir, fileName))
// Resolve scope dir to canonical form.
scopeReal, err := filepath.EvalSymlinks(filepath.Clean(scopeDir))
if err != nil {
scopeReal = filepath.Clean(scopeDir)
}
return filepath.Join(dataDir, "teams", teamID.String())
// Resolve target path — follow symlinks to detect escapes.
diskReal, err := filepath.EvalSymlinks(diskPath)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("security.workspace_path_resolve_failed", "path", fileName, "error", err)
return "", fmt.Errorf("invalid file_name")
}
// File doesn't exist yet — validate parent directory.
parentReal, parentErr := filepath.EvalSymlinks(filepath.Dir(diskPath))
if parentErr != nil {
return "", fmt.Errorf("invalid file_name")
}
diskReal = filepath.Join(parentReal, filepath.Base(diskPath))
}
// Boundary check: canonical path must be inside canonical scope.
if diskReal != scopeReal && !strings.HasPrefix(diskReal, scopeReal+string(filepath.Separator)) {
slog.Warn("security.workspace_path_escape", "path", fileName, "resolved", diskReal, "scope", scopeReal)
return "", fmt.Errorf("invalid file_name")
}
return diskPath, nil
}
// teamWorkspaceDir returns the base directory for a team's workspace files.
// Scoped to tenant via config.TenantTeamDir: {dataDir}/tenants/{slug}/teams/{teamID}/
// Master tenant: {dataDir}/teams/{teamID}/ (backward compat).
// If chatID is provided, further scopes to .../{chatID}/
func teamWorkspaceDir(ctx context.Context, dataDir string, teamID uuid.UUID, chatID string) string {
tid := store.TenantIDFromContext(ctx)
slug := store.TenantSlugFromContext(ctx)
base := config.TenantTeamDir(dataDir, tid, slug, teamID)
if chatID != "" {
return filepath.Join(base, chatID)
}
return base
}
// workspaceFileEntry is the response shape for workspace file listing.
@@ -76,7 +118,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.
shared = tools.IsSharedWorkspace(team.Settings)
}
baseDir := teamWorkspaceDir(m.dataDir, teamID, "")
baseDir := teamWorkspaceDir(ctx, m.dataDir, teamID, "")
var files []workspaceFileEntry
if shared || params.ChatID != "" {
@@ -84,7 +126,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway.
scopeDir := baseDir
scopeChatID := ""
if !shared && params.ChatID != "" {
scopeDir = teamWorkspaceDir(m.dataDir, teamID, params.ChatID)
scopeDir = teamWorkspaceDir(ctx, m.dataDir, teamID, params.ChatID)
scopeChatID = params.ChatID
}
files = walkDir(scopeDir, "", scopeChatID)
@@ -207,11 +249,10 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway.
return
}
scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName))
// Ensure resolved path stays within the workspace scope directory.
if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
scopeDir := teamWorkspaceDir(ctx, m.dataDir, teamID, chatID)
diskPath, pathErr := resolveWorkspacePath(scopeDir, params.FileName)
if pathErr != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, pathErr.Error()))
return
}
data, err := os.ReadFile(diskPath)
@@ -283,11 +324,10 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa
return
}
scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID)
diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName))
// Ensure resolved path stays within the workspace scope directory.
if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name"))
scopeDir := teamWorkspaceDir(ctx, m.dataDir, teamID, chatID)
diskPath, pathErr := resolveWorkspacePath(scopeDir, params.FileName)
if pathErr != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, pathErr.Error()))
return
}
if err := os.Remove(diskPath); err != nil {
@@ -0,0 +1,124 @@
package methods
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestResolveWorkspacePath_NormalFile(t *testing.T) {
scope := t.TempDir()
if err := os.WriteFile(filepath.Join(scope, "file.txt"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
diskPath, err := resolveWorkspacePath(scope, "file.txt")
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
if filepath.Base(diskPath) != "file.txt" {
t.Fatalf("expected file.txt, got: %s", diskPath)
}
}
func TestResolveWorkspacePath_NestedFile(t *testing.T) {
scope := t.TempDir()
sub := filepath.Join(scope, "sub")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
diskPath, err := resolveWorkspacePath(scope, "sub/nested.txt")
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
if filepath.Base(diskPath) != "nested.txt" {
t.Fatalf("expected nested.txt, got: %s", diskPath)
}
}
func TestResolveWorkspacePath_TraversalBlocked(t *testing.T) {
scope := t.TempDir()
// Even though teams_workspace.go checks for ".." in the RPC handler,
// resolveWorkspacePath should also catch it via boundary check.
_, err := resolveWorkspacePath(scope, "../../../etc/passwd")
if err == nil {
t.Fatal("expected error for path traversal, got nil")
}
}
func TestResolveWorkspacePath_SymlinkEscapeBlocked(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require special privileges on Windows")
}
scope := t.TempDir()
outside := t.TempDir()
secret := filepath.Join(outside, "secret.txt")
if err := os.WriteFile(secret, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
// Plant a symlink inside workspace pointing outside
link := filepath.Join(scope, "evil_link")
if err := os.Symlink(secret, link); err != nil {
t.Fatal(err)
}
_, err := resolveWorkspacePath(scope, "evil_link")
if err == nil {
t.Fatal("expected error for symlink escaping workspace, got nil")
}
}
func TestResolveWorkspacePath_DirSymlinkEscapeBlocked(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require special privileges on Windows")
}
scope := t.TempDir()
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
// Plant a directory symlink inside workspace pointing outside
link := filepath.Join(scope, "evil_dir")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
_, err := resolveWorkspacePath(scope, "evil_dir/secret.txt")
if err == nil {
t.Fatal("expected error for dir symlink escape, got nil")
}
}
func TestResolveWorkspacePath_SymlinkInsideAllowed(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require special privileges on Windows")
}
scope := t.TempDir()
target := filepath.Join(scope, "real.txt")
if err := os.WriteFile(target, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(scope, "good_link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
_, err := resolveWorkspacePath(scope, "good_link")
if err != nil {
t.Fatalf("expected success for symlink within workspace, got: %v", err)
}
}
func TestResolveWorkspacePath_NonExistentFile(t *testing.T) {
scope := t.TempDir()
// Non-existent file in workspace should succeed (for new file creation)
_, err := resolveWorkspacePath(scope, "new_file.txt")
if err != nil {
t.Fatalf("expected success for non-existent file, got: %v", err)
}
}
+404
View File
@@ -0,0 +1,404 @@
package methods
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"regexp"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
var slugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
// TenantsMethods handles tenant management RPC methods.
type TenantsMethods struct {
tenantStore store.TenantStore
msgBus *bus.MessageBus
workspace string // base workspace directory for tenant dirs
}
// NewTenantsMethods creates a new TenantsMethods handler.
func NewTenantsMethods(tenantStore store.TenantStore, msgBus *bus.MessageBus, workspace string) *TenantsMethods {
return &TenantsMethods{tenantStore: tenantStore, msgBus: msgBus, workspace: workspace}
}
// Register registers tenant management RPC methods.
func (m *TenantsMethods) Register(router *gateway.MethodRouter) {
router.Register("tenants.list", m.handleList)
router.Register("tenants.get", m.handleGet)
router.Register("tenants.create", m.handleCreate)
router.Register("tenants.update", m.handleUpdate)
router.Register("tenants.users.list", m.handleUsersList)
router.Register("tenants.users.add", m.handleUsersAdd)
router.Register("tenants.users.remove", m.handleUsersRemove)
router.Register("tenants.mine", m.handleMine)
}
func (m *TenantsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if !client.IsCrossTenant() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list")))
return
}
tenants, err := m.tenantStore.ListTenants(ctx)
if err != nil {
slog.Error("tenants.list failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
return
}
if tenants == nil {
tenants = []store.TenantData{}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": tenants}))
}
func (m *TenantsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if !client.IsCrossTenant() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get")))
return
}
var params struct {
ID string `json:"id"`
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant")))
return
}
tenant, err := m.tenantStore.GetTenant(ctx, id)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "tenant", params.ID)))
return
}
client.SendResponse(protocol.NewOKResponse(req.ID, tenant))
}
func (m *TenantsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if !client.IsCrossTenant() && !client.HasScope(permissions.ScopeProvision) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create")))
return
}
var params struct {
Name string `json:"name"`
Slug string `json:"slug"`
Settings any `json:"settings"`
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if params.Slug == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "slug")))
return
}
if !slugRe.MatchString(params.Slug) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "slug")))
return
}
tenant := &store.TenantData{
ID: store.GenNewID(),
Name: params.Name,
Slug: params.Slug,
Status: store.TenantStatusActive,
}
if err := m.tenantStore.CreateTenant(ctx, tenant); err != nil {
slog.Error("tenants.create failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "tenant", err.Error())))
return
}
// Create workspace directory for the tenant.
if m.workspace != "" {
tenantDir := filepath.Join(m.workspace, "tenants", tenant.Slug)
if err := os.MkdirAll(tenantDir, 0755); err != nil {
slog.Warn("tenants.create: failed to create workspace dir", "dir", tenantDir, "error", err)
}
}
m.emitCacheInvalidate(bus.CacheKindTenantUsers, tenant.ID.String())
m.emitCacheInvalidate(bus.CacheKindTenants, "")
client.SendResponse(protocol.NewOKResponse(req.ID, tenant))
}
func (m *TenantsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if !client.IsCrossTenant() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update")))
return
}
var params struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Settings map[string]any `json:"settings"`
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
id, err := uuid.Parse(params.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant")))
return
}
updates := make(map[string]any)
if params.Name != "" {
updates["name"] = params.Name
}
if params.Status != "" {
updates["status"] = params.Status
}
if params.Settings != nil {
updates["settings"] = params.Settings
}
if len(updates) == 0 {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidUpdates)))
return
}
if err := m.tenantStore.UpdateTenant(ctx, id, updates); err != nil {
slog.Error("tenants.update failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "tenant", err.Error())))
return
}
m.emitCacheInvalidate(bus.CacheKindTenantUsers, id.String())
m.emitCacheInvalidate(bus.CacheKindTenants, "")
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
}
func (m *TenantsMethods) handleUsersList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if !client.IsCrossTenant() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list")))
return
}
var params struct {
TenantID string `json:"tenant_id"`
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
tid, err := uuid.Parse(params.TenantID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
return
}
users, err := m.tenantStore.ListUsers(ctx, tid)
if err != nil {
slog.Error("tenants.users.list failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenant users")))
return
}
if users == nil {
users = []store.TenantUserData{}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"users": users}))
}
func (m *TenantsMethods) handleUsersAdd(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if !client.IsCrossTenant() && !client.HasScope(permissions.ScopeProvision) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add")))
return
}
var params struct {
TenantID string `json:"tenant_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
if params.UserID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "user_id")))
return
}
if params.Role == "" {
params.Role = store.TenantRoleMember
}
validRoles := map[string]bool{
store.TenantRoleOwner: true, store.TenantRoleAdmin: true,
store.TenantRoleOperator: true, store.TenantRoleMember: true, store.TenantRoleViewer: true,
}
if !validRoles[params.Role] {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRole)))
return
}
tid, err := uuid.Parse(params.TenantID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
return
}
if err := m.tenantStore.AddUser(ctx, tid, params.UserID, params.Role); err != nil {
slog.Error("tenants.users.add failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())))
return
}
m.emitCacheInvalidate(bus.CacheKindTenantUsers, params.UserID)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
}
func (m *TenantsMethods) handleUsersRemove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
if !client.IsCrossTenant() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove")))
return
}
var params struct {
TenantID string `json:"tenant_id"`
UserID string `json:"user_id"`
}
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
}
if params.UserID == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "user_id")))
return
}
tid, err := uuid.Parse(params.TenantID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
return
}
if err := m.tenantStore.RemoveUser(ctx, tid, params.UserID); err != nil {
slog.Error("tenants.users.remove failed", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "tenant user", err.Error())))
return
}
m.emitCacheInvalidate(bus.CacheKindTenantUsers, params.UserID)
// Notify affected user's WS sessions to force logout
m.msgBus.Broadcast(bus.Event{
Name: protocol.EventTenantAccessRevoked,
Payload: map[string]string{"user_id": params.UserID, "tenant_id": tid.String()},
})
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
}
// handleMine returns the current user's tenant memberships.
// Unlike other tenant methods, this does NOT require cross-tenant access.
// Cross-tenant admins receive all tenants instead.
func (m *TenantsMethods) handleMine(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
type tenantEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Role string `json:"role"`
Status string `json:"status"`
}
// Cross-tenant admin: return all tenants with "owner" role
if client.IsCrossTenant() {
tenants, err := m.tenantStore.ListTenants(ctx)
if err != nil {
slog.Error("tenants.mine failed (cross-tenant)", "error", err)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
return
}
entries := make([]tenantEntry, len(tenants))
for i, t := range tenants {
entries[i] = tenantEntry{ID: t.ID.String(), Name: t.Name, Slug: t.Slug, Role: "owner", Status: t.Status}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": entries}))
return
}
// Regular user: return their tenant memberships enriched with name/slug
userID := client.UserID()
if userID == "" {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": []tenantEntry{}}))
return
}
memberships, err := m.tenantStore.ListUserTenants(ctx, userID)
if err != nil {
slog.Error("tenants.mine failed", "error", err, "user_id", userID)
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
return
}
entries := make([]tenantEntry, 0, len(memberships))
for _, mem := range memberships {
t, err := m.tenantStore.GetTenant(ctx, mem.TenantID)
if err != nil || t == nil || t.Status != store.TenantStatusActive {
continue
}
entries = append(entries, tenantEntry{ID: t.ID.String(), Name: t.Name, Slug: t.Slug, Role: mem.Role, Status: t.Status})
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": entries}))
}
func (m *TenantsMethods) emitCacheInvalidate(kind, key string) {
if m.msgBus == nil {
return
}
m.msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key},
})
}
+6 -6
View File
@@ -37,7 +37,7 @@ func (m *UsageMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodUsageSummary, m.handleSummary)
}
func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
func (m *UsageMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
AgentID string `json:"agentId"`
Limit int `json:"limit"`
@@ -50,12 +50,12 @@ func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req
params.Limit = 20
}
sessions := m.sessions.List(params.AgentID)
sessions := m.sessions.List(ctx, params.AgentID)
records := make([]UsageRecord, 0, len(sessions))
for _, s := range sessions {
// Get full session data for token info
data := m.sessions.GetOrCreate(s.Key)
data := m.sessions.GetOrCreate(ctx, s.Key)
if data.InputTokens == 0 && data.OutputTokens == 0 {
continue
}
@@ -95,8 +95,8 @@ func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req
}))
}
func (m *UsageMethods) handleSummary(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
sessions := m.sessions.List("") // all agents
func (m *UsageMethods) handleSummary(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
sessions := m.sessions.List(ctx, "") // all agents
type agentSummary struct {
InputTokens int64 `json:"inputTokens"`
@@ -109,7 +109,7 @@ func (m *UsageMethods) handleSummary(_ context.Context, client *gateway.Client,
var totalRecords int
for _, s := range sessions {
data := m.sessions.GetOrCreate(s.Key)
data := m.sessions.GetOrCreate(ctx, s.Key)
if data.InputTokens == 0 && data.OutputTokens == 0 {
continue
}
+231 -23
View File
@@ -7,6 +7,8 @@ import (
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/cache"
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
@@ -19,8 +21,10 @@ type MethodHandler func(ctx context.Context, client *Client, req *protocol.Reque
// MethodRouter maps method names to handlers.
type MethodRouter struct {
handlers map[string]MethodHandler
server *Server
handlers map[string]MethodHandler
server *Server
tenantStore store.TenantStore // optional, for enriching connect response
permCache *cache.PermissionCache // optional, for caching tenant membership checks
}
func NewMethodRouter(server *Server) *MethodRouter {
@@ -32,6 +36,12 @@ func NewMethodRouter(server *Server) *MethodRouter {
return r
}
// SetTenantStore sets the tenant store for enriching connect responses with tenant name/slug.
func (r *MethodRouter) SetTenantStore(ts store.TenantStore) { r.tenantStore = ts }
// SetPermissionCache sets the permission cache for tenant membership checks.
func (r *MethodRouter) SetPermissionCache(pc *cache.PermissionCache) { r.permCache = pc }
// Register adds a method handler.
func (r *MethodRouter) Register(method string, handler MethodHandler) {
r.handlers[method] = handler
@@ -67,8 +77,13 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol
}
}
// Inject locale into context for i18n support
// Inject locale + tenant into context.
// All connect paths now guarantee client.tenantID is set (cross-tenant defaults to MasterTenantID),
// so WithCrossTenant is no longer needed here.
ctx = store.WithLocale(ctx, i18n.Normalize(client.locale))
if client.TenantID() != uuid.Nil {
ctx = store.WithTenantID(ctx, client.TenantID())
}
slog.Debug("handling method", "method", req.Method, "client", client.id, "req_id", req.ID)
handler(ctx, client, req)
@@ -87,10 +102,13 @@ func (r *MethodRouter) registerDefaults() {
func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *protocol.RequestFrame) {
// Parse connect params
var params struct {
Token string `json:"token"`
UserID string `json:"user_id"`
SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect
Locale string `json:"locale"` // user's preferred locale (en, vi, zh)
Token string `json:"token"`
UserID string `json:"user_id"`
SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect
Locale string `json:"locale"` // user's preferred locale (en, vi, zh)
TenantHint string `json:"tenant_hint"` // optional tenant slug for browser pairing multi-tenant
TenantID string `json:"tenant_id"` // cross-tenant admin: narrow scope to specific tenant (UUID or slug)
TenantScope string `json:"tenant_scope"` // deprecated: alias for tenant_id (backward compat)
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
@@ -106,7 +124,39 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.role = permissions.RoleAdmin
client.authenticated = true
client.userID = params.UserID
r.sendConnectResponse(client, req.ID)
// Only owner IDs get cross-tenant (god-mode) access;
// other users with the gateway token still get admin role
// but are scoped to their tenant memberships.
isOwner := isOwnerID(params.UserID, r.server.cfg.Gateway.OwnerIDs)
client.crossTenant = isOwner
if isOwner {
// Cross-tenant admin can narrow scope to a specific tenant
tenantScope := params.TenantID
if tenantScope == "" {
tenantScope = params.TenantScope // backward compat
}
r.applyTenantScope(ctx, client, tenantScope)
// Always ensure tenant is set — prevents unscoped operations
// that use MasterTenantID fallback inconsistently with scoped sessions.
if client.tenantID == uuid.Nil {
client.tenantID = store.MasterTenantID
}
} else {
// Non-owner with gateway token: resolve tenant via hint or membership
hint := params.TenantID
if hint == "" {
hint = params.TenantScope
}
tid, errCode := r.resolveTenantHint(ctx, hint, params.UserID)
if errCode != "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked"))
return
}
client.tenantID = tid
}
r.sendConnectResponse(ctx, client, req.ID)
return
}
@@ -120,8 +170,44 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.role = role
client.scopes = scopes
client.authenticated = true
client.userID = params.UserID
r.sendConnectResponse(client, req.ID)
// If the key has a bound owner, force user_id to owner_id.
if keyData.OwnerID != "" {
if params.UserID != "" && params.UserID != keyData.OwnerID {
slog.Warn("security.ws_api_key_owner_override",
"param_user_id", params.UserID,
"owner_id", keyData.OwnerID,
)
}
client.userID = keyData.OwnerID
} else {
client.userID = params.UserID
}
if keyData.TenantID == uuid.Nil {
client.crossTenant = true
// Cross-tenant API key can narrow scope to a specific tenant
apiKeyScope := params.TenantID
if apiKeyScope == "" {
apiKeyScope = params.TenantScope // backward compat
}
r.applyTenantScope(ctx, client, apiKeyScope)
if client.tenantID == uuid.Nil {
client.tenantID = store.MasterTenantID
}
slog.Debug("security.ws_connect_resolved",
"client", client.id,
"role", string(client.role),
"cross_tenant", client.crossTenant,
"tenant_id", client.tenantID.String(),
)
} else {
client.tenantID = keyData.TenantID
slog.Debug("security.ws_connect_resolved",
"client", client.id,
"role", string(client.role),
"tenant_id", client.tenantID.String(),
)
}
r.sendConnectResponse(ctx, client, req.ID)
return
}
}
@@ -131,7 +217,8 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.role = permissions.RoleOperator
client.authenticated = true
client.userID = params.UserID
r.sendConnectResponse(client, req.ID)
client.tenantID = store.MasterTenantID
r.sendConnectResponse(ctx, client, req.ID)
return
}
@@ -140,7 +227,7 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
// Path 3a: Reconnecting with a previously-paired sender_id
if ps != nil && params.SenderID != "" {
paired, pairErr := ps.IsPaired(params.SenderID, "browser")
paired, pairErr := ps.IsPaired(ctx, params.SenderID, "browser")
if pairErr != nil {
slog.Warn("security.pairing_check_failed",
"sender_id", params.SenderID, "error", pairErr)
@@ -156,15 +243,21 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.userID = params.UserID
client.pairedSenderID = params.SenderID
client.pairedChannel = "browser"
slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id)
r.sendConnectResponse(client, req.ID)
tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID)
if errCode != "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked"))
return
}
client.tenantID = tid
slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id, "tenant_id", client.tenantID)
r.sendConnectResponse(ctx, client, req.ID)
return
}
}
// Path 3b: No token, no valid pairing → initiate browser pairing (if service available)
if ps != nil && params.Token == "" {
code, err := ps.RequestPairing(client.id, "browser", "", "default", nil)
code, err := ps.RequestPairing(ctx, client.id, "browser", "", "default", nil)
if err != nil {
slog.Warn("browser pairing request failed", "error", err, "client", client.id)
// Fall through to viewer role
@@ -190,19 +283,134 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p
client.role = permissions.RoleViewer
client.authenticated = true
client.userID = params.UserID
r.sendConnectResponse(client, req.ID)
tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID)
if errCode != "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked"))
return
}
client.tenantID = tid
r.sendConnectResponse(ctx, client, req.ID)
}
func (r *MethodRouter) sendConnectResponse(client *Client, reqID string) {
client.SendResponse(protocol.NewOKResponse(reqID, map[string]any{
"protocol": protocol.ProtocolVersion,
"role": string(client.role),
"user_id": client.userID,
func (r *MethodRouter) sendConnectResponse(ctx context.Context, client *Client, reqID string) {
resp := map[string]any{
"protocol": protocol.ProtocolVersion,
"role": string(client.role),
"user_id": client.userID,
"tenant_id": client.tenantID.String(),
"cross_tenant": client.crossTenant,
"server": map[string]any{
"name": "goclaw",
"version": "0.2.0",
},
}))
}
// Enrich with tenant name/slug if tenant store available and tenant is set
if r.tenantStore != nil && client.tenantID != uuid.Nil {
if t, err := r.tenantStore.GetTenant(ctx, client.tenantID); err == nil && t != nil {
resp["tenant_name"] = t.Name
resp["tenant_slug"] = t.Slug
client.tenantName = t.Name
client.tenantSlug = t.Slug
}
}
client.SendResponse(protocol.NewOKResponse(reqID, resp))
}
// isOwnerID checks if the given user ID is in the configured owner list.
// If no owner IDs configured, only "system" is treated as owner (fail-closed).
func isOwnerID(userID string, ownerIDs []string) bool {
if userID == "" {
return false
}
if len(ownerIDs) == 0 {
return userID == "system"
}
for _, id := range ownerIDs {
if id == userID {
return true
}
}
return false
}
// resolveTenantHint resolves a tenant slug/UUID hint to a UUID with membership validation.
// Non-admin users must be a member of the tenant.
// Returns (uuid.Nil, ErrTenantAccessRevoked) when the user is not a member of the requested tenant.
// Returns (MasterTenantID, "") when no hint is provided.
func (r *MethodRouter) resolveTenantHint(ctx context.Context, hint, userID string) (uuid.UUID, string) {
if hint == "" || r.tenantStore == nil {
return store.MasterTenantID, ""
}
t, err := r.tenantStore.GetTenantBySlug(ctx, hint)
if err != nil || t == nil {
slog.Debug("tenant_hint not resolved, falling back to master", "hint", hint)
return store.MasterTenantID, ""
}
// Validate membership: user must belong to the requested tenant.
// Deny tenant access for anonymous users (no userID) — fail-closed.
if userID == "" {
slog.Warn("security.tenant_hint_denied_anonymous", "hint", hint, "tenant_id", t.ID)
return uuid.Nil, protocol.ErrTenantAccessRevoked
}
role, err := r.getUserTenantRole(ctx, t.ID, userID)
if err != nil || role == "" {
slog.Warn("security.tenant_access_revoked",
"hint", hint, "user", userID, "tenant_id", t.ID, "error", err)
return uuid.Nil, protocol.ErrTenantAccessRevoked
}
return t.ID, ""
}
// getUserTenantRole returns the user's role in a tenant, using permission cache if available.
func (r *MethodRouter) getUserTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) {
// Check cache first
if r.permCache != nil {
if role, ok := r.permCache.GetTenantRole(ctx, tenantID, userID); ok {
slog.Debug("perm_cache.tenant_role.hit", "tenant", tenantID, "user", userID, "role", role)
return role, nil
}
slog.Debug("perm_cache.tenant_role.miss", "tenant", tenantID, "user", userID)
}
// Fallback to DB
role, err := r.tenantStore.GetUserRole(ctx, tenantID, userID)
if err != nil {
return "", err
}
// Cache the result (including empty role = not a member)
if r.permCache != nil {
r.permCache.SetTenantRole(ctx, tenantID, userID, role)
}
return role, nil
}
// applyTenantScope narrows a cross-tenant client's data scope to a specific tenant.
// Client stays crossTenant=true (retains admin privileges) but tenantID is set
// so the router injects WithTenantID instead of WithCrossTenant for data filtering.
// Accepts both UUID and slug values.
func (r *MethodRouter) applyTenantScope(ctx context.Context, client *Client, tenantVal string) {
if tenantVal == "" || r.tenantStore == nil {
return
}
// Try UUID first, then slug
var t *store.TenantData
var err error
if tid, parseErr := uuid.Parse(tenantVal); parseErr == nil {
t, err = r.tenantStore.GetTenant(ctx, tid)
} else {
t, err = r.tenantStore.GetTenantBySlug(ctx, tenantVal)
}
if err != nil || t == nil {
slog.Debug("tenant scope not resolved, keeping unscoped", "value", tenantVal)
return
}
client.tenantID = t.ID
// Keep crossTenant=true so client retains admin role + tenant admin access
slog.Info("tenant scope applied", "client", client.id, "tenant", t.Slug, "tenant_id", t.ID)
}
func (r *MethodRouter) handleHealth(ctx context.Context, client *Client, req *protocol.RequestFrame) {
@@ -266,7 +474,7 @@ func (r *MethodRouter) handleStatus(ctx context.Context, client *Client, req *pr
sessionCount := 0
if r.server.sessions != nil {
sessionCount = len(r.server.sessions.List(""))
sessionCount = len(r.server.sessions.List(ctx, ""))
}
// Agents are lazily resolved — router only has loaded agents.
+18 -21
View File
@@ -43,15 +43,14 @@ type Server struct {
tracesHandler *httpapi.TracesHandler // LLM trace listing API
wakeHandler *httpapi.WakeHandler // external wake/trigger API
mcpHandler *httpapi.MCPHandler // MCP server management API
customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API
channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API
providersHandler *httpapi.ProvidersHandler // provider CRUD API
delegationsHandler *httpapi.DelegationsHandler // delegation history API
teamEventsHandler *httpapi.TeamEventsHandler // team event history API
teamAttachmentsHandler *httpapi.TeamAttachmentsHandler // team attachment download API
builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API
pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API
secureCLIHandler *httpapi.SecureCLIHandler // secure CLI credential CRUD API
mcpUserCredsHandler *httpapi.MCPUserCredentialsHandler // MCP per-user credentials API
packagesHandler *httpapi.PackagesHandler // runtime package management API
memoryHandler *httpapi.MemoryHandler // memory management API
kgHandler *httpapi.KnowledgeGraphHandler // knowledge graph API
@@ -64,6 +63,7 @@ type Server struct {
usageHandler *httpapi.UsageHandler // usage analytics API
apiKeysHandler *httpapi.APIKeysHandler // API key management
apiKeyStore store.APIKeyStore // for API key auth lookup
tenantsHandler *httpapi.TenantsHandler // tenant management API
docsHandler *httpapi.DocsHandler // OpenAPI spec + Swagger UI
agentStore store.AgentStore // for context injection in tools_invoke
msgBus *bus.MessageBus // for MCP bridge media delivery
@@ -207,10 +207,9 @@ func (s *Server) BuildMux() *http.ServeMux {
if s.mcpHandler != nil {
s.mcpHandler.RegisterRoutes(mux)
}
// Custom tool CRUD API
if s.customToolsHandler != nil {
s.customToolsHandler.RegisterRoutes(mux)
// MCP per-user credentials API
if s.mcpUserCredsHandler != nil {
s.mcpUserCredsHandler.RegisterRoutes(mux)
}
// Secure CLI credential CRUD API
@@ -228,10 +227,6 @@ func (s *Server) BuildMux() *http.ServeMux {
s.providersHandler.RegisterRoutes(mux)
}
// Delegation history API
if s.delegationsHandler != nil {
s.delegationsHandler.RegisterRoutes(mux)
}
// Team event history API
if s.teamEventsHandler != nil {
@@ -287,6 +282,11 @@ func (s *Server) BuildMux() *http.ServeMux {
s.apiKeysHandler.RegisterRoutes(mux)
}
// Tenant management API
if s.tenantsHandler != nil {
s.tenantsHandler.RegisterRoutes(mux)
}
if s.activityHandler != nil {
s.activityHandler.RegisterRoutes(mux)
}
@@ -485,10 +485,8 @@ func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler =
func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.wakeHandler = h }
// SetMCPHandler sets the MCP server management handler.
func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h }
// SetCustomToolsHandler sets the custom tool CRUD handler.
func (s *Server) SetCustomToolsHandler(h *httpapi.CustomToolsHandler) { s.customToolsHandler = h }
func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h }
func (s *Server) SetMCPUserCredentialsHandler(h *httpapi.MCPUserCredentialsHandler) { s.mcpUserCredsHandler = h }
// SetChannelInstancesHandler sets the channel instance CRUD handler.
func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) {
@@ -498,9 +496,6 @@ func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler)
// SetProvidersHandler sets the provider CRUD handler.
func (s *Server) SetProvidersHandler(h *httpapi.ProvidersHandler) { s.providersHandler = h }
// SetDelegationsHandler sets the delegation history handler.
func (s *Server) SetDelegationsHandler(h *httpapi.DelegationsHandler) { s.delegationsHandler = h }
// SetTeamEventsHandler sets the team event history handler.
func (s *Server) SetTeamEventsHandler(h *httpapi.TeamEventsHandler) { s.teamEventsHandler = h }
@@ -531,6 +526,9 @@ func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h }
// SetAPIKeysHandler sets the API key management handler.
func (s *Server) SetAPIKeysHandler(h *httpapi.APIKeysHandler) { s.apiKeysHandler = h }
// SetTenantsHandler sets the tenant management handler.
func (s *Server) SetTenantsHandler(h *httpapi.TenantsHandler) { s.tenantsHandler = h }
// SetAPIKeyStore sets the API key store for token-based auth lookup.
func (s *Server) SetAPIKeyStore(st store.APIKeyStore) { s.apiKeyStore = st }
@@ -623,12 +621,11 @@ func (s *Server) registerClient(c *Client) {
defer s.mu.Unlock()
s.clients[c.id] = c
// Subscribe to bus events for this client (skip internal cache events)
// Subscribe to bus events with per-user/team filtering.
s.eventPub.Subscribe(c.id, func(event bus.Event) {
if strings.HasPrefix(event.Name, "cache.") {
return // internal event, don't forward to WS clients
if clientCanReceiveEvent(c, event) {
c.SendEvent(*protocol.NewEvent(event.Name, event.Payload))
}
c.SendEvent(*protocol.NewEvent(event.Name, event.Payload))
})
slog.Info("client connected", "id", c.id)
+17 -4
View File
@@ -152,10 +152,23 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) {
start := time.Now()
agentIDStr := hb.AgentID.String()
// Resolve agent key for events.
// Resolve agent to get tenant scope + display key.
// System-level lookup (cross-tenant) since ticker is a global scheduler.
sysCtx := store.WithCrossTenant(context.Background())
agentKey := agentIDStr
if ag, err := t.agents.GetByID(ctx, hb.AgentID); err == nil {
agentKey = ag.AgentKey
ag, agErr := t.agents.GetByID(sysCtx, hb.AgentID)
if agErr != nil {
slog.Warn("heartbeat.agent_not_found", "agent_id", agentIDStr, "error", agErr)
return
}
agentKey = ag.AgentKey
// Inject agent's tenant into context so all store operations
// (context files, sessions, etc.) are tenant-scoped.
if ag.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, ag.TenantID)
} else {
ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
// [1] Active hours filter.
@@ -336,7 +349,7 @@ func (t *Ticker) finishRun(ctx context.Context, hb store.AgentHeartbeat, session
// Cleanup isolated session — data is already in heartbeat_run_logs.
if hb.IsolatedSession && t.sessions != nil && sessionKey != "" {
if err := t.sessions.Delete(sessionKey); err != nil {
if err := t.sessions.Delete(ctx, sessionKey); err != nil {
slog.Debug("heartbeat.session_cleanup_failed", "session_key", sessionKey, "error", err)
}
}
+8
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strconv"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
@@ -39,6 +40,13 @@ func (h *ActivityHandler) handleList(w http.ResponseWriter, r *http.Request) {
if v := r.URL.Query().Get("actor_id"); v != "" {
opts.ActorID = v
}
// Non-admin callers may only see their own activity logs.
auth := resolveAuth(r, h.token)
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
callerID := store.UserIDFromContext(r.Context())
opts.ActorID = callerID
}
if v := r.URL.Query().Get("action"); v != "" {
opts.Action = v
}
+16 -6
View File
@@ -120,6 +120,17 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
}
req.OwnerID = userID
// Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant.
if store.IsCrossTenant(r.Context()) {
if req.TenantID == uuid.Nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "tenant_id")})
return
}
} else {
req.TenantID = store.TenantIDFromContext(r.Context())
}
if req.AgentType == "" {
req.AgentType = store.AgentTypeOpen
}
@@ -167,7 +178,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
// Start LLM summoning in background if applicable
if req.Status == store.AgentStatusSummoning {
go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description)
go h.summoner.SummonAgent(req.ID, req.TenantID, req.Provider, req.Model, description)
}
emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String())
@@ -256,14 +267,13 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
// Cascade: if status changed, broadcast so channel instances and cron jobs react.
if newStatus, ok := allowed["status"].(string); ok && newStatus != ag.Status {
if h.msgBus != nil {
h.msgBus.Broadcast(bus.Event{
Name: bus.EventAgentStatusChanged,
Payload: bus.AgentStatusChangedPayload{
bus.BroadcastForTenant(h.msgBus, bus.EventAgentStatusChanged,
store.TenantIDFromContext(r.Context()),
bus.AgentStatusChangedPayload{
AgentID: id.String(),
OldStatus: ag.Status,
NewStatus: newStatus,
},
})
})
}
}
+2 -2
View File
@@ -168,7 +168,7 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request)
return
}
go h.summoner.RegenerateAgent(id, ag.Provider, ag.Model, req.Prompt)
go h.summoner.RegenerateAgent(id, store.TenantIDFromContext(r.Context()), ag.Provider, ag.Model, req.Prompt)
emitAudit(h.msgBus, r, "agent.regenerated", "agent", id.String())
writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning})
@@ -214,7 +214,7 @@ func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) {
return
}
go h.summoner.SummonAgent(id, ag.Provider, ag.Model, description)
go h.summoner.SummonAgent(id, store.TenantIDFromContext(r.Context()), ag.Provider, ag.Model, description)
emitAudit(h.msgBus, r, "agent.resummoned", "agent", id.String())
writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning})
+7
View File
@@ -2,6 +2,7 @@ package http
import (
"context"
"log/slog"
"sync"
"time"
@@ -49,9 +50,15 @@ func (c *apiKeyCache) get(hash string) (*store.APIKeyData, permissions.Role, boo
// getOrFetch returns a cached entry or fetches from the store on cache miss.
func (c *apiKeyCache) getOrFetch(ctx context.Context, hash string) (*store.APIKeyData, permissions.Role) {
hashPrefix := hash
if len(hashPrefix) > 8 {
hashPrefix = hashPrefix[:8]
}
if key, role, ok := c.get(hash); ok {
slog.Debug("api_key_cache.hit", "hash_prefix", hashPrefix)
return key, role
}
slog.Debug("api_key_cache.miss", "hash_prefix", hashPrefix)
// Cache miss — fetch from DB
keyData, err := c.store.GetByHash(ctx, hash)
+4 -4
View File
@@ -41,10 +41,10 @@ func (m *mockAPIKeyStore) TouchLastUsed(_ context.Context, id uuid.UUID) error {
return nil
}
func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil }
func (m *mockAPIKeyStore) List(_ context.Context) ([]store.APIKeyData, error) { return nil, nil }
func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil }
func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID) error { return nil }
func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil }
func (m *mockAPIKeyStore) List(_ context.Context, _ string) ([]store.APIKeyData, error) { return nil, nil }
func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID, _ string) error { return nil }
func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID, _ string) error { return nil }
func (m *mockAPIKeyStore) getCalls() int {
m.mu.Lock()
+22 -2
View File
@@ -42,7 +42,8 @@ func (h *APIKeysHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc {
func (h *APIKeysHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
keys, err := h.apiKeys.List(r.Context())
// HTTP API key list is admin-only (adminAuth middleware), so no owner filter needed.
keys, err := h.apiKeys.List(r.Context(), "")
if err != nil {
slog.Error("api_keys.list failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "API keys")})
@@ -61,6 +62,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
Scopes []string `json:"scopes"`
ExpiresIn *int `json:"expires_in"` // seconds; nil = never
TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key)
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
@@ -95,6 +97,22 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
return
}
// Resolve tenant_id based on caller type.
var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB)
if store.IsCrossTenant(r.Context()) {
if input.TenantID != "" {
tid, err := uuid.Parse(input.TenantID)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant_id")})
return
}
tenantID = tid
}
// else: uuid.Nil stays → system-level key
} else {
tenantID = store.TenantIDFromContext(r.Context())
}
now := time.Now()
key := &store.APIKeyData{
ID: store.GenNewID(),
@@ -102,6 +120,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
Prefix: prefix,
KeyHash: hash,
Scopes: input.Scopes,
TenantID: tenantID,
CreatedBy: extractUserID(r),
CreatedAt: now,
UpdatedAt: now,
@@ -141,7 +160,8 @@ func (h *APIKeysHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.apiKeys.Revoke(r.Context(), id); err != nil {
// HTTP revoke is admin-only (adminAuth middleware), so no owner filter needed.
if err := h.apiKeys.Revoke(r.Context(), id, ""); err != nil {
slog.Error("api_keys.revoke failed", "error", err, "id", idStr)
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "API key", idStr)})
return
+1
View File
@@ -29,6 +29,7 @@ func emitAudit(msgBus *bus.MessageBus, r *http.Request, action, entityType, enti
EntityType: entityType,
EntityID: entityID,
IPAddress: r.RemoteAddr,
TenantID: store.TenantIDFromContext(r.Context()),
},
})
}
+157 -11
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
@@ -77,6 +78,8 @@ func extractAgentID(r *http.Request, model string) string {
var pkgAPIKeyCache *apiKeyCache
var pkgPairingStore store.PairingStore
var pkgTenantCache *tenantCache
var pkgOwnerIDs []string
// InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation.
// Must be called once during server startup before handling requests.
@@ -97,6 +100,42 @@ func InitPairingAuth(ps store.PairingStore) {
pkgPairingStore = ps
}
// InitOwnerIDs sets the configured owner user IDs for HTTP auth.
// Only owners get cross-tenant access with gateway token; others are tenant-scoped.
func InitOwnerIDs(ids []string) {
pkgOwnerIDs = ids
}
// isHTTPOwnerID checks if the user ID is a configured owner.
// If no owner IDs configured, only "system" is treated as owner (fail-closed).
func isHTTPOwnerID(userID string, ownerIDs []string) bool {
if userID == "" {
return false
}
if len(ownerIDs) == 0 {
return userID == "system"
}
for _, id := range ownerIDs {
if id == userID {
return true
}
}
return false
}
// InitTenantStore sets the tenant cache for HTTP auth with TTL and pubsub invalidation.
// Allows gateway token requests to scope to a specific tenant via X-GoClaw-Tenant-Id header.
func InitTenantStore(ts store.TenantStore, mb *bus.MessageBus) {
pkgTenantCache = newTenantCache(ts, 5*time.Minute)
if mb != nil {
mb.Subscribe("http-tenant-cache", func(e bus.Event) {
if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok && p.Kind == bus.CacheKindTenants {
pkgTenantCache.invalidateAll()
}
})
}
}
// ResolveAPIKey checks if the bearer token is a valid API key using the shared cache.
// Returns the key data and derived role, or nil if not found/expired/revoked.
func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permissions.Role) {
@@ -111,6 +150,9 @@ func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permis
type authResult struct {
Role permissions.Role
Authenticated bool
KeyData *store.APIKeyData // non-nil when authenticated via API key
TenantID uuid.UUID // resolved tenant; uuid.Nil for cross-tenant
CrossTenant bool // true for owner/system admin
}
// resolveAuth determines the caller's role from the request.
@@ -123,18 +165,54 @@ func resolveAuth(r *http.Request, gatewayToken string) authResult {
// Useful for handlers that also accept tokens from query params.
func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult {
// Gateway token → admin
// Only owner IDs get cross-tenant access; others are tenant-scoped.
// If no owner IDs configured, all gateway token users get cross-tenant (backward compat).
if gatewayToken != "" && tokenMatch(bearer, gatewayToken) {
return authResult{Role: permissions.RoleAdmin, Authenticated: true}
userID := extractUserID(r)
isOwner := isHTTPOwnerID(userID, pkgOwnerIDs)
res := authResult{Role: permissions.RoleAdmin, Authenticated: true, CrossTenant: isOwner}
tenantVal := r.Header.Get("X-GoClaw-Tenant-Id")
if isOwner && tenantVal != "" && pkgTenantCache != nil {
// Cross-tenant admin can narrow scope via header
if tid, err := uuid.Parse(tenantVal); err == nil {
if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil {
res.TenantID = t.ID
}
} else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil {
res.TenantID = t.ID
}
} else if !isOwner && pkgTenantCache != nil {
// Non-owner with gateway token: resolve tenant from header or fallback to master
if tenantVal != "" {
if tid, err := uuid.Parse(tenantVal); err == nil {
if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil {
res.TenantID = t.ID
}
} else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil {
res.TenantID = t.ID
}
}
if res.TenantID == uuid.Nil {
res.TenantID = store.MasterTenantID
}
}
return res
}
// API key → role from scopes
if _, role := ResolveAPIKey(r.Context(), bearer); role != "" {
return authResult{Role: role, Authenticated: true}
if keyData, role := ResolveAPIKey(r.Context(), bearer); role != "" {
res := authResult{Role: role, Authenticated: true, KeyData: keyData}
if keyData.TenantID == uuid.Nil {
res.CrossTenant = true
} else {
res.TenantID = keyData.TenantID
}
return res
}
// Browser pairing → operator (via X-GoClaw-Sender-Id header)
if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil {
paired, err := pkgPairingStore.IsPaired(senderID, "browser")
paired, err := pkgPairingStore.IsPaired(r.Context(), senderID, "browser")
if err == nil && paired {
return authResult{Role: permissions.RoleOperator, Authenticated: true}
return authResult{Role: permissions.RoleOperator, Authenticated: true, TenantID: store.MasterTenantID}
}
if err != nil {
slog.Warn("security.http_pairing_check_failed", "sender_id", senderID, "error", err)
@@ -144,7 +222,7 @@ func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult
}
// No auth configured → admin (no token = dev/single-user mode, full access)
if gatewayToken == "" {
return authResult{Role: permissions.RoleAdmin, Authenticated: true}
return authResult{Role: permissions.RoleAdmin, Authenticated: true, TenantID: store.MasterTenantID}
}
return authResult{}
}
@@ -187,16 +265,60 @@ func requireAuth(token string, minRole permissions.Role, next http.HandlerFunc)
}
ctx := store.WithLocale(r.Context(), locale)
if userID := extractUserID(r); userID != "" {
userID := extractUserID(r)
// If the API key has a bound owner, force user_id to owner regardless of header.
if auth.KeyData != nil && auth.KeyData.OwnerID != "" {
if userID != "" && userID != auth.KeyData.OwnerID {
slog.Warn("security.api_key_owner_override",
"header_user_id", userID,
"owner_id", auth.KeyData.OwnerID,
)
}
userID = auth.KeyData.OwnerID
}
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
if auth.CrossTenant && auth.TenantID != uuid.Nil {
// Cross-tenant admin with tenant scope: filter data by chosen tenant
ctx = store.WithTenantID(ctx, auth.TenantID)
slog.Debug("security.http_auth_resolved",
"path", r.URL.Path,
"role", string(auth.Role),
"tenant_scope", auth.TenantID.String(),
)
} else if auth.CrossTenant {
// Auto-scope to MasterTenantID so all operations use a concrete tenant.
ctx = store.WithTenantID(ctx, store.MasterTenantID)
slog.Debug("security.http_auth_resolved",
"path", r.URL.Path,
"role", string(auth.Role),
"cross_tenant", true,
"auto_scope", store.MasterTenantID.String(),
)
} else if auth.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, auth.TenantID)
slog.Debug("security.http_auth_resolved",
"path", r.URL.Path,
"role", string(auth.Role),
"tenant_id", auth.TenantID.String(),
)
} else {
ctx = store.WithTenantID(ctx, store.MasterTenantID)
slog.Debug("security.http_auth_resolved",
"path", r.URL.Path,
"role", string(auth.Role),
"tenant_id", store.MasterTenantID.String(),
)
}
next(w, r.WithContext(ctx))
}
}
// requireAuthBearer is like requireAuth but accepts a pre-extracted bearer token.
// Used by handlers that accept tokens from query params (files, media).
func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) bool {
// Returns the authenticated request with user context applied (owner_id enforcement included).
func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) (*http.Request, bool) {
locale := extractLocale(r)
auth := resolveAuthBearer(r, token, bearer)
@@ -204,7 +326,7 @@ func requireAuthBearer(token string, minRole permissions.Role, bearer string, w
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": i18n.T(locale, i18n.MsgUnauthorized),
})
return false
return r, false
}
required := minRole
@@ -216,9 +338,33 @@ func requireAuthBearer(token string, minRole permissions.Role, bearer string, w
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path),
})
return false
return r, false
}
return true
// Apply user context + owner_id enforcement (same as requireAuth).
ctx := store.WithLocale(r.Context(), locale)
userID := extractUserID(r)
if auth.KeyData != nil && auth.KeyData.OwnerID != "" {
if userID != "" && userID != auth.KeyData.OwnerID {
slog.Warn("security.api_key_owner_override",
"header_user_id", userID,
"owner_id", auth.KeyData.OwnerID,
"path", r.URL.Path,
)
}
userID = auth.KeyData.OwnerID
}
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
if auth.CrossTenant {
ctx = store.WithTenantID(ctx, store.MasterTenantID)
} else if auth.TenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, auth.TenantID)
} else {
ctx = store.WithTenantID(ctx, store.MasterTenantID)
}
return r.WithContext(ctx), true
}
// extractLocale parses the Accept-Language header and returns a supported locale.
Loaded 100 of 417 files, more files were not shown because too many files have changed in this diff. Show more