* fix(worker): treat empty LLM response after text output as completion
When a job's LLM produces a substantive text response (e.g., formatted
results from a routine) and the next LLM call returns empty or errors,
the worker now treats this as successful completion instead of
continuing the loop until failure.
Previously, empty responses always triggered TextAction::Continue,
causing the loop to re-call the LLM. The LLM had nothing more to say,
so the provider returned "Response contained no message or tool call
(empty)". This made routine jobs that successfully produced results
report as "failed".
The fix adds a `has_text_response` flag to JobDelegate:
- After any non-empty text response: flag is set
- Empty text after flag is set: treated as completion
- LLM errors (select_tools/respond_with_tools) after flag: treated
as completion instead of propagating
- Empty text before any output: still retries (rate-limit backoff)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(worker): restrict error swallowing to EmptyResponse variant only
- Add LlmError::EmptyResponse variant for when LLM returns no content
- Update nearai_chat and github_copilot providers to emit EmptyResponse
instead of InvalidResponse for empty/no-choice responses
- try_complete_on_error now only swallows EmptyResponse (not AuthFailed,
ContextLengthExceeded, Http, Io, etc.)
- Extract is_completion_eligible_error as testable pure function
- Log mark_completed errors at warn level instead of silently dropping
- Add EmptyResponse to retry and circuit breaker transient classifications
- Rewrite test to exercise real classification logic against all variants
Addresses review feedback from zmanian and gemini-code-assist.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* refactor(worker): extract mark_completed_or_warn helper to DRY completion logic
Extract shared mark-completed + warn-on-failure pattern into a single
helper method used by both try_complete_on_error and handle_text_response.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: j-bloggs <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
- Add pty-process crate (MIT, tokio async support) for PTY allocation
- Spawn claude CLI with pty-process::Command::arg() chaining instead of
building a shell string for script -qfc
- Eliminates all shell injection surfaces: prompt, model, session_id
are passed via execve, never interpreted by a shell
- Keep stderr on separate pipe to prevent NDJSON parse breakage
(pty-process attaches PTY to all fds by default)
- Gate PTY behind #[cfg(unix)] with direct-spawn fallback for Windows CI
- Read stdout from PTY master (implements tokio::io::AsyncRead)
- Add regression tests: arg vector construction + PTY allocation
Addresses review feedback from zmanian and gemini-code-assist.
Co-authored-by: j-bloggs <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
- Implement broadcast_dm() that creates a DM channel with the target
user (POST /users/@me/channels, cached by Discord) and sends the
message to it
- Extract DISCORD_API_BASE constant for all Discord REST API URLs
- Extract send_channel_message() shared helper to deduplicate message
posting between on_respond and broadcast_dm
- Add snowflake validation on user_id before API calls
- Fix pre-existing clippy redundant_closure warning
- Use typed DmChannelResponse struct instead of serde_json::Value
Closes no specific issue — completes the previously stubbed on_broadcast.
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* feat: complete multi-tenant isolation — per-user budgets, model selection, heartbeat cycling
Finishes the remaining isolation work from phases 2–4 of #59:
Phase 2 (DB scoping): Fix /status and /list commands to use _for_user
DB variants instead of global queries that leaked cross-user job data.
Phase 3 (Runtime isolation): Per-user workspace in routine engine's
spawn_fire so lightweight routines run in the correct user context.
Per-user daily cost tracking in CostGuard with configurable budget via
MAX_COST_PER_USER_PER_DAY_CENTS. Multi-user heartbeat that cycles
through all users with routines, auto-detected from GATEWAY_USER_TOKENS.
Phase 4 (Provider/tools): Per-user model selection via preferred_model
setting — looked up from SettingsStore on first iteration, threaded
through ReasoningContext.model_override to CompletionRequest. Works
with providers that support per-request model overrides (NearAI).
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: use selected_model setting key to match /model command persistence
The dispatcher was reading "preferred_model" but the /model command
(merged from staging) persists to "selected_model". Since set_setting
is already per-user scoped, using the same key makes /model work as
the per-user model override in multi-tenant mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: heartbeat hygiene, /model multi-tenant guard, RigAdapter model override
Three follow-up fixes for multi-tenant isolation:
1. Multi-user heartbeat now runs memory hygiene per user before each
heartbeat check, matching single-user heartbeat behavior.
2. /model command in multi-tenant mode only persists to per-user
settings (selected_model) without calling set_model() on the shared
LlmProvider. The per-request model_override in the dispatcher reads
from the same setting. Added multi_tenant flag to AgentConfig
(auto-detected from GATEWAY_USER_TOKENS).
3. RigAdapter now supports per-request model overrides by injecting the
model name into rig-core's additional_params. OpenAI/Anthropic/Ollama
API servers use last-key-wins for duplicate JSON keys, so the override
takes effect via serde's flatten serialization order.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address PR review — cost model attribution, heartbeat concurrency, pruning
Fixes from review comments on #1614:
- Cost tracking now uses the override model name (not active_model_name)
when a per-user model override is active, for accurate attribution.
- Multi-user heartbeat runs per-user checks concurrently via JoinSet
instead of sequentially, preventing one slow user from blocking others.
- Per-user failure counts tracked independently; users exceeding
max_failures are skipped (matching single-user semantics).
- per_user_daily_cost HashMap pruned on day rollover to prevent
unbounded growth in long-lived deployments.
- Doc comment fixed: says "routines" not "active routines".
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: /status ownership, model persistence scoping, heartbeat robustness
Addresses second round of PR review on #1614:
- /status <job_id> DB path now validates job.user_id == requesting user
before returning data (was missing ownership check, security fix).
- persist_selected_model takes user_id param instead of owner_id, and
skips .env/TOML writes in multi-tenant mode (these are shared global
files). handle_system_command now receives user_id from caller.
- JoinSet collection handles Err(JoinError) explicitly instead of
silently dropping panicked tasks.
- Notification forwarder extracts owner_id from response metadata in
multi-tenant mode for per-user routing instead of broadcasting to
the agent owner.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: cost pricing, fire_manual workspace, heartbeat concurrency cap
Round 3 review fixes:
- Cost tracking passes None for cost_per_token when model override is
active, letting CostGuard look up pricing by model name instead of
using the default provider's rates (serrrfirat).
- fire_manual() now uses per-user workspace, matching spawn_fire()
pattern (serrrfirat).
- Removed MULTI_TENANT env var — multi-tenant mode is auto-detected
solely from GATEWAY_USER_TOKENS presence (serrrfirat + Copilot).
- Multi-user heartbeat capped at 8 concurrent tasks to avoid flooding
the LLM provider (serrrfirat + Copilot).
- Fixed inject_model_override doc comment accuracy (Copilot).
- Added comment explaining multi-tenant notification routing priority
(Copilot).
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: user-scoped webhook endpoint for multi-tenant isolation
Adds POST /api/webhooks/u/{user_id}/{path} — a user-scoped webhook
endpoint that filters the routine lookup by user_id, preventing
cross-user webhook triggering when paths collide.
The existing /api/webhooks/{path} endpoint remains unchanged for
backward compatibility in single-user deployments.
Changes:
- get_webhook_routine_by_path gains user_id: Option<&str> param
- Both postgres and libsql implementations add AND user_id = ? filter
when user_id is provided
- New webhook_trigger_user_scoped_handler extracts (user_id, path)
from URL and passes to shared fire_webhook_inner logic
- Route registered on public router (webhooks are called by external
services that can't send bearer tokens)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat(db): add UserStore trait with users, api_tokens, invitations tables
Foundation for DB-backed user management (#1605):
- UserRecord, ApiTokenRecord, InvitationRecord types in db/mod.rs
- UserStore sub-trait (17 methods) added to Database supertrait
- PostgreSQL migration V14__users.sql (users, api_tokens, invitations)
- libSQL schema + incremental migration V14
- Full implementations for both PgBackend (via Store delegation) and
LibSqlBackend (direct SQL in libsql/users.rs)
- authenticate_token JOINs api_tokens+users with active/non-revoked
checks; has_any_users for bootstrap detection
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat(web): DB-backed auth, user/token/invitation API handlers
Adds the web gateway layer for DB-backed user management (#1605):
Auth refactor:
- CombinedAuthState wraps env-var tokens (MultiAuthState) + optional
DbAuthenticator for DB-backed token lookup with LRU cache (60s TTL,
1024 max entries)
- auth_middleware tries env-var tokens first, then DB fallback
- From<MultiAuthState> impl for backward compatibility
- main.rs wires with_db_auth when database is available
API handlers (12 new endpoints):
- /api/admin/users — CRUD: create, list, detail, update, suspend, activate
- /api/tokens — create (returns plaintext once), list, revoke
- /api/invitations — create, list, accept (creates user + first token)
Token creation: 32 random bytes → hex plaintext, SHA-256 hash stored.
Invitation accept: validates hash + pending + not expired, creates
user record and first API token atomically.
All test files updated for CombinedAuthState type change.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: startup env-var user migration + UserStore integration tests
Completes the DB-backed user management feature (#1605):
- Startup migration: when GATEWAY_USER_TOKENS is set and the users
table is empty, inserts env-var users + hashed tokens into DB.
Logs deprecation notice when DB already has users.
- hash_token made pub for reuse in migration code.
- 10 integration tests for UserStore (libsql file-backed):
- has_any_users bootstrap detection
- create/get/get_by_email/list/update user lifecycle
- token create → authenticate → revoke → reject cycle
- suspended user tokens rejected
- wrong-user token revoke returns false
- invitation create → accept → user created
- record_login and record_token_usage timestamps
- libSQL migration: removed FK constraints from V14 (incompatible
with execute_batch inside transactions). Tables in both base SCHEMA
and incremental migration for fresh and existing databases.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* refactor: remove GATEWAY_USER_TOKENS, fix review feedback
GATEWAY_USER_TOKENS never went to production — replaced entirely by
DB-backed user management via /api/admin/users and /api/tokens.
Removed:
- UserTokenConfig struct and GATEWAY_USER_TOKENS env var parsing
- user_tokens field from GatewayConfig
- GatewayChannel::new_multi_auth() constructor
- Env-var user migration block in main.rs (~90 lines)
- multi_tenant auto-detection from GATEWAY_USER_TOKENS (now runtime
via db.has_any_users() in app.rs)
Review fixes (zmanian):
- User ID generation: UUID instead of display-name derivation (#1)
- Invitation accept moved to public router (no auth needed) (#3)
- libSQL get_invitation_by_hash aligned with postgres: filters
status='pending' AND expires_at > now (#4)
- UUID parse: returns DatabaseError::Serialization instead of
unwrap_or_default (#7)
- PostgreSQL SELECT * replaced with explicit column lists (#8)
- Sort order aligned (both backends use DESC) (#6)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: add role-based access control (admin/member)
Adds a `role` field (admin|member) to user management:
Schema:
- `role TEXT NOT NULL DEFAULT 'member'` added to users table in both
PostgreSQL V14 migration and libSQL schema/incremental migration
- UserRecord gains `role: String` field
- UserIdentity gains `role: String` field, populated from DB in
DbAuthenticator and defaulting to "admin" for single-user mode
Access control:
- AdminUser extractor: returns 403 Forbidden if role != "admin"
- /api/admin/users/* handlers: require AdminUser (create, list,
detail, update, suspend, activate)
- POST /api/invitations: requires AdminUser (only admins can invite)
- User creation accepts optional "role" param (defaults to "member")
- Invitation acceptance creates users with "member" role
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat(web): add Users admin tab to web UI
Adds a Users tab to the web gateway UI for managing users, tokens,
and roles without needing direct API calls.
Features:
- User list table with ID, name, email, role, status, created date
- Create user form with display name, email, role selector
- Suspend/activate actions per user
- Create API token for any user (shows plaintext once with copy button)
- Role badges (admin highlighted, member muted)
- Non-admin users see "Admin access required" message
- Keyboard shortcut: Cmd/Ctrl+5 switches to Users tab
CSS:
- Reuses routines-table styles for the user list
- Badge, token-display, btn-small, btn-danger, btn-primary components
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: move Users to Settings subtab, bootstrap admin user on first run
- Moved Users from top-level tab to Settings sidebar subtab (under
Skills, before Theme toggle)
- On first startup with empty users table, automatically creates an
admin user from GATEWAY_USER_ID config with a corresponding API
token from GATEWAY_AUTH_TOKEN. This ensures the owner appears in
the Users panel immediately.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: user creation shows token, + Token works, no password save popup
Three UI/UX fixes:
1. Create user now generates an initial API token and shows it in a
copy-able banner instead of triggering the browser's password save
dialog. Uses autocomplete="off" and type="text" for email field.
2. "+ Token" button works: exposed createTokenForUser/suspendUser/
activateUser on window for inline onclick handlers in dynamically
generated table rows. Token creation uses showTokenBanner helper.
3. Admin token creation: POST /api/tokens now accepts optional
"user_id" field when the requesting user is admin, allowing
token creation for other users from the Users panel.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: use event delegation for user action buttons (CSP compliance)
Inline onclick handlers are blocked by the Content-Security-Policy
(script-src 'self' without 'unsafe-inline'). Switched to data-action
attributes with a delegated click listener on the users table.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: add i18n for Users subtab, show login link on user creation
- Added 'settings.users' i18n key for English and Chinese
- Token banner now shows a full login link (domain/?token=xxx)
with a Copy Link button, plus the raw token below
- Login link works automatically via existing ?token= auto-auth
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: token hash mismatch — hash hex string, not raw bytes
Critical auth bug: token creation hashed the raw 32 bytes
(hasher.update(token_bytes)) but authentication hashed the hex-encoded
string (hash_token(candidate) where candidate is the hex string the
user sends). This meant newly created tokens could never authenticate.
Fixed all 4 token creation sites (users, tokens, invitations create,
invitations accept) to use hash_token(&plaintext_token) which hashes
the hex string consistently with the auth lookup path.
Removed now-unused sha2::Digest imports from handlers.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* refactor: remove invitation system
The invitation flow is redundant — admin create user already generates
a token and shows a login link. Invitations add complexity without
value until email integration exists.
Removed:
- InvitationRecord struct and 4 UserStore trait methods
- invitations table from V14 migration (postgres + both libsql schemas)
- PostgreSQL Store methods (create/get/accept/list invitations)
- libSQL UserStore invitation methods + row_to_invitation helper
- invitations.rs handler file (212 lines)
- /api/invitations routes (create, list, accept)
- test_invitation_lifecycle test
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: user deletion, self-service profile, per-user job limits, usage API
Four multi-tenancy improvements:
1. User deletion cascade (DELETE /api/admin/users/{id}):
Deletes user and all data across 11 user-scoped tables (settings,
secrets, routines, memory, jobs, conversations, etc.). Admin only.
2. Self-service profile (GET/PATCH /api/profile):
Users can read and update their own display_name and metadata
without admin privileges.
3. Per-user job concurrency (MAX_JOBS_PER_USER env var):
Scheduler checks active_jobs_for(user_id) before dispatch.
Prevents one user from exhausting all job slots.
4. Usage reporting (GET /api/admin/usage?user_id=X&period=day|week|month):
Aggregates LLM costs from llm_calls via agent_jobs.user_id.
Returns per-user, per-model breakdown of calls, tokens, and cost.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: add TenantCtx for compile-time tenant isolation
Implements zmanian's architectural proposal from #1614 review:
two-tier scoped database access (TenantScope/AdminScope) so handler
code cannot accidentally bypass tenant scoping.
TenantScope (default): wraps user_id + Arc<dyn Database>, auto-binds
user_id on every operation. ID-based lookups return None for cross-
tenant resources. No escape hatch — forgetting to scope is a compile
error.
AdminScope (explicit opt-in): cross-tenant access for system-level
components (heartbeat, routine engine, self-repair, scheduler, worker).
TenantCtx bundles TenantScope + workspace + cost guard + per-user
rate limiting. Constructed once per request in handle_message, threaded
through all command handlers and ChatDelegate.
Key changes:
- New src/tenant.rs (~920 lines): TenantScope, AdminScope, TenantCtx,
TenantRateState, TenantRateRegistry
- All command handlers: user_id: &str → ctx: &TenantCtx
- ChatDelegate: cost check/record/settings via self.tenant
- System components: store field changed to AdminScope
- Config: TENANT_MAX_LLM_CONCURRENT, TENANT_MAX_JOBS_CONCURRENT env vars
- Fixes bug: /status <job_id> cross-tenant leak (now auto-filtered)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address PR #1626 review feedback — bounded LRU cache, admin auth, FK cleanup
- Replace HashMap with lru::LruCache in DbAuthenticator so the token
cache is hard-bounded at 1024 entries (evicts LRU, not just expired)
- Gate admin user endpoints (list/detail/update/suspend/activate) with
AdminUser extractor so members get 403 instead of full access
- Add api_tokens to libSQL delete_user cleanup list to prevent orphaned
tokens (libSQL has no FK cascade)
- Add regression tests for all three fixes
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: update CA certificates in runtime Docker image
Ensures the root certificate bundle is current so TLS handshakes
to services like Supabase succeed on Railway.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: resolve CI failures — formatting, no-panics check
- Run cargo fmt on test code
- Replace .expect() with const NonZeroUsize in DbAuthenticator
- Add // safety: comments for test-only code in multi_tenant.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: switch PostgreSQL TLS from rustls to native-tls
rustls with rustls-native-certs fails TLS handshake on Railway's
slim container (empty or stale root cert store). native-tls delegates
to OpenSSL on Linux which handles system certs more reliably.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* Adding user management api
* feat: admin secrets provisioning API + API documentation
- Add PUT/GET/DELETE /api/admin/users/{id}/secrets/{name} endpoints for
application backends to provision per-user secrets (AES-256-GCM encrypted)
- Add secrets_store field to GatewayState with builder wiring
- Create docs/USER_MANAGEMENT_API.md with full API spec covering users,
secrets, tokens, profile, and usage endpoints
- Update web gateway CLAUDE.md route table
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: add CatchPanicLayer to capture handler panics
Without this, panics in async handlers silently drop the connection
and the edge proxy returns a generic 503. Now panics are caught,
logged, and returned as 500 with the panic message.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address second-round review — transactional delete, overflow, error logging
- C1: Wrap PostgreSQL delete_user() in a transaction so partial cleanup
can't leave users in a half-deleted state
- M2: Add job_events to delete cleanup (both backends) — FK to
agent_jobs without CASCADE would cause FK violation
- H1/M4: Cap expires_in_days to 36500 before i64 cast (tokens + secrets)
- H2: Validate target user exists before creating admin token to prevent
orphan tokens on libSQL
- H3: Log DB errors in DbAuthenticator::authenticate() instead of
silently swallowing them as 401
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: revert to rustls with webpki-roots fallback for PostgreSQL TLS
native-tls/OpenSSL caused silent crashes (segfaults in C code) during
DB writes on Railway containers. Switch back to rustls but add
webpki-roots as a fallback when system certs are missing, which was
the original TLS handshake failure on slim container images.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* chore: update Cargo.lock for rustls + webpki-roots
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* debug: add /api/debug/db-write endpoint to diagnose user insert failure
Temporary diagnostic endpoint that tests DB INSERT to users table
with full error logging. No auth required. Will be removed after
debugging.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* perf: use cargo-chef in Dockerfile for dependency caching
Splits the build into planner/deps/builder stages. Dependencies are
only recompiled when Cargo.toml or Cargo.lock change. Source-only
changes skip straight to the final build stage.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* debug: add tracing to users_create_handler
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: guard created_by FK in user creation handler
The auth identity user_id (from owner_id scope) may not match any
user row in the DB, causing a FK violation on the created_by column.
Check that the referenced user exists before setting created_by.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* refactor: collapse GATEWAY_USER_ID into IRONCLAW_OWNER_ID
Remove the separate GATEWAY_USER_ID config. The gateway now uses
IRONCLAW_OWNER_ID (config.owner_id) directly for auth identity,
bootstrap user creation, and workspace scoping.
Previously, with_owner_scope() rebinds the auth identity to owner_id
while keeping default_sender_id as the gateway user_id. This caused
a FK constraint violation when creating users because the auth
identity ("default") didn't match any user in the DB ("nearai").
Changes:
- Remove GATEWAY_USER_ID env var and gateway_user_id from settings
- Remove user_id field from GatewayConfig
- Add owner_id parameter to GatewayChannel::new()
- Remove with_owner_scope() method
- Remove default_sender_id from GatewayState
- Remove sender override logic in chat/approval handlers
- Remove debug endpoint and tracing from prior debugging
- Update all tests and E2E fixtures
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: hide Users tab for non-admins, remove auth hint text
- Fetch /api/profile after login and hide the Users settings tab
when the user's role is not admin
- Remove the "Enter the GATEWAY_AUTH_TOKEN" hint from the login page
since tokens are now managed via the admin panel, not .env files
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address review feedback (auth 503, token expiry, CORS PATCH)
- DB auth errors now return 503 instead of 401 so outages are
distinguishable from invalid tokens (serrrfirat H3)
- Cap expires_in_days to 36500 before i64 cast to prevent negative
duration from u64 overflow (serrrfirat H1)
- Add PATCH to CORS allowed methods for profile/user update
endpoints (Copilot)
- Stop leaking panic details in CatchPanicLayer response body
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: harden multi-tenant isolation — review fixes from #1614
- Add conversation ownership checks in TenantScope: add_conversation_message,
touch_conversation, list_conversation_messages (+ paginated),
update_conversation_metadata_field, get_conversation_metadata now return
NotFound for conversations not owned by the tenant (cross-tenant data leak)
- Fix multi-user heartbeat: clear notify_user_id per runner so notifications
persist to the correct user, not the shared config target
- Move hygiene tasks into bounded JoinSet instead of unbounded tokio::spawn
- Revert send_notification to private visibility (only used within module)
- Use effective_model_name() for cost attribution in dispatcher so providers
that ignore per-request model overrides report the actual model used
- Fix inject_model_override doc comment; add 3 unit tests
- Fix heartbeat doc comment ("routines" not "active routines")
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: add Jobs, Cost, Last Active columns to admin Users table
Add UserSummaryStats struct and user_summary_stats() batch query to the
UserStore trait (both PostgreSQL and libSQL backends). The admin users
list endpoint now fetches per-user aggregates (job count, total LLM
spend, most recent activity) in a single query and includes them inline
in the response. The frontend Users table displays three new columns.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address review comments and CI formatting failures
CI fixes:
- cargo fmt fixes in cli/mod.rs and db/tls.rs
Security/correctness (from Copilot + serrrfirat + pranavraja99 reviews):
- Token create: reject expires_in_days > 36500 with 400 instead of silent clamp
- Token create: return 404 when admin targets non-existent user
- User create: map duplicate email constraint violations to 409 Conflict
- User create: remove unnecessary DB roundtrip for created_by (use AdminUser directly)
- DB auth: log warn on DB lookup failures instead of silently swallowing errors
- libSQL: add FK constraints on users.created_by and api_tokens.user_id
Config fixes:
- agent.multi_tenant: resolve from AGENT_MULTI_TENANT env var instead of hardcoding false
- heartbeat.multi_tenant: fix doc comment to match actual env-var-based behavior
UI fix:
- showTokenBanner: pass correct title ("Token created!" vs "User created!")
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address remaining review comments (round 2)
- Secrets handlers: normalize name to lowercase before store operations,
validate target user_id exists (returns 404 if not found)
- libSQL: propagate cost parsing errors instead of unwrap_or_default()
in both user_usage_stats and user_summary_stats
- users_list_handler: propagate user_summary_stats DB errors (was
silently swallowed with unwrap_or_default)
- loadUsers: distinguish 401/403 (admin required) from other errors
- Docs: fix users.id type (TEXT not UUID), remove "invitation flow"
from V14 migration comment
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: i18n for Users tab, atomic user+token creation, transactional delete_user
i18n:
- Add 31 translation keys for all Users tab strings (en + zh-CN)
- Wire data-i18n attributes on HTML elements (headings, buttons, inputs,
table headers, empty state)
- Replace all hard-coded strings in app.js with I18n.t() calls
Atomic user+token creation:
- Add create_user_with_token() to UserStore trait
- PostgreSQL: wraps both INSERTs in conn.transaction() with auto-rollback
- libSQL: wraps in explicit BEGIN/COMMIT with ROLLBACK on error
- Handler uses single atomic call instead of two separate operations
Transactional delete_user for libSQL:
- Wrap multi-table DELETE cascade in BEGIN/COMMIT transaction
- ROLLBACK on any error to prevent partial cleanup / inconsistent state
- Matches the PostgreSQL implementation which already used transactions
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: revert V14 migration to match deployed checksum [skip-regression-check]
Refinery checksums applied migrations — editing V14__users.sql after
it was already applied causes deployment failures. Revert the cosmetic
comment changes (added in df40b22f) to restore the original checksum.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: bootstrap onboarding flow for multi-tenant users
The bootstrap greeting and workspace seeding only ran for the owner
workspace at startup, so new users created via the admin API never
received the welcome message or identity files (BOOTSTRAP.md, SOUL.md,
AGENTS.md, USER.md, etc.).
Three fixes:
- tenant_ctx(): seed per-user workspace on first creation via
seed_if_empty(), which writes identity files and sets
bootstrap_pending when the workspace is truly fresh
- handle_message(): check take_bootstrap_pending() on the tenant
workspace (not the owner workspace) and persist the greeting to
the user's own assistant conversation + broadcast via SSE
- WorkspacePool: seed new per-user workspaces in the web gateway
so memory tools also see identity files immediately
The existing single-user bootstrap in Agent::run() is preserved for
non-multi-tenant deployments.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address remaining PR review comments (round 3)
- Docs: fix metadata description from "merge patch" to "full replacement"
- Secrets: reject expires_in_days > 36500 with 400 (was silently clamped)
- libSQL: CAST(SUM(cost) AS TEXT) in user_usage_stats and user_summary_stats
to prevent SQLite numeric coercion from crashing get_text() — this was
the root cause of the Copilot "SUM returns numeric type" comments
- Add 3 regression tests: user_summary_stats (empty + with data) and
user_usage_stats (multi-model aggregation)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: add role change support for users (admin/member toggle)
- Add update_user_role() to UserStore trait + both backends (PostgreSQL
and libSQL)
- Extend PATCH /api/admin/users/{id} to accept optional "role" field
with validation (must be "admin" or "member")
- Add "Make Admin" / "Make Member" toggle button in Users table actions
- Add i18n keys for role change (en + zh-CN)
- Update API docs to document the role field on PATCH
- Fix test helpers to use fmt_ts() for timestamps (was using SQLite
datetime('now') which produces incompatible format for string comparison)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: show live LLM spend in Users table instead of only DB-recorded costs [skip-regression-check]
Chat turns record LLM cost in CostGuard (in-memory) but don't create
agent_jobs/llm_calls DB rows — those are only written for background
jobs. The Users table was querying only from DB, so it showed $0.00
for users who only chatted.
Now supplements DB stats with CostGuard.daily_spend_for_user() —
the same source displayed in the status bar token counter. Shows
whichever is larger (DB historical total vs live daily spend).
Also falls back to last_login_at for "Last Active" when no DB job
activity exists.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: persist chat LLM calls to DB and fix usage stats query
Two root causes for zero usage stats:
1. ChatDelegate only recorded LLM costs to CostGuard (in-memory) —
never to the llm_calls DB table. Added DB persistence via
TenantScope.record_llm_call() after each chat LLM call, with
job_id=NULL and conversation_id=thread_id.
2. user_summary_stats query only joined agent_jobs→llm_calls, missing
chat calls (which have job_id=NULL). Redesigned query to start from
llm_calls and resolve user_id via COALESCE(agent_jobs.user_id,
conversations.user_id) — covers both job and chat LLM calls.
Both PostgreSQL and libSQL queries updated. TenantScope gets
record_llm_call() method. Tests updated for new query semantics.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address review comments — input validation, cost semantics, panic safety [skip-regression-check]
- Validate display_name: trim whitespace, reject empty strings (create + update)
- Validate metadata: must be a JSON object, return 400 if not (admin + profile)
- secrets_list_handler: verify target user_id exists before listing
- Cost display: use DB total directly (chat calls now persist to DB),
remove confusing max(db,live) CostGuard fallback
- CatchPanicLayer: truncate panic payload to 200 chars in log to limit
potential sensitive data exposure
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address Copilot round 5 — docs, secrets consistency, token name, provider field [skip-regression-check]
- Docs: users.id note updated to "typically UUID v4 strings (bootstrap
admin may use a custom ID)"
- secrets_list_handler: return 503 when DB store is None (was falling
through to list secrets without user validation)
- tokens_create: trim + reject empty token name (matching display_name
pattern)
- LlmCallRecord.provider: use llm_backend ("nearai","openai") instead
of model_name() which returns the model identifier
- user_summary_stats zero-LLM users: acceptable — handler already falls
back to 0 cost and last_login_at for missing entries
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: DB auth returns 503 on outage, scheduler counts only blocking jobs
From serrrfirat review:
- DB auth: return Err(()) on database errors so middleware returns 503
instead of silently returning Ok(None) → 401 (auth miss)
- Scheduler: add parallel_blocking_count_for() that uses
is_parallel_blocking() (Pending/InProgress/Stuck) instead of
is_active() for per-user concurrency — Completed/Submitted jobs
no longer count against MAX_JOBS_PER_USER
From Copilot:
- CLAUDE.md: fix secrets route paths from {id} to {user_id}
- token_hash: use .as_slice() instead of .to_vec() to avoid
heap allocation on every token auth/creation call
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: immediate auth cache invalidation on security-critical actions (zmanian review #6)
Add DbAuthenticator::invalidate_user() that evicts all cached entries
for a user. Called after:
- Suspend user (immediate lockout, was 60s delay)
- Activate user (immediate access restoration)
- Role change (admin↔member takes effect immediately)
- Token revocation (revoked token can't be reused from cache)
The DbAuthenticator is shared (via Clone, which Arc-clones the cache)
between the auth middleware and GatewayState, so handlers can evict
entries from the same cache the middleware reads.
Also from zmanian's review:
- Items 1-5, 7-11 were already resolved in prior commits
- Item 12 (String→enum for status/role) is deferred as a broader refactor
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: last-admin protection, usage stats for chat calls, UTF-8 safe panic truncation
Last-admin protection:
- Suspend, delete, and role-demotion of the last active admin now
return 409 Conflict instead of succeeding and locking out the admin API
- Helper is_last_admin() checks active admin count before destructive ops
Usage stats:
- user_usage_stats() now includes chat LLM calls (job_id=NULL) by
joining via conversations.user_id, matching user_summary_stats()
- Both PostgreSQL and libSQL queries updated
Panic handler:
- Use floor_char_boundary(200) instead of byte-index [..200] to
prevent panic on multi-byte UTF-8 characters in panic messages
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: workspace seed race, bootstrap atomicity, email trim, secrets upsert response [skip-regression-check]
- WorkspacePool: await seed_if_empty() synchronously after inserting
into cache (drop lock first to avoid blocking), so callers see
identity files immediately instead of racing a background task
- Bootstrap admin: use create_user_with_token() for atomic user+token
creation, matching the admin create endpoint
- Email: trim whitespace, treat empty as None to prevent " " being
stored and breaking uniqueness
- Secrets PUT: report "updated" vs "created" based on prior existence
- Last token_hash.to_vec() → .as_slice() in authenticate_token
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: disable unscoped webhook endpoint in multi-tenant mode [skip-regression-check]
The original /api/webhooks/{path} endpoint looks up routines across all
users. In multi-tenant mode, anyone who knows the webhook path + secret
could trigger another user's routine. Now returns 410 Gone with a
message pointing to the scoped endpoint /api/webhooks/u/{user_id}/{path}.
Detection uses state.db_auth.is_some() — present only when DB-backed
auth is enabled (multi-tenant). Single-user deployments are unaffected.
From: standardtoaster review comment
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: webhook multi-tenant check, secrets error propagation, stale doc comment [skip-regression-check]
- Webhook: use workspace_pool.is_some() instead of db_auth.is_some()
for multi-tenant detection — db_auth is set for any DB deployment,
workspace_pool is only set when has_any_users() was true at startup
- Secrets: propagate exists() errors instead of unwrap_or(false) so
backend outages surface as 500 rather than incorrect "created" status
- Config: fix stale workspace_read_scopes comment referencing user_id
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* fix(llm): prevent UTF-8 panic in line_bounds() (fixes#1669)
`line_bounds()` used `text[..pos]` slicing which panics when `pos`
lands inside a multi-byte UTF-8 character. This happens when
`end.saturating_sub(1)` in `is_recoverable_tool_call_segment()` steps
back into a multi-byte char like emoji.
Fix: clamp `pos` to `text.len()` and walk backward to the nearest
char boundary before slicing. Add 5 regression tests covering
mid-char positions, emoji boundaries, and out-of-bounds pos.
Also fix pre-existing clippy `unnecessary_sort_by` warnings in
web gateway handlers.
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Happy <[email protected]>
* test: assert expected values in line_bounds UTF-8 tests
Address Gemini review: strengthen regression tests to verify correct
return values (not just absence of panic) when pos lands mid-char.
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)
Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Happy <[email protected]>
---------
Co-authored-by: willamhou <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Happy <[email protected]>
* feat(gateway): add OpenAI Responses API endpoints
Add POST /v1/responses and GET /v1/responses/{id} to the web gateway,
implementing the OpenAI Responses API. Unlike the existing Chat
Completions proxy which passes through to the raw LLM, the Responses
API routes requests through the full agent loop — giving external
clients access to tools, memory, safety, and server-side conversation
state via a standard OpenAI-compatible interface.
Key design decisions:
- Response IDs encode thread UUIDs statelessly (resp_{uuid_simple})
- previous_response_id enables multi-turn conversations
- Streaming maps AppEvent variants to Responses API SSE events
- Tool approval returns response.failed (no interactive approval flow)
- GET endpoint reconstructs ResponseObject from conversation_messages
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(responses-api): address all review feedback on PR #1656
- Decouple response ID from thread ID: encode both a per-call
response_uuid and the thread_uuid so each POST produces a unique ID
- Reject unsupported fields (instructions, tools, tool_choice,
temperature, max_output_tokens, non-default model) with 400
- Add user_id to IncomingMessage metadata for user-scoped SSE events
- Add conversation_belongs_to_user() ownership check on GET endpoint
- Fix tool call parsing: handle both legacy array and object wrapper
format; use call_id/tool_call_id/id key fallback chain
- Correlate tool role messages to preceding FunctionCall call_id
- Stabilize created_at (capture once in accumulator, reuse everywhere)
- Surface error_message via new ResponseObject.error field
- Handle streaming tool failures (emit FunctionCallOutput on error)
- Remove dead Incomplete status variant
- Fix formatting (cargo fmt)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
PR #1681 introduced 23 debug-level log statements across relay client,
web server handlers, and extension manager functions. Many of these fire
on every HTTP request or in loops (e.g. has_stored_team_id called per
extension in list_installed). Downgrade them to trace level to reduce
noise at the default debug log level while preserving warn/info logs
for actionable diagnostics.
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* Support direct hosted OAuth callbacks with proxy auth token
* Make OAuth env tests panic-safe
* Preserve public OAuth field compatibility
* Fix OAuth proxy token whitespace fallback
* fix(mcp): handle 202 Accepted for Streamable HTTP notifications
The MCP Streamable HTTP spec requires servers to respond with
202 Accepted (empty body) for JSON-RPC notifications like
`notifications/initialized`. The HTTP transport tried to parse
this empty body as JSON, which failed and broke the session
handshake — subsequent requests like `tools/list` were rejected
because the server considered the session uninitialized.
Add an early return for 202 responses that produces an empty
McpResponse without attempting body parsing.
Fixes#1436
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix(mcp): wire session manager into transport for non-OAuth HTTP clients
The factory used McpClient::new_with_config().with_session_manager()
which only set the session manager on the client, not on the
HttpMcpTransport. The transport never captured Mcp-Session-Id from
responses, so subsequent requests lacked the header and the server
rejected them as uninitialized.
Fix by constructing the HttpMcpTransport with the session manager
before wrapping it in Arc, matching the pattern already used by
new_authenticated().
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* refactor(mcp): deduplicate factory HTTP path, gate dead-code methods as test-only
- Collapse the two identical non-OAuth HTTP branches in
`create_client_from_config()` into one (early-return for the
authenticated path, fall through for the common case).
- Gate `McpClient::new_with_config()` and `McpClient::with_session_manager()`
as `#[cfg(test)]` — the factory was their only production caller and no
longer uses them. Both methods silently skip wiring the session manager
into the transport, which was the root cause of #1436.
- Add doc warnings on both methods explaining the footgun.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: [email protected] <[email protected]>
* fix(extensions): channel-relay auth dead-end, add observability and relay URL override
Fix a bug where clicking Activate on the Slack relay extension produces
a dead-end "Authentication required" error with no OAuth URL. The root
cause: `auth_channel_relay()` used `is_relay_channel()` to check auth
status, but that function returns true as soon as the extension is
*installed* (in-memory set), before OAuth completes. This short-circuits
the OAuth flow so the authorization URL is never offered.
Changes:
1. **Bug fix** — `auth_channel_relay()` now uses `has_stored_team_id()`
which only checks the persistent settings store for an actual team_id.
The extension list `authenticated` field uses the same check so the UI
accurately reflects OAuth completion status.
2. **Observability** — Added debug/warn/info tracing to all channel-relay
code paths that were previously silent on failure:
- `activate_channel_relay`: team_id retrieval, relay config, signing
secret fetch, hot_add, cache operations
- `auth_channel_relay`: auth check, OAuth initiation, nonce storage
- `extensions_activate_handler`: request entry, auth fallback flow
- `slack_relay_oauth_callback_handler`: team_id persistence (was
silently ignored with `let _`)
- `RelayClient`: initiate_oauth, get_signing_secret, proxy_provider
all log URL, status, and errors
- `has_stored_team_id`: store read success/failure
3. **Per-extension relay URL override** — Users can now override the
CHANNEL_RELAY_URL via Settings > Extensions > Reconfigure. Stored
under `extensions.{name}.relay_url` in settings. Both auth and
activate read this override before falling back to the env default.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address review feedback — clear relay_url override and improve log message
1. Allow clearing the relay_url override: when an optional setup field
with a setting_path is submitted empty, delete the stored setting so
the system reverts to the env/default value. Previously empty values
were silently skipped, making it impossible to undo an override from
the UI.
2. Improve the OAuth callback team_id persistence error log to be
self-contained without referencing implementation details.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* style: collapse nested if per clippy::collapsible_if
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address review feedback — security, scope consistency, and error handling
1. OAuth callback team_id persistence is now fatal: if set_setting fails,
the callback returns an error instead of proceeding to activate (which
would re-read from the store and fail anyway).
2. effective_relay_url uses owner scope (self.user_id) for reads, matching
configure() which writes under the same scope. Prevents multi-user
mismatch where an override saved via Reconfigure was invisible during
auth/activation.
3. has_stored_team_id uses owner scope for the same reason — the OAuth
callback stores team_id under state.owner_id (= self.user_id).
4. Security: effective_relay_url validates the override URL — only
http/https without embedded credentials (userinfo) is accepted. This
prevents API-key exfiltration if a user points relay_url at an
attacker-controlled host. Logs only host portion, not full URL.
5. Fixed effective_relay_url docstring to match behavior (returns Option,
callers handle the fallback).
6. get_setup_schema for ChannelRelay now logs a warning on settings store
errors instead of silently returning None.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
* feat: complete multi-tenant isolation — per-user budgets, model selection, heartbeat cycling
Finishes the remaining isolation work from phases 2–4 of #59:
Phase 2 (DB scoping): Fix /status and /list commands to use _for_user
DB variants instead of global queries that leaked cross-user job data.
Phase 3 (Runtime isolation): Per-user workspace in routine engine's
spawn_fire so lightweight routines run in the correct user context.
Per-user daily cost tracking in CostGuard with configurable budget via
MAX_COST_PER_USER_PER_DAY_CENTS. Multi-user heartbeat that cycles
through all users with routines, auto-detected from GATEWAY_USER_TOKENS.
Phase 4 (Provider/tools): Per-user model selection via preferred_model
setting — looked up from SettingsStore on first iteration, threaded
through ReasoningContext.model_override to CompletionRequest. Works
with providers that support per-request model overrides (NearAI).
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: use selected_model setting key to match /model command persistence
The dispatcher was reading "preferred_model" but the /model command
(merged from staging) persists to "selected_model". Since set_setting
is already per-user scoped, using the same key makes /model work as
the per-user model override in multi-tenant mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: heartbeat hygiene, /model multi-tenant guard, RigAdapter model override
Three follow-up fixes for multi-tenant isolation:
1. Multi-user heartbeat now runs memory hygiene per user before each
heartbeat check, matching single-user heartbeat behavior.
2. /model command in multi-tenant mode only persists to per-user
settings (selected_model) without calling set_model() on the shared
LlmProvider. The per-request model_override in the dispatcher reads
from the same setting. Added multi_tenant flag to AgentConfig
(auto-detected from GATEWAY_USER_TOKENS).
3. RigAdapter now supports per-request model overrides by injecting the
model name into rig-core's additional_params. OpenAI/Anthropic/Ollama
API servers use last-key-wins for duplicate JSON keys, so the override
takes effect via serde's flatten serialization order.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: address PR review — cost model attribution, heartbeat concurrency, pruning
Fixes from review comments on #1614:
- Cost tracking now uses the override model name (not active_model_name)
when a per-user model override is active, for accurate attribution.
- Multi-user heartbeat runs per-user checks concurrently via JoinSet
instead of sequentially, preventing one slow user from blocking others.
- Per-user failure counts tracked independently; users exceeding
max_failures are skipped (matching single-user semantics).
- per_user_daily_cost HashMap pruned on day rollover to prevent
unbounded growth in long-lived deployments.
- Doc comment fixed: says "routines" not "active routines".
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: /status ownership, model persistence scoping, heartbeat robustness
Addresses second round of PR review on #1614:
- /status <job_id> DB path now validates job.user_id == requesting user
before returning data (was missing ownership check, security fix).
- persist_selected_model takes user_id param instead of owner_id, and
skips .env/TOML writes in multi-tenant mode (these are shared global
files). handle_system_command now receives user_id from caller.
- JoinSet collection handles Err(JoinError) explicitly instead of
silently dropping panicked tasks.
- Notification forwarder extracts owner_id from response metadata in
multi-tenant mode for per-user routing instead of broadcasting to
the agent owner.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: cost pricing, fire_manual workspace, heartbeat concurrency cap
Round 3 review fixes:
- Cost tracking passes None for cost_per_token when model override is
active, letting CostGuard look up pricing by model name instead of
using the default provider's rates (serrrfirat).
- fire_manual() now uses per-user workspace, matching spawn_fire()
pattern (serrrfirat).
- Removed MULTI_TENANT env var — multi-tenant mode is auto-detected
solely from GATEWAY_USER_TOKENS presence (serrrfirat + Copilot).
- Multi-user heartbeat capped at 8 concurrent tasks to avoid flooding
the LLM provider (serrrfirat + Copilot).
- Fixed inject_model_override doc comment accuracy (Copilot).
- Added comment explaining multi-tenant notification routing priority
(Copilot).
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: user-scoped webhook endpoint for multi-tenant isolation
Adds POST /api/webhooks/u/{user_id}/{path} — a user-scoped webhook
endpoint that filters the routine lookup by user_id, preventing
cross-user webhook triggering when paths collide.
The existing /api/webhooks/{path} endpoint remains unchanged for
backward compatibility in single-user deployments.
Changes:
- get_webhook_routine_by_path gains user_id: Option<&str> param
- Both postgres and libsql implementations add AND user_id = ? filter
when user_id is provided
- New webhook_trigger_user_scoped_handler extracts (user_id, path)
from URL and passes to shared fire_webhook_inner logic
- Route registered on public router (webhooks are called by external
services that can't send bearer tokens)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* feat: add TenantCtx for compile-time tenant isolation
Implements zmanian's architectural proposal from #1614 review:
two-tier scoped database access (TenantScope/AdminScope) so handler
code cannot accidentally bypass tenant scoping.
TenantScope (default): wraps user_id + Arc<dyn Database>, auto-binds
user_id on every operation. ID-based lookups return None for cross-
tenant resources. No escape hatch — forgetting to scope is a compile
error.
AdminScope (explicit opt-in): cross-tenant access for system-level
components (heartbeat, routine engine, self-repair, scheduler, worker).
TenantCtx bundles TenantScope + workspace + cost guard + per-user
rate limiting. Constructed once per request in handle_message, threaded
through all command handlers and ChatDelegate.
Key changes:
- New src/tenant.rs (~920 lines): TenantScope, AdminScope, TenantCtx,
TenantRateState, TenantRateRegistry
- All command handlers: user_id: &str → ctx: &TenantCtx
- ChatDelegate: cost check/record/settings via self.tenant
- System components: store field changed to AdminScope
- Config: TENANT_MAX_LLM_CONCURRENT, TENANT_MAX_JOBS_CONCURRENT env vars
- Fixes bug: /status <job_id> cross-tenant leak (now auto-filtered)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>