Compare commits

..
Author SHA1 Message Date
Pranav Raja 8353b6f234 intitial 2026-03-26 19:41:42 -07:00
6691f1408a 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]>
2026-03-26 14:10:31 -07:00
[email protected] e808fbaf54 Merge remote-tracking branch 'origin/staging' into feat/db-user-management
# Conflicts:
#	Cargo.lock
2026-03-26 11:41:07 -07:00
[email protected]andClaude Opus 4.6 42bb52e0d7 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]>
2026-03-26 11:31:15 -07:00
Illia PolosukhinandClaude Opus 4.6 8eb32c7370 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]>
2026-03-26 11:30:09 -07:00
Illia PolosukhinandClaude Opus 4.6 e5e3335eb9 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]>
2026-03-26 11:24:56 -07:00
Illia PolosukhinandClaude Opus 4.6 82a9df1ebf 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]>
2026-03-26 11:19:45 -07:00
Illia PolosukhinandClaude Opus 4.6 6cc77fe162 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]>
2026-03-26 10:06:32 -07:00
Illia PolosukhinandClaude Opus 4.6 27c6d1d6b7 debug: add tracing to users_create_handler
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-26 09:27:22 -07:00
Illia PolosukhinandClaude Opus 4.6 4bba26ea7f 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]>
2026-03-26 09:21:34 -07:00
Illia PolosukhinandClaude Opus 4.6 4a75c11477 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]>
2026-03-26 09:13:38 -07:00
Illia PolosukhinandClaude Opus 4.6 c1dfeb3586 chore: update Cargo.lock for rustls + webpki-roots
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-26 09:09:52 -07:00
Illia PolosukhinandClaude Opus 4.6 d20e3e5316 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]>
2026-03-26 09:06:44 -07:00
[email protected]andClaude Opus 4.6 af23210483 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]>
2026-03-26 09:02:28 -07:00
Illia PolosukhinandClaude Opus 4.6 55c8ca4347 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]>
2026-03-26 08:52:16 -07:00
[email protected]andClaude Opus 4.6 5692fcd37d 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]>
2026-03-25 23:04:08 -07:00
[email protected] 964f900d1d Adding user management api 2026-03-25 22:47:22 -07:00
Illia PolosukhinandClaude Opus 4.6 ea2ef15489 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]>
2026-03-25 19:49:55 -07:00
[email protected]andClaude Opus 4.6 aa27c72cbb 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]>
2026-03-25 18:58:26 -07:00
[email protected] abe78f6cd8 Merge branch 'feat/db-user-management' of github.com:nearai/ironclaw into feat/db-user-management 2026-03-25 18:52:45 -07:00
[email protected] 5866df1931 Merge remote-tracking branch 'origin/staging' into feat/db-user-management
# Conflicts:
#	src/config/agent.rs
#	src/config/heartbeat.rs
2026-03-25 18:52:39 -07:00
Illia PolosukhinandClaude Opus 4.6 48d8b16e88 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]>
2026-03-25 18:50:17 -07:00
[email protected]andClaude Opus 4.6 4006ba2a12 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]>
2026-03-25 18:46:21 -07:00
[email protected] 1c9a9bd4f3 Merge remote-tracking branch 'origin/feat/multi-tenant-isolation-phases-2-4' into feat/db-user-management
# Conflicts:
#	src/channels/web/mod.rs
#	src/config/agent.rs
#	src/main.rs
2026-03-25 16:56:17 -07:00
[email protected] 3a945a81e7 Merge remote-tracking branch 'origin/staging' into feat/multi-tenant-isolation-phases-2-4
# Conflicts:
#	src/agent/agent_loop.rs
2026-03-25 16:43:03 -07:00
[email protected]andClaude Opus 4.6 8f1e0df0cd 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]>
2026-03-25 16:36:11 -07:00
[email protected]andClaude Opus 4.6 ffe872f3b1 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]>
2026-03-25 01:01:12 -07:00
[email protected]andClaude Opus 4.6 e83249d0e0 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]>
2026-03-25 00:18:50 -07:00
[email protected]andClaude Opus 4.6 34883aaa6c 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]>
2026-03-25 00:04:51 -07:00
[email protected]andClaude Opus 4.6 df42a389a2 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]>
2026-03-24 23:55:56 -07:00
[email protected]andClaude Opus 4.6 420245a0bc 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]>
2026-03-24 23:51:27 -07:00
[email protected]andClaude Opus 4.6 87886278bb 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]>
2026-03-24 23:48:48 -07:00
[email protected]andClaude Opus 4.6 18b8589549 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]>
2026-03-24 23:44:35 -07:00
[email protected]andClaude Opus 4.6 b469a1b87f 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]>
2026-03-24 23:38:12 -07:00
[email protected]andClaude Opus 4.6 29e9aeb156 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]>
2026-03-24 23:09:00 -07:00
[email protected]andClaude Opus 4.6 aa35c2e1ce 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]>
2026-03-24 22:34:05 -07:00
[email protected]andClaude Opus 4.6 240cee82e1 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]>
2026-03-24 14:46:20 -07:00
[email protected]andClaude Opus 4.6 27c43e185f 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]>
2026-03-24 14:31:02 -07:00
[email protected]andClaude Opus 4.6 80cee7d742 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]>
2026-03-24 13:47:38 -07:00
[email protected]andClaude Opus 4.6 746735c59c 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]>
2026-03-24 12:40:27 -07:00
[email protected]andClaude Opus 4.6 11646d20e6 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]>
2026-03-24 12:25:25 -07:00
[email protected] 3c42e68bad Merge remote-tracking branch 'origin/staging' into feat/multi-tenant-isolation-phases-2-4 2026-03-24 12:11:01 -07:00
[email protected]andClaude Opus 4.6 0d168fb644 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]>
2026-03-24 08:58:48 -07:00
[email protected]andClaude Opus 4.6 6dfe246288 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]>
2026-03-24 00:06:16 -07:00
[email protected]andClaude Opus 4.6 9ff4af5734 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]>
2026-03-23 23:29:26 -07:00
[email protected]andClaude Opus 4.6 af5daca0d9 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]>
2026-03-23 23:11:34 -07:00
[email protected] 8db3638a42 Merge remote-tracking branch 'origin/staging' into feat/multi-tenant-isolation-phases-2-4 2026-03-23 23:09:17 -07:00
[email protected]andClaude Opus 4.6 9d7cdc0cf1 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]>
2026-03-23 22:46:59 -07:00
92 changed files with 5891 additions and 7958 deletions
Generated
+26 -145
View File
@@ -121,15 +121,6 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "anstream"
version = "0.6.21"
@@ -166,7 +157,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -177,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -401,17 +392,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -961,29 +941,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.59.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8"
dependencies = [
"bitflags 1.3.2",
"cexpr",
"clang-sys",
"clap 2.34.0",
"env_logger",
"lazy_static",
"lazycell",
"log",
"peeking_take_while",
"proc-macro2",
"quote",
"regex",
"rustc-hash 1.1.0",
"shlex",
"which",
]
[[package]]
name = "bindgen"
version = "0.66.1"
@@ -1403,21 +1360,6 @@ dependencies = [
"libloading",
]
[[package]]
name = "clap"
version = "2.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
dependencies = [
"ansi_term",
"atty",
"bitflags 1.3.2",
"strsim 0.8.0",
"textwrap",
"unicode-width 0.1.14",
"vec_map",
]
[[package]]
name = "clap"
version = "4.5.60"
@@ -1437,7 +1379,7 @@ dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim 0.11.1",
"strsim",
]
[[package]]
@@ -1446,7 +1388,7 @@ version = "4.5.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031"
dependencies = [
"clap 4.5.60",
"clap",
]
[[package]]
@@ -1755,7 +1697,7 @@ dependencies = [
"anes",
"cast",
"ciborium",
"clap 4.5.60",
"clap",
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
@@ -1998,7 +1940,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"strsim",
"syn 2.0.117",
]
@@ -2194,7 +2136,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2368,19 +2310,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_logger"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
dependencies = [
"atty",
"humantime",
"log",
"regex",
"termcolor",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -2394,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2919,15 +2848,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
@@ -3088,12 +3008,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hyper"
version = "0.14.32"
@@ -3236,7 +3150,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"system-configuration",
"tokio",
"tower-service",
@@ -3478,7 +3392,6 @@ dependencies = [
name = "ironclaw"
version = "0.22.0"
dependencies = [
"aes",
"aes-gcm",
"aho-corasick",
"anyhow",
@@ -3493,7 +3406,7 @@ dependencies = [
"bytes",
"chrono",
"chrono-tz",
"clap 4.5.60",
"clap",
"clap_complete",
"criterion",
"cron",
@@ -3545,7 +3458,6 @@ dependencies = [
"serde_json",
"serde_yml",
"sha2",
"silk-rs",
"subtle",
"tar",
"tempfile",
@@ -3570,6 +3482,7 @@ dependencies = [
"wasmparser 0.220.1",
"wasmtime",
"wasmtime-wasi",
"webpki-roots 0.26.11",
"zbus",
"zip",
]
@@ -3609,9 +3522,9 @@ version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi 0.5.2",
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3845,7 +3758,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf"
dependencies = [
"bindgen 0.66.1",
"bindgen",
"cc",
]
@@ -4231,7 +4144,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4319,7 +4232,7 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi 0.5.2",
"hermit-abi",
"libc",
]
@@ -4797,7 +4710,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi 0.5.2",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -5017,7 +4930,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.37",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5054,9 +4967,9 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5569,7 +5482,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6195,18 +6108,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "silk-rs"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014e6619f35a385ff848570e73a0b8c36b31031e0ee11cac70192b50097b1cfe"
dependencies = [
"bindgen 0.59.2",
"bytes",
"cc",
"thiserror 1.0.69",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
@@ -6263,7 +6164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6335,12 +6236,6 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -6494,7 +6389,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6581,15 +6476,6 @@ dependencies = [
"testcontainers",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -7106,6 +6992,7 @@ dependencies = [
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"iri-string",
"pin-project-lite",
"tower 0.5.3",
@@ -7303,7 +7190,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7456,12 +7343,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -8159,7 +8040,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+4 -3
View File
@@ -57,6 +57,7 @@ refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
tokio-postgres-rustls = { version = "0.13", optional = true }
rustls = { version = "0.23", optional = true, default-features = false }
rustls-native-certs = { version = "0.8", optional = true }
webpki-roots = { version = "0.26", optional = true }
# Database - libSQL/Turso (optional embedded database)
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
@@ -95,7 +96,7 @@ termimad = "0.34"
# Channel integrations
axum = { version = "0.8", features = ["ws"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "catch-panic"] }
# Cron scheduling for routines
cron = "0.13"
@@ -138,7 +139,6 @@ wasmtime-wasi = "28" # WASI support for component model
wasmparser = "0.220" # WASM binary parsing for validation
# Cryptography for secrets management
aes = "0.8"
aes-gcm = "0.10"
hkdf = "0.12"
hmac = "0.12"
@@ -175,7 +175,6 @@ base64 = "0.22.1"
mime_guess = "2.0.5"
clap_complete = "4.5.0"
lru = "0.16.3"
silk-rs = "0.2.0"
# HTML to Markdown conversion (feature gated)
html-to-markdown-rs = { version = "2.3", optional = true }
@@ -221,6 +220,7 @@ postgres = [
"dep:tokio-postgres-rustls",
"dep:rustls",
"dep:rustls-native-certs",
"dep:webpki-roots",
"dep:postgres-types",
"dep:refinery",
"dep:pgvector",
@@ -232,6 +232,7 @@ libsql = ["dep:libsql"]
integration = []
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
demo = []
import = ["dep:json5", "libsql"]
[[test]]
+39 -9
View File
@@ -1,49 +1,78 @@
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
#
# Uses cargo-chef for dependency caching — only rebuilds deps when
# Cargo.toml/Cargo.lock change, not on every source edit.
#
# Build:
# docker build --platform linux/amd64 -t ironclaw:latest .
#
# Run:
# docker run --env-file .env -p 3000:3000 ironclaw:latest
# Stage 1: Build
FROM rust:1.92-slim-bookworm AS builder
# Stage 1: Install cargo-chef
FROM rust:1.92-slim-bookworm AS chef
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev cmake gcc g++ \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add wasm32-wasip2 \
&& cargo install wasm-tools
&& cargo install cargo-chef wasm-tools
WORKDIR /app
# Copy manifests first for layer caching
# Stage 2: Generate the dependency recipe (changes only when Cargo.toml/lock change)
FROM chef AS planner
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
# Copy source, build script, tests, and supporting directories
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
COPY benches/ benches/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
COPY providers.json providers.json
# [[bench]] entries in Cargo.toml require bench sources to exist for cargo to parse the manifest
RUN cargo chef prepare --recipe-path recipe.json
# Stage 3: Build dependencies (cached unless Cargo.toml/lock change)
FROM chef AS deps
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# Stage 4: Build the actual binary (only recompiles ironclaw source)
FROM deps AS builder
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
COPY build.rs build.rs
COPY src/ src/
COPY tests/ tests/
COPY benches/ benches/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
COPY providers.json providers.json
RUN cargo build --release --bin ironclaw
COPY skills/ skills/
# Stage 2: Runtime
RUN cargo build --release --features demo --bin ironclaw
# Stage 5: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
COPY --from=builder /app/migrations /app/migrations
COPY --from=builder /app/skills /app/skills
# Non-root user
RUN useradd -m -u 1000 -s /bin/bash ironclaw
@@ -52,5 +81,6 @@ USER ironclaw
EXPOSE 3000
ENV RUST_LOG=ironclaw=info
ENV SKILLS_DIR=/app/skills
ENTRYPOINT ["ironclaw"]
-1
View File
@@ -77,7 +77,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
| Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned |
| LINE | ✅ | ❌ | P3 | |
| WeChat (iLink bot) | ✅ | 🚧 | P2 | Extension-first channel (`channels-src/wechat`), single-account DM flow with QR login, typing, image send/receive, inbound file extraction, and inbound voice handling with SILK-to-WAV fallback; multi-account plus video and outbound file parity follow-up |
| WebChat | ✅ | ✅ | - | Web gateway chat |
| Matrix | ✅ | ❌ | P3 | E2EE support |
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
-2
View File
@@ -1,2 +0,0 @@
/target
/*.wasm
-568
View File
@@ -1,568 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spdx"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
dependencies = [
"smallvec",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-encoder"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
dependencies = [
"leb128",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
dependencies = [
"anyhow",
"indexmap",
"serde",
"serde_derive",
"serde_json",
"spdx",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
dependencies = [
"ahash",
"bitflags",
"hashbrown 0.14.5",
"indexmap",
"semver",
]
[[package]]
name = "wechat-channel"
version = "0.1.0"
dependencies = [
"aes",
"base64",
"cipher",
"md-5",
"rand",
"serde",
"serde_json",
"wit-bindgen",
]
[[package]]
name = "wit-bindgen"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
dependencies = [
"wit-bindgen-rt",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
dependencies = [
"bitflags",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
-27
View File
@@ -1,27 +0,0 @@
[package]
name = "wechat-channel"
version = "0.1.0"
edition = "2021"
description = "WeChat iLink Bot channel for IronClaw"
license = "MIT OR Apache-2.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.36"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.22"
aes = "0.8"
cipher = "0.4"
md-5 = "0.10"
rand = "0.8"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
[workspace]
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "Building WeChat channel WASM component..."
cargo build --release --target wasm32-wasip2
WASM_PATH="target/wasm32-wasip2/release/wechat_channel.wasm"
if [ -f "$WASM_PATH" ]; then
if command -v wasm-tools >/dev/null 2>&1; then
wasm-tools component new "$WASM_PATH" -o wechat.wasm 2>/dev/null || cp "$WASM_PATH" wechat.wasm
wasm-tools strip wechat.wasm -o wechat.wasm
else
cp "$WASM_PATH" wechat.wasm
echo "wasm-tools not found; copied raw wasm output without component conversion/strip"
fi
echo "Built: wechat.wasm ($(du -h wechat.wasm | cut -f1))"
echo ""
echo "To install:"
echo " mkdir -p ~/.ironclaw/channels"
echo " cp wechat.wasm wechat.capabilities.json ~/.ironclaw/channels/"
else
echo "Error: WASM output not found at $WASM_PATH"
exit 1
fi
-277
View File
@@ -1,277 +0,0 @@
use base64::Engine as _;
use crate::near::agent::channel_host;
use crate::types::{
BaseInfo, GetConfigRequest, GetConfigResponse, GetUpdatesRequest, GetUpdatesResponse,
GetUploadUrlRequest, GetUploadUrlResponse, MessageItem, OutboundWechatMessage,
SendMessageRequest, SendTypingRequest, SendTypingResponse, TextItem, WechatConfig,
MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT,
};
pub fn base_info() -> BaseInfo {
BaseInfo {
channel_version: env!("CARGO_PKG_VERSION").to_string(),
}
}
fn ensure_trailing_slash(base_url: &str) -> String {
if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{base_url}/")
}
}
fn random_wechat_uin() -> String {
let seed = (channel_host::now_millis() % u32::MAX as u64) as u32;
base64::engine::general_purpose::STANDARD.encode(seed.to_string())
}
fn request_headers(body: &[u8]) -> String {
serde_json::json!({
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"Authorization": "Bearer {WECHAT_BOT_TOKEN}",
"Content-Length": body.len().to_string(),
"X-WECHAT-UIN": random_wechat_uin(),
})
.to_string()
}
fn summarize_body_preview(bytes: &[u8], limit: usize) -> String {
let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(limit)]);
let normalized = preview.replace(['\n', '\r'], " ");
if bytes.len() > limit {
format!("{normalized}...")
} else {
normalized
}
}
pub fn get_updates(
config: &WechatConfig,
get_updates_buf: &str,
) -> Result<GetUpdatesResponse, String> {
get_updates_with_timeout(config, get_updates_buf, config.long_poll_timeout_ms)
}
pub fn get_updates_with_timeout(
config: &WechatConfig,
get_updates_buf: &str,
timeout_ms: u32,
) -> Result<GetUpdatesResponse, String> {
let body = serde_json::to_vec(&GetUpdatesRequest {
get_updates_buf: get_updates_buf.to_string(),
base_info: base_info(),
})
.map_err(|e| format!("Failed to encode getUpdates request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/getupdates",
ensure_trailing_slash(&config.base_url)
);
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates request: cursor_len={} timeout_ms={}",
get_updates_buf.len(),
config.long_poll_timeout_ms
),
);
let response =
channel_host::http_request("POST", &url, &headers, Some(&body), Some(timeout_ms))
.map_err(|e| format!("getUpdates request failed: {e}"))?;
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates response: status={} bytes={} has_image_marker={} has_aeskey_marker={} preview={}",
response.status,
response.body.len(),
response
.body
.windows(b"image_item".len())
.any(|window| window == b"image_item"),
response
.body
.windows(b"aeskey".len())
.any(|window| window == b"aeskey"),
summarize_body_preview(&response.body, 160)
),
);
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!("getUpdates returned {}: {}", response.status, body));
}
let parsed: GetUpdatesResponse = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getUpdates response: {e}"))?;
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates parsed: ret={:?} errcode={:?} msg_count={} next_cursor_len={}",
parsed.ret,
parsed.errcode,
parsed.msgs.len(),
parsed.get_updates_buf.as_deref().unwrap_or_default().len()
),
);
Ok(parsed)
}
pub fn send_text_message(
config: &WechatConfig,
to_user_id: &str,
text: &str,
context_token: Option<&str>,
) -> Result<(), String> {
let message = SendMessageRequest {
msg: OutboundWechatMessage {
from_user_id: String::new(),
to_user_id: to_user_id.to_string(),
client_id: format!("wechat-{}", channel_host::now_millis()),
message_type: MESSAGE_TYPE_BOT,
message_state: MESSAGE_STATE_FINISH,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_TEXT),
text_item: Some(TextItem {
text: text.to_string(),
}),
image_item: None,
voice_item: None,
file_item: None,
}],
context_token: context_token.map(str::to_string),
},
base_info: base_info(),
};
send_message_request(config, &message)
}
pub fn send_message_request(
config: &WechatConfig,
message: &SendMessageRequest,
) -> Result<(), String> {
let body = serde_json::to_vec(message)
.map_err(|e| format!("Failed to encode sendMessage request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/sendmessage",
ensure_trailing_slash(&config.base_url)
);
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000))
.map_err(|e| format!("sendMessage request failed: {e}"))?;
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!(
"sendMessage returned {}: {}",
response.status, body
));
}
Ok(())
}
pub fn get_upload_url(
config: &WechatConfig,
request: &GetUploadUrlRequest,
) -> Result<GetUploadUrlResponse, String> {
let body = serde_json::to_vec(request)
.map_err(|e| format!("Failed to encode getUploadUrl request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/getuploadurl",
ensure_trailing_slash(&config.base_url)
);
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000))
.map_err(|e| format!("getUploadUrl request failed: {e}"))?;
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!(
"getUploadUrl returned {}: {}",
response.status, body
));
}
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getUploadUrl response: {e}"))
}
pub fn get_config(
config: &WechatConfig,
ilink_user_id: &str,
context_token: Option<&str>,
) -> Result<GetConfigResponse, String> {
let body = serde_json::to_vec(&GetConfigRequest {
ilink_user_id: ilink_user_id.to_string(),
context_token: context_token.map(str::to_string),
base_info: base_info(),
})
.map_err(|e| format!("Failed to encode getConfig request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/getconfig",
ensure_trailing_slash(&config.base_url)
);
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000))
.map_err(|e| format!("getConfig request failed: {e}"))?;
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!("getConfig returned {}: {}", response.status, body));
}
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getConfig response: {e}"))
}
pub fn send_typing(
config: &WechatConfig,
ilink_user_id: &str,
typing_ticket: &str,
status: i32,
) -> Result<(), String> {
let body = serde_json::to_vec(&SendTypingRequest {
ilink_user_id: ilink_user_id.to_string(),
typing_ticket: typing_ticket.to_string(),
status,
base_info: base_info(),
})
.map_err(|e| format!("Failed to encode sendTyping request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/sendtyping",
ensure_trailing_slash(&config.base_url)
);
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000))
.map_err(|e| format!("sendTyping request failed: {e}"))?;
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!("sendTyping returned {}: {}", response.status, body));
}
let parsed: SendTypingResponse = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse sendTyping response: {e}"))?;
if parsed.ret.unwrap_or(0) != 0 {
let errmsg = parsed
.errmsg
.as_deref()
.unwrap_or("unknown WeChat sendTyping error");
return Err(format!(
"sendTyping returned ret={} errmsg={errmsg}",
parsed.ret.unwrap_or(-1)
));
}
Ok(())
}
-6
View File
@@ -1,6 +0,0 @@
pub const TOKEN_SECRET_NAME: &str = "wechat_bot_token";
pub const CONFIG_PATH: &str = "config.json";
pub const GET_UPDATES_BUF_PATH: &str = "state/get_updates_buf.json";
pub const CONTEXT_TOKENS_PATH: &str = "state/context_tokens.json";
pub const TYPING_TICKETS_PATH: &str = "state/typing_tickets.json";
pub const PENDING_INBOUND_PATH: &str = "state/pending_inbound.json";
-942
View File
@@ -1,942 +0,0 @@
wit_bindgen::generate!({
world: "sandboxed-channel",
path: "../../wit/channel.wit",
});
mod api;
mod auth;
mod media;
mod state;
mod types;
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, PollConfig, StatusType, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
use serde_json::json;
use crate::auth::TOKEN_SECRET_NAME;
use crate::state::{
load_config, load_context_tokens, load_get_updates_buf, load_pending_inbound_bundles,
load_typing_tickets, persist_config, persist_context_tokens, persist_get_updates_buf,
persist_pending_inbound_bundles, persist_typing_tickets, PendingInboundBundle,
StoredInboundAttachment, TypingTicketEntry,
};
use crate::types::{
OutboundMetadata, WechatConfig, WechatMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER,
TYPING_STATUS_CANCEL, TYPING_STATUS_TYPING,
};
const TYPING_TICKET_TTL_MS: u64 = 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WechatStatusAction {
Typing,
Cancel,
}
struct WechatChannel;
impl Guest for WechatChannel {
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
let config = serde_json::from_str::<WechatConfig>(&config_json)
.map_err(|e| format!("Failed to parse WeChat config: {e}"))?;
persist_config(&config)?;
Ok(ChannelConfig {
display_name: "WeChat".to_string(),
http_endpoints: Vec::new(),
poll: Some(PollConfig {
interval_ms: config.poll_interval_ms.max(30_000),
enabled: true,
}),
})
}
fn on_http_request(
_req: exports::near::agent::channel::IncomingHttpRequest,
) -> exports::near::agent::channel::OutgoingHttpResponse {
exports::near::agent::channel::OutgoingHttpResponse {
status: 404,
headers_json: "{}".to_string(),
body: b"{\"error\":\"wechat channel does not expose webhooks\"}".to_vec(),
}
}
fn on_poll() {
if !channel_host::secret_exists(TOKEN_SECRET_NAME) {
channel_host::log(
channel_host::LogLevel::Warn,
"WeChat bot token is missing; skipping poll",
);
return;
}
let config = load_config();
let cursor = load_get_updates_buf();
let mut current_cursor = cursor.clone();
let mut context_tokens = load_context_tokens();
let mut pending_inbound = match load_pending_inbound_bundles() {
Ok(bundles) => bundles,
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to load WeChat pending inbound bundles: {error}"),
);
return;
}
};
let mut pending_inbound_changed = false;
for bundle in take_due_pending_bundles(&mut pending_inbound, channel_host::now_millis()) {
pending_inbound_changed = true;
emit_buffered_bundle(bundle);
}
match api::get_updates(&config, &current_cursor) {
Ok(response) => {
if response.errcode == Some(-14) {
channel_host::log(
channel_host::LogLevel::Error,
"WeChat getUpdates returned errcode=-14; reconnect the channel",
);
return;
}
if response.ret.unwrap_or(0) != 0 {
let errmsg = response
.errmsg
.as_deref()
.unwrap_or("unknown WeChat polling error");
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"WeChat getUpdates returned ret={} errmsg={errmsg}",
response.ret.unwrap_or(-1)
),
);
}
if let Some(next_cursor) = response.get_updates_buf.as_deref() {
if next_cursor != current_cursor {
current_cursor = next_cursor.to_string();
if let Err(error) = persist_get_updates_buf(next_cursor) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat polling cursor: {error}"),
);
}
}
}
let mut context_tokens_changed = false;
for message in response.msgs {
if let Some(from_user_id) = message.from_user_id.as_deref() {
if let Some(context_token) = message.context_token.as_deref() {
let changed = context_tokens
.insert(from_user_id.to_string(), context_token.to_string())
.as_deref()
!= Some(context_token);
context_tokens_changed |= changed;
}
}
match incoming_bundle_from_message(&config, message) {
Ok(Some(bundle)) => {
let emitted = process_incoming_bundle(
&mut pending_inbound,
bundle,
&mut pending_inbound_changed,
channel_host::now_millis(),
u64::from(config.inbound_merge_window_ms),
);
for emitted_bundle in emitted {
emit_buffered_bundle(emitted_bundle);
}
}
Ok(None) => {}
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to map WeChat inbound message: {error}"),
);
}
}
}
collect_follow_up_bundles(
&config,
&mut current_cursor,
&mut context_tokens,
&mut context_tokens_changed,
&mut pending_inbound,
&mut pending_inbound_changed,
);
for bundle in
take_due_pending_bundles(&mut pending_inbound, channel_host::now_millis())
{
pending_inbound_changed = true;
emit_buffered_bundle(bundle);
}
if context_tokens_changed {
if let Err(error) = persist_context_tokens(&context_tokens) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat context tokens: {error}"),
);
}
}
if pending_inbound_changed {
if let Err(error) = persist_pending_inbound_bundles(&pending_inbound) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat pending inbound bundles: {error}"),
);
}
}
}
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("WeChat polling failed: {error}"),
);
}
}
}
fn on_respond(response: AgentResponse) -> Result<(), String> {
let metadata = serde_json::from_str::<OutboundMetadata>(&response.metadata_json)
.map_err(|e| format!("Invalid WeChat response metadata: {e}"))?;
let config = load_config();
let context_tokens = load_context_tokens();
let context_token = metadata
.context_token
.clone()
.or_else(|| context_tokens.get(&metadata.from_user_id).cloned());
if let Err(error) = send_typing_indicator(
&config,
&metadata,
context_token.as_deref(),
TYPING_STATUS_CANCEL,
false,
) {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Failed to cancel WeChat typing indicator before reply: {error}"),
);
}
send_response(&config, &metadata, &response, context_token.as_deref())
}
fn on_status(update: StatusUpdate) {
let Some(action) = classify_status_update(&update) else {
return;
};
let metadata = match serde_json::from_str::<OutboundMetadata>(&update.metadata_json) {
Ok(metadata) => metadata,
Err(_) => {
channel_host::log(
channel_host::LogLevel::Debug,
"on_status: no valid WeChat metadata, skipping typing update",
);
return;
}
};
let config = load_config();
let context_tokens = load_context_tokens();
let context_token = resolve_context_token(&metadata, &context_tokens);
let (typing_status, allow_ticket_fetch) = match action {
WechatStatusAction::Typing => (TYPING_STATUS_TYPING, true),
WechatStatusAction::Cancel => (TYPING_STATUS_CANCEL, false),
};
if let Err(error) = send_typing_indicator(
&config,
&metadata,
context_token.as_deref(),
typing_status,
allow_ticket_fetch,
) {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("WeChat typing update failed: {error}"),
);
}
}
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
Ok(())
}
fn on_shutdown() {}
}
fn incoming_bundle_from_message(
config: &WechatConfig,
message: WechatMessage,
) -> Result<Option<PendingInboundBundle>, String> {
if message.message_type != Some(MESSAGE_TYPE_USER) {
return Ok(None);
}
let from_user_id = match message.from_user_id.as_deref() {
Some(user_id) => user_id,
None => return Ok(None),
};
let text = extract_text(&message);
let attachments = media::extract_inbound_attachments(config, &message)?
.into_iter()
.map(StoredInboundAttachment::from)
.collect::<Vec<_>>();
if text.trim().is_empty() && attachments.is_empty() {
return Ok(None);
}
Ok(Some(PendingInboundBundle {
from_user_id: from_user_id.to_string(),
to_user_id: message.to_user_id,
session_id: message.session_id,
context_token: message.context_token,
message_id: message.message_id,
flush_at_ms: 0,
text,
attachments,
}))
}
fn process_incoming_bundle(
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
mut bundle: PendingInboundBundle,
pending_inbound_changed: &mut bool,
now_ms: u64,
inbound_merge_window_ms: u64,
) -> Vec<PendingInboundBundle> {
let key = bundle.from_user_id.clone();
let bundle_has_text = !bundle.text.trim().is_empty();
let bundle_has_attachments = !bundle.attachments.is_empty();
if let Some(mut pending) = pending_inbound.remove(&key) {
*pending_inbound_changed = true;
if bundle_has_text {
let incoming_metadata = bundle.clone();
pending.text = merge_text(&pending.text, &bundle.text);
pending.attachments.extend(bundle.attachments);
merge_bundle_metadata(&mut pending, &incoming_metadata);
return vec![pending];
}
let incoming_metadata = bundle.clone();
pending.attachments.extend(bundle.attachments);
merge_bundle_metadata(&mut pending, &incoming_metadata);
pending.flush_at_ms = next_flush_deadline(now_ms, inbound_merge_window_ms);
pending_inbound.insert(key, pending);
return Vec::new();
}
if bundle_has_attachments && !bundle_has_text {
*pending_inbound_changed = true;
bundle.flush_at_ms = next_flush_deadline(now_ms, inbound_merge_window_ms);
pending_inbound.insert(key, bundle);
Vec::new()
} else {
vec![bundle]
}
}
fn collect_follow_up_bundles(
config: &WechatConfig,
current_cursor: &mut String,
context_tokens: &mut std::collections::HashMap<String, String>,
context_tokens_changed: &mut bool,
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
pending_inbound_changed: &mut bool,
) {
while !pending_inbound.is_empty() {
let now_ms = channel_host::now_millis();
let Some(timeout_ms) = next_follow_up_timeout_ms(pending_inbound, now_ms) else {
break;
};
if timeout_ms == 0 {
break;
}
let timeout_ms_u32 = timeout_ms.min(u64::from(u32::MAX)) as u32;
let response = match api::get_updates_with_timeout(config, current_cursor, timeout_ms_u32) {
Ok(response) => response,
Err(_) => break,
};
if response.errcode == Some(-14) {
channel_host::log(
channel_host::LogLevel::Error,
"WeChat getUpdates returned errcode=-14 during follow-up merge window; reconnect the channel",
);
break;
}
if response.ret.unwrap_or(0) != 0 {
let errmsg = response
.errmsg
.as_deref()
.unwrap_or("unknown WeChat polling error");
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"WeChat getUpdates returned ret={} errmsg={errmsg} during follow-up merge window",
response.ret.unwrap_or(-1)
),
);
}
if let Some(next_cursor) = response.get_updates_buf.as_deref() {
if next_cursor != current_cursor {
*current_cursor = next_cursor.to_string();
if let Err(error) = persist_get_updates_buf(next_cursor) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat polling cursor: {error}"),
);
}
}
}
let mut saw_relevant_message = false;
for message in response.msgs {
if let Some(from_user_id) = message.from_user_id.as_deref() {
if let Some(context_token) = message.context_token.as_deref() {
let changed = context_tokens
.insert(from_user_id.to_string(), context_token.to_string())
.as_deref()
!= Some(context_token);
*context_tokens_changed |= changed;
}
}
match incoming_bundle_from_message(config, message) {
Ok(Some(bundle)) => {
let emitted = process_incoming_bundle(
pending_inbound,
bundle,
pending_inbound_changed,
channel_host::now_millis(),
u64::from(config.inbound_merge_window_ms),
);
for emitted_bundle in emitted {
saw_relevant_message = true;
emit_buffered_bundle(emitted_bundle);
}
}
Ok(None) => {}
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to map WeChat inbound message: {error}"),
);
}
}
}
if !saw_relevant_message && pending_inbound.is_empty() {
break;
}
}
}
fn next_flush_deadline(now_ms: u64, inbound_merge_window_ms: u64) -> u64 {
now_ms.saturating_add(inbound_merge_window_ms)
}
fn next_follow_up_timeout_ms(
pending_inbound: &std::collections::HashMap<String, PendingInboundBundle>,
now_ms: u64,
) -> Option<u64> {
pending_inbound
.values()
.map(|bundle| bundle.flush_at_ms.saturating_sub(now_ms))
.min()
}
fn take_due_pending_bundles(
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
now_ms: u64,
) -> Vec<PendingInboundBundle> {
let due_keys = pending_inbound
.iter()
.filter_map(|(key, bundle)| (bundle.flush_at_ms <= now_ms).then_some(key.clone()))
.collect::<Vec<_>>();
due_keys
.into_iter()
.filter_map(|key| pending_inbound.remove(&key))
.collect()
}
fn emit_buffered_bundle(bundle: PendingInboundBundle) {
let metadata = json!({
"from_user_id": bundle.from_user_id,
"to_user_id": bundle.to_user_id,
"message_id": bundle.message_id,
"session_id": bundle.session_id,
"context_token": bundle.context_token,
});
channel_host::emit_message(&EmittedMessage {
user_id: bundle.from_user_id.clone(),
user_name: None,
content: bundle.text,
thread_id: Some(format!("wechat:{}", bundle.from_user_id)),
metadata_json: metadata.to_string(),
attachments: bundle.attachments.into_iter().map(Into::into).collect(),
});
}
fn merge_bundle_metadata(target: &mut PendingInboundBundle, incoming: &PendingInboundBundle) {
if incoming.to_user_id.is_some() {
target.to_user_id = incoming.to_user_id.clone();
}
if incoming.session_id.is_some() {
target.session_id = incoming.session_id.clone();
}
if incoming.context_token.is_some() {
target.context_token = incoming.context_token.clone();
}
if incoming.message_id.is_some() {
target.message_id = incoming.message_id;
}
}
fn merge_text(existing: &str, incoming: &str) -> String {
let existing = existing.trim();
let incoming = incoming.trim();
match (existing.is_empty(), incoming.is_empty()) {
(true, true) => String::new(),
(true, false) => incoming.to_string(),
(false, true) => existing.to_string(),
(false, false) => format!("{existing}\n\n{incoming}"),
}
}
fn send_response(
config: &WechatConfig,
metadata: &OutboundMetadata,
response: &AgentResponse,
context_token: Option<&str>,
) -> Result<(), String> {
let mut remaining_text = response.content.trim().to_string();
let mut sent_attachment = false;
for attachment in &response.attachments {
if !attachment.mime_type.starts_with("image/") {
return Err(format!(
"WeChat currently supports image attachments only, got {} ({})",
attachment.filename, attachment.mime_type
));
}
let caption = if sent_attachment {
""
} else {
remaining_text.as_str()
};
media::send_image_attachment(
config,
&metadata.from_user_id,
attachment,
context_token,
caption,
)?;
sent_attachment = true;
remaining_text.clear();
}
if !remaining_text.is_empty() || !sent_attachment {
api::send_text_message(
config,
&metadata.from_user_id,
&remaining_text,
context_token,
)?;
}
Ok(())
}
fn extract_text(message: &WechatMessage) -> String {
message
.item_list
.iter()
.find_map(|item| {
if item.r#type == Some(MESSAGE_ITEM_TEXT) {
item.text_item.as_ref().map(|item| item.text.clone())
} else if item.r#type == Some(crate::types::MESSAGE_ITEM_VOICE) {
item.voice_item
.as_ref()
.and_then(|item| item.text.as_ref())
.cloned()
} else {
None
}
})
.unwrap_or_default()
}
fn is_terminal_text_status(message: &str) -> bool {
let trimmed = message.trim();
trimmed.eq_ignore_ascii_case("done")
|| trimmed.eq_ignore_ascii_case("interrupted")
|| trimmed.eq_ignore_ascii_case("awaiting approval")
|| trimmed.eq_ignore_ascii_case("rejected")
}
fn classify_status_update(update: &StatusUpdate) -> Option<WechatStatusAction> {
match update.status {
StatusType::Thinking => Some(WechatStatusAction::Typing),
StatusType::Done
| StatusType::Interrupted
| StatusType::ApprovalNeeded
| StatusType::AuthRequired => Some(WechatStatusAction::Cancel),
StatusType::Status if is_terminal_text_status(&update.message) => {
Some(WechatStatusAction::Cancel)
}
StatusType::ToolStarted
| StatusType::ToolCompleted
| StatusType::ToolResult
| StatusType::Status
| StatusType::JobStarted
| StatusType::AuthCompleted => None,
}
}
fn resolve_context_token(
metadata: &OutboundMetadata,
context_tokens: &std::collections::HashMap<String, String>,
) -> Option<String> {
metadata
.context_token
.clone()
.or_else(|| context_tokens.get(&metadata.from_user_id).cloned())
}
fn cached_typing_ticket(user_id: &str) -> Option<String> {
let tickets = load_typing_tickets();
let ticket = tickets.get(user_id)?;
let trimmed = ticket.ticket.trim();
if trimmed.is_empty() {
return None;
}
let age_ms = channel_host::now_millis().saturating_sub(ticket.fetched_at_ms);
if age_ms >= TYPING_TICKET_TTL_MS {
return None;
}
Some(trimmed.to_string())
}
fn persist_typing_ticket(user_id: &str, ticket: &str) -> Result<(), String> {
let mut tickets = load_typing_tickets();
tickets.insert(
user_id.to_string(),
TypingTicketEntry {
ticket: ticket.to_string(),
fetched_at_ms: channel_host::now_millis(),
},
);
persist_typing_tickets(&tickets)
}
fn clear_typing_ticket(user_id: &str) -> Result<(), String> {
let mut tickets = load_typing_tickets();
if tickets.remove(user_id).is_some() {
persist_typing_tickets(&tickets)?;
}
Ok(())
}
fn resolve_typing_ticket(
config: &WechatConfig,
user_id: &str,
context_token: Option<&str>,
) -> Result<Option<String>, String> {
if let Some(ticket) = cached_typing_ticket(user_id) {
return Ok(Some(ticket));
}
let response = api::get_config(config, user_id, context_token)?;
if response.ret.unwrap_or(0) != 0 {
let errmsg = response
.errmsg
.as_deref()
.unwrap_or("unknown WeChat getConfig error");
return Err(format!(
"WeChat getConfig returned ret={} errmsg={errmsg}",
response.ret.unwrap_or(-1)
));
}
let Some(ticket) = response
.typing_ticket
.as_deref()
.map(str::trim)
.filter(|ticket| !ticket.is_empty())
else {
return Ok(None);
};
if let Err(error) = persist_typing_ticket(user_id, ticket) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat typing ticket: {error}"),
);
}
Ok(Some(ticket.to_string()))
}
fn send_typing_indicator(
config: &WechatConfig,
metadata: &OutboundMetadata,
context_token: Option<&str>,
status: i32,
allow_ticket_fetch: bool,
) -> Result<(), String> {
let ticket = if allow_ticket_fetch {
resolve_typing_ticket(config, &metadata.from_user_id, context_token)?
} else {
cached_typing_ticket(&metadata.from_user_id)
};
let Some(ticket) = ticket else {
return Ok(());
};
if let Err(error) = api::send_typing(config, &metadata.from_user_id, &ticket, status) {
let _ = clear_typing_ticket(&metadata.from_user_id);
return Err(error);
}
Ok(())
}
export!(WechatChannel);
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{
classify_status_update, extract_text, merge_text, process_incoming_bundle,
take_due_pending_bundles, PendingInboundBundle, StoredInboundAttachment,
WechatStatusAction,
};
use crate::exports::near::agent::channel::{StatusType, StatusUpdate};
use crate::types::{MessageItem, VoiceItem, WechatMessage, MESSAGE_ITEM_VOICE};
fn make_bundle(user_id: &str, text: &str, image_count: usize) -> PendingInboundBundle {
PendingInboundBundle {
from_user_id: user_id.to_string(),
to_user_id: Some("bot".to_string()),
session_id: Some("session-1".to_string()),
context_token: Some("ctx-1".to_string()),
message_id: Some(1),
flush_at_ms: 0,
text: text.to_string(),
attachments: (0..image_count)
.map(|index| StoredInboundAttachment {
id: format!("att-{index}"),
mime_type: "image/jpeg".to_string(),
filename: Some(format!("photo-{index}.jpg")),
size_bytes: Some(128),
source_url: Some("https://example.com/image.jpg".to_string()),
storage_key: None,
extracted_text: None,
extras_json: "{}".to_string(),
})
.collect(),
}
}
#[test]
fn test_classify_status_update_thinking_starts_typing() {
let update = StatusUpdate {
status: StatusType::Thinking,
message: "Thinking...".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(WechatStatusAction::Typing)
);
}
#[test]
fn test_classify_status_update_done_cancels_typing() {
let update = StatusUpdate {
status: StatusType::Done,
message: "Done".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(WechatStatusAction::Cancel)
);
}
#[test]
fn test_classify_status_update_approval_needed_cancels_typing() {
let update = StatusUpdate {
status: StatusType::ApprovalNeeded,
message: "Approval needed".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(WechatStatusAction::Cancel)
);
}
#[test]
fn test_classify_status_update_tool_started_is_ignored() {
let update = StatusUpdate {
status: StatusType::ToolStarted,
message: "Tool started".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_terminal_text_status_cancels_typing() {
let update = StatusUpdate {
status: StatusType::Status,
message: "Awaiting approval".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(WechatStatusAction::Cancel)
);
}
#[test]
fn test_classify_status_update_progress_status_is_ignored() {
let update = StatusUpdate {
status: StatusType::Status,
message: "Context compaction started".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_merge_text_joins_non_empty_segments() {
assert_eq!(merge_text("", "hello"), "hello");
assert_eq!(merge_text("look", "what is this"), "look\n\nwhat is this");
assert_eq!(merge_text("look", ""), "look");
}
#[test]
fn test_extract_text_uses_voice_transcript_when_present() {
let message = WechatMessage {
message_id: Some(1),
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_VOICE),
text_item: None,
image_item: None,
voice_item: Some(VoiceItem {
media: None,
encode_type: Some(6),
playtime: Some(1500),
text: Some("voice transcript".to_string()),
}),
file_item: None,
}],
};
assert_eq!(extract_text(&message), "voice transcript");
}
#[test]
fn test_process_incoming_bundle_merges_buffered_image_with_follow_up_text() {
let mut pending = HashMap::new();
let mut changed = false;
let emitted = process_incoming_bundle(
&mut pending,
make_bundle("u1", "", 1),
&mut changed,
100,
5_000,
);
assert!(emitted.is_empty());
assert!(changed);
assert_eq!(pending.len(), 1);
assert_eq!(pending["u1"].flush_at_ms, 5100);
changed = false;
let emitted = process_incoming_bundle(
&mut pending,
make_bundle("u1", "What is in this image?", 0),
&mut changed,
200,
5_000,
);
assert!(changed);
assert!(pending.is_empty());
assert_eq!(emitted.len(), 1);
assert_eq!(emitted[0].text, "What is in this image?");
assert_eq!(emitted[0].attachments.len(), 1);
}
#[test]
fn test_process_incoming_bundle_emits_text_and_images_together_without_buffering() {
let mut pending = HashMap::new();
let mut changed = false;
let emitted = process_incoming_bundle(
&mut pending,
make_bundle("u1", "Look at this image", 1),
&mut changed,
100,
5_000,
);
assert!(!changed);
assert!(pending.is_empty());
assert_eq!(emitted.len(), 1);
assert_eq!(emitted[0].text, "Look at this image");
assert_eq!(emitted[0].attachments.len(), 1);
}
#[test]
fn test_take_due_pending_bundles_emits_only_expired_entries() {
let mut pending = HashMap::new();
let mut expired = make_bundle("u1", "", 1);
expired.flush_at_ms = 100;
let mut fresh = make_bundle("u2", "", 1);
fresh.flush_at_ms = 300;
pending.insert(expired.from_user_id.clone(), expired);
pending.insert(fresh.from_user_id.clone(), fresh);
let due = take_due_pending_bundles(&mut pending, 200);
assert_eq!(due.len(), 1);
assert_eq!(due[0].from_user_id, "u1");
assert_eq!(pending.len(), 1);
assert!(pending.contains_key("u2"));
}
}
-718
View File
@@ -1,718 +0,0 @@
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use base64::Engine as _;
use md5::{Digest, Md5};
use rand::RngCore;
use serde_json::json;
use crate::exports::near::agent::channel::Attachment;
use crate::near::agent::channel_host::{self, InboundAttachment};
use crate::types::{
CdnMedia, FileItem, ImageItem, MessageItem, SendMessageRequest, WechatConfig,
MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE, MESSAGE_STATE_FINISH,
MESSAGE_TYPE_BOT, UPLOAD_MEDIA_TYPE_IMAGE,
};
const AES_BLOCK_SIZE: usize = 16;
#[derive(Debug, Clone)]
pub struct UploadImage {
pub download_encrypted_query_param: String,
pub aes_key_base64: String,
pub file_size_ciphertext: u64,
}
pub fn extract_inbound_attachments(
config: &WechatConfig,
message: &crate::types::WechatMessage,
) -> Result<Vec<InboundAttachment>, String> {
message
.item_list
.iter()
.enumerate()
.filter_map(|(index, item)| {
map_inbound_attachment(config, message, item, index).transpose()
})
.collect()
}
pub fn send_image_attachment(
config: &WechatConfig,
to_user_id: &str,
attachment: &Attachment,
context_token: Option<&str>,
text: &str,
) -> Result<(), String> {
if attachment.data.is_empty() {
return Err(format!(
"WeChat image attachment '{}' has no data",
attachment.filename
));
}
let upload = upload_image(config, to_user_id, attachment)?;
if !text.trim().is_empty() {
crate::api::send_text_message(config, to_user_id, text.trim(), context_token)?;
}
let request = SendMessageRequest {
msg: crate::types::OutboundWechatMessage {
from_user_id: String::new(),
to_user_id: to_user_id.to_string(),
client_id: format!("wechat-{}", channel_host::now_millis()),
message_type: MESSAGE_TYPE_BOT,
message_state: MESSAGE_STATE_FINISH,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_IMAGE),
text_item: None,
image_item: Some(ImageItem {
media: Some(CdnMedia {
encrypt_query_param: Some(upload.download_encrypted_query_param.clone()),
aes_key: Some(upload.aes_key_base64.clone()),
encrypt_type: Some(1),
}),
aeskey: None,
mid_size: Some(upload.file_size_ciphertext),
}),
voice_item: None,
file_item: None,
}],
context_token: context_token.map(str::to_string),
},
base_info: crate::api::base_info(),
};
crate::api::send_message_request(config, &request)
}
fn map_inbound_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type == Some(MESSAGE_ITEM_IMAGE) {
return map_image_attachment(config, message, item, index);
}
if item.r#type == Some(MESSAGE_ITEM_VOICE) {
return map_voice_attachment(config, message, item, index);
}
if item.r#type == Some(MESSAGE_ITEM_FILE) {
return map_file_attachment(config, message, item, index);
}
Ok(None)
}
fn map_image_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type != Some(MESSAGE_ITEM_IMAGE) {
return Ok(None);
}
let image = item.image_item.as_ref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing image_item payload",
message.message_id
)
})?;
let media = image.media.as_ref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing media payload",
message.message_id
)
})?;
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing encrypt_query_param",
message.message_id
)
})?;
let message_id = message
.message_id
.ok_or_else(|| "WeChat image message is missing message_id".to_string())?;
let aes_key = preferred_image_aes_key(image, media).map(str::to_string);
Ok(Some(InboundAttachment {
id: format!("wechat-image-{}-{}", message_id, index),
mime_type: "image/jpeg".to_string(),
filename: Some(format!("wechat-image-{}-{}.jpg", message_id, index)),
size_bytes: image.mid_size,
source_url: Some(build_cdn_download_url(
&config.cdn_base_url,
encrypt_query_param,
)),
storage_key: None,
extracted_text: None,
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
}))
}
fn map_file_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type != Some(MESSAGE_ITEM_FILE) {
return Ok(None);
}
let file = item.file_item.as_ref().ok_or_else(|| {
format!(
"WeChat file message {:?} is missing file_item payload",
message.message_id
)
})?;
let media = file.media.as_ref().ok_or_else(|| {
format!(
"WeChat file message {:?} is missing media payload",
message.message_id
)
})?;
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
format!(
"WeChat file message {:?} is missing encrypt_query_param",
message.message_id
)
})?;
let aes_key = media
.aes_key
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
format!(
"WeChat file message {:?} is missing aes_key",
message.message_id
)
})?;
let message_id = message
.message_id
.ok_or_else(|| "WeChat file message is missing message_id".to_string())?;
let filename = inbound_file_name(file, message_id, index);
let size_bytes = file.len.as_deref().and_then(parse_file_size);
Ok(Some(InboundAttachment {
id: format!("wechat-file-{}-{}", message_id, index),
mime_type: infer_file_mime_type(&filename),
filename: Some(filename),
size_bytes,
source_url: Some(build_cdn_download_url(
&config.cdn_base_url,
encrypt_query_param,
)),
storage_key: None,
extracted_text: None,
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
}))
}
fn map_voice_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type != Some(MESSAGE_ITEM_VOICE) {
return Ok(None);
}
let voice = item.voice_item.as_ref().ok_or_else(|| {
format!(
"WeChat voice message {:?} is missing voice_item payload",
message.message_id
)
})?;
let media = voice.media.as_ref().ok_or_else(|| {
format!(
"WeChat voice message {:?} is missing media payload",
message.message_id
)
})?;
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
format!(
"WeChat voice message {:?} is missing encrypt_query_param",
message.message_id
)
})?;
let aes_key = media
.aes_key
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
format!(
"WeChat voice message {:?} is missing aes_key",
message.message_id
)
})?;
let message_id = message
.message_id
.ok_or_else(|| "WeChat voice message is missing message_id".to_string())?;
let (mime_type, extension) = infer_voice_media_type(voice.encode_type);
let duration_secs = voice.playtime.map(|millis| (millis / 1000) as u32);
Ok(Some(InboundAttachment {
id: format!("wechat-voice-{}-{}", message_id, index),
mime_type: mime_type.to_string(),
filename: Some(format!(
"wechat-voice-{}-{}.{}",
message_id, index, extension
)),
size_bytes: None,
source_url: Some(build_cdn_download_url(
&config.cdn_base_url,
encrypt_query_param,
)),
storage_key: None,
extracted_text: voice
.text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
extras_json: build_voice_extras_json(aes_key, duration_secs),
}))
}
fn preferred_image_aes_key<'a>(image: &'a ImageItem, media: &'a CdnMedia) -> Option<&'a str> {
image
.aeskey
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
media
.aes_key
.as_deref()
.filter(|value| !value.trim().is_empty())
})
}
fn inbound_file_name(file: &FileItem, message_id: i64, index: usize) -> String {
file.file_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("wechat-file-{}-{}.bin", message_id, index))
}
fn parse_file_size(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok()
}
fn infer_voice_media_type(encode_type: Option<i32>) -> (&'static str, &'static str) {
match encode_type {
Some(7) => ("audio/mpeg", "mp3"),
Some(8) => ("audio/ogg", "ogg"),
Some(5) => ("audio/amr", "amr"),
Some(6) => ("audio/silk", "silk"),
_ => ("audio/silk", "silk"),
}
}
fn build_voice_extras_json(aes_key: &str, duration_secs: Option<u32>) -> String {
let mut extras = serde_json::Map::new();
extras.insert("wechat_aes_key".to_string(), json!(aes_key));
if let Some(duration_secs) = duration_secs {
extras.insert("duration_secs".to_string(), json!(duration_secs));
}
serde_json::Value::Object(extras).to_string()
}
fn infer_file_mime_type(filename: &str) -> String {
let extension = filename
.rsplit_once('.')
.map(|(_, ext)| ext.trim().to_ascii_lowercase());
match extension.as_deref() {
Some("pdf") => "application/pdf",
Some("doc") => "application/msword",
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("xls") => "application/vnd.ms-excel",
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Some("ppt") => "application/vnd.ms-powerpoint",
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
Some("txt") => "text/plain",
Some("csv") => "text/csv",
Some("json") => "application/json",
Some("xml") => "application/xml",
Some("md") => "text/markdown",
Some("zip") => "application/zip",
Some("tar") => "application/x-tar",
Some("gz") => "application/gzip",
Some("mp3") => "audio/mpeg",
Some("ogg") => "audio/ogg",
Some("wav") => "audio/wav",
Some("mp4") => "video/mp4",
Some("mov") => "video/quicktime",
Some("webm") => "video/webm",
Some("mkv") => "video/x-matroska",
Some("avi") => "video/x-msvideo",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
_ => "application/octet-stream",
}
.to_string()
}
fn upload_image(
config: &WechatConfig,
to_user_id: &str,
attachment: &Attachment,
) -> Result<UploadImage, String> {
let plaintext = &attachment.data;
let raw_size = plaintext.len() as u64;
let raw_md5 = hex_lower(md5_bytes(plaintext));
let file_size_ciphertext = padded_size(raw_size);
let filekey = hex_lower(random_bytes(16)?);
let aes_key = random_bytes(16)?;
let aes_key_hex = hex_lower(aes_key.clone());
let upload_url = crate::api::get_upload_url(
config,
&crate::types::GetUploadUrlRequest {
filekey: filekey.clone(),
media_type: UPLOAD_MEDIA_TYPE_IMAGE,
to_user_id: to_user_id.to_string(),
rawsize: raw_size,
rawfilemd5: raw_md5,
filesize: file_size_ciphertext,
no_need_thumb: true,
aeskey: aes_key_hex,
base_info: crate::api::base_info(),
},
)?;
let upload_param = upload_url
.upload_param
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "WeChat getUploadUrl returned no upload_param".to_string())?;
if upload_url.thumb_upload_param.is_some() {
channel_host::log(
channel_host::LogLevel::Debug,
"WeChat image upload returned thumb_upload_param; ignoring for single-image flow",
);
}
let ciphertext = encrypt_aes_ecb_pkcs7(plaintext, &aes_key)?;
let upload_response = channel_host::http_request(
"POST",
&build_cdn_upload_url(&config.cdn_base_url, upload_param, &filekey),
r#"{"Content-Type":"application/octet-stream"}"#,
Some(&ciphertext),
Some(15_000),
)
.map_err(|e| format!("WeChat CDN upload failed: {e}"))?;
if upload_response.status != 200 {
let body = String::from_utf8_lossy(&upload_response.body);
return Err(format!(
"WeChat CDN upload returned {}: {}",
upload_response.status, body
));
}
let headers: std::collections::HashMap<String, String> =
serde_json::from_str(&upload_response.headers_json)
.map_err(|e| format!("Failed to parse WeChat CDN upload headers: {e}"))?;
let download_encrypted_query_param = headers
.iter()
.find_map(|(key, value)| {
if key.eq_ignore_ascii_case("x-encrypted-param") {
Some(value.clone())
} else {
None
}
})
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "WeChat CDN upload response missing x-encrypted-param".to_string())?;
Ok(UploadImage {
download_encrypted_query_param,
aes_key_base64: base64::engine::general_purpose::STANDARD.encode(aes_key),
file_size_ciphertext,
})
}
fn build_cdn_download_url(cdn_base_url: &str, encrypted_query_param: &str) -> String {
format!(
"{}/download?encrypted_query_param={}",
cdn_base_url.trim_end_matches('/'),
percent_encode(encrypted_query_param)
)
}
fn build_cdn_upload_url(cdn_base_url: &str, upload_param: &str, filekey: &str) -> String {
format!(
"{}/upload?encrypted_query_param={}&filekey={}",
cdn_base_url.trim_end_matches('/'),
percent_encode(upload_param),
percent_encode(filekey)
)
}
fn percent_encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(byte as char);
} else {
encoded.push('%');
encoded.push(nibble_to_hex(byte >> 4));
encoded.push(nibble_to_hex(byte & 0x0F));
}
}
encoded
}
fn nibble_to_hex(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
10..=15 => (b'A' + (nibble - 10)) as char,
_ => '0',
}
}
fn encode_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(nibble_to_hex(byte >> 4));
out.push(nibble_to_hex(byte & 0x0F));
}
out
}
fn hex_lower(bytes: Vec<u8>) -> String {
encode_hex(&bytes).to_ascii_lowercase()
}
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
let mut padded = plaintext.to_vec();
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
}
Ok(padded)
}
fn md5_bytes(bytes: &[u8]) -> Vec<u8> {
Md5::digest(bytes).to_vec()
}
fn random_bytes(len: usize) -> Result<Vec<u8>, String> {
let mut bytes = vec![0u8; len];
rand::rngs::OsRng.fill_bytes(&mut bytes);
if bytes.iter().all(|byte| *byte == 0) {
return Err("OS RNG returned all-zero bytes unexpectedly".to_string());
}
Ok(bytes)
}
fn padded_size(raw_size: u64) -> u64 {
((raw_size / AES_BLOCK_SIZE as u64) + 1) * AES_BLOCK_SIZE as u64
}
#[cfg(test)]
mod tests {
use super::{
build_voice_extras_json, encode_hex, encrypt_aes_ecb_pkcs7, infer_file_mime_type,
infer_voice_media_type, map_file_attachment, map_image_attachment, map_voice_attachment,
AES_BLOCK_SIZE,
};
use crate::types::{
CdnMedia, FileItem, ImageItem, MessageItem, VoiceItem, WechatConfig, WechatMessage,
MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE,
};
#[test]
fn test_encrypt_aes_ecb_pkcs7_is_block_aligned() {
let key = [0x11u8; 16];
let plaintext = b"wechat image payload".to_vec();
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
assert_eq!(ciphertext.len() % AES_BLOCK_SIZE, 0);
assert_ne!(ciphertext, plaintext);
assert_eq!(
encode_hex(&ciphertext).to_ascii_lowercase(),
"a7464c94a03fb2c5aa783597a1d2f5a461f1cd5d83a7bd92721e8ac1853f881f"
);
}
#[test]
fn test_map_image_attachment_errors_when_message_id_missing() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: None,
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_IMAGE),
text_item: None,
image_item: Some(ImageItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("aes".to_string()),
encrypt_type: Some(1),
}),
aeskey: None,
mid_size: Some(128),
}),
voice_item: None,
file_item: None,
}],
};
let error = map_image_attachment(&config, &message, &message.item_list[0], 0)
.expect_err("missing message_id should error");
assert!(error.contains("missing message_id"));
}
#[test]
fn test_map_file_attachment_uses_filename_and_size_metadata() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: Some(42),
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_FILE),
text_item: None,
image_item: None,
voice_item: None,
file_item: Some(FileItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()),
encrypt_type: Some(1),
}),
file_name: Some("report.PDF".to_string()),
len: Some("256".to_string()),
}),
}],
};
let attachment = map_file_attachment(&config, &message, &message.item_list[0], 0)
.expect("file attachment should map")
.expect("file attachment should be present");
assert_eq!(attachment.id, "wechat-file-42-0");
assert_eq!(attachment.mime_type, "application/pdf");
assert_eq!(attachment.filename.as_deref(), Some("report.PDF"));
assert_eq!(attachment.size_bytes, Some(256));
assert!(attachment.extras_json.contains("wechat_aes_key"));
}
#[test]
fn test_map_file_attachment_errors_when_message_id_missing() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: None,
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_FILE),
text_item: None,
image_item: None,
voice_item: None,
file_item: Some(FileItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("aes".to_string()),
encrypt_type: Some(1),
}),
file_name: Some("report.pdf".to_string()),
len: Some("256".to_string()),
}),
}],
};
let error = map_file_attachment(&config, &message, &message.item_list[0], 0)
.expect_err("missing message_id should error");
assert!(error.contains("missing message_id"));
}
#[test]
fn test_infer_file_mime_type_defaults_to_octet_stream() {
assert_eq!(
infer_file_mime_type("archive.unknown"),
"application/octet-stream"
);
assert_eq!(infer_file_mime_type("README"), "application/octet-stream");
}
#[test]
fn test_infer_voice_media_type_defaults_to_silk() {
assert_eq!(infer_voice_media_type(Some(6)), ("audio/silk", "silk"));
assert_eq!(infer_voice_media_type(Some(8)), ("audio/ogg", "ogg"));
assert_eq!(infer_voice_media_type(None), ("audio/silk", "silk"));
}
#[test]
fn test_build_voice_extras_json_includes_duration() {
let extras = build_voice_extras_json("aes-key", Some(9));
assert!(extras.contains("wechat_aes_key"));
assert!(extras.contains("duration_secs"));
}
#[test]
fn test_map_voice_attachment_sets_audio_metadata() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: Some(77),
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_VOICE),
text_item: None,
image_item: None,
voice_item: Some(VoiceItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()),
encrypt_type: Some(1),
}),
encode_type: Some(8),
playtime: Some(4200),
text: Some("hello from voice".to_string()),
}),
file_item: None,
}],
};
let attachment = map_voice_attachment(&config, &message, &message.item_list[0], 0)
.expect("voice attachment should map")
.expect("voice attachment should be present");
assert_eq!(attachment.id, "wechat-voice-77-0");
assert_eq!(attachment.mime_type, "audio/ogg");
assert_eq!(
attachment.filename.as_deref(),
Some("wechat-voice-77-0.ogg")
);
assert_eq!(
attachment.extracted_text.as_deref(),
Some("hello from voice")
);
assert!(attachment.extras_json.contains("duration_secs"));
}
}
-158
View File
@@ -1,158 +0,0 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::auth::{
CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, PENDING_INBOUND_PATH,
TYPING_TICKETS_PATH,
};
use crate::near::agent::channel_host;
use crate::types::WechatConfig;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TypingTicketEntry {
pub ticket: String,
pub fetched_at_ms: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct StoredInboundAttachment {
pub id: String,
pub mime_type: String,
pub filename: Option<String>,
pub size_bytes: Option<u64>,
pub source_url: Option<String>,
pub storage_key: Option<String>,
pub extracted_text: Option<String>,
pub extras_json: String,
}
impl From<channel_host::InboundAttachment> for StoredInboundAttachment {
fn from(value: channel_host::InboundAttachment) -> Self {
Self {
id: value.id,
mime_type: value.mime_type,
filename: value.filename,
size_bytes: value.size_bytes,
source_url: value.source_url,
storage_key: value.storage_key,
extracted_text: value.extracted_text,
extras_json: value.extras_json,
}
}
}
impl From<StoredInboundAttachment> for channel_host::InboundAttachment {
fn from(value: StoredInboundAttachment) -> Self {
Self {
id: value.id,
mime_type: value.mime_type,
filename: value.filename,
size_bytes: value.size_bytes,
source_url: value.source_url,
storage_key: value.storage_key,
extracted_text: value.extracted_text,
extras_json: value.extras_json,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct PendingInboundBundle {
pub from_user_id: String,
pub to_user_id: Option<String>,
pub session_id: Option<String>,
pub context_token: Option<String>,
pub message_id: Option<i64>,
pub flush_at_ms: u64,
pub text: String,
pub attachments: Vec<StoredInboundAttachment>,
}
pub fn load_config() -> WechatConfig {
channel_host::workspace_read(CONFIG_PATH)
.and_then(|raw| serde_json::from_str::<WechatConfig>(&raw).ok())
.unwrap_or_default()
}
pub fn persist_config(config: &WechatConfig) -> Result<(), String> {
let serialized =
serde_json::to_string(config).map_err(|e| format!("Failed to serialize config: {e}"))?;
channel_host::workspace_write(CONFIG_PATH, &serialized).map_err(|e| e.to_string())
}
pub fn load_get_updates_buf() -> String {
channel_host::workspace_read(GET_UPDATES_BUF_PATH)
.and_then(|raw| serde_json::from_str::<String>(&raw).ok())
.unwrap_or_default()
}
pub fn persist_get_updates_buf(value: &str) -> Result<(), String> {
let serialized =
serde_json::to_string(value).map_err(|e| format!("Failed to serialize cursor: {e}"))?;
channel_host::workspace_write(GET_UPDATES_BUF_PATH, &serialized).map_err(|e| e.to_string())
}
pub fn load_context_tokens() -> HashMap<String, String> {
channel_host::workspace_read(CONTEXT_TOKENS_PATH)
.and_then(|raw| serde_json::from_str::<HashMap<String, String>>(&raw).ok())
.unwrap_or_default()
}
pub fn persist_context_tokens(tokens: &HashMap<String, String>) -> Result<(), String> {
let serialized =
serde_json::to_string(tokens).map_err(|e| format!("Failed to serialize tokens: {e}"))?;
channel_host::workspace_write(CONTEXT_TOKENS_PATH, &serialized).map_err(|e| e.to_string())
}
pub fn load_typing_tickets() -> HashMap<String, TypingTicketEntry> {
channel_host::workspace_read(TYPING_TICKETS_PATH)
.and_then(|raw| serde_json::from_str::<HashMap<String, TypingTicketEntry>>(&raw).ok())
.unwrap_or_default()
}
pub fn persist_typing_tickets(tickets: &HashMap<String, TypingTicketEntry>) -> Result<(), String> {
let serialized =
serde_json::to_string(tickets).map_err(|e| format!("Failed to serialize tickets: {e}"))?;
channel_host::workspace_write(TYPING_TICKETS_PATH, &serialized).map_err(|e| e.to_string())
}
pub fn load_pending_inbound_bundles() -> Result<HashMap<String, PendingInboundBundle>, String> {
parse_pending_inbound_bundles(channel_host::workspace_read(PENDING_INBOUND_PATH).as_deref())
}
pub fn persist_pending_inbound_bundles(
bundles: &HashMap<String, PendingInboundBundle>,
) -> Result<(), String> {
let serialized =
serde_json::to_string(bundles).map_err(|e| format!("Failed to serialize bundles: {e}"))?;
channel_host::workspace_write(PENDING_INBOUND_PATH, &serialized).map_err(|e| e.to_string())
}
fn parse_pending_inbound_bundles(
raw: Option<&str>,
) -> Result<HashMap<String, PendingInboundBundle>, String> {
match raw {
None => Ok(HashMap::new()),
Some(raw) => serde_json::from_str(raw)
.map_err(|e| format!("Failed to parse pending inbound bundles: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pending_inbound_bundles_missing_file_returns_empty_map() {
let bundles = parse_pending_inbound_bundles(None).expect("missing state should be empty");
assert!(bundles.is_empty());
}
#[test]
fn test_parse_pending_inbound_bundles_invalid_json_returns_error() {
let error =
parse_pending_inbound_bundles(Some("{not json")).expect_err("invalid json should err");
assert!(error.contains("Failed to parse pending inbound bundles"));
}
}
-216
View File
@@ -1,216 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WechatConfig {
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default = "default_cdn_base_url")]
pub cdn_base_url: String,
#[serde(default = "default_bot_type")]
pub bot_type: String,
#[serde(default = "default_poll_interval_ms")]
pub poll_interval_ms: u32,
#[serde(default = "default_long_poll_timeout_ms")]
pub long_poll_timeout_ms: u32,
#[serde(default = "default_inbound_merge_window_ms")]
pub inbound_merge_window_ms: u32,
}
fn default_base_url() -> String {
"https://ilinkai.weixin.qq.com".to_string()
}
fn default_cdn_base_url() -> String {
"https://novac2c.cdn.weixin.qq.com/c2c".to_string()
}
fn default_bot_type() -> String {
"3".to_string()
}
fn default_poll_interval_ms() -> u32 {
30_000
}
fn default_long_poll_timeout_ms() -> u32 {
35_000
}
fn default_inbound_merge_window_ms() -> u32 {
5_000
}
impl Default for WechatConfig {
fn default() -> Self {
Self {
base_url: default_base_url(),
cdn_base_url: default_cdn_base_url(),
bot_type: default_bot_type(),
poll_interval_ms: default_poll_interval_ms(),
long_poll_timeout_ms: default_long_poll_timeout_ms(),
inbound_merge_window_ms: default_inbound_merge_window_ms(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BaseInfo {
pub channel_version: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GetUploadUrlRequest {
pub filekey: String,
pub media_type: i32,
pub to_user_id: String,
pub rawsize: u64,
pub rawfilemd5: String,
pub filesize: u64,
pub no_need_thumb: bool,
pub aeskey: String,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GetUpdatesRequest {
pub get_updates_buf: String,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GetConfigRequest {
pub ilink_user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetUpdatesResponse {
pub ret: Option<i32>,
pub errcode: Option<i32>,
pub errmsg: Option<String>,
#[serde(default)]
pub msgs: Vec<WechatMessage>,
pub get_updates_buf: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetUploadUrlResponse {
pub upload_param: Option<String>,
pub thumb_upload_param: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SendMessageRequest {
pub msg: OutboundWechatMessage,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SendTypingRequest {
pub ilink_user_id: String,
pub typing_ticket: String,
pub status: i32,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OutboundWechatMessage {
pub from_user_id: String,
pub to_user_id: String,
pub client_id: String,
pub message_type: i32,
pub message_state: i32,
pub item_list: Vec<MessageItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WechatMessage {
pub message_id: Option<i64>,
pub from_user_id: Option<String>,
pub to_user_id: Option<String>,
pub session_id: Option<String>,
pub message_type: Option<i32>,
pub context_token: Option<String>,
#[serde(default)]
pub item_list: Vec<MessageItem>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetConfigResponse {
pub ret: Option<i32>,
pub errmsg: Option<String>,
pub typing_ticket: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SendTypingResponse {
pub ret: Option<i32>,
pub errmsg: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessageItem {
pub r#type: Option<i32>,
pub text_item: Option<TextItem>,
pub image_item: Option<ImageItem>,
pub voice_item: Option<VoiceItem>,
pub file_item: Option<FileItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TextItem {
pub text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CdnMedia {
pub encrypt_query_param: Option<String>,
pub aes_key: Option<String>,
pub encrypt_type: Option<i32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImageItem {
pub media: Option<CdnMedia>,
pub aeskey: Option<String>,
pub mid_size: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct VoiceItem {
pub media: Option<CdnMedia>,
pub encode_type: Option<i32>,
pub playtime: Option<u64>,
pub text: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FileItem {
pub media: Option<CdnMedia>,
pub file_name: Option<String>,
pub len: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OutboundMetadata {
pub from_user_id: String,
pub to_user_id: Option<String>,
pub message_id: Option<i64>,
pub session_id: Option<String>,
pub context_token: Option<String>,
}
pub const MESSAGE_TYPE_USER: i32 = 1;
pub const MESSAGE_TYPE_BOT: i32 = 2;
pub const MESSAGE_STATE_FINISH: i32 = 2;
pub const MESSAGE_ITEM_TEXT: i32 = 1;
pub const MESSAGE_ITEM_IMAGE: i32 = 2;
pub const MESSAGE_ITEM_VOICE: i32 = 3;
pub const MESSAGE_ITEM_FILE: i32 = 4;
pub const TYPING_STATUS_TYPING: i32 = 1;
pub const TYPING_STATUS_CANCEL: i32 = 2;
pub const UPLOAD_MEDIA_TYPE_IMAGE: i32 = 1;
@@ -1,51 +0,0 @@
{
"version": "0.1.0",
"wit_version": "0.3.0",
"type": "channel",
"name": "wechat",
"description": "WeChat iLink Bot channel for direct-message chat via long polling",
"setup": {
"required_secrets": [
{
"name": "wechat_bot_token",
"prompt": "Connect this channel from the WeChat setup flow. IronClaw stores the bot token after QR login succeeds.",
"optional": false
}
],
"setup_url": "https://ilinkai.weixin.qq.com"
},
"capabilities": {
"http": {
"allowlist": [
{ "host": "ilinkai.weixin.qq.com", "path_prefix": "/" },
{ "host": "novac2c.cdn.weixin.qq.com", "path_prefix": "/c2c/" }
],
"rate_limit": {
"requests_per_minute": 60,
"requests_per_hour": 1200
}
},
"secrets": {
"allowed_names": ["wechat_*"]
},
"channel": {
"allowed_paths": [],
"allow_polling": true,
"min_poll_interval_ms": 30000,
"workspace_prefix": "channels/wechat/",
"callback_timeout_secs": 45,
"emit_rate_limit": {
"messages_per_minute": 100,
"messages_per_hour": 5000
}
}
},
"config": {
"base_url": "https://ilinkai.weixin.qq.com",
"cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c",
"bot_type": "3",
"poll_interval_ms": 30000,
"long_poll_timeout_ms": 35000,
"inbound_merge_window_ms": 5000
}
}
+1 -1
View File
@@ -269,7 +269,7 @@ dependencies = [
[[package]]
name = "whatsapp-channel"
version = "0.2.0"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
+571
View File
@@ -0,0 +1,571 @@
# User Management API
DB-backed user management for multi-tenant IronClaw deployments. Covers admin user CRUD, per-user secrets provisioning, self-service profile, API token management, and usage reporting.
## Authentication
All endpoints require `Authorization: Bearer <token>`. Tokens are either:
- **Env-var tokens** — configured via `GATEWAY_AUTH_TOKEN` (single-user) at startup
- **DB-backed tokens** — created via `POST /api/tokens` or `POST /api/admin/users`
DB tokens are SHA-256 hashed at rest; plaintext is returned exactly once at creation time.
Auth is cached in a bounded LRU (1024 entries, 60s TTL). Suspending a user or revoking a token may take up to 60s to take effect.
## Roles
| Role | Scope |
|------|-------|
| `admin` | Full access to all endpoints |
| `member` | Self-service profile + own token management only |
Endpoints marked **Admin** return `403 Forbidden` for `member` role.
---
## Admin: Users
### POST /api/admin/users
Create a new user. Returns the user record and a one-time plaintext API token.
**Auth:** Admin
**Request body:**
```json
{
"display_name": "Alice Smith",
"email": "[email protected]",
"role": "member"
}
```
| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `display_name` | string | yes | | |
| `email` | string | no | `null` | Must be unique if provided |
| `role` | string | no | `"member"` | `"admin"` or `"member"` |
**Response:** `200 OK`
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"token": "a1b2c3d4e5f6...64-char hex...",
"created_at": "2026-03-25T12:00:00+00:00",
"created_by": "admin-user-id"
}
```
The `token` field is the plaintext API token. It is shown **only once** — store it securely.
**Errors:** `400` (missing display_name, invalid role), `403` (not admin), `503` (no database)
---
### GET /api/admin/users
List all users.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"users": [
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"updated_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00",
"created_by": "admin-user-id"
}
]
}
```
---
### GET /api/admin/users/{id}
Get a single user by ID.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"updated_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00",
"created_by": "admin-user-id",
"metadata": {}
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### PATCH /api/admin/users/{id}
Update a user's display name and/or metadata. Omitted fields are left unchanged.
**Auth:** Admin
**Request body:**
```json
{
"display_name": "Alice Johnson",
"metadata": {"department": "engineering"}
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `display_name` | string | no | |
| `metadata` | object | no | Replaces entire metadata object (merge patch) |
**Response:** `200 OK` — returns the full updated user record (same shape as GET detail, without `last_login_at`/`created_by`).
**Errors:** `404` (user not found), `403` (not admin)
---
### POST /api/admin/users/{id}/suspend
Suspend a user. Suspended users cannot authenticate (DB auth checks user status).
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"status": "suspended"
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### POST /api/admin/users/{id}/activate
Re-activate a suspended user.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"status": "active"
}
```
**Errors:** `404` (user not found), `403` (not admin)
---
### DELETE /api/admin/users/{id}
Permanently delete a user and all associated data (tokens, jobs, conversations, memory, routines, settings, secrets).
**Auth:** Admin
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"deleted": true
}
```
**Errors:** `404` (user not found), `403` (not admin)
**Cascade:** Deletes from `api_tokens`, `agent_jobs`, `conversations`, `memory_documents`, `routines`, `secrets`, `settings`, `wasm_tools`, and related tables. On PostgreSQL this uses FK cascades; on libSQL it uses explicit deletes.
---
## Admin: Per-User Secrets
Provision secrets on behalf of individual users. The primary use case is an application backend (acting as admin) that configures per-user credentials so each user's IronClaw agent can call back to external services.
Secrets are encrypted at rest with AES-256-GCM using a per-secret HKDF-derived key. Plaintext values are **never returned** by any endpoint — they can only be used by the agent's tool system at runtime.
### PUT /api/admin/users/{user_id}/secrets/{name}
Create or update a secret for the specified user. If a secret with the same name already exists, it is overwritten.
**Auth:** Admin
**Path parameters:**
| Param | Type | Notes |
|-------|------|-------|
| `user_id` | string | The user's ID |
| `name` | string | Secret name (normalized to lowercase) |
**Request body:**
```json
{
"value": "sk-live-abc123...",
"provider": "my-app-backend",
"expires_in_days": 90
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `value` | string | yes | The secret value (encrypted at rest, never returned) |
| `provider` | string | no | Tag for grouping (e.g. `"stripe"`, `"my-app"`) |
| `expires_in_days` | integer | no | Auto-expire after N days; `null` = never |
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"name": "my_app_callback_token",
"status": "created"
}
```
**Errors:** `400` (missing value), `403` (not admin), `503` (secrets store not available)
**Example — application backend provisioning a callback token:**
```bash
# Admin creates a user
curl -X POST https://ironclaw.example.com/api/admin/users \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"display_name": "Alice", "role": "member"}'
# Response includes: {"id": "alice-uuid", "token": "alice-bearer-token", ...}
# Admin provisions a per-user callback secret
curl -X PUT https://ironclaw.example.com/api/admin/users/alice-uuid/secrets/app_callback_token \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"value": "per-user-jwt-for-alice", "provider": "my-app"}'
# Now Alice's IronClaw agent can use the "app_callback_token" secret
# when calling tools that need to authenticate back to the app backend.
```
---
### GET /api/admin/users/{user_id}/secrets
List a user's secrets. Returns names and providers only — **never values or hashes**.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"secrets": [
{"name": "app_callback_token", "provider": "my-app"},
{"name": "openai_api_key", "provider": "openai"}
]
}
```
---
### DELETE /api/admin/users/{user_id}/secrets/{name}
Delete a specific secret for a user.
**Auth:** Admin
**Response:** `200 OK`
```json
{
"user_id": "550e8400-...",
"name": "app_callback_token",
"deleted": true
}
```
**Errors:** `404` (secret not found), `403` (not admin), `503` (secrets store not available)
---
## Admin: Usage
### GET /api/admin/usage
Per-user LLM usage statistics aggregated from `llm_calls` via `agent_jobs.user_id`.
**Auth:** Admin
**Query parameters:**
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `user_id` | string | all users | Filter to a single user |
| `period` | string | `"day"` | `"day"` (24h), `"week"` (7d), or `"month"` (30d) |
**Response:** `200 OK`
```json
{
"period": "week",
"since": "2026-03-18T12:00:00+00:00",
"usage": [
{
"user_id": "alice-id",
"model": "claude-sonnet-4-5-20250514",
"call_count": 42,
"input_tokens": 150000,
"output_tokens": 30000,
"total_cost": "1.23"
}
]
}
```
---
## Self-Service: Profile
### GET /api/profile
Get the authenticated user's own profile.
**Auth:** Any authenticated user
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"email": "[email protected]",
"display_name": "Alice Smith",
"status": "active",
"role": "member",
"created_at": "2026-03-25T12:00:00+00:00",
"last_login_at": "2026-03-25T14:30:00+00:00"
}
```
---
### PATCH /api/profile
Update the authenticated user's own display name and/or metadata.
**Auth:** Any authenticated user
**Request body:**
```json
{
"display_name": "Alice Johnson",
"metadata": {"theme": "dark"}
}
```
**Response:** `200 OK`
```json
{
"id": "550e8400-...",
"display_name": "Alice Johnson",
"updated": true
}
```
---
## Self-Service: Tokens
### POST /api/tokens
Create a new API token for the authenticated user. Admins can optionally create tokens for other users by including `user_id`.
**Auth:** Any authenticated user
**Request body:**
```json
{
"name": "CI pipeline",
"expires_in_days": 90,
"user_id": "other-user-id"
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | Human-readable label |
| `expires_in_days` | integer | no | `null` = never expires |
| `user_id` | string | no | Admin-only; create token for another user |
**Response:** `200 OK`
```json
{
"token": "a1b2c3d4...64-char hex...",
"id": "token-uuid",
"name": "CI pipeline",
"token_prefix": "a1b2c3d4",
"expires_at": "2026-06-23T12:00:00+00:00",
"created_at": "2026-03-25T12:00:00+00:00"
}
```
The `token` field is shown **only once**.
---
### GET /api/tokens
List the authenticated user's tokens. Token hashes are never returned.
**Auth:** Any authenticated user
**Response:** `200 OK`
```json
{
"tokens": [
{
"id": "token-uuid",
"name": "CI pipeline",
"token_prefix": "a1b2c3d4",
"expires_at": "2026-06-23T12:00:00+00:00",
"last_used_at": "2026-03-25T14:00:00+00:00",
"created_at": "2026-03-25T12:00:00+00:00",
"revoked_at": null
}
]
}
```
---
### DELETE /api/tokens/{id}
Revoke one of the authenticated user's tokens. Users can only revoke their own tokens.
**Auth:** Any authenticated user
**Path:** `id` — UUID of the token to revoke
**Response:** `200 OK`
```json
{
"status": "revoked",
"id": "token-uuid"
}
```
**Errors:** `400` (invalid UUID), `404` (token not found or belongs to another user)
---
## Error Format
All error responses return a plain text body with the error message and the corresponding HTTP status code:
| Code | Meaning |
|------|---------|
| `400` | Bad request (missing fields, invalid input) |
| `401` | Missing or invalid bearer token |
| `403` | Authenticated but insufficient role (member accessing admin endpoint) |
| `404` | Resource not found |
| `503` | Database or secrets store not available |
| `500` | Internal server error |
---
## Security Model
### Secrets Encryption
- **Algorithm:** AES-256-GCM with per-secret HKDF-SHA256 derived keys
- **Master key:** 32+ bytes, resolved from `SECRETS_MASTER_KEY` env var or OS keychain
- **Storage format:** `nonce (12B) || ciphertext || tag (16B)` in `encrypted_value` column
- **Per-secret salt:** 32 random bytes stored alongside the ciphertext
- **Zero-exposure:** Plaintext never appears in logs, debug output, API responses, or LLM conversations
### Auth Cache
- Bounded LRU cache (1024 entries max)
- 60-second TTL per entry
- Suspending a user or revoking a token takes up to 60s to propagate
---
## Database Schema
### users
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `UUID` / `TEXT` | Primary key, UUID v4 |
| `email` | `TEXT UNIQUE` | Nullable |
| `display_name` | `TEXT NOT NULL` | |
| `status` | `TEXT NOT NULL` | `"active"` or `"suspended"` |
| `role` | `TEXT NOT NULL` | `"admin"` or `"member"` |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
| `last_login_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `created_by` | `TEXT` | Nullable, references `users.id` |
| `metadata` | `JSONB` / `TEXT` | Default `{}` |
### api_tokens
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `UUID` / `TEXT` | Primary key |
| `user_id` | `TEXT NOT NULL` | FK to `users.id` (PG cascades; libSQL explicit cleanup) |
| `token_hash` | `BYTEA` / `BLOB` | SHA-256 of hex-encoded plaintext |
| `token_prefix` | `TEXT NOT NULL` | First 8 chars for identification |
| `name` | `TEXT NOT NULL` | Human-readable label |
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `revoked_at` | `TIMESTAMPTZ` / `TEXT` | Nullable; set on revocation |
### secrets
| Column | Type (PG / libSQL) | Notes |
|--------|--------------------|-------|
| `id` | `UUID` / `TEXT` | Primary key |
| `user_id` | `TEXT NOT NULL` | Scoped to user |
| `name` | `TEXT NOT NULL` | Unique per user (lowercase normalized) |
| `encrypted_value` | `BYTEA` / `BLOB` | AES-256-GCM (nonce + ciphertext + tag) |
| `key_salt` | `BYTEA` / `BLOB` | Per-secret HKDF salt |
| `provider` | `TEXT` | Optional grouping tag |
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Audit: last injection time |
| `usage_count` | `BIGINT` / `INTEGER` | Audit: total injections |
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
@@ -1,262 +0,0 @@
# WeChat Integration Design
**Date:** 2026-03-25
**Status:** Ready for implementation
**Goal:** Add WeChat support to IronClaw using the same upstream iLink Bot protocol as `@tencent-weixin/openclaw-weixin`, while keeping the implementation aligned with IronClaw's extension-first channel architecture.
---
## Upstream Baseline
The current upstream npm package is `@tencent-weixin/openclaw-weixin` version `2.0.1`.
From the package README and source, the upstream WeChat channel does all of the following:
- logs in by QR code against `https://ilinkai.weixin.qq.com`
- receives inbound messages by long-polling `ilink/bot/getupdates`
- sends outbound messages through `ilink/bot/sendmessage`
- uses `ilink/bot/getconfig` and `ilink/bot/sendtyping` for typing indicators
- uses `ilink/bot/getuploadurl` for media uploads
- persists `get_updates_buf` for long-poll resume
- persists `context_token` so replies stay attached to the right WeChat session
- supports multiple logged-in WeChat bot accounts
- treats WeChat as a direct-message-only channel
- block-sends replies instead of token streaming
This design treats that upstream behavior as the capability boundary. We should not add scope based on features the upstream plugin does not have.
---
## Implementation Direction
IronClaw should **not** try to load the upstream OpenClaw plugin directly.
Instead, IronClaw should implement a **native channel extension** under `channels-src/wechat/` and only extend the host/runtime where that support is generic and reusable.
### Why not host the npm plugin directly
- The upstream package depends on `openclaw/plugin-sdk/*` APIs and runtime contracts that IronClaw does not have.
- It assumes OpenClaw-specific lifecycle concepts such as `gateway.startAccount`.
- Recreating an OpenClaw-compatible Node plugin host inside IronClaw would be more work and more fragile than implementing the protocol directly.
### Why `channels-src/wechat/`
- It matches the existing layering used by other platform channels.
- It keeps platform protocol logic out of host-owned core modules.
- It leaves room for the channel to move outside this repo later without changing the host model.
Recommended layout:
```text
channels-src/
wechat/
Cargo.toml
build.sh
wechat.capabilities.json
src/
lib.rs
api.rs
auth.rs
state.rs
types.rs
```
---
## Phase 1 Scope
Phase 1 is a **single-account** implementation of the upstream WeChat channel.
The point of this phase is to keep the channel aligned with upstream behavior while removing the one biggest source of host/runtime complexity: multi-account lifecycle.
### Must-have in Phase 1
- QR code login
- one connected WeChat bot account
- direct-message text receive/send
- `getupdates` long-poll loop
- `sendmessage` outbound replies
- typing indicators via `getconfig` and `sendtyping`
- inbound image download/decrypt for vision
- outbound image upload/send via `getuploadurl`
- `context_token` persistence
- `get_updates_buf` persistence
- login persistence across restart
- extension-first packaging under `channels-src/wechat/`
### Explicit simplification from upstream
- multi-account support is deferred
### Follow-up after Phase 1
These are upstream features, so they belong on the roadmap, but they do not need to block the first implementation cut:
- broader media parity beyond the current image + inbound-file + inbound-voice path (outbound files and video)
We should not spend time listing non-goals that come from outside the upstream capability boundary.
---
## Proposed Architecture
```mermaid
flowchart LR
A["Core WASM channel host"] --> B["WeChat channel extension"]
C["Generic login UI/API"] --> D["QR login session"]
D --> E["Secret storage"]
E --> B
B --> F["getupdates long-poll"]
F --> G["IncomingMessage"]
G --> H["ChannelManager -> Agent"]
H --> I["sendmessage reply"]
```
### Extension responsibilities
`channels-src/wechat/` should own:
- iLink API request/response types
- QR login protocol calls
- long-polling `getupdates`
- `context_token` storage and lookup
- outbound `sendmessage`
- WeChat-specific status/error mapping
### Host responsibilities
IronClaw core should only own reusable pieces:
- installing and activating the WASM channel
- generic secret persistence
- generic QR/device-login session handling for channels
- exposing login flow through authenticated UI/API
- starting and polling the channel runtime
---
## Data And State Model
Phase 1 is single-account, so state should stay simple.
### Secrets
- `wechat_bot_token`
This is written after QR login succeeds and reused on restart.
### Channel state
Under the channel workspace prefix, persist:
- `state/get_updates_buf.json`
- `state/context_tokens.json`
`context_tokens.json` maps the WeChat peer to its latest `context_token`.
### Inbound message mapping
For each inbound WeChat DM:
- `channel = "wechat"`
- `user_id = <wechat sender id>` or owner scope if it is the bound owner
- `thread_id = Some("wechat:<sender_id>")`
- `conversation_scope_id = Some("wechat:<sender_id>")`
`metadata_json` should include:
- `from_user_id`
- `to_user_id`
- `message_id`
- `context_token`
That is enough for `on_respond()` to send the reply back to the right peer.
---
## Minimal Host Uplift
The current extension host is close, but Phase 1 still needs one important addition: a generic interactive login flow for channels.
Minimum host support needed:
1. Start a channel login session.
2. Return QR payload plus a session identifier.
3. Poll login session status.
4. On success, write the returned token to channel secrets.
5. Reload or reactivate the channel so polling starts automatically.
This should be added as a generic channel-auth capability, not as WeChat-specific core logic.
---
## User Flow
Phase 1 should be **web-first**, because the target user is a normal WeChat user rather than a CLI-only operator.
1. Install or enable the `wechat` channel extension.
2. Click "Connect WeChat".
3. Web UI requests a login session from the host.
4. Web UI displays the QR code.
5. User scans and confirms on their phone.
6. Host stores `wechat_bot_token`.
7. Channel reloads and starts polling.
8. User sends a DM in WeChat and receives IronClaw replies there.
CLI support can still exist for development, but it should not be the primary Phase 1 UX.
---
## Message Handling Semantics
### Inbound
On each poll:
1. load `get_updates_buf`
2. call `getupdates`
3. persist the new cursor if present
4. normalize inbound text messages into `IncomingMessage`
5. persist the latest `context_token` for that peer
6. emit the message to the agent
### Outbound
On response:
1. read peer routing info from `metadata_json`
2. load the latest `context_token`
3. convert the response to plain text if needed
4. send one coalesced text reply via `sendmessage`
This matches the upstream channel's block-send behavior.
---
## Testing Plan
### Unit tests
- QR login response parsing
- `get_updates_buf` round-trip
- `context_token` round-trip
- inbound message normalization
- outbound metadata routing
### Integration tests
Use a mock iLink server to cover:
- QR login success and expiry
- restart without re-login
- inbound poll -> agent -> outbound text reply
- cursor resume after restart
---
## Phase 2
After Phase 1 is stable, add the upstream features we intentionally deferred:
- multi-account support
- media upload/send
+31
View File
@@ -0,0 +1,31 @@
-- User management tables for multi-tenant deployments.
--
-- Replaces the static GATEWAY_USER_TOKENS env var with DB-backed
-- user registration, API token management, and invitation flow.
CREATE TABLE users (
id TEXT PRIMARY KEY, -- matches existing user_id pattern (string, not UUID)
email TEXT UNIQUE, -- nullable for token-only users
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | suspended | deactivated
role TEXT NOT NULL DEFAULT 'member', -- admin | member
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ,
created_by TEXT REFERENCES users(id), -- who invited this user (nullable for bootstrap)
metadata JSONB NOT NULL DEFAULT '{}' -- extensible profile data
);
CREATE TABLE api_tokens (
id UUID PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL, -- SHA-256 hash (never store plaintext)
token_prefix TEXT NOT NULL, -- first 8 hex chars for display
name TEXT NOT NULL, -- human label ("my-laptop", "ci-bot")
expires_at TIMESTAMPTZ, -- nullable = never expires
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ -- soft-revoke: set this instead of deleting
);
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
+2 -3
View File
@@ -15,14 +15,13 @@
},
"messaging": {
"display_name": "Messaging Channels",
"description": "Discord, Telegram, Slack, WhatsApp, and WeChat channels",
"description": "Discord, Telegram, Slack, and WhatsApp channels",
"extensions": [
"channels/discord",
"channels/telegram",
"channels/slack",
"channels/whatsapp",
"channels/feishu",
"channels/wechat"
"channels/feishu"
],
"shared_auth": null
},
-32
View File
@@ -1,32 +0,0 @@
{
"name": "wechat",
"display_name": "WeChat Channel",
"kind": "channel",
"version": "0.1.0",
"wit_version": "0.3.0",
"description": "Talk to your agent through a WeChat iLink bot account",
"keywords": [
"messaging",
"chat",
"wechat",
"wechat",
"qr"
],
"source": {
"dir": "channels-src/wechat",
"capabilities": "wechat.capabilities.json",
"crate_name": "wechat-channel"
},
"auth_summary": {
"method": "interactive",
"provider": "WeChat",
"secrets": [
"wechat_bot_token"
],
"shared_auth": null,
"setup_url": "https://ilinkai.weixin.qq.com"
},
"tags": [
"messaging"
]
}
+94
View File
@@ -0,0 +1,94 @@
---
name: abound-remittance
version: 0.1.0
description: Smart remittance assistant for Abound — helps users send money to India with intelligent forex timing and transfer management.
activation:
keywords:
- send money
- transfer
- remittance
- exchange rate
- forex
- INR
- India
- wire
- schedule trade
- trade tomorrow
- convert currency
- send dollars
- rupees
- beneficiary
- funding source
- payment
- how much
- rate today
- best time
- family maintenance
patterns:
- "send \\$?\\d+"
- "schedule.*(trade|transfer|send|wire)"
- "how much.*(INR|rupees|India)"
- "best time to (send|transfer|convert)"
- "(rate|forex).*(good|bad|high|low|today|now)"
- "transfer.*tomorrow|tomorrow.*transfer"
tags:
- fintech
- remittance
- forex
max_context_tokens: 2500
---
# Abound Remittance Assistant
You are a smart remittance assistant for Abound, helping users send money from USD to INR (India) with intelligent timing advice.
## Available Tools
You have these Abound-specific tools:
- **abound_get_account_info** — Get the user's account: limits, recipients, funding sources, payment reasons
- **abound_get_exchange_rate** — Get current USD/INR exchange rate (current + effective after fees)
- **abound_get_forex_score** — Get a 0-100 forex timing score with a signal (convert_now / split_transfer / wait)
- **abound_send_wire** — Execute a wire transfer (requires: funding_source_id, beneficiary_ref_id, amount, payment_reason_key)
- **abound_create_notification** — Send a notification to the user's Abound app
You also have **routine_create** for scheduling future/recurring transfers.
## Workflow: "Send $X" or "Transfer money"
1. **Always check the rate first.** Call `abound_get_exchange_rate` to get the current rate.
2. **Check the forex score.** Call `abound_get_forex_score` to assess timing.
3. **Get account info.** Call `abound_get_account_info` to know the user's limits, recipients, and funding sources.
4. **Advise based on the score:**
- **Score >= 60 (convert_now):** Tell the user it's a good time. Show the rate, the INR equivalent of their amount, and recommend proceeding.
- **Score 40-59 (split_transfer):** Suggest splitting — send half now at the current rate, schedule the rest for later when the rate may improve.
- **Score < 40 (wait):** Unless the transfer is urgent, recommend waiting. Explain why (rate below average, unfavorable season).
5. **Execute if user confirms.** Use `abound_send_wire` with the correct funding_source_id, beneficiary_ref_id, amount, and payment_reason_key from the account info.
6. **Notify.** After a successful wire, call `abound_create_notification` with relevant metadata.
## Workflow: "Schedule a trade" or "Send tomorrow morning"
1. Gather the same info (rate, score, account).
2. Use **routine_create** to schedule the transfer:
- For "tomorrow morning": use cron `"0 9 * * *"` with the user's timezone, set to fire once
- For "every week": use cron `"0 9 * * MON"` (or the user's preferred day)
- The routine prompt should instruct the agent to check the rate and execute the wire
3. Confirm the schedule with the user, showing when it will fire.
## Presentation Rules
- Always show amounts in **both USD and INR**: "$1,000 (~INR 85,420 at today's rate of 85.42)"
- Show the **effective rate** (after fees), not just the market rate
- When showing the forex score, explain it simply: "The forex timing score is 72/100 — this is a good time to send."
- If the user's amount exceeds their limit ($5,000), tell them and suggest splitting into multiple transfers
- Always mention the **estimated delivery time** (1-3 business days) after a wire
## Payment Reasons
When asking about the purpose, offer these options:
- Family Maintenance
- Gift
- Education Support
- Medical Support
If the user doesn't specify, ask which applies.
+18 -9
View File
@@ -303,6 +303,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
} else {
tool_defs
};
// Update context for this iteration
reason_ctx.available_tools = tool_defs;
// Preserve force_text if already set (e.g. by truncation escalation).
@@ -399,16 +400,21 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
};
// Record cost and track token usage (global + per-user).
// When a model override is active, use the override name for attribution
// and let CostGuard look up pricing via costs::model_cost() instead of
// using the default provider's cost_per_token (which reflects the wrong model).
let (model_name, cost_per_token) = if let Some(ref ovr) = reason_ctx.model_override {
(ovr.clone(), None)
// Use the provider's effective_model_name so cost attribution matches
// the model that actually served the request. When the override is
// honoured (e.g. NearAI), this returns the override name; when the
// provider ignores overrides (e.g. Rig-based), it returns the active
// model, keeping attribution accurate in both cases.
let model_name = self
.agent
.llm()
.effective_model_name(reason_ctx.model_override.as_deref());
let cost_per_token = if reason_ctx.model_override.is_some() {
// Override may use different pricing; let CostGuard fall back to
// costs::model_cost() for the effective model.
None
} else {
(
self.agent.llm().active_model_name(),
Some(self.agent.llm().cost_per_token()),
)
Some(self.agent.llm().cost_per_token())
};
let read_discount = self.agent.llm().cache_read_discount();
let write_multiplier = self.agent.llm().cache_write_multiplier();
@@ -1353,6 +1359,7 @@ mod tests {
max_tool_iterations: 50,
auto_approve_tools: false,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -2234,6 +2241,7 @@ mod tests {
max_tool_iterations,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -2361,6 +2369,7 @@ mod tests {
max_tool_iterations: max_iter,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
+25 -23
View File
@@ -400,7 +400,7 @@ impl HeartbeatRunner {
}
/// Send a notification about heartbeat findings.
pub(crate) async fn send_notification(&self, message: &str) {
async fn send_notification(&self, message: &str) {
let Some(ref tx) = self.response_tx else {
tracing::debug!("No response channel configured for heartbeat notifications");
return;
@@ -512,8 +512,8 @@ pub fn spawn_heartbeat(
})
}
/// Spawn a multi-user heartbeat runner that cycles through all users that
/// own routines (enabled or not). Each tick, it queries the DB for distinct
/// Spawn a multi-user heartbeat runner that cycles through all users who
/// have routines (enabled or not). Each tick, it queries the DB for distinct
/// user_ids, creates a per-user workspace, and runs a heartbeat check for
/// each user concurrently. Per-user failure counts are tracked independently.
pub fn spawn_multi_user_heartbeat(
@@ -574,8 +574,10 @@ pub fn spawn_multi_user_heartbeat(
}
};
// Run user heartbeats concurrently so one slow LLM call doesn't
// block others. Cap concurrency to avoid flooding the LLM provider.
// Run user heartbeats (and hygiene) concurrently so one slow LLM
// call doesn't block others. Cap concurrency to avoid flooding the
// LLM provider. Hygiene runs inside the same JoinSet so it is
// tracked and bounded by the same concurrency cap.
const MAX_CONCURRENT_HEARTBEATS: usize = 8;
let mut join_set = tokio::task::JoinSet::new();
@@ -588,23 +590,6 @@ pub fn spawn_multi_user_heartbeat(
let workspace = Arc::new(Workspace::new_with_db(user_id, Arc::clone(store.db())));
// Run memory hygiene per user (same as single-user heartbeat).
let hygiene_ws = Arc::clone(&workspace);
let hygiene_cfg = hygiene_config.clone();
let hygiene_user = user_id.clone();
tokio::spawn(async move {
let report =
crate::workspace::hygiene::run_if_due(&hygiene_ws, &hygiene_cfg).await;
if report.had_work() {
tracing::info!(
user_id = hygiene_user,
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"multi-user heartbeat: memory hygiene deleted stale documents"
);
}
});
// Drain completed tasks to stay within the concurrency cap.
while join_set.len() >= MAX_CONCURRENT_HEARTBEATS {
if let Some(join_result) = join_set.join_next().await {
@@ -613,13 +598,30 @@ pub fn spawn_multi_user_heartbeat(
}
let uid = user_id.clone();
let cfg = config.clone();
// In multi-tenant mode, clear notify_user_id so that
// HeartbeatRunner::send_notification falls back to
// workspace.user_id() — each user's heartbeat should persist
// and notify that user, not the shared config target.
let mut cfg = config.clone();
cfg.notify_user_id = None;
let hyg = hygiene_config.clone();
let llm_clone = llm.clone();
let tx = response_tx.clone();
let admin = store.clone();
join_set.spawn(async move {
// Run memory hygiene per user (same as single-user heartbeat)
// inside the tracked task so concurrency is bounded.
let report = crate::workspace::hygiene::run_if_due(&workspace, &hyg).await;
if report.had_work() {
tracing::info!(
user_id = uid,
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"multi-user heartbeat: memory hygiene deleted stale documents"
);
}
let mut runner = HeartbeatRunner::new(cfg, hyg, workspace, llm_clone);
if let Some(tx) = tx {
runner = runner.with_response_channel(tx);
+15
View File
@@ -267,6 +267,20 @@ impl Scheduler {
});
}
// Per-user concurrency check
if let Some(max_per_user) = self.config.max_jobs_per_user
&& let Ok(ctx) = self.context_manager.get_context(job_id).await
{
let user_active = self
.context_manager
.active_jobs_for(&ctx.user_id)
.await
.len();
if user_active >= max_per_user {
return Err(JobError::MaxJobsExceeded { max: max_per_user });
}
}
// Transition job to in_progress
self.context_manager
.update_context(job_id, |ctx| {
@@ -784,6 +798,7 @@ mod tests {
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job,
multi_tenant: false,
max_llm_concurrent_per_user: None,
+60 -102
View File
@@ -31,58 +31,7 @@ fn requires_preexisting_uuid_thread(channel: &str) -> bool {
matches!(channel, "gateway" | "test")
}
fn validate_inbound_text_for_message(
safety: &crate::safety::SafetyLayer,
content: &str,
attachments: &[crate::channels::IncomingAttachment],
) -> crate::safety::ValidationResult {
if content.trim().is_empty() && !attachments.is_empty() {
crate::safety::ValidationResult::ok()
} else {
safety.validate_input(content)
}
}
impl Agent {
fn reject_unsafe_inbound_user_message(
&self,
message: &IncomingMessage,
content: &str,
) -> Option<SubmissionResult> {
let validation =
validate_inbound_text_for_message(self.safety(), content, &message.attachments);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Some(SubmissionResult::error(format!(
"Input rejected by safety validation: {details}",
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
{
return Some(SubmissionResult::error("Input rejected by safety policy."));
}
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Inbound message blocked: contains leaked secret"
);
return Some(SubmissionResult::error(warning));
}
None
}
/// Hydrate a historical thread from DB into memory if not already present.
///
/// Called before `resolve_thread` so that the session manager finds the
@@ -278,11 +227,34 @@ impl Agent {
}
// Run the same safety checks that the normal path applies
// so blocked content is never stored in pending_messages.
if let Some(rejection) =
self.reject_unsafe_inbound_user_message(message, content)
// (validation, policy, secret scan) so that blocked content
// is never stored in pending_messages or serialized.
let validation = self.safety().validate_input(content);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Ok(SubmissionResult::error(format!(
"Input rejected by safety validation: {details}",
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
{
return Ok(rejection);
return Ok(SubmissionResult::error("Input rejected by safety policy."));
}
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Queued message blocked: contains leaked secret"
);
return Ok(SubmissionResult::error(warning));
}
if !thread.queue_message(content.to_string()) {
@@ -336,11 +308,39 @@ impl Agent {
}
}
// Validate inbound content before the turn is created. Attachment-only
// messages are allowed to pass through so multimodal channels can send
// an empty text body alongside real image/document payloads.
if let Some(rejection) = self.reject_unsafe_inbound_user_message(message, content) {
return Ok(rejection);
// Safety validation for user input
let validation = self.safety().validate_input(content);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Ok(SubmissionResult::error(format!(
"Input rejected by safety validation: {}",
details
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
{
return Ok(SubmissionResult::error("Input rejected by safety policy."));
}
// Scan inbound messages for secrets (API keys, tokens).
// Catching them here prevents the LLM from echoing them back, which
// would trigger the outbound leak detector and create error loops.
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Inbound message blocked: contains leaked secret"
);
return Ok(SubmissionResult::error(warning));
}
// Handle explicit commands (starting with /) directly
@@ -1934,9 +1934,6 @@ fn rebuild_chat_messages_from_db(
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::{AttachmentKind, IncomingAttachment};
use crate::config::SafetyConfig;
use crate::safety::SafetyLayer;
#[test]
fn test_rebuild_chat_messages_user_assistant_only() {
@@ -2074,45 +2071,6 @@ mod tests {
assert_eq!(result[7].content, "Written");
}
#[test]
fn test_validate_inbound_text_rejects_empty_text_without_attachments() {
let safety = SafetyLayer::new(&SafetyConfig {
max_output_length: 10_000,
injection_check_enabled: true,
});
let result = validate_inbound_text_for_message(&safety, "", &[]);
assert!(!result.is_valid);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "input");
assert_eq!(result.errors[0].message, "Input cannot be empty");
}
#[test]
fn test_validate_inbound_text_allows_empty_text_when_attachments_exist() {
let safety = SafetyLayer::new(&SafetyConfig {
max_output_length: 10_000,
injection_check_enabled: true,
});
let attachments = vec![IncomingAttachment {
id: "image-1".to_string(),
kind: AttachmentKind::Image,
mime_type: "image/jpeg".to_string(),
filename: Some("photo.jpg".to_string()),
size_bytes: Some(128),
source_url: Some("https://example.com/photo.jpg".to_string()),
storage_key: None,
extracted_text: None,
data: vec![1, 2, 3],
duration_secs: None,
}];
let result = validate_inbound_text_for_message(&safety, "", &attachments);
assert!(result.is_valid);
assert!(result.errors.is_empty());
}
fn make_db_msg(role: &str, content: &str) -> crate::history::ConversationMessage {
crate::history::ConversationMessage {
id: uuid::Uuid::new_v4(),
+2 -7
View File
@@ -336,17 +336,12 @@ impl AppBuilder {
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws);
// Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured,
// Detect multi-tenant mode: when the database has registered users,
// each authenticated user needs their own workspace scope. Use
// WorkspacePool (which implements WorkspaceResolver) to create
// per-user workspaces on demand instead of sharing the startup
// workspace across all users.
let is_multi_tenant = self
.config
.channels
.gateway
.as_ref()
.is_some_and(|gw| gw.user_tokens.is_some());
let is_multi_tenant = db.has_any_users().await.unwrap_or(false);
if is_multi_tenant {
let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
+6 -61
View File
@@ -122,32 +122,18 @@ impl RelayClient {
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
/// for validating the callback — no URLs.
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
let url = format!("{}/oauth/slack/auth", self.base_url);
tracing::debug!(relay_url = %url, "RelayClient::initiate_oauth: sending request");
let mut query: Vec<(&str, &str)> = vec![];
if let Some(nonce) = state_nonce {
query.push(("state_nonce", nonce));
}
let resp = self
.http
.get(&url)
.get(format!("{}/oauth/slack/auth", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::initiate_oauth: network request failed"
);
RelayError::Network(e.to_string())
})?;
tracing::debug!(
relay_url = %url,
status = %resp.status(),
"RelayClient::initiate_oauth: received response"
);
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status.is_redirection() {
@@ -238,39 +224,20 @@ impl RelayClient {
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
let url = format!("{}/proxy/{}/{}", self.base_url, provider, method);
tracing::debug!(
relay_url = %url,
provider = %provider,
method = %method,
"RelayClient::proxy_provider: sending request"
);
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
let resp = self
.http
.post(&url)
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::proxy_provider: network request failed"
);
RelayError::Network(e.to_string())
})?;
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
relay_url = %url,
status = status,
"RelayClient::proxy_provider: channel-relay returned error"
);
return Err(RelayError::Api {
status,
message: body,
@@ -288,45 +255,23 @@ impl RelayClient {
/// 32-byte secret. Called once at activation time; the result is cached in the
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
let url = format!("{}/relay/signing-secret", self.base_url);
tracing::debug!(
relay_url = %url,
"RelayClient::get_signing_secret: fetching signing secret"
);
let resp = self
.http
.get(&url)
.get(format!("{}/relay/signing-secret", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.query(&[("team_id", team_id)])
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::get_signing_secret: network request failed"
);
RelayError::Network(e.to_string())
})?;
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
relay_url = %url,
status = status,
body = %body,
"RelayClient::get_signing_secret: channel-relay returned error"
);
return Err(RelayError::Api {
status,
message: body,
});
}
tracing::debug!(
relay_url = %url,
"RelayClient::get_signing_secret: received successful response"
);
let body: serde_json::Value = resp
.json()
-435
View File
@@ -1,435 +0,0 @@
use std::time::Duration;
use aes::Aes128;
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
use base64::Engine as _;
use serde::Deserialize;
use silk_rs::decode_silk;
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::host::{Attachment, ChannelHostState};
const AES_BLOCK_SIZE: usize = 16;
const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
const WECHAT_CHANNEL_NAME: &str = "wechat";
const WECHAT_SILK_SAMPLE_RATE_HZ: i32 = 24_000;
#[derive(Debug, Deserialize)]
struct WechatAttachmentExtras {
wechat_aes_key: Option<String>,
}
pub(crate) async fn hydrate_attachment_for_channel(
channel_name: &str,
capabilities: &ChannelCapabilities,
attachment: &mut Attachment,
) {
if !should_hydrate_wechat_attachment(channel_name, attachment) {
return;
}
let Some(source_url) = attachment.source_url.as_deref() else {
return;
};
let Some(encoded_aes_key) = wechat_aes_key(&attachment.extras_json) else {
tracing::warn!(
channel = %channel_name,
attachment_id = %attachment.id,
"Skipping WeChat attachment hydration: missing AES key metadata"
);
return;
};
match download_wechat_attachment_bytes(channel_name, capabilities, source_url).await {
Ok(ciphertext) => match decrypt_wechat_attachment_bytes(&ciphertext, &encoded_aes_key) {
Ok(plaintext) => {
attachment.size_bytes = Some(plaintext.len() as u64);
attachment.data = plaintext;
if attachment.mime_type.starts_with("image/") {
attachment.mime_type = detect_image_mime(&attachment.data).to_string();
} else if is_wechat_silk_attachment(attachment) {
if let Err(error) = maybe_transcode_wechat_silk_attachment(attachment) {
tracing::warn!(
channel = %channel_name,
attachment_id = %attachment.id,
error = %error,
"Failed to transcode WeChat SILK attachment; preserving raw SILK"
);
}
}
}
Err(error) => {
tracing::warn!(
channel = %channel_name,
attachment_id = %attachment.id,
error = %error,
"Failed to decrypt WeChat attachment"
);
}
},
Err(error) => {
tracing::warn!(
channel = %channel_name,
attachment_id = %attachment.id,
error = %error,
"Failed to download WeChat attachment"
);
}
}
}
fn is_wechat_silk_attachment(attachment: &Attachment) -> bool {
attachment.mime_type.eq_ignore_ascii_case("audio/silk")
|| attachment
.filename
.as_deref()
.and_then(|filename| filename.rsplit_once('.').map(|(_, ext)| ext))
.is_some_and(|ext| ext.eq_ignore_ascii_case("silk"))
}
fn should_hydrate_wechat_attachment(channel_name: &str, attachment: &Attachment) -> bool {
channel_name == WECHAT_CHANNEL_NAME
&& attachment.data.is_empty()
&& attachment.source_url.is_some()
}
fn wechat_aes_key(extras_json: &str) -> Option<String> {
if extras_json.trim().is_empty() {
return None;
}
serde_json::from_str::<WechatAttachmentExtras>(extras_json)
.ok()
.and_then(|extras| extras.wechat_aes_key)
.filter(|value| !value.trim().is_empty())
}
async fn download_wechat_attachment_bytes(
channel_name: &str,
capabilities: &ChannelCapabilities,
source_url: &str,
) -> Result<Vec<u8>, String> {
let host_state = ChannelHostState::new(channel_name, capabilities.clone());
host_state.check_http_allowed(source_url, "GET")?;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let response = client
.get(source_url)
.timeout(Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("WeChat CDN download failed: {e}"))?;
if response.status() != reqwest::StatusCode::OK {
return Err(format!(
"WeChat CDN download returned {}",
response.status()
));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read WeChat CDN response body: {e}"))?
.to_vec();
if bytes.is_empty() {
return Err("WeChat CDN download returned an empty body".to_string());
}
if bytes.len() > MAX_ATTACHMENT_BYTES {
return Err(format!(
"WeChat attachment exceeds {MAX_ATTACHMENT_BYTES} bytes"
));
}
Ok(bytes)
}
fn decrypt_wechat_attachment_bytes(
ciphertext: &[u8],
encoded_aes_key: &str,
) -> Result<Vec<u8>, String> {
let key = parse_aes_key(encoded_aes_key)?;
decrypt_aes_ecb_pkcs7(ciphertext, &key)
}
fn parse_aes_key(encoded: &str) -> Result<Vec<u8>, String> {
let decoded = if encoded.len() == 32 && encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
decode_hex(encoded)?
} else {
base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| format!("Failed to decode WeChat AES key: {e}"))?
};
if decoded.len() == AES_BLOCK_SIZE {
return Ok(decoded);
}
if decoded.len() == 32 && decoded.iter().all(|byte| byte.is_ascii_hexdigit()) {
return decode_hex(
std::str::from_utf8(&decoded)
.map_err(|e| format!("WeChat AES key hex payload is not valid UTF-8: {e}"))?,
);
}
Err(format!(
"WeChat AES key must decode to 16 bytes or a 32-char hex string, got {} bytes",
decoded.len()
))
}
fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
if !input.len().is_multiple_of(2) {
return Err("hex input length must be even".to_string());
}
let mut bytes = Vec::with_capacity(input.len() / 2);
let chars: Vec<u8> = input.as_bytes().to_vec();
for idx in (0..chars.len()).step_by(2) {
let high = from_hex_digit(chars[idx])?;
let low = from_hex_digit(chars[idx + 1])?;
bytes.push((high << 4) | low);
}
Ok(bytes)
}
fn from_hex_digit(value: u8) -> Result<u8, String> {
match value {
b'0'..=b'9' => Ok(value - b'0'),
b'a'..=b'f' => Ok(value - b'a' + 10),
b'A'..=b'F' => Ok(value - b'A' + 10),
_ => Err(format!("invalid hex digit '{}'", value as char)),
}
}
fn decrypt_aes_ecb_pkcs7(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
if !ciphertext.len().is_multiple_of(AES_BLOCK_SIZE) {
return Err("ciphertext length is not a multiple of 16 bytes".to_string());
}
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
let mut plaintext = ciphertext.to_vec();
for chunk in plaintext.chunks_exact_mut(AES_BLOCK_SIZE) {
cipher.decrypt_block(GenericArray::from_mut_slice(chunk));
}
let pad_len = *plaintext
.last()
.ok_or_else(|| "ciphertext decrypted to an empty buffer".to_string())?
as usize;
if pad_len == 0 || pad_len > AES_BLOCK_SIZE || pad_len > plaintext.len() {
return Err("invalid PKCS7 padding".to_string());
}
if !plaintext[plaintext.len() - pad_len..]
.iter()
.all(|byte| *byte as usize == pad_len)
{
return Err("invalid PKCS7 padding bytes".to_string());
}
plaintext.truncate(plaintext.len() - pad_len);
Ok(plaintext)
}
fn detect_image_mime(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
"image/png"
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
"image/jpeg"
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
"image/gif"
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
"image/webp"
} else {
"image/jpeg"
}
}
fn maybe_transcode_wechat_silk_attachment(attachment: &mut Attachment) -> Result<(), String> {
if attachment.data.is_empty() {
return Err("SILK attachment has no data".to_string());
}
let pcm = decode_silk(&attachment.data, WECHAT_SILK_SAMPLE_RATE_HZ)
.map_err(|error| format!("SILK decode failed: {error}"))?;
if pcm.is_empty() {
return Err("SILK decoder returned empty PCM".to_string());
}
let wav = pcm_s16le_to_wav(&pcm, WECHAT_SILK_SAMPLE_RATE_HZ as u32)?;
attachment.data = wav;
attachment.size_bytes = Some(attachment.data.len() as u64);
attachment.mime_type = "audio/wav".to_string();
if let Some(filename) = attachment.filename.as_mut() {
replace_attachment_extension(filename, "wav");
}
Ok(())
}
fn pcm_s16le_to_wav(pcm: &[u8], sample_rate_hz: u32) -> Result<Vec<u8>, String> {
if !pcm.len().is_multiple_of(2) {
return Err("PCM buffer length must be even for 16-bit mono audio".to_string());
}
let data_len = u32::try_from(pcm.len())
.map_err(|_| "PCM buffer exceeds WAV container size limits".to_string())?;
let total_len = 44u32
.checked_add(data_len)
.ok_or_else(|| "WAV container size overflowed".to_string())?;
let byte_rate = sample_rate_hz
.checked_mul(2)
.ok_or_else(|| "WAV byte rate overflowed".to_string())?;
let mut wav = Vec::with_capacity(total_len as usize);
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(total_len - 8).to_le_bytes());
wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&sample_rate_hz.to_le_bytes());
wav.extend_from_slice(&byte_rate.to_le_bytes());
wav.extend_from_slice(&2u16.to_le_bytes());
wav.extend_from_slice(&16u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_len.to_le_bytes());
wav.extend_from_slice(pcm);
Ok(wav)
}
fn replace_attachment_extension(filename: &mut String, replacement: &str) {
if let Some((stem, _)) = filename.rsplit_once('.') {
*filename = format!("{stem}.{replacement}");
} else {
filename.push('.');
filename.push_str(replacement);
}
}
#[cfg(test)]
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
use aes::cipher::BlockEncrypt;
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
let mut padded = plaintext.to_vec();
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
}
Ok(padded)
}
#[cfg(test)]
mod tests {
use super::{
Attachment, decrypt_wechat_attachment_bytes, detect_image_mime, encrypt_aes_ecb_pkcs7,
hydrate_attachment_for_channel, maybe_transcode_wechat_silk_attachment, pcm_s16le_to_wav,
should_hydrate_wechat_attachment,
};
use crate::channels::wasm::ChannelCapabilities;
use base64::Engine as _;
fn make_attachment() -> Attachment {
Attachment {
id: "wechat-image-1".to_string(),
mime_type: "image/jpeg".to_string(),
filename: Some("wechat-image.jpg".to_string()),
size_bytes: None,
source_url: Some(
"https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=test"
.to_string(),
),
storage_key: None,
extracted_text: None,
extras_json: String::new(),
data: Vec::new(),
duration_secs: None,
}
}
fn encode_test_extras_json(aes_key: &str) -> String {
serde_json::json!({ "wechat_aes_key": aes_key }).to_string()
}
#[test]
fn decrypt_wechat_image_bytes_round_trips() {
let key = [7u8; 16];
let plaintext = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x11];
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
let encoded_key = base64::engine::general_purpose::STANDARD.encode(key);
let decrypted = decrypt_wechat_attachment_bytes(&ciphertext, &encoded_key).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn detect_image_mime_prefers_magic_bytes() {
assert_eq!(detect_image_mime(&[0xFF, 0xD8, 0xFF, 0x00]), "image/jpeg");
assert_eq!(
detect_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
"image/png"
);
}
#[test]
fn wechat_attachment_hydration_applies_to_wechat_encrypted_media() {
let mut attachment = make_attachment();
attachment.extras_json = encode_test_extras_json("ZmFrZS1rZXk=");
assert!(should_hydrate_wechat_attachment("wechat", &attachment));
assert!(!should_hydrate_wechat_attachment("telegram", &attachment));
attachment.mime_type = "application/pdf".to_string();
assert!(should_hydrate_wechat_attachment("wechat", &attachment));
}
#[tokio::test]
async fn hydration_skips_when_metadata_is_missing() {
let mut attachment = make_attachment();
let caps = ChannelCapabilities::for_channel("wechat");
hydrate_attachment_for_channel("wechat", &caps, &mut attachment).await;
assert!(attachment.data.is_empty());
assert_eq!(attachment.size_bytes, None);
}
#[test]
fn pcm_s16le_to_wav_wraps_pcm_with_expected_header() {
let wav = pcm_s16le_to_wav(&[0x00, 0x00, 0x01, 0x00], 24_000).expect("wav wrapping");
assert!(wav.starts_with(b"RIFF"));
assert_eq!(&wav[8..12], b"WAVE");
assert_eq!(&wav[12..16], b"fmt ");
assert_eq!(&wav[36..40], b"data");
assert_eq!(&wav[40..44], &(4u32).to_le_bytes());
assert_eq!(&wav[44..], &[0x00, 0x00, 0x01, 0x00]);
}
#[test]
fn silk_transcode_failure_preserves_raw_silk_path_for_callers() {
let mut attachment = Attachment {
id: "wechat-voice-1".to_string(),
mime_type: "audio/silk".to_string(),
filename: Some("wechat-voice-1.silk".to_string()),
size_bytes: Some(3),
source_url: None,
storage_key: None,
extracted_text: None,
extras_json: encode_test_extras_json("ZmFrZS1rZXk="),
data: vec![1, 2, 3],
duration_secs: Some(1),
};
let original = attachment.data.clone();
let error =
maybe_transcode_wechat_silk_attachment(&mut attachment).expect_err("invalid SILK");
assert!(error.contains("SILK decode failed"));
assert_eq!(attachment.mime_type, "audio/silk");
assert_eq!(attachment.filename.as_deref(), Some("wechat-voice-1.silk"));
assert_eq!(attachment.data, original);
}
}
-3
View File
@@ -35,8 +35,6 @@ pub struct Attachment {
pub storage_key: Option<String>,
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
pub extracted_text: Option<String>,
/// Extensible metadata from the channel payload.
pub extras_json: String,
/// Raw file bytes (for small files downloaded by the channel).
pub data: Vec<u8>,
/// Duration in seconds (for audio/video).
@@ -997,7 +995,6 @@ mod tests {
source_url: None,
storage_key: None,
extracted_text: None,
extras_json: String::new(),
data: Vec::new(),
duration_secs: None,
}
-1
View File
@@ -78,7 +78,6 @@
//! }
//! ```
mod attachment_hydration;
mod bundled;
mod capabilities;
mod error;
-9
View File
@@ -159,11 +159,6 @@ impl WasmChannelRouter {
self.channels.read().await.get(channel_name).cloned()
}
/// Get a registered channel directly by name.
pub async fn get_channel_by_name(&self, channel_name: &str) -> Option<Arc<WasmChannel>> {
self.channels.read().await.get(channel_name).cloned()
}
/// Validate a secret for a channel.
pub async fn validate_secret(&self, channel_name: &str, provided: &str) -> bool {
let secrets = self.secrets.read().await;
@@ -715,10 +710,6 @@ mod tests {
// Should not find non-existent path
let not_found = router.get_channel_for_path("/webhook/telegram").await;
assert!(not_found.is_none());
let found_by_name = router.get_channel_by_name("slack").await;
assert!(found_by_name.is_some());
assert_eq!(found_by_name.unwrap().channel_name(), "slack");
}
#[tokio::test]
-35
View File
@@ -139,13 +139,6 @@ impl ChannelCapabilitiesFile {
serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
}
/// Whether this channel declares owner/pairing gating in its config.
pub fn requires_binding(&self) -> bool {
["owner_id", "dm_policy", "allow_from"]
.iter()
.any(|key| self.config.contains_key(*key))
}
/// Get the webhook secret header name for this channel.
///
/// Returns the configured header name from capabilities, or a sensible default.
@@ -576,34 +569,6 @@ mod tests {
assert_eq!(caps.workspace_prefix, "integrations/custom/");
}
#[test]
fn test_requires_binding_detects_dm_owner_fields() {
let telegram = ChannelCapabilitiesFile::from_json(
r#"{
"name": "telegram",
"config": {
"owner_id": null,
"dm_policy": "pairing",
"allow_from": []
}
}"#,
)
.unwrap();
assert!(telegram.requires_binding());
let wechat = ChannelCapabilitiesFile::from_json(
r#"{
"name": "wechat",
"config": {
"base_url": "https://ilinkai.weixin.qq.com",
"bot_type": "3"
}
}"#,
)
.unwrap();
assert!(!wechat.requires_binding());
}
#[test]
fn test_emit_rate_limit() {
let json = r#"{
+2 -107
View File
@@ -190,20 +190,7 @@ async fn register_channel(
// The credential injection system only replaces placeholders in URLs
// and headers, so channels like Feishu that exchange app_id + app_secret
// for a tenant token need the raw values in their config.
inject_channel_secrets_into_config(
&channel_name,
&config.owner_id,
secrets_store,
&mut config_updates,
)
.await;
inject_channel_settings_into_config(
&channel_name,
&config.owner_id,
settings_store,
&mut config_updates,
)
.await;
inject_channel_secrets_into_config(&channel_name, secrets_store, &mut config_updates).await;
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
@@ -409,7 +396,6 @@ pub async fn inject_channel_credentials(
/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`.
async fn inject_channel_secrets_into_config(
channel_name: &str,
owner_id: &str,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
) {
@@ -427,7 +413,7 @@ async fn inject_channel_secrets_into_config(
};
for &(config_key, secret_name) in secret_config_mappings {
match secrets.get_decrypted(owner_id, secret_name).await {
match secrets.get_decrypted("default", secret_name).await {
Ok(decrypted) => {
config_updates.insert(
config_key.to_string(),
@@ -456,94 +442,3 @@ async fn inject_channel_secrets_into_config(
}
}
}
/// Inject channel-specific settings into config for channels that persist
/// runtime-discovered values (for example a custom API base URL after login).
async fn inject_channel_settings_into_config(
channel_name: &str,
owner_id: &str,
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
) {
let Some(store) = settings_store else {
return;
};
let setting_mappings: &[(&str, &str)] = match channel_name {
"wechat" => &[("base_url", "extensions.wechat.base_url")],
_ => return,
};
for &(config_key, setting_path) in setting_mappings {
if let Ok(Some(serde_json::Value::String(value))) =
store.get_setting(owner_id, setting_path).await
{
let trimmed = value.trim();
if trimmed.is_empty() {
continue;
}
config_updates.insert(
config_key.to_string(),
serde_json::Value::String(trimmed.to_string()),
);
tracing::debug!(
channel = %channel_name,
config_key = %config_key,
setting_path = %setting_path,
"Injected setting into channel config"
);
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::db::{Database, SettingsStore};
#[tokio::test]
async fn test_inject_channel_settings_uses_owner_scope() -> Result<(), String> {
let dir = tempfile::tempdir().map_err(|e| format!("tempdir failed: {e}"))?;
let db_path = dir.path().join("wechat-settings.db");
let db = Arc::new(
crate::db::libsql::LibSqlBackend::new_local(&db_path)
.await
.map_err(|e| format!("create local libsql backend failed: {e}"))?,
);
db.run_migrations()
.await
.map_err(|e| format!("run libsql migrations failed: {e}"))?;
db.set_setting(
"default",
"extensions.wechat.base_url",
&serde_json::json!("https://default.example"),
)
.await
.map_err(|e| format!("persist default setting failed: {e}"))?;
db.set_setting(
"owner-123",
"extensions.wechat.base_url",
&serde_json::json!("https://owner.example"),
)
.await
.map_err(|e| format!("persist owner setting failed: {e}"))?;
let settings_store: Arc<dyn crate::db::SettingsStore> = db;
let mut config_updates = std::collections::HashMap::new();
super::inject_channel_settings_into_config(
"wechat",
"owner-123",
Some(&settings_store),
&mut config_updates,
)
.await;
assert_eq!(
config_updates.get("base_url"),
Some(&serde_json::json!("https://owner.example"))
);
Ok(())
}
}
+78 -145
View File
@@ -573,7 +573,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
source_url: a.source_url,
storage_key: a.storage_key,
extracted_text: a.extracted_text,
extras_json: a.extras_json,
data,
duration_secs,
}
@@ -1182,32 +1181,22 @@ impl WasmChannel {
)
}
fn log_host_state_entries(channel_name: &str, host_state: &mut ChannelHostState) {
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
for entry in host_state.take_logs() {
match entry.level {
crate::tools::wasm::LogLevel::Trace => {
tracing::trace!(channel = %channel_name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Debug => {
tracing::debug!(channel = %channel_name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Info => {
tracing::info!(channel = %channel_name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Error => {
tracing::error!(channel = %channel_name, "{}", entry.message);
tracing::error!(channel = %self.name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Warn => {
tracing::warn!(channel = %channel_name, "{}", entry.message);
tracing::warn!(channel = %self.name, "{}", entry.message);
}
_ => {
tracing::debug!(channel = %self.name, "{}", entry.message);
}
}
}
}
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
Self::log_host_state_entries(&self.name, host_state);
}
async fn execute_on_start_with_state(
&self,
) -> Result<(Result<ChannelConfig, WasmChannelError>, ChannelHostState), WasmChannelError> {
@@ -1491,20 +1480,18 @@ impl WasmChannel {
// Call on_poll using the generated typed interface
let channel_iface = instance.near_agent_channel();
let poll_result = channel_iface
channel_iface
.call_on_poll(&mut store)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel));
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
if poll_result.is_ok() {
// Commit pending workspace writes only after a successful callback.
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
}
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok((poll_result, host_state))
Ok(((), host_state))
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
@@ -1516,10 +1503,7 @@ impl WasmChannel {
let channel_name = self.name.clone();
match result {
Ok(Ok((poll_result, mut host_state))) => {
Self::log_host_state_entries(&channel_name, &mut host_state);
poll_result?;
Ok(Ok(((), mut host_state))) => {
// Process emitted messages
let emitted = host_state.take_emitted_messages();
self.process_emitted_messages(emitted).await?;
@@ -2197,16 +2181,6 @@ impl WasmChannel {
};
for emitted in messages {
let EmittedMessage {
user_id,
user_name,
content,
thread_id,
metadata_json,
attachments,
..
} = emitted;
// Check rate limit — acquire and release the write lock before send().await
{
let mut rate_limiter = self.rate_limiter.write().await;
@@ -2224,41 +2198,55 @@ impl WasmChannel {
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
&self.owner_scope_id,
self.owner_actor_id.as_deref(),
&user_id,
&emitted.user_id,
);
// Convert to IncomingMessage
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &content)
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &emitted.content)
.with_owner_id(&self.owner_scope_id)
.with_sender_id(&user_id);
.with_sender_id(&emitted.user_id);
if let Some(name) = user_name {
if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name);
}
if let Some(thread_id) = thread_id {
if let Some(thread_id) = emitted.thread_id {
msg = msg.with_thread(thread_id);
}
// Convert attachments
if !attachments.is_empty() {
let incoming_attachments =
convert_emitted_attachments(&self.name, &self.capabilities, attachments).await;
if !emitted.attachments.is_empty() {
let incoming_attachments = emitted
.attachments
.iter()
.map(|a| crate::channels::IncomingAttachment {
id: a.id.clone(),
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
mime_type: a.mime_type.clone(),
filename: a.filename.clone(),
size_bytes: a.size_bytes,
source_url: a.source_url.clone(),
storage_key: a.storage_key.clone(),
extracted_text: a.extracted_text.clone(),
data: a.data.clone(),
duration_secs: a.duration_secs,
})
.collect();
msg = msg.with_attachments(incoming_attachments);
}
// Parse metadata JSON
msg = apply_emitted_metadata(msg, &metadata_json);
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
if is_owner_sender {
// Store for owner-target routing (chat_id etc.).
self.update_broadcast_metadata(&metadata_json).await;
self.update_broadcast_metadata(&emitted.metadata_json).await;
}
// Send to stream — no locks held across this await
tracing::info!(
channel = %self.name,
user_id = %user_id,
content_len = content.len(),
user_id = %emitted.user_id,
content_len = emitted.content.len(),
attachment_count = msg.attachments.len(),
"Sending emitted message to agent"
);
@@ -2343,7 +2331,6 @@ impl WasmChannel {
&& let Err(e) = Self::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: &channel_name,
capabilities: &capabilities,
owner_scope_id: &owner_scope_id,
owner_actor_id: owner_actor_id.as_deref(),
message_tx: &message_tx,
@@ -2429,20 +2416,18 @@ impl WasmChannel {
// Call on_poll using the generated typed interface
let channel_iface = instance.near_agent_channel();
let poll_result = channel_iface
channel_iface
.call_on_poll(&mut store)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel));
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
if poll_result.is_ok() {
// Commit pending workspace writes only after a successful callback.
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
}
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok((poll_result, host_state))
Ok(host_state)
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
@@ -2453,10 +2438,7 @@ impl WasmChannel {
.await;
match result {
Ok(Ok((poll_result, mut host_state))) => {
Self::log_host_state_entries(channel_name, &mut host_state);
poll_result?;
Ok(Ok(mut host_state)) => {
let emitted = host_state.take_emitted_messages();
tracing::debug!(
channel = %channel_name,
@@ -2502,16 +2484,6 @@ impl WasmChannel {
};
for emitted in messages {
let EmittedMessage {
user_id,
user_name,
content,
thread_id,
metadata_json,
attachments,
..
} = emitted;
// Check rate limit — acquire and release the write lock before send().await
{
let mut limiter = dispatch.rate_limiter.write().await;
@@ -2526,40 +2498,54 @@ impl WasmChannel {
}
}
let (resolved_user_id, is_owner_sender) =
resolve_message_scope(dispatch.owner_scope_id, dispatch.owner_actor_id, &user_id);
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
dispatch.owner_scope_id,
dispatch.owner_actor_id,
&emitted.user_id,
);
// Convert to IncomingMessage
let mut msg = IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &content)
.with_owner_id(dispatch.owner_scope_id)
.with_sender_id(&user_id);
let mut msg =
IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &emitted.content)
.with_owner_id(dispatch.owner_scope_id)
.with_sender_id(&emitted.user_id);
if let Some(name) = user_name {
if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name);
}
if let Some(thread_id) = thread_id {
if let Some(thread_id) = emitted.thread_id {
msg = msg.with_thread(thread_id);
}
// Convert attachments
if !attachments.is_empty() {
let incoming_attachments = convert_emitted_attachments(
dispatch.channel_name,
dispatch.capabilities,
attachments,
)
.await;
if !emitted.attachments.is_empty() {
let incoming_attachments = emitted
.attachments
.iter()
.map(|a| crate::channels::IncomingAttachment {
id: a.id.clone(),
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
mime_type: a.mime_type.clone(),
filename: a.filename.clone(),
size_bytes: a.size_bytes,
source_url: a.source_url.clone(),
storage_key: a.storage_key.clone(),
extracted_text: a.extracted_text.clone(),
data: a.data.clone(),
duration_secs: a.duration_secs,
})
.collect();
msg = msg.with_attachments(incoming_attachments);
}
msg = apply_emitted_metadata(msg, &metadata_json);
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
if is_owner_sender {
// Store for owner-target routing (chat_id etc.)
do_update_broadcast_metadata(
dispatch.channel_name,
dispatch.owner_scope_id,
&metadata_json,
&emitted.metadata_json,
dispatch.last_broadcast_metadata,
dispatch.settings_store,
)
@@ -2569,8 +2555,8 @@ impl WasmChannel {
// Send to stream — no locks held across this await
tracing::info!(
channel = %dispatch.channel_name,
user_id = %user_id,
content_len = content.len(),
user_id = %emitted.user_id,
content_len = emitted.content.len(),
attachment_count = msg.attachments.len(),
"Sending polled message to agent"
);
@@ -2595,7 +2581,6 @@ impl WasmChannel {
struct EmitDispatchContext<'a> {
channel_name: &'a str,
capabilities: &'a ChannelCapabilities,
owner_scope_id: &'a str,
owner_actor_id: Option<&'a str>,
message_tx: &'a RwLock<Option<mpsc::Sender<IncomingMessage>>>,
@@ -3272,38 +3257,6 @@ async fn resolve_channel_host_credentials(
/// Maximum total attachment size (50 MB).
const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 50 * 1024 * 1024;
async fn convert_emitted_attachments(
channel_name: &str,
capabilities: &ChannelCapabilities,
attachments: Vec<crate::channels::wasm::host::Attachment>,
) -> Vec<crate::channels::IncomingAttachment> {
let mut hydrated = attachments;
for attachment in &mut hydrated {
crate::channels::wasm::attachment_hydration::hydrate_attachment_for_channel(
channel_name,
capabilities,
attachment,
)
.await;
}
hydrated
.into_iter()
.map(|attachment| crate::channels::IncomingAttachment {
id: attachment.id,
kind: crate::channels::AttachmentKind::from_mime_type(&attachment.mime_type),
mime_type: attachment.mime_type,
filename: attachment.filename,
size_bytes: attachment.size_bytes,
source_url: attachment.source_url,
storage_key: attachment.storage_key,
extracted_text: attachment.extracted_text,
data: attachment.data,
duration_secs: attachment.duration_secs,
})
.collect()
}
/// Detect MIME type from file extension using the `mime_guess` crate.
fn mime_from_extension(path: &str) -> String {
mime_guess::from_path(path)
@@ -3516,8 +3469,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
@@ -3534,7 +3485,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "test-channel",
capabilities: &capabilities,
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
@@ -3567,8 +3517,6 @@ mod tests {
// No sender available (channel not started)
let message_tx = Arc::new(tokio::sync::RwLock::new(None));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
@@ -3582,7 +3530,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "test-channel",
capabilities: &capabilities,
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
@@ -4573,8 +4520,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
@@ -4591,7 +4536,6 @@ mod tests {
source_url: Some("https://api.telegram.org/file/photo123".to_string()),
storage_key: None,
extracted_text: None,
extras_json: String::new(),
data: Vec::new(),
duration_secs: None,
},
@@ -4603,7 +4547,6 @@ mod tests {
source_url: None,
storage_key: Some("store/doc456".to_string()),
extracted_text: Some("Report contents...".to_string()),
extras_json: String::new(),
data: Vec::new(),
duration_secs: None,
},
@@ -4616,7 +4559,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "test-channel",
capabilities: &capabilities,
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
@@ -4663,8 +4605,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("telegram");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
@@ -4680,7 +4620,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "telegram",
capabilities: &capabilities,
owner_scope_id: "owner-scope",
owner_actor_id: Some("telegram-owner"),
message_tx: &message_tx,
@@ -4709,8 +4648,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("telegram");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
@@ -4725,7 +4662,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "telegram",
capabilities: &capabilities,
owner_scope_id: "owner-scope",
owner_actor_id: Some("telegram-owner"),
message_tx: &message_tx,
@@ -4795,8 +4731,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
@@ -4810,7 +4744,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "test-channel",
capabilities: &capabilities,
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
+28
View File
@@ -91,6 +91,34 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
| DELETE | `/api/routines/{id}` | Delete a routine |
| GET | `/api/routines/{id}/runs` | List runs for a specific routine |
### User Management (admin — requires `admin` role, see `docs/USER_MANAGEMENT_API.md`)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/admin/users` | Create a new user (returns one-time token) |
| GET | `/api/admin/users` | List all users |
| GET | `/api/admin/users/{id}` | Get a single user |
| PATCH | `/api/admin/users/{id}` | Update user profile/metadata |
| DELETE | `/api/admin/users/{id}` | Delete user and all data |
| POST | `/api/admin/users/{id}/suspend` | Suspend a user |
| POST | `/api/admin/users/{id}/activate` | Re-activate a user |
| GET | `/api/admin/usage` | Per-user LLM usage stats |
| GET | `/api/admin/users/{id}/secrets` | List a user's secrets (names only) |
| PUT | `/api/admin/users/{id}/secrets/{name}` | Create or update a user's secret |
| DELETE | `/api/admin/users/{id}/secrets/{name}` | Delete a user's secret |
### Profile (self-service)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/profile` | Get own profile |
| PATCH | `/api/profile` | Update own display name/metadata |
### Tokens (self-service)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/tokens` | Create API token (returns plaintext once) |
| GET | `/api/tokens` | List own tokens |
| DELETE | `/api/tokens/{id}` | Revoke a token |
### Settings
| Method | Path | Description |
|--------|------|-------------|
+201 -20
View File
@@ -5,6 +5,7 @@
//! handlers can extract it via `AuthenticatedUser`.
use std::collections::HashMap;
use std::num::NonZeroUsize;
use axum::{
extract::{FromRequestParts, Request, State},
@@ -13,18 +14,25 @@ use axum::{
response::{IntoResponse, Response},
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use std::time::Instant;
use subtle::ConstantTimeEq;
use tokio::sync::RwLock;
use crate::db::Database;
/// Identity resolved from a bearer token.
#[derive(Debug, Clone)]
pub struct UserIdentity {
pub user_id: String,
/// `admin` or `member`.
pub role: String,
/// Additional user scopes this identity can read from.
pub workspace_read_scopes: Vec<String>,
}
/// Hash a token with SHA-256 for constant-size, timing-safe storage.
fn hash_token(token: &str) -> [u8; 32] {
pub fn hash_token(token: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hasher.finalize().into()
@@ -56,6 +64,7 @@ impl MultiAuthState {
hash,
UserIdentity {
user_id,
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
)],
@@ -64,6 +73,11 @@ impl MultiAuthState {
}
/// Create a multi-user auth state from a map of tokens to identities.
///
/// **Test-only** — production multi-user auth is DB-backed via
/// `DbAuthenticator`. This constructor is kept public (not `#[cfg(test)]`)
/// because integration tests in `tests/` compile the crate as a library
/// where `cfg(test)` is not set.
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
.into_iter()
@@ -108,6 +122,112 @@ impl MultiAuthState {
}
}
/// DB-backed token authenticator with a bounded LRU cache.
///
/// Checks an LRU cache first (TTL 60s), then falls back to a DB query.
/// The cache is bounded to `MAX_CACHE_ENTRIES` — when full, the least
/// recently used entry is evicted regardless of TTL.
///
/// Revoking a token or suspending a user has at most 60s of stale
/// authentication before the cache entry expires.
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct DbAuthenticator {
store: Arc<dyn Database>,
/// Bounded LRU cache: token_hash → (identity, inserted_at).
cache: Arc<RwLock<lru::LruCache<[u8; 32], (UserIdentity, Instant)>>>,
}
impl DbAuthenticator {
/// Cache TTL — how long a successful auth is cached before re-querying the DB.
const CACHE_TTL_SECS: u64 = 60;
/// Maximum cache entries to prevent unbounded growth.
// SAFETY: 1024 is non-zero, so the unwrap in `new()` is infallible.
const MAX_CACHE_ENTRIES: NonZeroUsize = match NonZeroUsize::new(1024) {
Some(v) => v,
None => unreachable!(),
};
pub fn new(store: Arc<dyn Database>) -> Self {
Self {
store,
cache: Arc::new(RwLock::new(lru::LruCache::new(Self::MAX_CACHE_ENTRIES))),
}
}
/// Authenticate a token against the database, using cache when possible.
///
/// Returns `Ok(Some(identity))` on success, `Ok(None)` if the token is
/// not found, or `Err(())` if the database is unreachable (so the caller
/// can return 503 instead of 401).
pub async fn authenticate(&self, candidate: &str) -> Result<Option<UserIdentity>, ()> {
let hash = hash_token(candidate);
// Check cache first (promotes to most-recent on hit)
{
let mut cache = self.cache.write().await;
if let Some((identity, inserted_at)) = cache.get(&hash) {
if inserted_at.elapsed().as_secs() < Self::CACHE_TTL_SECS {
return Ok(Some(identity.clone()));
}
// Expired — remove stale entry
cache.pop(&hash);
}
}
// Cache miss or expired — query DB
let (token_record, user_record) = match self.store.authenticate_token(&hash).await {
Ok(Some(pair)) => pair,
Ok(None) => return Ok(None),
Err(e) => {
tracing::error!(error = %e, "DB auth lookup failed, returning 503");
return Err(());
}
};
let identity = UserIdentity {
user_id: user_record.id.clone(),
role: user_record.role.clone(),
workspace_read_scopes: Vec::new(),
};
// Record token usage (best-effort, don't block auth)
let store = self.store.clone();
let token_id = token_record.id;
let user_id = user_record.id;
tokio::spawn(async move {
let _ = store.record_token_usage(token_id).await;
let _ = store.record_login(&user_id).await;
});
// Insert into bounded LRU — if full, least-recently-used entry is evicted
{
let mut cache = self.cache.write().await;
cache.put(hash, (identity.clone(), Instant::now()));
}
Ok(Some(identity))
}
}
/// Combined auth state: tries env-var tokens first, then DB-backed tokens.
#[derive(Clone)]
pub struct CombinedAuthState {
/// In-memory tokens from GATEWAY_AUTH_TOKEN.
pub env_auth: MultiAuthState,
/// DB-backed token authenticator (optional — only when a database is available).
pub db_auth: Option<DbAuthenticator>,
}
impl From<MultiAuthState> for CombinedAuthState {
fn from(env_auth: MultiAuthState) -> Self {
Self {
env_auth,
db_auth: None,
}
}
}
/// Axum extractor that provides the authenticated user identity.
///
/// Only available on routes behind `auth_middleware`. Extracts the
@@ -130,6 +250,31 @@ where
}
}
/// Axum extractor that requires the authenticated user to have the `admin` role.
///
/// Use instead of `AuthenticatedUser` on endpoints that modify system-wide
/// state (user management, model selection, extension/skill installation).
pub struct AdminUser(pub UserIdentity);
impl<S> FromRequestParts<S> for AdminUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let identity = parts
.extensions
.get::<UserIdentity>()
.cloned()
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?;
if identity.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Admin role required"));
}
Ok(AdminUser(identity))
}
}
/// Whether query-string token auth is allowed for this request.
///
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
@@ -166,39 +311,65 @@ fn query_token(request: &Request) -> Option<String> {
/// Auth middleware that validates bearer token from header or query param.
///
/// SSE connections can't set headers from `EventSource`, so we also accept
/// `?token=xxx` as a query parameter, but only on SSE/WS endpoints.
/// Tries env-var tokens first (constant-time, in-memory), then falls back
/// to DB-backed token lookup if configured. SSE connections can't set
/// headers from `EventSource`, so we also accept `?token=xxx` as a query
/// parameter, but only on SSE/WS endpoints.
///
/// On successful authentication, inserts the matching `UserIdentity` into
/// request extensions for downstream extraction via `AuthenticatedUser`.
pub async fn auth_middleware(
State(auth): State<MultiAuthState>,
State(auth): State<CombinedAuthState>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Response {
// Try Authorization header first.
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
// Extract the candidate token from header or query param.
let token = extract_token(&headers, &request);
if let Some(ref tok) = token {
// 1. Try env-var tokens first (fast, constant-time, in-memory).
if let Some(identity) = auth.env_auth.authenticate(tok) {
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
}
// 2. Fall back to DB-backed token lookup.
if let Some(ref db_auth) = auth.db_auth {
match db_auth.authenticate(tok).await {
Ok(Some(identity)) => {
request.extensions_mut().insert(identity);
return next.run(request).await;
}
Err(()) => {
return (StatusCode::SERVICE_UNAVAILABLE, "Database unavailable")
.into_response();
}
Ok(None) => {}
}
}
}
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
}
/// Extract a bearer token from the Authorization header or query parameter.
fn extract_token(headers: &HeaderMap, request: &Request) -> Option<String> {
// Try Authorization header first (RFC 6750).
if let Some(auth_header) = headers.get("authorization")
&& let Ok(value) = auth_header.to_str()
&& value.len() > 7
&& value[..7].eq_ignore_ascii_case("Bearer ")
&& let Some(identity) = auth.authenticate(&value[7..])
{
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
return Some(value[7..].to_string());
}
// Fall back to query parameter, but only for SSE/WS endpoints.
if allows_query_token_auth(&request)
&& let Some(token) = query_token(&request)
&& let Some(identity) = auth.authenticate(&token)
{
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
// Fall back to query parameter for SSE/WS endpoints.
if allows_query_token_auth(request) {
return query_token(request);
}
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
None
}
#[cfg(test)]
@@ -227,6 +398,7 @@ mod tests {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -234,6 +406,7 @@ mod tests {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -274,7 +447,10 @@ mod tests {
/// Router with streaming endpoints (query auth allowed) and regular
/// endpoints (query auth rejected).
fn test_app(token: &str) -> Router {
let state = MultiAuthState::single(token.to_string(), "test-user".to_string());
let state = CombinedAuthState::from(MultiAuthState::single(
token.to_string(),
"test-user".to_string(),
));
Router::new()
.route("/api/chat/events", get(dummy_handler))
.route("/api/logs/events", get(dummy_handler))
@@ -486,7 +662,7 @@ mod tests {
/// Build a multi-user router where each token maps to a distinct identity.
fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router {
let state = MultiAuthState::multi(tokens);
let state = CombinedAuthState::from(MultiAuthState::multi(tokens));
Router::new()
.route("/api/chat/events", get(identity_handler))
.route("/api/chat/send", post(identity_handler))
@@ -500,6 +676,7 @@ mod tests {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -507,6 +684,7 @@ mod tests {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
},
);
@@ -643,7 +821,10 @@ mod tests {
#[tokio::test]
async fn test_multi_user_empty_scopes_for_single_user() {
// Single-user mode creates identity with empty workspace_read_scopes.
let state = MultiAuthState::single("tok-only".to_string(), "solo".to_string());
let state = CombinedAuthState::from(MultiAuthState::single(
"tok-only".to_string(),
"solo".to_string(),
));
let app = Router::new()
.route("/api/scopes", get(scopes_handler))
.layer(middleware::from_fn_with_state(state, auth_middleware));
-1
View File
@@ -47,7 +47,6 @@ pub async fn extensions_list_handler(
&ext,
has_paired,
owner_bound_channels.contains(&ext.name),
ext.requires_binding,
)
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
Some(if ext.active {
+3
View File
@@ -5,7 +5,10 @@
pub mod jobs;
pub mod memory;
pub mod routines;
pub mod secrets;
pub mod skills;
pub mod tokens;
pub mod users;
// Modules not yet wired into server.rs router -- suppress dead_code until
// they replace their inline counterparts.
+134
View File
@@ -0,0 +1,134 @@
//! Admin secrets provisioning handlers.
//!
//! Allows an admin (typically an application backend) to create, list, and
//! delete secrets on behalf of individual users so their IronClaw agent can
//! call back to external services with per-user credentials.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use crate::channels::web::auth::AdminUser;
use crate::channels::web::server::GatewayState;
use crate::secrets::CreateSecretParams;
/// PUT /api/admin/users/{user_id}/secrets/{name} — create or update a secret.
///
/// Upserts: if a secret with the same (user_id, name) already exists it is
/// overwritten. The plaintext value is encrypted at rest (AES-256-GCM) and
/// never returned by any endpoint.
pub async fn secrets_put_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path((user_id, name)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let value = body
.get("value")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing required field 'value'".to_string(),
))?
.to_string();
let provider = body
.get("provider")
.and_then(|v| v.as_str())
.map(String::from);
let expires_at = body
.get("expires_in_days")
.and_then(|v| v.as_u64())
.map(|d| d.min(36500))
.map(|days| chrono::Utc::now() + chrono::Duration::days(days as i64));
let mut params = CreateSecretParams::new(name.clone(), value);
if let Some(p) = provider {
params = params.with_provider(p);
}
if let Some(exp) = expires_at {
params = params.with_expiry(exp);
}
secrets
.create(&user_id, params)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"user_id": user_id,
"name": name.to_lowercase(),
"status": "created",
})))
}
/// GET /api/admin/users/{user_id}/secrets — list a user's secrets (names only).
///
/// Never returns secret values or hashes.
pub async fn secrets_list_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path(user_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let refs = secrets
.list(&user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let secrets_json: Vec<serde_json::Value> = refs
.into_iter()
.map(|r| {
serde_json::json!({
"name": r.name,
"provider": r.provider,
})
})
.collect();
Ok(Json(serde_json::json!({
"user_id": user_id,
"secrets": secrets_json,
})))
}
/// DELETE /api/admin/users/{user_id}/secrets/{name} — delete a user's secret.
pub async fn secrets_delete_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_admin): AdminUser,
Path((user_id, name)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let secrets = state.secrets_store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Secrets store not available".to_string(),
))?;
let deleted = secrets
.delete(&user_id, &name)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !deleted {
return Err((StatusCode::NOT_FOUND, "Secret not found".to_string()));
}
Ok(Json(serde_json::json!({
"user_id": user_id,
"name": name,
"deleted": true,
})))
}
+150
View File
@@ -0,0 +1,150 @@
//! API token management handlers.
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use rand::RngCore;
use rand::rngs::OsRng;
use uuid::Uuid;
use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState;
/// POST /api/tokens — create a new API token (returns plaintext ONCE).
pub async fn tokens_create_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let name = body
.get("name")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing required field 'name'".to_string(),
))?
.to_string();
let expires_in_days: Option<i64> = body
.get("expires_in_days")
.and_then(|v| v.as_u64())
.map(|d| d.min(36500) as i64);
let expires_at = expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days));
// Generate 32 random bytes for the token.
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
// NOT the raw bytes — must match hash_token() in auth.rs.
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
let hash = crate::channels::web::auth::hash_token(&plaintext_token);
// First 8 chars of the hex token as a prefix for identification.
let token_prefix = &plaintext_token[..8];
// Admin users can create tokens for other users via optional "user_id" field.
let target_user = body
.get("user_id")
.and_then(|v| v.as_str())
.filter(|_| user.role == "admin")
.unwrap_or(&user.user_id);
// Verify the target user exists to prevent orphan tokens.
if target_user != user.user_id {
store
.get_user(target_user)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((
StatusCode::BAD_REQUEST,
format!("Target user '{target_user}' not found"),
))?;
}
let record = store
.create_api_token(target_user, &name, &hash, token_prefix, expires_at)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Return the plaintext token — this is the ONLY time it is shown.
Ok(Json(serde_json::json!({
"token": plaintext_token,
"id": record.id.to_string(),
"name": record.name,
"token_prefix": record.token_prefix,
"expires_at": record.expires_at.map(|dt| dt.to_rfc3339()),
"created_at": record.created_at.to_rfc3339(),
})))
}
/// GET /api/tokens — list the current user's tokens (no hashes).
pub async fn tokens_list_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let tokens = store
.list_api_tokens(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let tokens_json: Vec<serde_json::Value> = tokens
.into_iter()
.map(|t| {
serde_json::json!({
"id": t.id.to_string(),
"name": t.name,
"token_prefix": t.token_prefix,
"expires_at": t.expires_at.map(|dt| dt.to_rfc3339()),
"last_used_at": t.last_used_at.map(|dt| dt.to_rfc3339()),
"created_at": t.created_at.to_rfc3339(),
"revoked_at": t.revoked_at.map(|dt| dt.to_rfc3339()),
})
})
.collect();
Ok(Json(serde_json::json!({ "tokens": tokens_json })))
}
/// DELETE /api/tokens/{id} — revoke a token.
pub async fn tokens_revoke_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let token_id = Uuid::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid token ID".to_string()))?;
let revoked = store
.revoke_api_token(token_id, &user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !revoked {
return Err((StatusCode::NOT_FOUND, "Token not found".to_string()));
}
Ok(Json(serde_json::json!({
"status": "revoked",
"id": token_id.to_string(),
})))
}
+406
View File
@@ -0,0 +1,406 @@
//! User management API handlers (admin).
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use rand::RngCore;
use rand::rngs::OsRng;
use uuid::Uuid;
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
use crate::channels::web::server::GatewayState;
use crate::db::UserRecord;
/// POST /api/admin/users — create a new user.
pub async fn users_create_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(user): AdminUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.ok_or((
StatusCode::BAD_REQUEST,
"Missing required field 'display_name'".to_string(),
))?
.to_string();
let email = body.get("email").and_then(|v| v.as_str()).map(String::from);
let role = body
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("member")
.to_string();
if role != "admin" && role != "member" {
return Err((
StatusCode::BAD_REQUEST,
"role must be 'admin' or 'member'".to_string(),
));
}
let user_id = Uuid::new_v4().to_string();
let now = chrono::Utc::now();
let user_record = UserRecord {
id: user_id.clone(),
email,
display_name: display_name.clone(),
status: "active".to_string(),
role,
created_at: now,
updated_at: now,
last_login_at: None,
created_by: match store.get_user(&user.user_id).await {
Ok(Some(_)) => Some(user.user_id.clone()),
_ => None,
},
metadata: serde_json::json!({}),
};
store
.create_user(&user_record)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Generate a first API token so the new user can authenticate immediately.
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
// NOT the raw bytes — must match hash_token() in auth.rs.
let mut token_bytes = [0u8; 32];
OsRng.fill_bytes(&mut token_bytes);
let plaintext_token = hex::encode(token_bytes);
let token_hash = crate::channels::web::auth::hash_token(&plaintext_token);
let token_prefix = &plaintext_token[..8];
let _token_record = store
.create_api_token(&user_id, "initial", &token_hash, token_prefix, None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"id": user_record.id,
"email": user_record.email,
"display_name": user_record.display_name,
"status": user_record.status,
"role": user_record.role,
"token": plaintext_token,
"created_at": user_record.created_at.to_rfc3339(),
"created_by": user_record.created_by,
})))
}
/// GET /api/admin/users — list all users.
pub async fn users_list_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let users = store
.list_users(None)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let users_json: Vec<serde_json::Value> = users
.into_iter()
.map(|u| {
serde_json::json!({
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"status": u.status,
"role": u.role,
"created_at": u.created_at.to_rfc3339(),
"updated_at": u.updated_at.to_rfc3339(),
"last_login_at": u.last_login_at.map(|dt| dt.to_rfc3339()),
"created_by": u.created_by,
})
})
.collect();
Ok(Json(serde_json::json!({ "users": users_json })))
}
/// GET /api/admin/users/{id} — get a single user.
pub async fn users_detail_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let user_record = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": user_record.id,
"email": user_record.email,
"display_name": user_record.display_name,
"status": user_record.status,
"role": user_record.role,
"created_at": user_record.created_at.to_rfc3339(),
"updated_at": user_record.updated_at.to_rfc3339(),
"last_login_at": user_record.last_login_at.map(|dt| dt.to_rfc3339()),
"created_by": user_record.created_by,
"metadata": user_record.metadata,
})))
}
/// PATCH /api/admin/users/{id} — update a user's profile.
pub async fn users_update_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
let existing = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or(&existing.display_name);
let metadata = body.get("metadata").unwrap_or(&existing.metadata);
store
.update_user_profile(&id, display_name, metadata)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Re-fetch the updated record to return consistent data.
let updated = store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": updated.id,
"email": updated.email,
"display_name": updated.display_name,
"status": updated.status,
"role": updated.role,
"created_at": updated.created_at.to_rfc3339(),
"updated_at": updated.updated_at.to_rfc3339(),
"metadata": updated.metadata,
})))
}
/// POST /api/admin/users/{id}/suspend — suspend a user.
pub async fn users_suspend_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
store
.update_user_status(&id, "suspended")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"id": id,
"status": "suspended",
})))
}
/// POST /api/admin/users/{id}/activate — activate a user.
pub async fn users_activate_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
// Verify the user exists.
store
.get_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
store
.update_user_status(&id, "active")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"id": id,
"status": "active",
})))
}
/// DELETE /api/admin/users/{id} — delete a user and all their data.
pub async fn users_delete_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let deleted = store
.delete_user(&id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !deleted {
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
}
Ok(Json(serde_json::json!({
"id": id,
"deleted": true,
})))
}
/// GET /api/profile — get the authenticated user's own profile.
pub async fn profile_get_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let record = store
.get_user(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": record.id,
"email": record.email,
"display_name": record.display_name,
"status": record.status,
"role": record.role,
"created_at": record.created_at.to_rfc3339(),
"last_login_at": record.last_login_at.map(|dt| dt.to_rfc3339()),
})))
}
/// PATCH /api/profile — update the authenticated user's own profile.
pub async fn profile_update_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let current = store
.get_user(&user.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
let display_name = body
.get("display_name")
.and_then(|v| v.as_str())
.unwrap_or(&current.display_name);
let metadata = body.get("metadata").unwrap_or(&current.metadata);
store
.update_user_profile(&user.user_id, display_name, metadata)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"id": user.user_id,
"display_name": display_name,
"updated": true,
})))
}
/// GET /api/admin/usage — per-user LLM usage stats.
pub async fn usage_stats_handler(
State(state): State<Arc<GatewayState>>,
AdminUser(_user): AdminUser,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let store = state.store.as_ref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"Database not available".to_string(),
))?;
let user_id = params.get("user_id").map(|s| s.as_str());
let period = params.get("period").map(|s| s.as_str()).unwrap_or("day");
let since = match period {
"week" => chrono::Utc::now() - chrono::Duration::days(7),
"month" => chrono::Utc::now() - chrono::Duration::days(30),
_ => chrono::Utc::now() - chrono::Duration::days(1),
};
let stats = store
.user_usage_stats(user_id, since)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let entries: Vec<serde_json::Value> = stats
.iter()
.map(|s| {
serde_json::json!({
"user_id": s.user_id,
"model": s.model,
"call_count": s.call_count,
"input_tokens": s.input_tokens,
"output_tokens": s.output_tokens,
"total_cost": s.total_cost.to_string(),
})
})
.collect();
Ok(Json(serde_json::json!({
"period": period,
"since": since.to_rfc3339(),
"usage": entries,
})))
}
+28 -65
View File
@@ -18,6 +18,7 @@ pub mod auth;
pub(crate) mod handlers;
pub mod log_layer;
pub mod openai_compat;
pub mod responses_api;
pub mod server;
pub mod sse;
pub mod types;
@@ -55,7 +56,7 @@ use crate::workspace::Workspace;
use self::log_layer::{LogBroadcaster, LogLevelHandle};
use self::auth::MultiAuthState;
use self::auth::{CombinedAuthState, DbAuthenticator, MultiAuthState};
use self::server::GatewayState;
use self::sse::SseManager;
use self::types::AppEvent;
@@ -64,8 +65,8 @@ use self::types::AppEvent;
pub struct GatewayChannel {
config: GatewayConfig,
state: Arc<GatewayState>,
/// Multi-user auth state (replaces bare auth_token).
auth: MultiAuthState,
/// Combined auth state: env-var tokens + optional DB-backed tokens.
auth: CombinedAuthState,
}
impl GatewayChannel {
@@ -73,7 +74,7 @@ impl GatewayChannel {
///
/// If no auth token is configured, generates a random one and prints it.
/// Builds a single-user `MultiAuthState` from the config.
pub fn new(config: GatewayConfig) -> Self {
pub fn new(config: GatewayConfig, owner_id: String) -> Self {
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
use rand::RngCore;
use rand::rngs::OsRng;
@@ -82,64 +83,11 @@ impl GatewayChannel {
bytes.iter().map(|b| format!("{b:02x}")).collect()
});
let auth = MultiAuthState::single(auth_token, config.user_id.clone());
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
sse: Arc::new(SseManager::new()),
workspace: None,
workspace_pool: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
owner_id: config.user_id.clone(),
default_sender_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
llm_provider: None,
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
webhook_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: server::ActiveConfigSnapshot::default(),
});
Self {
config,
state,
auth,
}
}
/// Rebind the single-user auth identity to the durable owner scope while
/// preserving the configured gateway sender/routing identity.
pub fn with_owner_scope(mut self, owner_id: impl Into<String>) -> Self {
let owner_id = owner_id.into();
let single_user_token = if self.config.user_tokens.is_none() {
self.auth.first_token().map(ToOwned::to_owned)
} else {
None
let auth = CombinedAuthState {
env_auth: MultiAuthState::single(auth_token, owner_id.clone()),
db_auth: None,
};
if let Some(token) = single_user_token {
self.auth = MultiAuthState::single(token, owner_id.clone());
}
self.rebuild_state(|s| s.owner_id = owner_id);
self
}
/// Create a gateway channel with a pre-built multi-user auth state.
pub fn new_multi_auth(config: GatewayConfig, auth: MultiAuthState) -> Self {
let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None),
sse: Arc::new(SseManager::new()),
@@ -154,8 +102,7 @@ impl GatewayChannel {
job_manager: None,
prompt_queue: None,
scheduler: None,
owner_id: config.user_id.clone(),
default_sender_id: config.user_id.clone(),
owner_id,
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
llm_provider: None,
@@ -163,12 +110,13 @@ impl GatewayChannel {
skill_catalog: None,
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: server::RateLimiter::new(10, 60),
webhook_rate_limiter: server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
webhook_rate_limiter: server::RateLimiter::new(10, 60),
active_config: server::ActiveConfigSnapshot::default(),
secrets_store: None,
});
Self {
@@ -196,7 +144,6 @@ impl GatewayChannel {
prompt_queue: self.state.prompt_queue.clone(),
scheduler: self.state.scheduler.clone(),
owner_id: self.state.owner_id.clone(),
default_sender_id: self.state.default_sender_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: self.state.ws_tracker.clone(),
llm_provider: self.state.llm_provider.clone(),
@@ -210,6 +157,7 @@ impl GatewayChannel {
routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time,
active_config: self.state.active_config.clone(),
secrets_store: self.state.secrets_store.clone(),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -257,6 +205,12 @@ impl GatewayChannel {
self
}
/// Enable DB-backed token authentication alongside env-var tokens.
pub fn with_db_auth(mut self, store: Arc<dyn Database>) -> Self {
self.auth.db_auth = Some(DbAuthenticator::new(store));
self
}
/// Inject the container job manager for sandbox operations.
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
self.rebuild_state(|s| s.job_manager = Some(jm));
@@ -327,6 +281,15 @@ impl GatewayChannel {
self
}
/// Inject the secrets store for admin secret provisioning.
pub fn with_secrets_store(
mut self,
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
) -> Self {
self.rebuild_state(|s| s.secrets_store = Some(store));
self
}
/// Inject the per-user workspace pool for multi-user mode.
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
self.rebuild_state(|s| s.workspace_pool = Some(pool));
@@ -335,7 +298,7 @@ impl GatewayChannel {
/// Get the first auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
self.auth.first_token().unwrap_or("")
self.auth.env_auth.first_token().unwrap_or("")
}
/// Get a reference to the shared gateway state (for the agent to push SSE events).
File diff suppressed because it is too large Load Diff
+101 -977
View File
File diff suppressed because it is too large Load Diff
+159 -234
View File
@@ -186,6 +186,13 @@ function authenticate() {
connectSSE();
connectLogSSE();
startGatewayStatusPolling();
// Hide the Users settings tab for non-admin users.
apiFetch('/api/profile').then(function(profile) {
if (profile && profile.role !== 'admin') {
var usersTab = document.querySelector('[data-settings-subtab="users"]');
if (usersTab) usersTab.style.display = 'none';
}
}).catch(function() {});
checkTeeStatus();
loadThreads();
loadMemoryTree();
@@ -3056,17 +3063,16 @@ function showConfigureModal(name) {
.then((setup) => {
const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
const interactiveLogin = setup.interactive_login || null;
if (secrets.length === 0 && setupFields.length === 0 && !interactiveLogin) {
showToast(I18n.t('extensions.noConfigNeeded', { name: name }), 'info');
if (secrets.length === 0 && setupFields.length === 0) {
showToast('No configuration needed for ' + name, 'info');
return;
}
renderConfigureModal(name, secrets, setupFields, interactiveLogin);
renderConfigureModal(name, secrets, setupFields);
})
.catch((err) => showToast(I18n.t('error.loadFailed', { message: err.message }), 'error'));
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
}
function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
function renderConfigureModal(name, secrets, setupFields) {
closeConfigureModal();
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
@@ -3092,13 +3098,6 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
modal.appendChild(hint);
}
if (interactiveLogin) {
const hint = document.createElement('div');
hint.className = 'configure-hint';
hint.textContent = interactiveLoginHintText(name, interactiveLogin);
modal.appendChild(hint);
}
const form = document.createElement('div');
form.className = 'configure-form';
@@ -3188,13 +3187,7 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
fields.push({ kind: 'field', name: setupField.name, input: input });
}
if (fields.length > 0) {
modal.appendChild(form);
}
if (interactiveLogin) {
modal.appendChild(renderInteractiveLoginPanel(name));
}
modal.appendChild(form);
const error = document.createElement('div');
error.className = 'configure-inline-error';
@@ -3209,23 +3202,11 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
const actions = document.createElement('div');
actions.className = 'configure-actions';
if (fields.length > 0) {
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = I18n.t('config.save');
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
}
if (interactiveLogin) {
const loginBtn = document.createElement('button');
loginBtn.className = 'btn-ext activate';
loginBtn.dataset.defaultLabel = interactiveLoginDefaultLabel(name, interactiveLogin);
loginBtn.textContent = loginBtn.dataset.defaultLabel;
loginBtn.dataset.interactiveLogin = 'true';
loginBtn.addEventListener('click', () => startInteractiveLogin(name, overlay));
actions.appendChild(loginBtn);
}
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = I18n.t('config.save');
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-ext remove';
@@ -3237,200 +3218,7 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (fields.length > 0) {
fields[0].input.focus();
} else {
const loginBtn = overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
if (loginBtn) loginBtn.focus();
}
}
function renderInteractiveLoginPanel(name) {
const panel = document.createElement('div');
panel.className = 'configure-qr-login';
panel.style.display = 'none';
const title = document.createElement('div');
title.className = 'configure-verification-title';
title.textContent =
name === 'wechat' ? I18n.t('config.wechatQrTitle') : I18n.t('auth.connect');
panel.appendChild(title);
const status = document.createElement('div');
status.className = 'configure-verification-instructions';
status.textContent = interactiveLoginStatusText(name, null);
status.dataset.qrStatus = 'true';
panel.appendChild(status);
const link = document.createElement('a');
link.className = 'configure-verification-link';
link.textContent =
name === 'wechat' ? I18n.t('config.wechatQrOpen') : I18n.t('auth.connect');
link.target = '_blank';
link.rel = 'noreferrer noopener';
link.style.display = 'none';
link.dataset.qrLink = 'true';
panel.appendChild(link);
return panel;
}
function interactiveLoginHintText(name, interactiveLogin) {
if (name === 'wechat') return I18n.t('config.wechatHint');
return (interactiveLogin && interactiveLogin.instructions) || '';
}
function interactiveLoginDefaultLabel(name, interactiveLogin) {
if (name === 'wechat') return I18n.t('config.wechatConnect');
return (interactiveLogin && interactiveLogin.button_label) || I18n.t('auth.connect');
}
function interactiveLoginWaitingLabel(name) {
if (name === 'wechat') return I18n.t('config.wechatWaiting');
return I18n.t('status.connecting');
}
function interactiveLoginStatusText(name, res) {
if (name !== 'wechat') return (res && res.message) || '';
if (!res) return I18n.t('config.wechatQrIntro');
switch (res.status) {
case 'pending':
return res.qr_code_url ? I18n.t('config.wechatQrReady') : I18n.t('config.wechatQrWaiting');
case 'scanned':
return I18n.t('config.wechatQrScanned');
case 'refreshed':
return I18n.t('config.wechatQrRefreshed');
case 'succeeded':
return I18n.t('config.wechatConnected');
case 'failed':
return res.message || I18n.t('config.wechatQrFailed');
default:
return res.message || I18n.t('config.wechatQrIntro');
}
}
function getInteractiveLoginButton(overlay) {
return overlay && overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
}
function getInteractiveLoginPanel(overlay) {
return overlay && overlay.querySelector('.configure-qr-login');
}
function updateInteractiveLoginPanel(overlay, res) {
const panel = getInteractiveLoginPanel(overlay);
if (!panel) return;
const name = overlay && overlay.dataset ? overlay.dataset.extensionName : '';
const status = panel.querySelector('[data-qr-status="true"]');
const link = panel.querySelector('[data-qr-link="true"]');
panel.style.display = '';
if (status) {
if (name === 'wechat' && res.status === 'refreshed') {
status.textContent = I18n.t('config.wechatQrRefreshedHint');
} else {
status.textContent = interactiveLoginStatusText(name, res);
}
}
if (link && res.qr_code_url) {
link.href = res.qr_code_url;
link.style.display = '';
}
}
function setInteractiveLoginBusy(overlay, busy, label) {
const loginBtn = getInteractiveLoginButton(overlay);
if (!loginBtn) return;
loginBtn.disabled = !!busy;
loginBtn.textContent = label || loginBtn.dataset.defaultLabel || I18n.t('auth.connect');
}
function startInteractiveLogin(name, overlay) {
if (!overlay || !document.body.contains(overlay)) return;
clearConfigureInlineError(overlay);
setConfigureInlineStatus(
overlay,
name === 'wechat' ? I18n.t('config.wechatPreparingQr') : I18n.t('status.connecting'),
);
setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name));
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/start', {
method: 'POST',
body: { force: true },
})
.then((res) => {
if (!overlay || !document.body.contains(overlay)) return;
if (!res.success || !res.session_id) {
setInteractiveLoginBusy(overlay, false);
setConfigureInlineError(
overlay,
res.message || I18n.t('config.interactiveLoginStartFailed'),
);
setConfigureInlineStatus(overlay, '');
return;
}
overlay.dataset.interactiveLoginSessionId = res.session_id;
updateInteractiveLoginPanel(overlay, res);
setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res));
pollInteractiveLogin(name, overlay, res.session_id);
})
.catch((err) => {
if (!overlay || !document.body.contains(overlay)) return;
setInteractiveLoginBusy(overlay, false);
setConfigureInlineError(
overlay,
err.message || I18n.t('config.interactiveLoginStartFailed'),
);
setConfigureInlineStatus(overlay, '');
});
}
function pollInteractiveLogin(name, overlay, sessionId) {
if (!overlay || !document.body.contains(overlay)) return;
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/poll', {
method: 'POST',
body: { session_id: sessionId },
})
.then((res) => {
if (!overlay || !document.body.contains(overlay)) return;
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
updateInteractiveLoginPanel(overlay, res);
setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res));
if (res.status === 'pending' || res.status === 'scanned' || res.status === 'refreshed') {
if (res.status === 'refreshed') {
setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name));
}
window.setTimeout(function() {
pollInteractiveLogin(name, overlay, sessionId);
}, 0);
return;
}
if (res.success && res.activated) {
closeConfigureModal(name);
showToast(res.message || I18n.t('config.connectedSuccess', { name: name }), 'success');
refreshCurrentSettingsTab();
return;
}
setInteractiveLoginBusy(overlay, false);
setConfigureInlineError(overlay, res.message || I18n.t('config.interactiveLoginFailed'));
setConfigureInlineStatus(overlay, '');
})
.catch((err) => {
if (!overlay || !document.body.contains(overlay)) return;
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
setInteractiveLoginBusy(overlay, false);
setConfigureInlineError(overlay, err.message || I18n.t('config.interactiveLoginFailed'));
setConfigureInlineStatus(overlay, '');
});
if (fields.length > 0) fields[0].input.focus();
}
function renderTelegramVerificationChallenge(overlay, verification) {
@@ -3736,9 +3524,9 @@ function renderWasmChannelStepper(ext) {
var status = ext.activation_status || 'installed';
var steps = [
{ label: I18n.t('status.installed'), key: 'installed' },
{ label: I18n.t('status.configured'), key: 'configured' },
{ label: status === 'pairing' ? I18n.t('status.pairingShort') : I18n.t('status.active'), key: 'active' },
{ label: 'Installed', key: 'installed' },
{ label: 'Configured', key: 'configured' },
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
];
var reachedIdx;
@@ -4558,6 +4346,142 @@ function formatRelativeTime(isoString) {
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
}
// --- Users (admin) ---
function loadUsers() {
apiFetch('/api/admin/users').then(function(data) {
renderUsersList(data.users || []);
}).catch(function(err) {
// Non-admin users get 403 — show a message instead of an error
var tbody = document.getElementById('users-tbody');
var empty = document.getElementById('users-empty');
if (tbody) tbody.innerHTML = '';
if (empty) {
empty.style.display = 'block';
empty.textContent = 'Admin access required to manage users.';
}
});
}
function renderUsersList(users) {
var tbody = document.getElementById('users-tbody');
var empty = document.getElementById('users-empty');
if (!users || users.length === 0) {
tbody.innerHTML = '';
empty.style.display = 'block';
empty.textContent = 'No users found. Create the first user to get started.';
return;
}
empty.style.display = 'none';
tbody.innerHTML = users.map(function(u) {
var statusClass = u.status === 'active' ? 'active' : 'failed';
var roleLabel = u.role === 'admin' ? '<span class="badge badge-admin">admin</span>' : '<span class="badge">member</span>';
var actions = '';
if (u.status === 'active') {
actions += '<button class="btn-small btn-danger" data-action="suspend-user" data-user-id="' + escapeHtml(u.id) + '">Suspend</button> ';
} else {
actions += '<button class="btn-small btn-primary" data-action="activate-user" data-user-id="' + escapeHtml(u.id) + '">Activate</button> ';
}
actions += '<button class="btn-small" data-action="create-token" data-user-id="' + escapeHtml(u.id) + '" data-user-name="' + escapeHtml(u.display_name) + '">+ Token</button>';
return '<tr>'
+ '<td class="user-id" title="' + escapeHtml(u.id) + '">' + escapeHtml(u.id.substring(0, 8)) + '…</td>'
+ '<td>' + escapeHtml(u.display_name) + '</td>'
+ '<td>' + escapeHtml(u.email || '—') + '</td>'
+ '<td>' + roleLabel + '</td>'
+ '<td><span class="status-badge ' + statusClass + '">' + escapeHtml(u.status) + '</span></td>'
+ '<td>' + formatRelativeTime(u.created_at) + '</td>'
+ '<td>' + actions + '</td>'
+ '</tr>';
}).join('');
}
function suspendUser(userId) {
apiFetch('/api/admin/users/' + userId + '/suspend', { method: 'POST' })
.then(function() { loadUsers(); })
.catch(function(e) { alert('Failed to suspend user: ' + e.message); });
}
function activateUser(userId) {
apiFetch('/api/admin/users/' + userId + '/activate', { method: 'POST' })
.then(function() { loadUsers(); })
.catch(function(e) { alert('Failed to activate user: ' + e.message); });
}
function createTokenForUser(userId, displayName) {
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');
if (!tokenName) return;
apiFetch('/api/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: tokenName, user_id: userId }),
}).then(function(data) {
showTokenBanner(data.token);
}).catch(function(e) { alert('Failed to create token: ' + e.message); });
}
function showTokenBanner(tokenValue) {
var banner = document.getElementById('users-token-result');
if (!banner) return;
var loginUrl = window.location.origin + '/?token=' + encodeURIComponent(tokenValue);
banner.style.display = 'block';
banner.innerHTML = '<strong>User created!</strong> Share this login link — it won\'t be shown again:<br>'
+ '<code class="token-display" id="token-copy-value">' + escapeHtml(loginUrl) + '</code>'
+ '<button class="btn-small" id="token-copy-link">Copy Link</button>'
+ '<br><span style="font-size:0.8em;color:var(--text-muted)">Raw token: ' + escapeHtml(tokenValue) + '</span>';
document.getElementById('token-copy-link').addEventListener('click', function() {
navigator.clipboard.writeText(loginUrl);
this.textContent = 'Copied!';
});
}
// Delegated click handler for user action buttons (CSP-safe, no inline onclick)
document.getElementById('users-table')?.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
var userId = btn.getAttribute('data-user-id');
var userName = btn.getAttribute('data-user-name');
if (action === 'suspend-user') suspendUser(userId);
else if (action === 'activate-user') activateUser(userId);
else if (action === 'create-token') createTokenForUser(userId, userName || '');
});
// Wire up Users tab create form
document.getElementById('users-create-btn')?.addEventListener('click', function() {
document.getElementById('users-create-form').style.display = 'flex';
document.getElementById('users-token-result').style.display = 'none';
document.getElementById('user-display-name').focus();
});
document.getElementById('users-create-cancel')?.addEventListener('click', function() {
document.getElementById('users-create-form').style.display = 'none';
});
document.getElementById('users-create-submit')?.addEventListener('click', function() {
var displayName = document.getElementById('user-display-name').value.trim();
var email = document.getElementById('user-email').value.trim();
var role = document.getElementById('user-role').value;
if (!displayName) { alert('Display name is required'); return; }
apiFetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
display_name: displayName,
email: email || undefined,
role: role,
}),
}).then(function(data) {
document.getElementById('users-create-form').style.display = 'none';
document.getElementById('user-display-name').value = '';
document.getElementById('user-email').value = '';
if (data.token) {
showTokenBanner(data.token);
}
loadUsers();
}).catch(function(e) { alert('Failed to create user: ' + e.message); });
});
// --- Gateway status widget ---
let gatewayStatusInterval = null;
@@ -5247,6 +5171,7 @@ function loadSettingsSubtab(subtab) {
else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
else if (subtab === 'mcp') loadMcpServers();
else if (subtab === 'skills') loadSkills();
else if (subtab === 'users') loadUsers();
if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
}
+2 -22
View File
@@ -9,8 +9,6 @@ I18n.register('en', {
'auth.connect': 'Connect',
'auth.errorRequired': 'Token required',
'auth.errorInvalid': 'Invalid token',
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
// Chat
'chat.inputPlaceholder': 'Message or / for commands...',
@@ -44,7 +42,8 @@ I18n.register('en', {
'settings.channels': 'Channels',
'settings.networking': 'Networking',
'settings.mcp': 'MCP',
'settings.users': 'Users',
// Status
'status.connected': 'Connected',
'status.disconnected': 'Disconnected',
@@ -54,9 +53,7 @@ I18n.register('en', {
'status.restart': 'Restart',
'status.active': 'Active',
'status.installed': 'Installed',
'status.configured': 'Configured',
'status.awaitingPairing': 'Awaiting Pairing',
'status.pairingShort': 'Pairing',
// Dashboard
'dashboard.connections': 'Connections',
@@ -361,23 +358,6 @@ I18n.register('en', {
'config.telegramStartOver': 'Start over',
'config.telegramStartOverHint': 'Telegram verification did not complete. Click Start over to generate a new code and try again.',
'config.telegramOpenBot': 'Open bot in Telegram',
'config.wechatHint': 'Open the WeChat QR page in a new tab, then scan and confirm in WeChat.',
'config.wechatConnect': 'Open QR Page',
'config.wechatWaiting': 'Waiting for scan...',
'config.wechatPreparingQr': 'Preparing WeChat QR page...',
'config.wechatQrTitle': 'Open WeChat QR Page',
'config.wechatQrOpen': 'Open QR Page',
'config.wechatQrIntro': 'The QR flow opens in a separate tab.',
'config.wechatQrReady': 'QR page is ready. Open it in a new tab, then scan and confirm in WeChat.',
'config.wechatQrWaiting': 'Preparing the WeChat QR page...',
'config.wechatQrScanned': 'QR scanned. Confirm the login in WeChat.',
'config.wechatQrRefreshed': 'QR page refreshed.',
'config.wechatQrRefreshedHint': 'The previous QR page expired. Open the new page and scan again.',
'config.wechatConnected': 'WeChat connected.',
'config.wechatQrFailed': 'WeChat connection failed.',
'config.interactiveLoginStartFailed': 'Failed to start interactive login',
'config.interactiveLoginFailed': 'Interactive login failed',
'config.connectedSuccess': '{name} connected successfully',
'config.optional': ' (optional)',
'config.alreadySet': '(already set — leave empty to keep)',
'config.alreadyConfigured': 'Already configured',
+2 -23
View File
@@ -9,8 +9,6 @@ I18n.register('zh-CN', {
'auth.connect': '连接',
'auth.errorRequired': '请输入令牌',
'auth.errorInvalid': '令牌无效',
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
// 聊天
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
@@ -44,7 +42,8 @@ I18n.register('zh-CN', {
'settings.channels': '频道',
'settings.networking': '网络',
'settings.mcp': 'MCP',
'settings.users': '用户管理',
// 状态
'status.connected': '已连接',
'status.disconnected': '已断开',
@@ -54,9 +53,7 @@ I18n.register('zh-CN', {
'status.restart': '重启',
'status.active': '已激活',
'status.installed': '已安装',
'status.configured': '已配置',
'status.awaitingPairing': '等待配对',
'status.pairingShort': '配对中',
// 仪表盘
'dashboard.connections': '连接数',
@@ -360,24 +357,6 @@ I18n.register('zh-CN', {
'config.telegramCommandLabel': '请在 Telegram 中发送:',
'config.telegramStartOver': '重新开始',
'config.telegramStartOverHint': 'Telegram 验证未完成。点击“重新开始”以生成新的验证码并重试。',
'config.telegramOpenBot': '在 Telegram 中打开机器人',
'config.wechatHint': '在新标签页打开微信扫码页,然后在微信里扫码并确认。',
'config.wechatConnect': '打开扫码页',
'config.wechatWaiting': '等待扫码中...',
'config.wechatPreparingQr': '正在准备微信扫码页...',
'config.wechatQrTitle': '打开微信扫码页',
'config.wechatQrOpen': '打开扫码页',
'config.wechatQrIntro': '扫码流程会在新标签页中打开。',
'config.wechatQrReady': '扫码页已就绪。请在新标签页打开后,用微信扫码并确认。',
'config.wechatQrWaiting': '正在准备微信扫码页...',
'config.wechatQrScanned': '已扫码,请在微信中确认登录。',
'config.wechatQrRefreshed': '扫码页已刷新。',
'config.wechatQrRefreshedHint': '之前的扫码页已过期,请打开新页面重新扫码。',
'config.wechatConnected': '微信已连接。',
'config.wechatQrFailed': '微信连接失败。',
'config.interactiveLoginStartFailed': '启动交互式登录失败',
'config.interactiveLoginFailed': '交互式登录失败',
'config.connectedSuccess': '{name} 连接成功',
'config.optional': '(可选)',
'config.alreadySet': '(已设置 — 留空以保持不变)',
'config.alreadyConfigured': '已配置',
+24 -1
View File
@@ -41,7 +41,6 @@
<button id="auth-connect-btn" data-i18n="auth.connect">Connect</button>
</div>
<div id="auth-error"></div>
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
</div>
</div>
@@ -293,6 +292,7 @@
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
<button class="settings-subtab" data-settings-subtab="users" data-i18n="settings.users">Users</button>
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
</div>
<div class="settings-content">
@@ -390,6 +390,29 @@
</div>
</div>
</div>
<div class="settings-subpanel" id="settings-users">
<div class="users-container">
<div class="users-header">
<h3>User Management</h3>
<button id="users-create-btn" class="btn-primary">+ New User</button>
</div>
<div id="users-create-form" style="display:none" class="users-form" autocomplete="off">
<input type="text" id="user-display-name" placeholder="Display name" autocomplete="off" />
<input type="text" id="user-email" placeholder="Email (optional)" autocomplete="off" />
<select id="user-role"><option value="member">Member</option><option value="admin">Admin</option></select>
<button id="users-create-submit" class="btn-primary">Create</button>
<button id="users-create-cancel" class="btn-secondary">Cancel</button>
</div>
<div id="users-token-result" style="display:none" class="users-token-banner"></div>
<table class="routines-table" id="users-table">
<thead><tr>
<th>ID</th><th>Display Name</th><th>Email</th><th>Role</th><th>Status</th><th>Created</th><th>Actions</th>
</tr></thead>
<tbody id="users-tbody"></tbody>
</table>
<div id="users-empty" class="empty-state" style="display:none">No users found. Create the first user to get started.</div>
</div>
</div>
</div>
</div>
</div>
+24 -38
View File
@@ -2961,18 +2961,15 @@ body {
/* WASM channel setup stepper */
.ext-stepper {
display: flex;
align-items: flex-start;
align-items: center;
gap: 0;
margin: 8px 0 4px;
min-width: 0;
}
.stepper-step {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1 1 0;
gap: 4px;
}
.stepper-circle {
@@ -2989,10 +2986,7 @@ body {
.stepper-label {
font-size: var(--text-xs);
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.25;
min-width: 0;
white-space: nowrap;
}
.stepper-step.completed .stepper-circle {
@@ -3049,8 +3043,7 @@ body {
height: 2px;
background: var(--border);
margin: 0 4px;
flex: 0 0 20px;
align-self: center;
flex-shrink: 0;
}
.stepper-connector.completed {
@@ -3245,17 +3238,6 @@ body {
border: 1px solid var(--border);
}
.configure-qr-login {
display: flex;
flex-direction: column;
gap: 12px;
margin: 16px 0 0 0;
padding: 12px;
border-radius: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.configure-verification-title {
font-size: var(--text-sm);
font-weight: 600;
@@ -3280,29 +3262,14 @@ body {
}
.configure-verification-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--accent);
background: var(--accent-subtle);
color: var(--accent, var(--text-link, #4ea3ff));
font-size: var(--text-sm);
font-weight: 600;
text-decoration: none;
transition: background var(--transition-fast), transform 150ms var(--ease-spring);
}
.configure-verification-link:hover {
background: var(--badge-sandbox-bg);
transform: translateY(-1px);
text-decoration: none;
}
.configure-verification-link:active {
transform: scale(0.98);
text-decoration: underline;
}
.configure-inline-error {
@@ -5462,3 +5429,22 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
--text-muted: #a1a1aa;
}
}
/* --- Users Tab --- */
.users-container { padding: 1rem; }
.users-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.users-header h3 { margin: 0; font-size: 1.1rem; }
.users-form { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
.users-form input, .users-form select { padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem; }
.users-token-banner { background: var(--bg-tertiary); border: 1px solid var(--accent); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; }
.token-display { display: inline-block; padding: 0.3rem 0.6rem; background: var(--bg-primary); border-radius: 4px; font-family: var(--font-mono); word-break: break-all; margin: 0.4rem 0; user-select: all; }
.user-id { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.75rem; background: var(--bg-tertiary); color: var(--text-secondary); }
.badge-admin { background: var(--accent); color: #fff; }
.btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; }
.btn-small:hover { background: var(--bg-tertiary); }
.btn-danger { border-color: #ef4444; color: #ef4444; }
.btn-danger:hover { background: #ef4444; color: #fff; }
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
.btn-primary:hover { opacity: 0.9; }
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
+3 -3
View File
@@ -77,7 +77,6 @@ impl TestGatewayBuilder {
job_manager: None,
prompt_queue: None,
owner_id: self.user_id.clone(),
default_sender_id: self.user_id,
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: self.llm_provider,
@@ -92,6 +91,7 @@ impl TestGatewayBuilder {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
})
}
@@ -106,7 +106,7 @@ impl TestGatewayBuilder {
let addr: SocketAddr = "127.0.0.1:0"
.parse()
.expect("hard-coded address must parse"); // safety: constant literal
let bound = start_server(addr, state.clone(), auth).await?;
let bound = start_server(addr, state.clone(), auth.into()).await?;
Ok((bound, state))
}
@@ -120,7 +120,7 @@ impl TestGatewayBuilder {
let addr: SocketAddr = "127.0.0.1:0"
.parse()
.expect("hard-coded address must parse"); // safety: constant literal
let bound = start_server(addr, state.clone(), auth).await?;
let bound = start_server(addr, state.clone(), auth.into()).await?;
Ok((bound, state))
}
}
+158 -40
View File
@@ -16,7 +16,6 @@ use axum::routing::{delete, get, post};
use tower::ServiceExt;
use uuid::Uuid;
use crate::channels::web::GatewayChannel;
use crate::channels::web::auth::{
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
};
@@ -24,7 +23,6 @@ use crate::channels::web::server::{
ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool,
};
use crate::channels::web::sse::SseManager;
use crate::config::GatewayConfig;
// ── Helpers ────────────────────────────────────────────────────────────
@@ -35,6 +33,7 @@ fn two_user_auth() -> MultiAuthState {
"tok-alice".to_string(),
UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -42,6 +41,7 @@ fn two_user_auth() -> MultiAuthState {
"tok-bob".to_string(),
UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
},
);
@@ -67,7 +67,6 @@ fn build_state(
job_manager: None,
prompt_queue,
owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None,
llm_provider: None,
@@ -82,43 +81,10 @@ fn build_state(
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(),
secrets_store: None,
})
}
fn gateway_config() -> GatewayConfig {
GatewayConfig {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: Some("gateway-auth".to_string()),
user_id: "gateway-sender".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
}
}
#[test]
fn with_owner_scope_updates_gateway_owner_scope_in_multi_user_mode() {
let mut gateway = GatewayChannel::new(gateway_config());
gateway.auth = two_user_auth();
gateway.config.user_tokens = Some(HashMap::new());
let gateway = gateway.with_owner_scope("owner-scope");
assert_eq!(gateway.state.owner_id, "owner-scope");
assert_eq!(gateway.state.default_sender_id, "gateway-sender");
let alice = gateway
.auth
.authenticate("tok-alice")
.expect("alice token should remain valid");
let bob = gateway
.auth
.authenticate("tok-bob")
.expect("bob token should remain valid");
assert_eq!(alice.user_id, "alice");
assert_eq!(bob.user_id, "bob");
}
/// Create a libSQL-backed test database in a temporary directory.
///
/// Returns the database and a `TempDir` guard — the database file is
@@ -225,6 +191,7 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let ws = pool.get_or_create(&identity).await;
@@ -253,6 +220,7 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let ws = pool.get_or_create(&identity).await;
@@ -276,6 +244,7 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()],
};
let ws = pool.get_or_create(&identity).await;
@@ -302,10 +271,12 @@ mod workspace_pool {
);
let alice_id = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
let bob_id = UserIdentity {
user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
};
@@ -337,6 +308,7 @@ mod workspace_pool {
);
let identity = UserIdentity {
user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["token-scope".to_string()],
};
let ws = pool.get_or_create(&identity).await;
@@ -377,7 +349,10 @@ mod jobs_isolation {
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
.route("/api/jobs/{id}/restart", post(jobs_restart_handler))
.route("/api/jobs/{id}/prompt", post(jobs_prompt_handler))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state)
}
@@ -583,7 +558,10 @@ mod routines_isolation {
.route("/api/routines/{id}", get(routines_detail_handler))
.route("/api/routines/{id}/toggle", post(routines_toggle_handler))
.route("/api/routines/{id}", delete(routines_delete_handler))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state)
}
@@ -708,7 +686,10 @@ mod auth_enforcement {
.route("/api/logs/level", get(authed_handler).put(authed_handler))
// Gateway status
.route("/api/gateway/status", get(authed_handler))
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state)
}
@@ -831,3 +812,140 @@ mod auth_enforcement {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
// ═══════════════════════════════════════════════════════════════════════
// Admin Endpoint Role Enforcement Tests
// ═══════════════════════════════════════════════════════════════════════
mod admin_role_enforcement {
use super::*;
use crate::channels::web::handlers::users::{
users_activate_handler, users_detail_handler, users_list_handler, users_suspend_handler,
users_update_handler,
};
use axum::routing::patch;
/// Build a router with admin user endpoints behind multi-user auth.
/// Uses a member-role token and an admin-role token.
fn admin_router() -> Router {
let mut tokens = HashMap::new();
tokens.insert(
"tok-admin".to_string(),
UserIdentity {
user_id: "admin-user".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![],
},
);
tokens.insert(
"tok-member".to_string(),
UserIdentity {
user_id: "member-user".to_string(),
role: "member".to_string(),
workspace_read_scopes: vec![],
},
);
let auth = MultiAuthState::multi(tokens);
let state = build_state(None, None);
Router::new()
.route("/api/admin/users", get(users_list_handler))
.route("/api/admin/users/{id}", get(users_detail_handler))
.route("/api/admin/users/{id}", patch(users_update_handler))
.route("/api/admin/users/{id}/suspend", post(users_suspend_handler))
.route(
"/api/admin/users/{id}/activate",
post(users_activate_handler),
)
.layer(middleware::from_fn_with_state(
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state)
}
/// Assert a request returns FORBIDDEN for a member token.
async fn assert_forbidden_for_member(app: &Router, method: Method, uri: &str) {
let req = Request::builder()
.method(method)
.uri(uri)
.header("Authorization", "Bearer tok-member")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"expected 403 for member on {}",
uri
);
}
#[tokio::test]
async fn test_admin_user_endpoints_reject_member_role() {
let app = admin_router();
assert_forbidden_for_member(&app, Method::GET, "/api/admin/users").await;
assert_forbidden_for_member(&app, Method::GET, "/api/admin/users/some-id").await;
assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/suspend").await;
assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/activate").await;
}
#[tokio::test]
async fn test_admin_user_endpoints_accept_admin_role() {
let app = admin_router();
// Admin token should pass auth (will get 503 since no DB, but not 403).
let req = Request::builder()
.uri("/api/admin/users")
.header("Authorization", "Bearer tok-admin")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::FORBIDDEN,
"admin should not get 403"
);
}
}
// ═══════════════════════════════════════════════════════════════════════
// DbAuthenticator Cache Bounded Tests
// ═══════════════════════════════════════════════════════════════════════
mod db_auth_cache {
use super::*;
use std::time::Instant;
#[tokio::test]
async fn test_cache_bounded_by_max_entries() {
// Access the internal cache and verify LRU eviction.
// We can't easily test through `authenticate()` since it hits the DB,
// so we test the LRU cache directly.
let cap = std::num::NonZeroUsize::new(4).unwrap(); // safety: test-only, 4 is non-zero
let cache: lru::LruCache<[u8; 32], (UserIdentity, Instant)> = lru::LruCache::new(cap);
let cache = Arc::new(tokio::sync::RwLock::new(cache));
{
let mut c = cache.write().await;
for i in 0..10u8 {
let mut hash = [0u8; 32];
hash[0] = i;
c.put(
hash,
(
UserIdentity {
user_id: format!("user-{i}"),
role: "member".to_string(),
workspace_read_scopes: vec![],
},
Instant::now(),
),
);
}
// Cache must be bounded at capacity, not grown to 10.
assert_eq!(c.len(), 4, "cache should be bounded to capacity"); // safety: test assertion
}
}
}
+1 -30
View File
@@ -306,7 +306,6 @@ pub fn classify_wasm_channel_activation(
ext: &crate::extensions::InstalledExtension,
has_paired: bool,
has_owner_binding: bool,
requires_binding: bool,
) -> Option<ExtensionActivationStatus> {
if ext.kind != crate::extensions::ExtensionKind::WasmChannel {
return None;
@@ -317,7 +316,7 @@ pub fn classify_wasm_channel_activation(
} else if !ext.authenticated {
ExtensionActivationStatus::Installed
} else if ext.active {
if !requires_binding || has_paired || has_owner_binding {
if has_paired || has_owner_binding {
ExtensionActivationStatus::Active
} else {
ExtensionActivationStatus::Pairing
@@ -387,8 +386,6 @@ pub struct ExtensionSetupResponse {
pub kind: String,
pub secrets: Vec<SecretFieldInfo>,
pub fields: Vec<SetupFieldInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interactive_login: Option<crate::extensions::InteractiveLoginInfo>,
}
#[derive(Debug, Serialize)]
@@ -421,32 +418,6 @@ pub struct ExtensionSetupRequest {
pub fields: std::collections::HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct ExtensionInteractiveLoginStartRequest {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize)]
pub struct ExtensionInteractiveLoginPollRequest {
pub session_id: String,
}
#[derive(Debug, Serialize)]
pub struct ExtensionInteractiveLoginResponse {
pub success: bool,
pub status: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct ActionResponse {
pub success: bool,
+1 -1
View File
@@ -521,7 +521,6 @@ mod tests {
prompt_queue: None,
scheduler: None,
owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -535,6 +534,7 @@ mod tests {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
}
}
}
+2 -1
View File
@@ -352,7 +352,8 @@ pub async fn run_routines_cli(
.await
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string());
let user_id =
std::env::var("IRONCLAW_OWNER_ID").unwrap_or_else(|_| "default".to_string());
run_routines_command(routines_cmd.clone(), db, &user_id).await
}
+5 -184
View File
@@ -473,8 +473,7 @@ pub struct PendingOAuthFlow {
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
/// SSE broadcast manager for notifying the web UI.
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
/// Kept as `gateway_token` for public API compatibility.
/// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>,
/// Additional form params for the token exchange request.
/// Used for provider-specific requirements such as RFC 8707 `resource`.
@@ -497,12 +496,6 @@ impl std::fmt::Debug for PendingOAuthFlow {
}
}
impl PendingOAuthFlow {
pub fn oauth_proxy_auth_token(&self) -> Option<&str> {
self.gateway_token.as_deref()
}
}
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
@@ -536,22 +529,6 @@ pub fn exchange_proxy_url() -> Option<String> {
.filter(|url| !url.is_empty())
}
/// Returns the configured OAuth proxy auth token, if any.
///
/// New hosted infra can inject a dedicated shared proxy secret via
/// `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`. Existing hosted instances continue to
/// work by falling back to `GATEWAY_AUTH_TOKEN`.
pub fn oauth_proxy_auth_token() -> Option<String> {
fn normalized_env_value(key: &str) -> Option<String> {
crate::config::helpers::env_or_override(key)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
normalized_env_value("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN")
.or_else(|| normalized_env_value("GATEWAY_AUTH_TOKEN"))
}
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
@@ -697,8 +674,6 @@ pub fn strip_instance_prefix(state: &str) -> &str {
pub struct ProxyTokenExchangeRequest<'a> {
pub proxy_url: &'a str,
/// OAuth proxy auth token.
/// Kept as `gateway_token` for public API compatibility.
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
@@ -712,8 +687,6 @@ pub struct ProxyTokenExchangeRequest<'a> {
pub struct ProxyRefreshTokenRequest<'a> {
pub proxy_url: &'a str,
/// OAuth proxy auth token.
/// Kept as `gateway_token` for public API compatibility.
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
@@ -756,7 +729,7 @@ fn oauth_token_response_from_json(
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
///
@@ -768,7 +741,7 @@ pub async fn exchange_via_proxy(
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"OAuth proxy auth token is required for proxy token exchange".to_string(),
"Gateway auth token is required for proxy token exchange".to_string(),
));
}
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
@@ -823,7 +796,7 @@ pub async fn exchange_via_proxy(
/// Refresh an OAuth access token via the platform's token refresh proxy.
///
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
pub async fn refresh_token_via_proxy(
@@ -831,7 +804,7 @@ pub async fn refresh_token_via_proxy(
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"OAuth proxy auth token is required for proxy token refresh".to_string(),
"Gateway auth token is required for proxy token refresh".to_string(),
));
}
@@ -1037,37 +1010,6 @@ mod tests {
}
}
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
if let Some(ref value) = self.original {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
let original = std::env::var(key).ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
EnvVarGuard { key, original }
}
#[test]
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
@@ -1088,79 +1030,6 @@ mod tests {
assert_eq!(result, client_secret);
}
#[tokio::test]
async fn test_exchange_via_proxy_sends_auth_and_form() {
let server = MockProxyServer::start().await;
let mut extra_token_params = HashMap::new();
extra_token_params.insert("resource".to_string(), "https://mcp.notion.com".to_string());
let response = super::exchange_via_proxy(super::ProxyTokenExchangeRequest {
proxy_url: &server.base_url(),
gateway_token: "shared-oauth-proxy-secret",
code: "auth-code-123",
redirect_uri: "https://oauth.example.com/oauth/callback",
token_url: "https://oauth2.googleapis.com/token",
client_id: TEST_OAUTH_CLIENT_ID,
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
access_token_field: "access_token",
code_verifier: Some("code-verifier-123"),
extra_token_params: &extra_token_params,
})
.await
.expect("proxy exchange succeeds");
assert_eq!(response.access_token, "proxy-access-token");
assert_eq!(
response.refresh_token.as_deref(),
Some("proxy-refresh-token")
);
assert_eq!(response.expires_in, Some(7200));
let requests = server.requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].authorization.as_deref(),
Some("Bearer shared-oauth-proxy-secret")
);
assert_eq!(
requests[0].form.get("code").map(String::as_str),
Some("auth-code-123")
);
assert_eq!(
requests[0].form.get("redirect_uri").map(String::as_str),
Some("https://oauth.example.com/oauth/callback")
);
assert_eq!(
requests[0].form.get("token_url").map(String::as_str),
Some("https://oauth2.googleapis.com/token")
);
assert_eq!(
requests[0].form.get("client_id").map(String::as_str),
Some(TEST_OAUTH_CLIENT_ID)
);
assert_eq!(
requests[0].form.get("client_secret").map(String::as_str),
Some(TEST_OAUTH_CLIENT_SECRET)
);
assert_eq!(
requests[0]
.form
.get("access_token_field")
.map(String::as_str),
Some("access_token")
);
assert_eq!(
requests[0].form.get("code_verifier").map(String::as_str),
Some("code-verifier-123")
);
assert_eq!(
requests[0].form.get("resource").map(String::as_str),
Some("https://mcp.notion.com")
);
server.shutdown().await;
}
#[tokio::test]
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
let server = MockProxyServer::start().await;
@@ -1666,54 +1535,6 @@ mod tests {
}
}
#[test]
fn test_oauth_proxy_auth_token_prefers_dedicated_env() {
let _guard = lock_env();
let _proxy_guard = set_env_var(
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
Some("shared-proxy-secret"),
);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("shared-proxy-secret")
);
}
#[test]
fn test_oauth_proxy_auth_token_falls_back_to_gateway_token() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("gateway-token")
);
}
#[test]
fn test_oauth_proxy_auth_token_whitespace_dedicated_env_falls_back_to_gateway_token() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", Some(" "));
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("gateway-token")
);
}
#[test]
fn test_oauth_proxy_auth_token_returns_none_when_unset() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
assert_eq!(crate::cli::oauth_defaults::oauth_proxy_auth_token(), None);
}
#[test]
fn test_strip_instance_prefix_with_colon() {
use crate::cli::oauth_defaults::strip_instance_prefix;
+10 -5
View File
@@ -1,6 +1,6 @@
use std::time::Duration;
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
@@ -31,10 +31,13 @@ pub struct AgentConfig {
pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
pub default_timezone: String,
/// Maximum concurrent jobs per user. None = use global max_parallel_jobs.
pub max_jobs_per_user: Option<usize>,
/// Maximum tokens per job (0 = unlimited).
pub max_tokens_per_job: u64,
/// Whether the deployment is multi-tenant (multiple users sharing one
/// instance). Auto-detected from GATEWAY_USER_TOKENS presence.
/// instance). Detected at runtime after DB initialization, not from config.
/// See app.rs startup logic.
pub multi_tenant: bool,
/// Maximum concurrent LLM calls per user. None = use default (4).
pub max_llm_concurrent_per_user: Option<usize>,
@@ -62,6 +65,7 @@ impl AgentConfig {
max_tool_iterations: 10,
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0,
multi_tenant: false,
max_llm_concurrent_per_user: None,
@@ -122,13 +126,14 @@ impl AgentConfig {
}
tz
},
max_jobs_per_user: parse_option_env("MAX_JOBS_PER_USER")?,
max_tokens_per_job: parse_optional_env(
"AGENT_MAX_TOKENS_PER_JOB",
settings.agent.max_tokens_per_job,
)?,
// Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
// knob — multi-tenant mode is always implied by configuring user tokens.
multi_tenant: optional_env("GATEWAY_USER_TOKENS")?.is_some(),
// Multi-tenant mode is detected at runtime after DB initialization,
// not from config. See app.rs startup logic.
multi_tenant: false,
max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?,
max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?,
})
+2 -64
View File
@@ -1,13 +1,11 @@
use std::collections::HashMap;
use std::path::PathBuf;
use secrecy::SecretString;
use serde::Deserialize;
use crate::bootstrap::ironclaw_base_dir;
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
use crate::error::ConfigError;
use crate::settings::Settings;
use secrecy::SecretString;
/// Channel configurations.
#[derive(Debug, Clone)]
@@ -45,7 +43,6 @@ pub struct GatewayConfig {
pub port: u16,
/// Bearer token for authentication. Random hex generated at startup if unset.
pub auth_token: Option<String>,
pub user_id: String,
/// Additional user scopes for workspace reads.
///
/// When set, the workspace will be able to read (search, read, list) from
@@ -54,18 +51,6 @@ pub struct GatewayConfig {
pub workspace_read_scopes: Vec<String>,
/// Memory layer definitions (JSON in env var, or from external config).
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
/// Multi-user token map. When set, each token maps to a user identity.
/// Parsed from `GATEWAY_USER_TOKENS` (JSON string). When absent, falls back
/// to single-user mode via `auth_token` + `user_id`.
pub user_tokens: Option<HashMap<String, UserTokenConfig>>,
}
/// Per-user token configuration for multi-user mode.
#[derive(Debug, Clone, Deserialize)]
pub struct UserTokenConfig {
pub user_id: String,
#[serde(default)]
pub workspace_read_scopes: Vec<String>,
}
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
@@ -132,10 +117,6 @@ impl ChannelsConfig {
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if gateway_enabled {
let user_id = optional_env("GATEWAY_USER_ID")?
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| owner_id.to_string());
let memory_layers: Vec<crate::workspace::layer::MemoryLayer> =
match optional_env("MEMORY_LAYERS")? {
Some(json_str) => {
@@ -144,7 +125,7 @@ impl ChannelsConfig {
message: format!("must be valid JSON array of layer objects: {e}"),
})?
}
None => crate::workspace::layer::MemoryLayer::default_for_user(&user_id),
None => crate::workspace::layer::MemoryLayer::default_for_user(owner_id),
};
// Validate layer names and scopes
@@ -196,41 +177,6 @@ impl ChannelsConfig {
}
}
let user_tokens: Option<HashMap<String, UserTokenConfig>> =
match optional_env("GATEWAY_USER_TOKENS")? {
Some(json_str) => {
let tokens: HashMap<String, UserTokenConfig> = serde_json::from_str(
&json_str,
)
.map_err(|e| ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message: format!(
"must be valid JSON object mapping tokens to user configs: {e}"
),
})?;
if tokens.is_empty() {
return Err(ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message:
"token map is empty — remove the variable to use single-user mode"
.to_string(),
});
}
for (tok, cfg) in &tokens {
if cfg.user_id.trim().is_empty() {
return Err(ConfigError::InvalidValue {
key: "GATEWAY_USER_TOKENS".to_string(),
message: format!(
"token '{}...' has an empty user_id",
&tok[..tok.len().min(8)]
),
});
}
}
Some(tokens)
}
None => None,
};
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
.map(|s| {
s.split(',')
@@ -258,10 +204,8 @@ impl ChannelsConfig {
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
user_id,
workspace_read_scopes,
memory_layers,
user_tokens,
})
} else {
None
@@ -416,15 +360,12 @@ mod tests {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: Some("tok-abc".to_string()),
user_id: "default".to_string(),
workspace_read_scopes: vec![],
memory_layers: vec![],
user_tokens: None,
};
assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 3000);
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
assert_eq!(cfg.user_id, "default");
}
#[test]
@@ -433,10 +374,8 @@ mod tests {
host: "0.0.0.0".to_string(),
port: 3001,
auth_token: None,
user_id: "anon".to_string(),
workspace_read_scopes: vec![],
memory_layers: vec![],
user_tokens: None,
};
assert!(cfg.auth_token.is_none());
}
@@ -563,7 +502,6 @@ mod tests {
assert_eq!(gateway.host, "127.0.0.3");
assert_eq!(gateway.port, 9191);
assert_eq!(gateway.auth_token.as_deref(), Some("tok"));
assert_eq!(gateway.user_id, "owner-scope");
let signal = cfg.signal.expect("signal config");
assert_eq!(signal.account, "+15551234567");
+3 -8
View File
@@ -21,8 +21,8 @@ pub struct HeartbeatConfig {
pub quiet_hours_end: Option<u32>,
/// Timezone for fire_at and quiet hours evaluation (IANA name).
pub timezone: Option<String>,
/// When true, cycle through all users with routines. Auto-detected from
/// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT.
/// When true, cycle through all users with routines. Set explicitly via
/// HEARTBEAT_MULTI_TENANT or detected at runtime after DB initialization.
pub multi_tenant: bool,
}
@@ -105,12 +105,7 @@ impl HeartbeatConfig {
}
tz
},
// Auto-detect multi-tenant mode from GATEWAY_USER_TOKENS presence,
// or allow explicit override via HEARTBEAT_MULTI_TENANT.
multi_tenant: parse_bool_env(
"HEARTBEAT_MULTI_TENANT",
optional_env("GATEWAY_USER_TOKENS")?.is_some(),
)?,
multi_tenant: parse_bool_env("HEARTBEAT_MULTI_TENANT", false)?,
})
}
}
-3
View File
@@ -192,9 +192,6 @@ pub struct JobContext {
/// but subsequent tools (e.g., `json`) may need the full output. This
/// stash stores the complete, unsanitized output so tools can reference
/// previous results by ID via `$tool_call_id` parameter syntax.
///
/// Also used for cross-tool implicit state (keys prefixed with `__`) such
/// as `__routine_last_name` for fallback recovery in routine tool chains.
#[serde(skip)]
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
+1
View File
@@ -12,6 +12,7 @@ mod routines;
mod sandbox;
mod settings;
mod tool_failures;
mod users;
mod workspace;
use std::path::Path;
+735
View File
@@ -0,0 +1,735 @@
//! UserStore implementation for LibSqlBackend.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use libsql::params;
use uuid::Uuid;
use super::{fmt_opt_ts, fmt_ts, get_opt_text, get_opt_ts, get_text, get_ts, opt_text};
use crate::db::libsql::LibSqlBackend;
use crate::db::{ApiTokenRecord, DatabaseError, UserRecord, UserStore};
fn row_to_user(row: &libsql::Row) -> Result<UserRecord, DatabaseError> {
let metadata_str = get_text(row, 9);
let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
Ok(UserRecord {
id: get_text(row, 0),
email: get_opt_text(row, 1),
display_name: get_text(row, 2),
status: get_text(row, 3),
role: get_text(row, 4),
created_at: get_ts(row, 5),
updated_at: get_ts(row, 6),
last_login_at: get_opt_ts(row, 7),
created_by: get_opt_text(row, 8),
metadata,
})
}
fn row_to_api_token(row: &libsql::Row) -> Result<ApiTokenRecord, DatabaseError> {
let id_str = get_text(row, 0);
let id: Uuid = id_str
.parse()
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
Ok(ApiTokenRecord {
id,
user_id: get_text(row, 1),
name: get_text(row, 2),
token_prefix: get_text(row, 3),
expires_at: get_opt_ts(row, 4),
last_used_at: get_opt_ts(row, 5),
created_at: get_ts(row, 6),
revoked_at: get_opt_ts(row, 7),
})
}
#[async_trait]
impl UserStore for LibSqlBackend {
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
let conn = self.connect().await?;
let metadata_json = serde_json::to_string(&user.metadata)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
conn.execute(
r#"
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
"#,
params![
user.id.as_str(),
opt_text(user.email.as_deref()),
user.display_name.as_str(),
user.status.as_str(),
user.role.as_str(),
fmt_ts(&user.created_at),
fmt_ts(&user.updated_at),
fmt_opt_ts(&user.last_login_at),
opt_text(user.created_by.as_deref()),
metadata_json,
],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
r#"
SELECT id, email, display_name, status, role, created_at, updated_at,
last_login_at, created_by, metadata
FROM users WHERE id = ?1
"#,
params![id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
match rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
Some(row) => Ok(Some(row_to_user(&row)?)),
None => Ok(None),
}
}
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
r#"
SELECT id, email, display_name, status, role, created_at, updated_at,
last_login_at, created_by, metadata
FROM users WHERE email = ?1
"#,
params![email],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
match rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
Some(row) => Ok(Some(row_to_user(&row)?)),
None => Ok(None),
}
}
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
let conn = self.connect().await?;
let mut users = Vec::new();
let mut rows = if let Some(status) = status {
conn.query(
r#"
SELECT id, email, display_name, status, role, created_at, updated_at,
last_login_at, created_by, metadata
FROM users WHERE status = ?1
ORDER BY created_at DESC
"#,
params![status],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
} else {
conn.query(
r#"
SELECT id, email, display_name, status, role, created_at, updated_at,
last_login_at, created_by, metadata
FROM users
ORDER BY created_at DESC
"#,
(),
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
};
while let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
users.push(row_to_user(&row)?);
}
Ok(users)
}
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
let conn = self.connect().await?;
let now = fmt_ts(&Utc::now());
conn.execute(
"UPDATE users SET status = ?2, updated_at = ?3 WHERE id = ?1",
params![id, status, now],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.connect().await?;
let now = fmt_ts(&Utc::now());
let metadata_json = serde_json::to_string(metadata)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
conn.execute(
"UPDATE users SET display_name = ?2, metadata = ?3, updated_at = ?4 WHERE id = ?1",
params![id, display_name, metadata_json, now],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
let conn = self.connect().await?;
let now = fmt_ts(&Utc::now());
conn.execute(
"UPDATE users SET last_login_at = ?2, updated_at = ?2 WHERE id = ?1",
params![id, now],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
let conn = self.connect().await?;
let id = Uuid::new_v4();
let now = Utc::now();
conn.execute(
r#"
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
"#,
params![
id.to_string(),
user_id,
libsql::Value::Blob(token_hash.to_vec()),
token_prefix,
name,
fmt_opt_ts(&expires_at),
fmt_ts(&now),
],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(ApiTokenRecord {
id,
user_id: user_id.to_string(),
name: name.to_string(),
token_prefix: token_prefix.to_string(),
expires_at,
last_used_at: None,
created_at: now,
revoked_at: None,
})
}
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query(
r#"
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
FROM api_tokens WHERE user_id = ?1
ORDER BY created_at DESC
"#,
params![user_id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
let mut tokens = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
tokens.push(row_to_api_token(&row)?);
}
Ok(tokens)
}
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
let conn = self.connect().await?;
let now = fmt_ts(&Utc::now());
let rows_affected = conn
.execute(
r#"
UPDATE api_tokens SET revoked_at = ?3
WHERE id = ?1 AND user_id = ?2 AND revoked_at IS NULL
"#,
params![token_id.to_string(), user_id, now],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(rows_affected > 0)
}
async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
let conn = self.connect().await?;
let now = fmt_ts(&Utc::now());
let mut rows = conn
.query(
r#"
SELECT
t.id, t.user_id, t.name, t.token_prefix, t.expires_at,
t.last_used_at, t.created_at, t.revoked_at,
u.id, u.email, u.display_name, u.status, u.role, u.created_at,
u.updated_at, u.last_login_at, u.created_by, u.metadata
FROM api_tokens t
JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ?1
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > ?2)
AND u.status = 'active'
"#,
params![libsql::Value::Blob(token_hash.to_vec()), now],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
match rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
Some(row) => {
let id_str = get_text(&row, 0);
let token_id: Uuid = id_str
.parse()
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
let token = ApiTokenRecord {
id: token_id,
user_id: get_text(&row, 1),
name: get_text(&row, 2),
token_prefix: get_text(&row, 3),
expires_at: get_opt_ts(&row, 4),
last_used_at: get_opt_ts(&row, 5),
created_at: get_ts(&row, 6),
revoked_at: get_opt_ts(&row, 7),
};
let metadata_str = get_text(&row, 17);
let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
let user = UserRecord {
id: get_text(&row, 8),
email: get_opt_text(&row, 9),
display_name: get_text(&row, 10),
status: get_text(&row, 11),
role: get_text(&row, 12),
created_at: get_ts(&row, 13),
updated_at: get_ts(&row, 14),
last_login_at: get_opt_ts(&row, 15),
created_by: get_opt_text(&row, 16),
metadata,
};
Ok(Some((token, user)))
}
None => Ok(None),
}
}
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
let conn = self.connect().await?;
let now = fmt_ts(&Utc::now());
conn.execute(
"UPDATE api_tokens SET last_used_at = ?2 WHERE id = ?1",
params![token_id.to_string(), now],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
let conn = self.connect().await?;
let mut rows = conn
.query("SELECT 1 FROM users LIMIT 1", ())
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
let has_users = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
.is_some();
Ok(has_users)
}
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
let conn = self.connect().await?;
// Delete from child tables first to avoid FK violations.
// agent_jobs cascades to job_actions, llm_calls, estimation_snapshots
// conversations cascades to conversation_messages
// memory_documents cascades to memory_chunks
// routines cascades to routine_runs
for table in &[
"settings",
"heartbeat_state",
"tool_rate_limit_state",
"secret_usage_log",
"leak_detection_events",
"secrets",
"wasm_tools",
"routines",
"memory_documents",
"conversations",
"api_tokens",
] {
conn.execute(
&format!("DELETE FROM {} WHERE user_id = ?1", table),
params![id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
}
// job_events references agent_jobs(id) without CASCADE — delete via subquery.
conn.execute(
"DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = ?1)",
params![id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
conn.execute("DELETE FROM agent_jobs WHERE user_id = ?1", params![id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Nullify self-referencing created_by before deleting the user
conn.execute(
"UPDATE users SET created_by = NULL WHERE created_by = ?1",
params![id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
let rows = conn
.execute("DELETE FROM users WHERE id = ?1", params![id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(rows > 0)
}
async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
let conn = self.connect().await?;
let since_str = fmt_ts(&since);
let mut rows = if let Some(uid) = user_id {
conn.query(
r#"
SELECT j.user_id, l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
JOIN agent_jobs j ON l.job_id = j.id
WHERE l.created_at >= ?1
AND j.user_id = ?2
GROUP BY j.user_id, l.model
ORDER BY total_cost DESC
"#,
params![since_str, uid],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
} else {
conn.query(
r#"
SELECT j.user_id, l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
JOIN agent_jobs j ON l.job_id = j.id
WHERE l.created_at >= ?1
GROUP BY j.user_id, l.model
ORDER BY total_cost DESC
"#,
params![since_str],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
};
let mut stats = Vec::new();
while let Some(row) = rows
.next()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?
{
let cost_str = get_text(&row, 5);
let total_cost = rust_decimal::Decimal::from_str_exact(&cost_str).unwrap_or_default();
stats.push(crate::db::UserUsageStats {
user_id: get_text(&row, 0),
model: get_text(&row, 1),
call_count: row
.get::<i64>(2)
.map_err(|e| DatabaseError::Query(e.to_string()))?,
input_tokens: row
.get::<i64>(3)
.map_err(|e| DatabaseError::Query(e.to_string()))?,
output_tokens: row
.get::<i64>(4)
.map_err(|e| DatabaseError::Query(e.to_string()))?,
total_cost,
});
}
Ok(stats)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::libsql::LibSqlBackend;
use crate::db::{Database, UserStore};
use sha2::{Digest, Sha256};
fn hash(s: &str) -> [u8; 32] {
let mut h = Sha256::new();
h.update(s.as_bytes());
h.finalize().into()
}
async fn setup() -> (LibSqlBackend, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_users.db");
let db = LibSqlBackend::new_local(&db_path).await.unwrap();
db.run_migrations().await.unwrap();
(db, dir) // keep dir alive so the DB file isn't deleted
}
fn test_user(id: &str) -> UserRecord {
UserRecord {
id: id.to_string(),
email: Some(format!("{}@test.com", id)),
display_name: id.to_string(),
status: "active".to_string(),
role: "member".to_string(),
created_at: Utc::now(),
updated_at: Utc::now(),
last_login_at: None,
created_by: None,
metadata: serde_json::json!({}),
}
}
#[tokio::test]
async fn test_has_any_users_empty() {
let (db, _dir) = setup().await;
assert!(!db.has_any_users().await.unwrap());
}
#[tokio::test]
async fn test_create_and_get_user() {
let (db, _dir) = setup().await;
let user = test_user("alice");
db.create_user(&user).await.unwrap();
assert!(db.has_any_users().await.unwrap());
let found = db.get_user("alice").await.unwrap().unwrap();
assert_eq!(found.id, "alice");
assert_eq!(found.email, Some("[email protected]".to_string()));
assert_eq!(found.status, "active");
}
#[tokio::test]
async fn test_get_user_by_email() {
let (db, _dir) = setup().await;
db.create_user(&test_user("bob")).await.unwrap();
let found = db.get_user_by_email("[email protected]").await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().id, "bob");
assert!(
db.get_user_by_email("[email protected]")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn test_list_users_with_status_filter() {
let (db, _dir) = setup().await;
db.create_user(&test_user("alice")).await.unwrap();
db.create_user(&test_user("bob")).await.unwrap();
db.update_user_status("bob", "suspended").await.unwrap();
let all = db.list_users(None).await.unwrap();
assert_eq!(all.len(), 2);
let active = db.list_users(Some("active")).await.unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].id, "alice");
let suspended = db.list_users(Some("suspended")).await.unwrap();
assert_eq!(suspended.len(), 1);
assert_eq!(suspended[0].id, "bob");
}
#[tokio::test]
async fn test_update_user_profile() {
let (db, _dir) = setup().await;
db.create_user(&test_user("alice")).await.unwrap();
let meta = serde_json::json!({"role": "admin"});
db.update_user_profile("alice", "Alice Smith", &meta)
.await
.unwrap();
let user = db.get_user("alice").await.unwrap().unwrap();
assert_eq!(user.display_name, "Alice Smith");
assert_eq!(user.metadata["role"], "admin");
}
#[tokio::test]
async fn test_token_lifecycle_create_authenticate_revoke() {
let (db, _dir) = setup().await;
db.create_user(&test_user("alice")).await.unwrap();
// Create token
let token_hash = hash("secret-token-123");
let record = db
.create_api_token("alice", "laptop", &token_hash, "secret-t", None)
.await
.unwrap();
assert_eq!(record.user_id, "alice");
assert_eq!(record.name, "laptop");
assert_eq!(record.token_prefix, "secret-t");
// Authenticate
let (tok, user) = db.authenticate_token(&token_hash).await.unwrap().unwrap();
assert_eq!(tok.id, record.id);
assert_eq!(user.id, "alice");
// List tokens
let tokens = db.list_api_tokens("alice").await.unwrap();
assert_eq!(tokens.len(), 1);
// Revoke
assert!(db.revoke_api_token(record.id, "alice").await.unwrap());
// Auth should fail after revoke
assert!(db.authenticate_token(&token_hash).await.unwrap().is_none());
}
#[tokio::test]
async fn test_token_auth_fails_for_suspended_user() {
let (db, _dir) = setup().await;
db.create_user(&test_user("alice")).await.unwrap();
let token_hash = hash("token-abc");
db.create_api_token("alice", "test", &token_hash, "token-ab", None)
.await
.unwrap();
// Auth works while active
assert!(db.authenticate_token(&token_hash).await.unwrap().is_some());
// Suspend user
db.update_user_status("alice", "suspended").await.unwrap();
// Auth should fail
assert!(db.authenticate_token(&token_hash).await.unwrap().is_none());
}
#[tokio::test]
async fn test_token_revoke_wrong_user_returns_false() {
let (db, _dir) = setup().await;
db.create_user(&test_user("alice")).await.unwrap();
db.create_user(&test_user("bob")).await.unwrap();
let token_hash = hash("alice-token");
let record = db
.create_api_token("alice", "test", &token_hash, "alice-to", None)
.await
.unwrap();
// Bob can't revoke Alice's token
assert!(!db.revoke_api_token(record.id, "bob").await.unwrap());
// Alice can
assert!(db.revoke_api_token(record.id, "alice").await.unwrap());
}
#[tokio::test]
async fn test_record_login_and_token_usage() {
let (db, _dir) = setup().await;
db.create_user(&test_user("alice")).await.unwrap();
let token_hash = hash("tok");
let record = db
.create_api_token("alice", "test", &token_hash, "tok", None)
.await
.unwrap();
// Record usage
db.record_token_usage(record.id).await.unwrap();
db.record_login("alice").await.unwrap();
// Verify timestamps updated
let user = db.get_user("alice").await.unwrap().unwrap();
assert!(user.last_login_at.is_some());
let tokens = db.list_api_tokens("alice").await.unwrap();
assert!(tokens[0].last_used_at.is_some());
}
#[tokio::test]
async fn test_delete_user_removes_api_tokens() {
let (db, _dir) = setup().await;
db.create_user(&test_user("alice")).await.unwrap();
let token_hash = hash("alice-tok");
db.create_api_token("alice", "primary", &token_hash, "alice-to", None)
.await
.unwrap();
// Verify token exists before deletion.
let tokens = db.list_api_tokens("alice").await.unwrap();
assert_eq!(tokens.len(), 1);
// Delete user — should also remove their api_tokens.
assert!(db.delete_user("alice").await.unwrap());
// api_tokens must be gone (not orphaned).
let tokens = db.list_api_tokens("alice").await.unwrap();
assert!(
tokens.is_empty(),
"expected api_tokens to be deleted with user, found {}",
tokens.len()
);
}
}
+62
View File
@@ -579,6 +579,36 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
-- ==================== User management (V14) ====================
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_login_at TEXT,
created_by TEXT,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash BLOB NOT NULL,
token_prefix TEXT NOT NULL,
name TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
"#;
/// Incremental migrations applied after the base schema.
@@ -723,6 +753,38 @@ CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
PRAGMA foreign_keys=ON;
"#,
),
(
14,
"users",
r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_login_at TEXT,
created_by TEXT,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash BLOB NOT NULL,
token_prefix TEXT NOT NULL,
name TEXT NOT NULL,
expires_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
"#,
),
];
+113
View File
@@ -309,6 +309,43 @@ async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), Databas
Ok(())
}
// ==================== User management record types ====================
/// A registered user.
#[derive(Debug, Clone)]
pub struct UserRecord {
/// User identifier (string, matches existing `user_id` throughout the codebase).
pub id: String,
pub email: Option<String>,
pub display_name: String,
/// `active`, `suspended`, or `deactivated`.
pub status: String,
/// `admin` or `member`.
pub role: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
/// Who created/invited this user (nullable for bootstrap users).
pub created_by: Option<String>,
pub metadata: serde_json::Value,
}
/// An API token for authenticating requests (hash stored, never plaintext).
#[derive(Debug, Clone)]
pub struct ApiTokenRecord {
pub id: Uuid,
pub user_id: String,
/// Human label (e.g. "my-laptop", "ci-bot").
pub name: String,
/// First 8 hex chars of the plaintext token for display/identification.
pub token_prefix: String,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
/// Soft-revoke timestamp. Non-null means revoked.
pub revoked_at: Option<DateTime<Utc>>,
}
// ==================== Sub-traits ====================
//
// Each sub-trait groups related persistence methods. The `Database` supertrait
@@ -761,6 +798,81 @@ pub trait WorkspaceStore: Send + Sync {
}
}
#[async_trait]
pub trait UserStore: Send + Sync {
// ---- Users ----
/// Create a new user record.
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError>;
/// Get a user by their string id.
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError>;
/// Get a user by email address.
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError>;
/// List users, optionally filtered by status.
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError>;
/// Update a user's status (active/suspended/deactivated).
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError>;
/// Update a user's display name and metadata.
async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError>;
/// Record a login timestamp.
async fn record_login(&self, id: &str) -> Result<(), DatabaseError>;
// ---- API Tokens ----
/// Create a new API token. The `token_hash` is SHA-256 of the plaintext.
async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError>;
/// List tokens for a user (never includes the hash).
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError>;
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError>;
/// Look up a token by hash, returning the token record and its owning user.
/// Only returns active (non-revoked, non-expired) tokens for active users.
async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError>;
/// Update `last_used_at` for a token.
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError>;
/// Check whether any user records exist (for first-run bootstrap detection).
async fn has_any_users(&self) -> Result<bool, DatabaseError>;
/// Delete a user and all their data across all user-scoped tables.
/// Returns false if the user doesn't exist.
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError>;
/// Get per-user LLM usage stats for a time period.
/// Aggregates from llm_calls via agent_jobs.user_id.
async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<UserUsageStats>, DatabaseError>;
}
/// Per-user LLM usage statistics.
#[derive(Debug, Clone)]
pub struct UserUsageStats {
pub user_id: String,
pub model: String,
pub call_count: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub total_cost: Decimal,
}
/// Backend-agnostic database supertrait.
///
/// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers
@@ -774,6 +886,7 @@ pub trait Database:
+ ToolFailureStore
+ SettingsStore
+ WorkspaceStore
+ UserStore
+ Send
+ Sync
{
+90 -2
View File
@@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus};
use crate::config::DatabaseConfig;
use crate::context::{ActionRecord, JobContext, JobState};
use crate::db::{
ConversationStore, Database, JobStore, RoutineStore, SandboxStore, SettingsStore,
ToolFailureStore, WorkspaceStore,
ApiTokenRecord, ConversationStore, Database, JobStore, RoutineStore, SandboxStore,
SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
};
use crate::error::{DatabaseError, WorkspaceError};
use crate::history::{
@@ -786,3 +786,91 @@ impl WorkspaceStore for PgBackend {
.await
}
}
// ==================== UserStore ====================
#[async_trait]
impl UserStore for PgBackend {
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
self.store.create_user(user).await
}
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
self.store.get_user(id).await
}
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
self.store.get_user_by_email(email).await
}
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
self.store.list_users(status).await
}
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
self.store.update_user_status(id, status).await
}
async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError> {
self.store
.update_user_profile(id, display_name, metadata)
.await
}
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
self.store.record_login(id).await
}
async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
self.store
.create_api_token(user_id, name, token_hash, token_prefix, expires_at)
.await
}
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
self.store.list_api_tokens(user_id).await
}
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
self.store.revoke_api_token(token_id, user_id).await
}
async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
self.store.authenticate_token(token_hash).await
}
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
self.store.record_token_usage(token_id).await
}
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
self.store.has_any_users().await
}
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
self.store.delete_user(id).await
}
async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
self.store.user_usage_stats(user_id, since).await
}
}
+18 -7
View File
@@ -2,7 +2,8 @@
//!
//! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector
//! based on the configured [`SslMode`]. Uses `rustls` with system root
//! certificates — the same TLS stack that `reqwest` already uses for HTTP.
//! certificates, falling back to Mozilla's bundled roots via `webpki-roots`
//! when the system store is empty (common in minimal container images).
use deadpool_postgres::{Pool, Runtime};
use thiserror::Error;
@@ -19,9 +20,15 @@ pub enum CreatePoolError {
TlsConfig(#[from] rustls::Error),
}
/// Build a rustls-based TLS connector using the platform's root certificate store.
/// Build a rustls-based TLS connector.
///
/// Tries the platform's native certificate store first. If that yields zero
/// certificates (slim container images, missing ca-certificates package),
/// falls back to Mozilla's root certificates bundled via `webpki-roots`.
fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
let mut root_store = rustls::RootCertStore::empty();
// Try native certs first.
let native = rustls_native_certs::load_native_certs();
for e in &native.errors {
tracing::warn!("error loading system root certs: {e}");
@@ -31,11 +38,16 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
tracing::warn!("skipping invalid system root cert: {e}");
}
}
// Fall back to bundled Mozilla roots when the system store is empty.
if root_store.is_empty() {
tracing::error!("no system root certificates found -- TLS connections will fail");
tracing::info!(
"no system root certificates found, using bundled Mozilla roots"
);
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
}
// `--all-features` brings in both aws-lc-rs and ring-backed rustls providers.
// Pick the same ring provider reqwest already uses so postgres TLS setup stays deterministic.
// Pick the ring crypto provider (same one reqwest uses).
let config = rustls::ClientConfig::builder_with_provider(
rustls::crypto::ring::default_provider().into(),
)
@@ -48,7 +60,7 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
/// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector.
///
/// - `Disable` → plain TCP (no TLS)
/// - `Prefer` / `Require` → rustls with system root certificates
/// - `Prefer` / `Require` → rustls with system or bundled root certificates
///
/// **Note:** `Prefer` and `Require` currently behave identically — both
/// provide a TLS connector and will fail if the server rejects the TLS
@@ -81,7 +93,6 @@ mod tests {
fn create_pool_disable_mode() {
let mut config = deadpool_postgres::Config::new();
config.url = Some("postgres://localhost/test".to_string());
// Should succeed — pool is created lazily, no actual connection needed.
let pool = create_pool(&config, SslMode::Disable);
assert!(pool.is_ok());
}
+53 -922
View File
File diff suppressed because it is too large Load Diff
+9 -54
View File
@@ -19,7 +19,6 @@
pub mod discovery;
pub mod manager;
pub mod registry;
pub(crate) mod wechat_login;
pub use discovery::OnlineDiscovery;
pub use manager::ExtensionManager;
@@ -70,12 +69,12 @@ pub struct RegistryEntry {
/// Where to get this extension.
pub source: ExtensionSource,
/// Fallback source when the primary source fails (e.g., download 404 → build from source).
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_source: Option<Box<ExtensionSource>>,
/// How authentication works.
pub auth_hint: AuthHint,
/// Extension version (semver), if known.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
@@ -88,14 +87,17 @@ pub enum ExtensionSource {
/// Downloadable WASM binary.
WasmDownload {
wasm_url: String,
#[serde(default)]
capabilities_url: Option<String>,
},
/// Build from local source directory.
WasmBuildable {
#[serde(alias = "repo_url")]
source_dir: String,
#[serde(default)]
build_dir: Option<String>,
/// Crate name used to locate the build artifact binary.
#[serde(default)]
crate_name: Option<String>,
},
/// Discovered online (not yet validated for a specific source type).
@@ -387,9 +389,13 @@ impl<'de> Deserialize<'de> for AuthResult {
struct Raw {
name: String,
kind: ExtensionKind,
#[serde(default)]
auth_url: Option<String>,
#[serde(default)]
callback_type: Option<String>,
#[serde(default)]
instructions: Option<String>,
#[serde(default)]
setup_url: Option<String>,
#[serde(default)]
awaiting_token: bool,
@@ -433,52 +439,6 @@ impl<'de> Deserialize<'de> for AuthResult {
}
}
/// Interactive login metadata surfaced to setup UIs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginInfo {
/// Login method identifier (for example `qr_code`).
pub method: String,
/// User-facing button label.
pub button_label: String,
/// Optional short instructions shown above the login control.
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
/// Result of starting an interactive extension login flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginStartResult {
/// Opaque session identifier used by follow-up poll requests.
pub session_id: String,
/// Flow status (`pending`, `error`).
pub status: String,
/// Human-readable message for the UI.
pub message: String,
/// Optional QR/image URL for browser display.
#[serde(skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
/// Optional short instructions shown alongside the QR code.
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
/// Result of polling an interactive extension login flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginPollResult {
/// Session identifier associated with this poll result.
pub session_id: String,
/// Flow status (`pending`, `scanned`, `refreshed`, `succeeded`, `failed`).
pub status: String,
/// Human-readable message for the UI.
pub message: String,
/// Optional refreshed QR/image URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
/// Whether the extension was successfully activated as part of login completion.
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
}
/// Result of activating an extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivateResult {
@@ -546,10 +506,6 @@ pub struct InstalledExtension {
/// Whether this extension has an auth configuration (OAuth or manual token).
#[serde(default)]
pub has_auth: bool,
/// Whether this extension still needs owner binding / pairing before it should
/// be treated as fully active in the UI.
#[serde(default)]
pub requires_binding: bool,
/// Whether this extension is installed locally (false = available in registry but not installed).
#[serde(default = "default_true")]
pub installed: bool,
@@ -1000,7 +956,6 @@ mod tests {
tools: vec!["send_email".to_string(), "read_inbox".to_string()],
needs_setup: true,
has_auth: true,
requires_binding: false,
installed: false,
activation_error: Some("token expired".to_string()),
version: None,
-443
View File
@@ -1,443 +0,0 @@
use std::time::{Duration, Instant};
use reqwest::Client;
use serde::Deserialize;
use uuid::Uuid;
use crate::extensions::{
ExtensionError, InteractiveLoginInfo, InteractiveLoginPollResult, InteractiveLoginStartResult,
};
pub(crate) const WECHAT_CHANNEL_NAME: &str = "wechat";
pub(crate) const WECHAT_BASE_URL_SETTING_PATH: &str = "extensions.wechat.base_url";
pub(crate) const WECHAT_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
pub(crate) const WECHAT_DEFAULT_BOT_TYPE: &str = "3";
const LOGIN_SESSION_TTL: Duration = Duration::from_secs(5 * 60);
const QR_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(35);
const QR_FETCH_TIMEOUT: Duration = Duration::from_secs(15);
const MAX_QR_REFRESH_COUNT: u8 = 3;
#[derive(Debug, Clone)]
pub(crate) struct PendingWechatLogin {
pub user_id: String,
pub session_id: String,
pub qrcode: String,
pub qr_code_url: String,
pub started_at: Instant,
pub base_url: String,
pub bot_type: String,
pub refresh_count: u8,
}
impl PendingWechatLogin {
pub fn is_fresh(&self) -> bool {
self.started_at.elapsed() < LOGIN_SESSION_TTL
}
}
#[derive(Debug, Clone)]
pub(crate) struct ConfirmedWechatLogin {
pub bot_token: String,
pub base_url: Option<String>,
pub ilink_bot_id: String,
}
pub(crate) enum WechatLoginPollOutcome {
Pending(InteractiveLoginPollResult),
Confirmed(ConfirmedWechatLogin),
}
#[derive(Debug, Clone, Deserialize)]
struct QrCodeResponse {
qrcode: String,
qrcode_img_content: String,
}
#[derive(Debug, Clone, Deserialize)]
struct QrStatusResponse {
status: String,
bot_token: Option<String>,
ilink_bot_id: Option<String>,
baseurl: Option<String>,
}
pub(crate) fn interactive_login_info() -> InteractiveLoginInfo {
InteractiveLoginInfo {
method: "qr_code".to_string(),
button_label: "Connect WeChat".to_string(),
instructions: Some("Scan the QR code with WeChat to connect this channel.".to_string()),
}
}
pub(crate) fn purge_expired_logins(
sessions: &mut std::collections::HashMap<String, PendingWechatLogin>,
) {
sessions.retain(|_, session| session.is_fresh());
}
pub(crate) async fn start_login(
user_id: &str,
base_url: &str,
bot_type: &str,
) -> Result<(PendingWechatLogin, InteractiveLoginStartResult), ExtensionError> {
let qr = fetch_qr_code(base_url, bot_type).await?;
Ok(build_pending_login(user_id, base_url, bot_type, qr))
}
pub(crate) async fn poll_login(
session: &mut PendingWechatLogin,
) -> Result<WechatLoginPollOutcome, ExtensionError> {
if !session.is_fresh() {
return Ok(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: "The QR code expired. Start a new WeChat connection.".to_string(),
qr_code_url: None,
activated: Some(false),
},
));
}
let status = poll_qr_status(&session.base_url, &session.qrcode).await?;
let refreshed_qr = if status.status == "expired" && session.refresh_count < MAX_QR_REFRESH_COUNT
{
Some(fetch_qr_code(&session.base_url, &session.bot_type).await?)
} else {
None
};
handle_poll_status(session, status, refreshed_qr)
}
fn build_pending_login(
user_id: &str,
base_url: &str,
bot_type: &str,
qr: QrCodeResponse,
) -> (PendingWechatLogin, InteractiveLoginStartResult) {
let session_id = Uuid::new_v4().to_string();
let session = PendingWechatLogin {
user_id: user_id.to_string(),
session_id: session_id.clone(),
qrcode: qr.qrcode,
qr_code_url: qr.qrcode_img_content.clone(),
started_at: Instant::now(),
base_url: base_url.to_string(),
bot_type: bot_type.to_string(),
refresh_count: 0,
};
let result = InteractiveLoginStartResult {
session_id,
status: "pending".to_string(),
message: "Open the WeChat QR page to continue.".to_string(),
qr_code_url: Some(qr.qrcode_img_content),
instructions: Some(
"Keep this window open while you scan and confirm on your phone.".to_string(),
),
};
(session, result)
}
fn handle_poll_status(
session: &mut PendingWechatLogin,
status: QrStatusResponse,
refreshed_qr: Option<QrCodeResponse>,
) -> Result<WechatLoginPollOutcome, ExtensionError> {
match status.status.as_str() {
"wait" => Ok(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "pending".to_string(),
message: "Waiting for the QR code to be scanned.".to_string(),
qr_code_url: None,
activated: None,
},
)),
"scaned" => Ok(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "scanned".to_string(),
message: "QR code scanned. Confirm the login in WeChat.".to_string(),
qr_code_url: None,
activated: None,
},
)),
"expired" => {
session.refresh_count = session.refresh_count.saturating_add(1);
if session.refresh_count > MAX_QR_REFRESH_COUNT {
return Ok(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: "The QR code expired too many times. Start again.".to_string(),
qr_code_url: None,
activated: Some(false),
},
));
}
let refreshed = refreshed_qr.ok_or_else(|| {
ExtensionError::Other(
"WeChat QR status expired without a refreshed QR code".to_string(),
)
})?;
session.qrcode = refreshed.qrcode;
session.qr_code_url = refreshed.qrcode_img_content.clone();
session.started_at = Instant::now();
Ok(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "refreshed".to_string(),
message: "The QR code expired, so a fresh one was generated.".to_string(),
qr_code_url: Some(refreshed.qrcode_img_content),
activated: None,
},
))
}
"confirmed" => {
let bot_token = status.bot_token.filter(|token| !token.trim().is_empty());
let ilink_bot_id = status
.ilink_bot_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
ExtensionError::Other(
"WeChat login succeeded but no bot account id was returned".to_string(),
)
})?;
let bot_token = bot_token.ok_or_else(|| {
ExtensionError::Other(
"WeChat login succeeded but no bot token was returned".to_string(),
)
})?;
Ok(WechatLoginPollOutcome::Confirmed(ConfirmedWechatLogin {
bot_token,
base_url: status.baseurl.filter(|value| !value.trim().is_empty()),
ilink_bot_id,
}))
}
other => {
tracing::warn!(status = other, "Unexpected WeChat QR status");
Ok(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: format!("Unexpected WeChat login status: {other}"),
qr_code_url: None,
activated: Some(false),
},
))
}
}
}
fn ensure_trailing_slash(base_url: &str) -> String {
if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{base_url}/")
}
}
async fn fetch_qr_code(base_url: &str, bot_type: &str) -> Result<QrCodeResponse, ExtensionError> {
let base = ensure_trailing_slash(base_url);
let url = format!(
"{base}ilink/bot/get_bot_qrcode?bot_type={}",
urlencoding::encode(bot_type)
);
let client = Client::builder()
.timeout(QR_FETCH_TIMEOUT)
.build()
.map_err(|e| ExtensionError::Other(format!("Failed to create WeChat login client: {e}")))?;
let response = client
.get(&url)
.send()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to fetch WeChat QR code: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status = %status, "WeChat QR code request failed");
return Err(ExtensionError::Other(format!(
"WeChat QR code request failed with {status}: {body}"
)));
}
response
.json::<QrCodeResponse>()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat QR code response: {e}")))
}
async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result<QrStatusResponse, ExtensionError> {
let base = ensure_trailing_slash(base_url);
let url = format!(
"{base}ilink/bot/get_qrcode_status?qrcode={}",
urlencoding::encode(qrcode)
);
let client = Client::builder()
.timeout(QR_LONG_POLL_TIMEOUT)
.build()
.map_err(|e| ExtensionError::Other(format!("Failed to create WeChat poll client: {e}")))?;
let response = client
.get(&url)
.header("iLink-App-ClientVersion", "1")
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) if error.is_timeout() => {
return Ok(QrStatusResponse {
status: "wait".to_string(),
bot_token: None,
ilink_bot_id: None,
baseurl: None,
});
}
Err(error) => {
return Err(ExtensionError::Other(format!(
"Failed to poll WeChat QR status: {error}"
)));
}
};
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status = %status, "WeChat QR status poll failed");
return Err(ExtensionError::Other(format!(
"WeChat QR status poll failed with {status}: {body}"
)));
}
response
.json::<QrStatusResponse>()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat QR status: {e}")))
}
#[cfg(test)]
mod tests {
use super::{
QrCodeResponse, QrStatusResponse, WechatLoginPollOutcome, build_pending_login,
handle_poll_status,
};
#[test]
fn test_build_pending_login_returns_qr_state_and_result() {
let (session, start_result) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-123".to_string(),
qrcode_img_content: "https://qr.example/one".to_string(),
},
);
assert_eq!(session.user_id, "owner");
assert_eq!(session.base_url, "https://ilink.example");
assert_eq!(session.bot_type, "3");
assert_eq!(session.qrcode, "qr-123");
assert_eq!(session.qr_code_url, "https://qr.example/one");
assert_eq!(start_result.status, "pending");
assert_eq!(
start_result.qr_code_url.as_deref(),
Some("https://qr.example/one")
);
assert_eq!(start_result.session_id, session.session_id);
}
#[test]
fn test_handle_poll_status_confirms_login() -> Result<(), String> {
let (mut session, _) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-123".to_string(),
qrcode_img_content: "https://qr.example/one".to_string(),
},
);
let outcome = handle_poll_status(
&mut session,
QrStatusResponse {
status: "confirmed".to_string(),
bot_token: Some("bot-token-123".to_string()),
ilink_bot_id: Some("wx-bot-1".to_string()),
baseurl: Some("https://override.example".to_string()),
},
None,
)
.map_err(|e| e.to_string())?;
match outcome {
WechatLoginPollOutcome::Confirmed(confirmed) => {
assert_eq!(confirmed.bot_token, "bot-token-123");
assert_eq!(confirmed.ilink_bot_id, "wx-bot-1");
assert_eq!(
confirmed.base_url.as_deref(),
Some("https://override.example")
);
Ok(())
}
WechatLoginPollOutcome::Pending(result) => Err(format!(
"expected confirmed login, got pending status {}",
result.status
)),
}
}
#[test]
fn test_handle_poll_status_refreshes_expired_qr() -> Result<(), String> {
let (mut session, _) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-initial".to_string(),
qrcode_img_content: "https://qr.example/initial".to_string(),
},
);
let outcome = handle_poll_status(
&mut session,
QrStatusResponse {
status: "expired".to_string(),
bot_token: None,
ilink_bot_id: None,
baseurl: None,
},
Some(QrCodeResponse {
qrcode: "qr-refreshed".to_string(),
qrcode_img_content: "https://qr.example/refreshed".to_string(),
}),
)
.map_err(|e| e.to_string())?;
match outcome {
WechatLoginPollOutcome::Pending(result) => {
assert_eq!(result.status, "refreshed");
assert_eq!(
result.qr_code_url.as_deref(),
Some("https://qr.example/refreshed")
);
assert_eq!(session.qrcode, "qr-refreshed");
assert_eq!(session.refresh_count, 1);
Ok(())
}
WechatLoginPollOutcome::Confirmed(_) => {
Err("expected QR refresh before confirmation".to_string())
}
}
}
}
+404
View File
@@ -2279,6 +2279,410 @@ impl Store {
}
}
// ==================== Users / API Tokens / Invitations ====================
#[cfg(feature = "postgres")]
use crate::db::{ApiTokenRecord, UserRecord};
#[cfg(feature = "postgres")]
impl Store {
/// Create a new user record.
pub async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
r#"
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
&[
&user.id,
&user.email,
&user.display_name,
&user.status,
&user.role,
&user.created_at,
&user.updated_at,
&user.last_login_at,
&user.created_by,
&user.metadata,
],
)
.await?;
Ok(())
}
/// Get a user by their string id.
pub async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id])
.await?;
Ok(row.map(|r| row_to_user(&r)))
}
/// Get a user by email address.
pub async fn get_user_by_email(
&self,
email: &str,
) -> Result<Option<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email])
.await?;
Ok(row.map(|r| row_to_user(&r)))
}
/// List users, optionally filtered by status.
pub async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = match status {
Some(s) => {
conn.query(
"SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = $1 ORDER BY created_at DESC",
&[&s],
)
.await?
}
None => {
conn.query("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC", &[])
.await?
}
};
Ok(rows.iter().map(row_to_user).collect())
}
/// Update a user's status.
pub async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
&[&status, &id],
)
.await?;
Ok(())
}
/// Update a user's display name and metadata.
pub async fn update_user_profile(
&self,
id: &str,
display_name: &str,
metadata: &serde_json::Value,
) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET display_name = $1, metadata = $2, updated_at = NOW() WHERE id = $3",
&[&display_name, metadata, &id],
)
.await?;
Ok(())
}
/// Record a login timestamp for a user.
pub async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1",
&[&id],
)
.await?;
Ok(())
}
/// Create a new API token.
pub async fn create_api_token(
&self,
user_id: &str,
name: &str,
token_hash: &[u8; 32],
token_prefix: &str,
expires_at: Option<DateTime<Utc>>,
) -> Result<ApiTokenRecord, DatabaseError> {
let conn = self.conn().await?;
let id = Uuid::new_v4();
let now = Utc::now();
conn.execute(
r#"
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
&[
&id,
&user_id,
&token_hash.to_vec(),
&token_prefix,
&name,
&expires_at,
&now,
],
)
.await?;
Ok(ApiTokenRecord {
id,
user_id: user_id.to_string(),
name: name.to_string(),
token_prefix: token_prefix.to_string(),
expires_at,
last_used_at: None,
created_at: now,
revoked_at: None,
})
}
/// List tokens for a user.
pub async fn list_api_tokens(
&self,
user_id: &str,
) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
let conn = self.conn().await?;
let rows = conn
.query(
r#"
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
FROM api_tokens
WHERE user_id = $1
ORDER BY created_at DESC
"#,
&[&user_id],
)
.await?;
Ok(rows.iter().map(row_to_api_token).collect())
}
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
pub async fn revoke_api_token(
&self,
token_id: Uuid,
user_id: &str,
) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let count = conn
.execute(
"UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL",
&[&token_id, &user_id],
)
.await?;
Ok(count > 0)
}
/// Authenticate a token by hash. Returns the token record and its owning user
/// if the token is active (non-revoked, non-expired) and the user is active.
pub async fn authenticate_token(
&self,
token_hash: &[u8; 32],
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_opt(
r#"
SELECT t.id, t.user_id, t.name, t.token_prefix, t.expires_at, t.last_used_at, t.created_at, t.revoked_at,
u.id as u_id, u.email, u.display_name, u.status, u.role, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata
FROM api_tokens t
JOIN users u ON t.user_id = u.id
WHERE t.token_hash = $1
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > NOW())
AND u.status = 'active'
"#,
&[&token_hash.to_vec()],
)
.await?;
Ok(row.map(|r| {
let token = ApiTokenRecord {
id: r.get("id"),
user_id: r.get("user_id"),
name: r.get("name"),
token_prefix: r.get("token_prefix"),
expires_at: r.get("expires_at"),
last_used_at: r.get("last_used_at"),
created_at: r.get("created_at"),
revoked_at: r.get("revoked_at"),
};
let user = UserRecord {
id: r.get("u_id"),
email: r.get("email"),
display_name: r.get("display_name"),
status: r.get("status"),
role: r.get("role"),
created_at: r.get("u_created_at"),
updated_at: r.get("updated_at"),
last_login_at: r.get("last_login_at"),
created_by: r.get("created_by"),
metadata: r.get("metadata"),
};
(token, user)
}))
}
/// Update `last_used_at` for a token.
pub async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
let conn = self.conn().await?;
conn.execute(
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1",
&[&token_id],
)
.await?;
Ok(())
}
/// Check whether any user records exist.
pub async fn has_any_users(&self) -> Result<bool, DatabaseError> {
let conn = self.conn().await?;
let row = conn
.query_one(
"SELECT EXISTS(SELECT 1 FROM users LIMIT 1) as has_users",
&[],
)
.await?;
Ok(row.get("has_users"))
}
/// Delete a user and all their data across all user-scoped tables.
/// Returns false if the user doesn't exist.
pub async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
let mut conn = self.conn().await?;
let tx = conn
.transaction()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Delete from child tables first to avoid FK violations.
// job_events must come before agent_jobs (FK without CASCADE).
// agent_jobs cascades to job_actions, llm_calls, estimation_snapshots.
// conversations cascades to conversation_messages.
// memory_documents cascades to memory_chunks.
// routines cascades to routine_runs.
// api_tokens cascade automatically via FK on users.
for table in &[
"settings",
"heartbeat_state",
"tool_rate_limit_state",
"secret_usage_log",
"leak_detection_events",
"secrets",
"wasm_tools",
"routines",
"memory_documents",
"conversations",
] {
tx.execute(&format!("DELETE FROM {table} WHERE user_id = $1"), &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
}
// job_events references agent_jobs(id) without CASCADE — delete via subquery.
tx.execute(
"DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = $1)",
&[&id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
tx.execute("DELETE FROM agent_jobs WHERE user_id = $1", &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// Nullify self-referencing created_by before deleting the user
tx.execute(
"UPDATE users SET created_by = NULL WHERE created_by = $1",
&[&id],
)
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
// api_tokens cascade automatically via FK
let result = tx
.execute("DELETE FROM users WHERE id = $1", &[&id])
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
tx.commit()
.await
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(result > 0)
}
/// Get per-user LLM usage stats for a time period.
/// Aggregates from llm_calls via agent_jobs.user_id.
pub async fn user_usage_stats(
&self,
user_id: Option<&str>,
since: DateTime<Utc>,
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
let conn = self.conn().await?;
let rows = if let Some(uid) = user_id {
conn.query(
r#"
SELECT j.user_id, l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
JOIN agent_jobs j ON l.job_id = j.id
WHERE l.created_at >= $1
AND j.user_id = $2
GROUP BY j.user_id, l.model
ORDER BY total_cost DESC
"#,
&[&since, &uid],
)
.await?
} else {
conn.query(
r#"
SELECT j.user_id, l.model, COUNT(*) as call_count,
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
COALESCE(SUM(l.cost), 0) as total_cost
FROM llm_calls l
JOIN agent_jobs j ON l.job_id = j.id
WHERE l.created_at >= $1
GROUP BY j.user_id, l.model
ORDER BY total_cost DESC
"#,
&[&since],
)
.await?
};
let mut stats = Vec::with_capacity(rows.len());
for row in &rows {
stats.push(crate::db::UserUsageStats {
user_id: row.get("user_id"),
model: row.get("model"),
call_count: row.get("call_count"),
input_tokens: row.get("input_tokens"),
output_tokens: row.get("output_tokens"),
total_cost: row.get("total_cost"),
});
}
Ok(stats)
}
}
#[cfg(feature = "postgres")]
fn row_to_user(row: &tokio_postgres::Row) -> UserRecord {
UserRecord {
id: row.get("id"),
email: row.get("email"),
display_name: row.get("display_name"),
status: row.get("status"),
role: row.get("role"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
last_login_at: row.get("last_login_at"),
created_by: row.get("created_by"),
metadata: row.get("metadata"),
}
}
#[cfg(feature = "postgres")]
fn row_to_api_token(row: &tokio_postgres::Row) -> ApiTokenRecord {
ApiTokenRecord {
id: row.get("id"),
user_id: row.get("user_id"),
name: row.get("name"),
token_prefix: row.get("token_prefix"),
expires_at: row.get("expires_at"),
last_used_at: row.get("last_used_at"),
created_at: row.get("created_at"),
revoked_at: row.get("revoked_at"),
}
}
#[cfg(test)]
mod tests {
use super::*;
+55 -5
View File
@@ -601,11 +601,14 @@ fn build_rig_request(
/// Inject a per-request model override into the rig request's `additional_params`.
///
/// Rig-core bakes the model name at construction time inside each provider's
/// `CompletionModel` implementation. The actual HTTP request body includes a
/// `model` field set by the provider. Rig-core's `#[serde(flatten)]` on
/// `additional_params` emits these fields AFTER the provider's own fields.
/// Most API servers (Python, Go) use last-key-wins when deserializing
/// duplicate JSON keys, so the injected `model` value takes effect.
/// `CompletionModel` implementation. This helper inserts a top-level `"model"`
/// key into `additional_params`, which rig-core flattens into the provider's
/// request payload via `#[serde(flatten)]`.
///
/// Whether the override takes effect depends on the downstream API server's
/// handling of duplicate JSON keys (most Python/Go servers use last-key-wins,
/// but this is not guaranteed by the JSON spec). The `effective_model_name()`
/// trait method should be consulted to determine the model actually used.
fn inject_model_override(rig_req: &mut RigRequest, model_override: Option<&str>) {
let Some(model) = model_override else {
return;
@@ -1515,4 +1518,51 @@ mod tests {
"different raw IDs should produce different hashed IDs"
);
}
fn make_rig_request(additional_params: Option<serde_json::Value>) -> RigRequest {
RigRequest {
preamble: None,
chat_history: OneOrMany::one(RigMessage::user("test")),
documents: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params,
}
}
#[test]
fn test_inject_model_override_creates_params_when_none() {
let mut req = make_rig_request(None);
inject_model_override(&mut req, Some("test-model"));
let params = req
.additional_params
.expect("additional_params should be Some");
assert_eq!(params, serde_json::json!({ "model": "test-model" }));
}
#[test]
fn test_inject_model_override_preserves_existing_params() {
let mut req = make_rig_request(Some(serde_json::json!({
"cache_control": { "type": "ephemeral" },
})));
inject_model_override(&mut req, Some("override-model"));
let params = req.additional_params.expect("should remain Some");
let obj = params.as_object().expect("should be object");
assert_eq!(
obj.get("cache_control"),
Some(&serde_json::json!({ "type": "ephemeral" }))
);
assert_eq!(obj.get("model"), Some(&serde_json::json!("override-model")));
}
#[test]
fn test_inject_model_override_noop_when_none() {
let mut req = make_rig_request(None);
inject_model_override(&mut req, None);
assert!(req.additional_params.is_none());
}
}
+5 -1
View File
@@ -119,7 +119,6 @@ impl TranscriptionMiddleware {
Some(f) => f,
None => {
tracing::warn!(
attachment_id = %attachment.id,
mime = %attachment.mime_type,
"Skipping audio attachment with unsupported format"
);
@@ -129,6 +128,11 @@ impl TranscriptionMiddleware {
match self.provider.transcribe(&attachment.data, format).await {
Ok(text) => {
tracing::info!(
attachment_id = %attachment.id,
text_len = text.len(),
"Transcribed audio attachment"
);
transcriptions.push((i, text));
}
Err(e) => {
+50 -27
View File
@@ -591,27 +591,7 @@ async fn async_main() -> anyhow::Result<()> {
let mut gateway_url: Option<String> = None;
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
if let Some(ref gw_config) = config.channels.gateway {
// Build multi-user auth state if user_tokens is configured, else single-user.
let mut gw = if let Some(ref user_tokens) = gw_config.user_tokens {
use ironclaw::channels::web::auth::{MultiAuthState, UserIdentity};
let tokens = user_tokens
.iter()
.map(|(token, cfg)| {
(
token.clone(),
UserIdentity {
user_id: cfg.user_id.clone(),
workspace_read_scopes: cfg.workspace_read_scopes.clone(),
},
)
})
.collect();
let auth = MultiAuthState::multi(tokens);
GatewayChannel::new_multi_auth(gw_config.clone(), auth)
} else {
GatewayChannel::new(gw_config.clone())
};
gw = gw.with_owner_scope(config.owner_id.clone());
let mut gw = GatewayChannel::new(gw_config.clone(), config.owner_id.clone());
gw = gw.with_llm_provider(Arc::clone(&components.llm));
if let Some(ref ws) = components.workspace {
gw = gw.with_workspace(Arc::clone(ws));
@@ -650,6 +630,54 @@ async fn async_main() -> anyhow::Result<()> {
}
if let Some(ref d) = components.db {
gw = gw.with_store(Arc::clone(d));
gw = gw.with_db_auth(Arc::clone(d));
if let Some(ref ss) = components.secrets_store {
gw = gw.with_secrets_store(Arc::clone(ss));
}
// Bootstrap: create the first admin user from single-user config
// so the owner appears in the Users admin panel immediately.
if let Ok(false) = d.has_any_users().await {
let now = chrono::Utc::now();
let user = ironclaw::db::UserRecord {
id: config.owner_id.clone(),
email: None,
display_name: config.owner_id.clone(),
status: "active".to_string(),
role: "admin".to_string(),
created_at: now,
updated_at: now,
last_login_at: None,
created_by: None,
metadata: serde_json::json!({"source": "bootstrap"}),
};
if let Err(e) = d.create_user(&user).await {
tracing::warn!("Failed to bootstrap admin user: {}", e);
} else {
// Also create an API token from the gateway auth token so
// DB-backed auth works for the bootstrapped user.
let auth_token = gw.auth_token();
if !auth_token.is_empty() {
use ironclaw::channels::web::auth::hash_token;
let hash = hash_token(auth_token);
let prefix = if auth_token.len() >= 8 {
&auth_token[..8]
} else {
auth_token
};
if let Err(e) = d
.create_api_token(&config.owner_id, "bootstrap", &hash, prefix, None)
.await
{
tracing::warn!("Failed to create bootstrap token: {}", e);
}
}
tracing::info!(
user_id = config.owner_id,
"Bootstrapped admin user from gateway config"
);
}
}
}
if let Some(ref jm) = container_job_manager {
gw = gw.with_job_manager(Arc::clone(jm));
@@ -791,12 +819,7 @@ async fn async_main() -> anyhow::Result<()> {
.await;
// Default user ID for extension operations (single-user mode).
let ext_user_id = config
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string());
let ext_user_id = config.owner_id.clone();
// Wire up channel runtime for hot-activation of WASM channels.
if let Some(ref ext_mgr) = components.extension_manager
-5
View File
@@ -269,10 +269,6 @@ pub struct ChannelSettings {
#[serde(default)]
pub gateway_auth_token: Option<String>,
/// Web gateway user ID.
#[serde(default)]
pub gateway_user_id: Option<String>,
/// Whether the CLI channel is enabled.
#[serde(default = "default_true")]
pub cli_enabled: bool,
@@ -342,7 +338,6 @@ impl Default for ChannelSettings {
gateway_host: None,
gateway_port: None,
gateway_auth_token: None,
gateway_user_id: None,
cli_enabled: true,
signal_enabled: false,
signal_http_url: None,
+42 -1
View File
@@ -330,35 +330,62 @@ impl TenantScope {
/// Add a message to a conversation owned by this tenant.
///
/// Verifies the conversation belongs to this user before adding.
/// Returns `NotFound` if the conversation does not belong to this user.
pub async fn add_conversation_message(
&self,
conversation_id: Uuid,
role: &str,
content: &str,
) -> Result<Uuid, DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner
.add_conversation_message(conversation_id, role, content)
.await
}
/// Touch a conversation timestamp. Returns `NotFound` if not owned by this user.
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner.touch_conversation(id).await
}
/// List messages in a conversation. Returns `NotFound` if not owned by this user.
pub async fn list_conversation_messages(
&self,
conversation_id: Uuid,
) -> Result<Vec<ConversationMessage>, DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner.list_conversation_messages(conversation_id).await
}
/// Paginated message listing. Returns `NotFound` if not owned by this user.
pub async fn list_conversation_messages_paginated(
&self,
conversation_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
if !self.conversation_belongs_to_user(conversation_id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: conversation_id.to_string(),
});
}
self.inner
.list_conversation_messages_paginated(conversation_id, before, limit)
.await
@@ -374,21 +401,35 @@ impl TenantScope {
.await
}
/// Update metadata on a conversation. Returns `NotFound` if not owned by this user.
pub async fn update_conversation_metadata_field(
&self,
id: Uuid,
key: &str,
value: &serde_json::Value,
) -> Result<(), DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner
.update_conversation_metadata_field(id, key, value)
.await
}
/// Get conversation metadata. Returns `NotFound` if not owned by this user.
pub async fn get_conversation_metadata(
&self,
id: Uuid,
) -> Result<Option<serde_json::Value>, DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner.get_conversation_metadata(id).await
}
}
+563
View File
@@ -0,0 +1,563 @@
//! Mock Abound API tools for demo purposes.
//!
//! These tools simulate Abound's backend API (account info, wire transfers,
//! exchange rates, notifications, forex scoring) with realistic mock data.
//! They are feature-gated behind `--features demo` and will be replaced by
//! real WASM tools once Abound's backend is live.
use std::time::Instant;
use async_trait::async_trait;
use chrono::{Datelike, Utc};
use rand::Rng;
use serde_json::json;
use crate::context::JobContext;
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
// ---------------------------------------------------------------------------
// Tool 1: Get Account Info
// ---------------------------------------------------------------------------
/// Returns mock Abound account data (limits, recipients, funding sources).
pub struct AboundGetAccountInfoTool;
#[async_trait]
impl Tool for AboundGetAccountInfoTool {
fn name(&self) -> &str {
"abound_get_account_info"
}
fn description(&self) -> &str {
"Retrieve the authenticated user's Abound account information including \
transfer limits, payment reasons, recipients, and funding sources."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {},
"required": []
})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = Instant::now();
let data = json!({
"status": "success",
"data": {
"user_id": "acc_123456",
"user_name": "John Doe",
"limits": {
"ach_limit": {
"limit": 5000,
"formatted_limit": "$5,000"
}
},
"payment_reasons": [
{ "key": "FAMILY_MAINTENANCE", "value": "Family Maintenance" },
{ "key": "GIFT", "value": "Gift" },
{ "key": "EDUCATION_SUPPORT", "value": "Education Support" },
{ "key": "MEDICAL_SUPPORT", "value": "Medical Support" }
],
"recipients": [
{
"beneficiary_ref_id": "ben_001",
"name": "Rahul Sharma",
"mask": "****2222"
}
],
"funding_sources": [
{
"funding_source_id": "fs_001",
"bank_name": "HDFC Bank",
"mask": "****2222"
}
]
}
});
Ok(ToolOutput::success(data, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ---------------------------------------------------------------------------
// Tool 2: Get Exchange Rate
// ---------------------------------------------------------------------------
/// Returns mock USD/INR exchange rate with slight randomization.
pub struct AboundGetExchangeRateTool;
/// Generate a mock USD/INR rate with slight jitter around 85.42.
fn mock_exchange_rate() -> f64 {
let mut rng = rand::thread_rng();
let jitter: f64 = rng.gen_range(-0.30..=0.30);
((85.42 + jitter) * 100.0).round() / 100.0
}
#[async_trait]
impl Tool for AboundGetExchangeRateTool {
fn name(&self) -> &str {
"abound_get_exchange_rate"
}
fn description(&self) -> &str {
"Get the current USD to INR exchange rate including the effective rate \
after fees. Use this before initiating any wire transfer."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {},
"required": []
})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = Instant::now();
let rate = mock_exchange_rate();
let effective = ((rate - 0.32) * 100.0).round() / 100.0;
let data = json!({
"status": "success",
"data": {
"from_currency": "USD",
"to_currency": "INR",
"current_exchange_rate": {
"formatted_value": format!("{rate:.2}"),
"value": rate
},
"effective_exchange_rate": {
"formatted_value": format!("{effective:.2}"),
"value": effective
}
}
});
Ok(ToolOutput::success(data, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ---------------------------------------------------------------------------
// Tool 3: Send Wire
// ---------------------------------------------------------------------------
/// Simulates a wire transfer. Requires approval before execution.
pub struct AboundSendWireTool;
#[async_trait]
impl Tool for AboundSendWireTool {
fn name(&self) -> &str {
"abound_send_wire"
}
fn description(&self) -> &str {
"Submit a wire transfer to send USD to an INR recipient. Requires a \
funding source, beneficiary, amount in USD, and payment reason. \
The transfer amount must not exceed the user's ACH limit."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"funding_source_id": {
"type": "string",
"description": "Funding source ID (e.g. 'fs_001')"
},
"beneficiary_ref_id": {
"type": "string",
"description": "Beneficiary reference ID (e.g. 'ben_001')"
},
"amount": {
"type": "number",
"description": "Amount in USD to send"
},
"payment_reason_key": {
"type": "string",
"description": "Payment reason key: FAMILY_MAINTENANCE, GIFT, EDUCATION_SUPPORT, or MEDICAL_SUPPORT"
}
},
"required": ["funding_source_id", "beneficiary_ref_id", "amount", "payment_reason_key"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = Instant::now();
let _funding_source = require_str(&params, "funding_source_id")?;
let _beneficiary = require_str(&params, "beneficiary_ref_id")?;
let _reason = require_str(&params, "payment_reason_key")?;
let amount = params
.get("amount")
.and_then(|v| v.as_f64())
.ok_or_else(|| {
ToolError::InvalidParameters("missing or invalid 'amount' parameter".to_string())
})?;
// Enforce mock ACH limit
if amount > 5000.0 {
let data = json!({
"status": "error",
"error": {
"code": "TRANSFER_NOT_ALLOWED",
"message": format!(
"Transfer amount ${:.2} exceeds your ACH limit of $5,000.00",
amount
)
}
});
return Ok(ToolOutput::success(data, start.elapsed()));
}
if amount <= 0.0 {
return Err(ToolError::InvalidParameters(
"amount must be greater than zero".to_string(),
));
}
let txn_id = uuid::Uuid::new_v4();
let trk_id = uuid::Uuid::new_v4();
let data = json!({
"status": "success",
"data": {
"transaction_id": format!("txn_{}", &txn_id.to_string()[..8]),
"tracking_id": format!("trk_{}", &trk_id.to_string()[..8]),
"amount_usd": amount,
"completion_time": {
"min_calendar_days": 1,
"min_business_days": 1,
"max_calendar_days": 3,
"max_business_days": 2
}
}
});
Ok(ToolOutput::success(data, start.elapsed()))
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::UnlessAutoApproved
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ---------------------------------------------------------------------------
// Tool 4: Create Notification
// ---------------------------------------------------------------------------
/// Simulates sending a notification to the Abound system.
pub struct AboundCreateNotificationTool;
#[async_trait]
impl Tool for AboundCreateNotificationTool {
fn name(&self) -> &str {
"abound_create_notification"
}
fn description(&self) -> &str {
"Create a notification in the Abound app (e.g. rate alert, transfer \
confirmation, forex scoring signal). Returns 202 accepted."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"message_id": {
"type": "string",
"description": "Unique message identifier"
},
"action_type": {
"type": "string",
"description": "Notification type: 'notification' or 'token_refresh'"
},
"meta_data": {
"type": "object",
"description": "Additional metadata (e.g. score, rate, signal)"
}
},
"required": ["message_id", "action_type"]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = Instant::now();
let message_id = require_str(&params, "message_id")?;
let data = json!({
"status": "accepted",
"message": "Notification request accepted for processing",
"data": {
"message_id": message_id
}
});
Ok(ToolOutput::success(data, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ---------------------------------------------------------------------------
// Tool 5: Forex Score
// ---------------------------------------------------------------------------
/// Seasonal bias factors by month (1-indexed: Jan=1 .. Dec=12).
/// High bias in Oct-Mar (favorable remittance window), low Apr-Sep.
const SEASONAL_BIAS: [f64; 13] = [
0.0, // placeholder for 0-index
0.75, // Jan
0.70, // Feb
0.65, // Mar
0.35, // Apr
0.30, // May
0.25, // Jun
0.25, // Jul
0.30, // Aug
0.35, // Sep
0.70, // Oct
0.75, // Nov
0.65, // Dec
];
/// Computes a forex timing score for USD/INR, biased toward interesting
/// signals (60-80 range) for demo purposes.
pub struct AboundGetForexScoreTool;
#[async_trait]
impl Tool for AboundGetForexScoreTool {
fn name(&self) -> &str {
"abound_get_forex_score"
}
fn description(&self) -> &str {
"Compute a forex timing score (0-100) for USD/INR transfers. Returns \
a score with a signal: 'convert_now' (>=60, good time to send), \
'split_transfer' (40-59, send half now), or 'wait' (<40, hold off). \
Use this to advise users on optimal transfer timing."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {},
"required": []
})
}
async fn execute(
&self,
_params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = Instant::now();
let mut rng = rand::thread_rng();
// Mock current rate and MA50
let rate = mock_exchange_rate();
let ma50_jitter: f64 = rng.gen_range(-0.20..=0.20);
let ma50 = ((84.50 + ma50_jitter) * 100.0).round() / 100.0;
// Get seasonal bias for current month
let month = Utc::now().month() as usize;
let month_bias = SEASONAL_BIAS[month.clamp(1, 12)];
// Scoring weights
let w_ma = 0.7;
let w_s = 0.3;
// MA-based signal: how far current rate is above the 50-day average
let ma_signal = (50.0 + ((rate - ma50) / ma50 * 100.0) * 15.0).clamp(0.0, 100.0);
// Combined score
let raw_score = (ma_signal * w_ma + month_bias * 100.0 * w_s) / (w_ma + w_s);
// Bias toward 60-80 for demo (blend raw score with a favorable base)
let demo_base = 68.0;
let score = ((raw_score * 0.4 + demo_base * 0.6) as u32).clamp(55, 85);
let signal = if score >= 60 {
"convert_now"
} else if score >= 40 {
"split_transfer"
} else {
"wait"
};
let explanation = match signal {
"convert_now" => format!(
"The current USD/INR rate of {rate:.2} is above the 50-day moving average \
of {ma50:.2}, and seasonal trends are favorable. This is a good time to \
convert and send money."
),
"split_transfer" => format!(
"The rate of {rate:.2} is near the 50-day average of {ma50:.2}. Consider \
splitting your transfer \u{2014} send half now and hold the rest for a \
potentially better rate."
),
_ => format!(
"The current rate of {rate:.2} is below the 50-day average of {ma50:.2}. \
Unless urgent, consider waiting for a better rate."
),
};
let data = json!({
"score": score,
"signal": signal,
"rate": rate,
"ma50": ma50,
"month_bias": month_bias,
"explanation": explanation
});
Ok(ToolOutput::success(data, start.elapsed()))
}
fn requires_sanitization(&self) -> bool {
false
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::context::JobContext;
fn test_ctx() -> JobContext {
JobContext::with_user("test-user", "test", "mock abound tool test")
}
#[tokio::test]
async fn account_info_returns_valid_json() {
let tool = AboundGetAccountInfoTool;
let result = tool.execute(json!({}), &test_ctx()).await.unwrap();
let data = &result.result["data"];
assert_eq!(data["user_id"], "acc_123456");
assert_eq!(data["limits"]["ach_limit"]["limit"], 5000);
assert_eq!(data["recipients"].as_array().unwrap().len(), 1);
assert_eq!(data["funding_sources"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn exchange_rate_returns_valid_range() {
let tool = AboundGetExchangeRateTool;
let result = tool.execute(json!({}), &test_ctx()).await.unwrap();
let rate = result.result["data"]["current_exchange_rate"]["value"]
.as_f64()
.unwrap();
// Rate should be within jitter range of base 85.42
assert!(rate > 85.0 && rate < 85.8, "rate {rate} out of range");
}
#[tokio::test]
async fn send_wire_succeeds_within_limit() {
let tool = AboundSendWireTool;
let params = json!({
"funding_source_id": "fs_001",
"beneficiary_ref_id": "ben_001",
"amount": 1000.0,
"payment_reason_key": "FAMILY_MAINTENANCE"
});
let result = tool.execute(params, &test_ctx()).await.unwrap();
assert_eq!(result.result["status"], "success");
assert!(result.result["data"]["transaction_id"]
.as_str()
.unwrap()
.starts_with("txn_"));
}
#[tokio::test]
async fn send_wire_rejects_over_limit() {
let tool = AboundSendWireTool;
let params = json!({
"funding_source_id": "fs_001",
"beneficiary_ref_id": "ben_001",
"amount": 6000.0,
"payment_reason_key": "FAMILY_MAINTENANCE"
});
let result = tool.execute(params, &test_ctx()).await.unwrap();
assert_eq!(result.result["status"], "error");
assert_eq!(result.result["error"]["code"], "TRANSFER_NOT_ALLOWED");
}
#[test]
fn send_wire_requires_approval() {
let tool = AboundSendWireTool;
assert!(matches!(
tool.requires_approval(&json!({})),
ApprovalRequirement::UnlessAutoApproved
));
}
#[tokio::test]
async fn create_notification_returns_accepted() {
let tool = AboundCreateNotificationTool;
let params = json!({
"message_id": "msg_001",
"action_type": "notification",
"meta_data": { "score": 72 }
});
let result = tool.execute(params, &test_ctx()).await.unwrap();
assert_eq!(result.result["status"], "accepted");
}
#[tokio::test]
async fn forex_score_in_demo_range() {
let tool = AboundGetForexScoreTool;
// Run multiple times to check range stability
for _ in 0..20 {
let result = tool.execute(json!({}), &test_ctx()).await.unwrap();
let score = result.result["score"].as_u64().unwrap();
assert!(
(55..=85).contains(&score),
"score {score} outside demo range [55, 85]"
);
let signal = result.result["signal"].as_str().unwrap();
assert!(
signal == "convert_now" || signal == "split_transfer" || signal == "wait",
"unexpected signal: {signal}"
);
}
}
}
+7
View File
@@ -1,5 +1,7 @@
//! Built-in tools that come with the agent.
#[cfg(feature = "demo")]
mod abound;
mod echo;
pub mod extension_tools;
mod file;
@@ -17,6 +19,11 @@ pub mod skill_tools;
mod time;
mod tool_info;
#[cfg(feature = "demo")]
pub use abound::{
AboundCreateNotificationTool, AboundGetAccountInfoTool, AboundGetExchangeRateTool,
AboundGetForexScoreTool, AboundSendWireTool,
};
pub use echo::EchoTool;
pub use extension_tools::{
ExtensionInfoTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool,
+3 -35
View File
@@ -650,23 +650,6 @@ pub(crate) fn routine_update_parameters_schema() -> Value {
})
}
const ROUTINE_LAST_NAME_STASH_KEY: &str = "__routine_last_name";
async fn stash_last_routine_name(ctx: &JobContext, name: &str) {
ctx.tool_output_stash
.write()
.await
.insert(ROUTINE_LAST_NAME_STASH_KEY.to_string(), name.to_string());
}
async fn restore_last_routine_name(ctx: &JobContext) -> Option<String> {
ctx.tool_output_stash
.read()
.await
.get(ROUTINE_LAST_NAME_STASH_KEY)
.cloned()
}
fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map<String, Value>> {
params.get(field).and_then(Value::as_object)
}
@@ -1110,7 +1093,6 @@ impl Tool for RoutineCreateTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let normalized = parse_routine_create_request(&params)?;
stash_last_routine_name(ctx, &normalized.name).await;
let trigger = build_routine_trigger(&normalized.trigger);
let action =
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
@@ -1292,7 +1274,6 @@ impl Tool for RoutineUpdateTool {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
stash_last_routine_name(ctx, name).await;
let mut routine = self
.store
@@ -1430,24 +1411,11 @@ impl Tool for RoutineDeleteTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = if let Some(name) = params.get("name").and_then(|v| v.as_str()) {
if name.trim().is_empty() {
return Err(ToolError::InvalidParameters(
"'name' parameter cannot be empty".to_string(),
));
}
name.to_string()
} else {
restore_last_routine_name(ctx).await.ok_or_else(|| {
ToolError::InvalidParameters(
"missing 'name' parameter and no previous routine target to infer".to_string(),
)
})?
};
let name = require_str(&params, "name")?;
let routine = self
.store
.get_routine_by_name(&ctx.user_id, &name)
.get_routine_by_name(&ctx.user_id, name)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
@@ -1462,7 +1430,7 @@ impl Tool for RoutineDeleteTool {
self.engine.refresh_event_cache().await;
let result = serde_json::json!({
"name": &name,
"name": name,
"deleted": deleted,
});
+1 -19
View File
@@ -117,11 +117,6 @@ impl McpClient {
/// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`.
///
/// Returns an error if the config uses a non-HTTP transport.
///
/// **Note:** The session manager is NOT wired into the transport. For
/// production use, prefer `create_client_from_config()` which constructs
/// the transport with session tracking.
#[cfg(test)]
pub fn new_with_config(config: McpServerConfig) -> Result<Self, ToolError> {
if !matches!(
config.effective_transport(),
@@ -219,14 +214,7 @@ impl McpClient {
}
}
/// Attach a session manager to the **client** only.
///
/// **Warning:** This does NOT wire the session manager into the underlying
/// `HttpMcpTransport`, so the transport will not capture `Mcp-Session-Id`
/// from responses. For production use, construct the transport with
/// `HttpMcpTransport::with_session_manager()` and pass it to
/// `new_with_transport()` instead. See `create_client_from_config()`.
#[cfg(test)]
/// Attach a session manager for Streamable HTTP session tracking.
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
self.session_manager = Some(session_manager);
self
@@ -247,12 +235,6 @@ impl McpClient {
self.session_manager.is_some()
}
/// Get the underlying transport (test-only).
#[cfg(test)]
pub(crate) fn transport(&self) -> &Arc<dyn McpTransport> {
&self.transport
}
/// Get the next request ID.
fn next_request_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::SeqCst)
+16 -101
View File
@@ -7,7 +7,6 @@ use std::sync::Arc;
use crate::secrets::SecretsStore;
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
use crate::tools::mcp::http_transport::HttpMcpTransport;
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
/// Error returned when MCP client creation fails.
@@ -79,37 +78,33 @@ pub async fn create_client_from_config(
Err(McpFactoryError::UnixNotSupported { name: server_name })
}
EffectiveTransport::Http => {
// Authenticated (OAuth) path: tokens exist or server requires auth.
if let Some(ref secrets) = secrets {
let has_tokens =
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
if has_tokens || server.requires_auth() {
return Ok(McpClient::new_authenticated(
Ok(McpClient::new_authenticated(
server,
Arc::clone(session_manager),
Arc::clone(secrets),
user_id,
));
))
} else {
Ok(McpClient::new_with_config(server)
.map_err(|e| McpFactoryError::InvalidConfig {
name: server_name.clone(),
reason: e.to_string(),
})?
.with_session_manager(Arc::clone(session_manager)))
}
} else {
Ok(McpClient::new_with_config(server)
.map_err(|e| McpFactoryError::InvalidConfig {
name: server_name,
reason: e.to_string(),
})?
.with_session_manager(Arc::clone(session_manager)))
}
// Non-OAuth HTTP: wire the session manager into the *transport* so
// it captures `Mcp-Session-Id` from responses. Passing it only to
// the client (via `with_session_manager`) is not enough — the
// transport must know about it to read/write the header.
let transport = Arc::new(
HttpMcpTransport::new(server.url.clone(), server.name.clone())
.with_session_manager(Arc::clone(session_manager)),
);
Ok(McpClient::new_with_transport(
server.name.clone(),
transport,
Some(Arc::clone(session_manager)),
secrets,
user_id,
Some(server),
))
}
}
}
@@ -139,84 +134,4 @@ mod tests {
"non-OAuth HTTP clients must carry a session manager"
);
}
/// Regression test: the factory must wire the session manager into the
/// *transport*, not just the client. Otherwise the transport never
/// captures `Mcp-Session-Id` from responses and subsequent requests
/// lack the header, causing the server to reject them.
#[tokio::test]
async fn test_factory_non_oauth_http_transport_captures_session_id() {
use axum::http::header::HeaderName;
use axum::{Router, http::StatusCode, response::IntoResponse, routing::post};
use tokio::net::TcpListener;
const SESSION_ID: &str = "test-session-abc123";
async fn session_echo() -> impl IntoResponse {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {}
})
.to_string();
(
StatusCode::OK,
[(
HeaderName::from_static("mcp-session-id"),
SESSION_ID.to_string(),
)],
body,
)
}
let app = Router::new().route("/", post(session_echo));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let server = McpServerConfig::new("session-test", &url);
let session_manager = Arc::new(McpSessionManager::new());
let process_manager = Arc::new(McpProcessManager::new());
let client = create_client_from_config(
server,
&session_manager,
&process_manager,
None,
"test-user",
)
.await
.expect("factory should succeed for HTTP config");
// Pre-create a session entry so that update_session_id has something to update.
// In production, the MCP initialize handshake calls get_or_create before responses arrive.
session_manager.get_or_create("session-test", &url).await;
// Send a request through the client's transport to trigger session capture.
use crate::tools::mcp::protocol::McpRequest;
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(1),
method: "test".to_string(),
params: Some(serde_json::json!({})),
};
let headers = std::collections::HashMap::new();
client
.transport()
.send(&request, &headers)
.await
.expect("request should succeed");
// Verify the session manager captured the session ID from the response.
let captured = session_manager.get_session_id("session-test").await;
assert_eq!(
captured.as_deref(),
Some(SESSION_ID),
"transport must capture Mcp-Session-Id into session manager"
);
}
}
-28
View File
@@ -494,34 +494,6 @@ mod tests {
assert_eq!(echoed["authorization"], "Bearer oauth-token");
}
/// Regression test for #1436: 202 Accepted responses for notifications
/// were parsed as JSON, causing "Failed to parse MCP response" errors
/// that broke the MCP session handshake.
#[tokio::test]
async fn test_wire_202_accepted_for_notification() {
use axum::{Router, http::StatusCode, routing::post};
use tokio::net::TcpListener;
async fn accept_notification() -> StatusCode {
StatusCode::ACCEPTED
}
let app = Router::new().route("/", post(accept_notification));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let transport = HttpMcpTransport::new(&url, "test-202");
let request = McpRequest::initialized_notification();
let response = transport.send(&request, &HashMap::new()).await.unwrap();
assert!(response.result.is_none());
assert!(response.error.is_none());
}
#[tokio::test]
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
let (url, _handle) = spawn_echo_server().await;
+15
View File
@@ -16,6 +16,11 @@ use crate::skills::registry::SkillRegistry;
use crate::tools::builder::{
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
};
#[cfg(feature = "demo")]
use crate::tools::builtin::{
AboundCreateNotificationTool, AboundGetAccountInfoTool, AboundGetExchangeRateTool,
AboundGetForexScoreTool, AboundSendWireTool,
};
use crate::tools::builtin::{
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
@@ -248,6 +253,16 @@ impl ToolRegistry {
}
self.register_sync(Arc::new(http));
// Abound demo tools (mock APIs — only compiled with --features demo)
#[cfg(feature = "demo")]
{
self.register_sync(Arc::new(AboundGetAccountInfoTool));
self.register_sync(Arc::new(AboundGetExchangeRateTool));
self.register_sync(Arc::new(AboundSendWireTool));
self.register_sync(Arc::new(AboundCreateNotificationTool));
self.register_sync(Arc::new(AboundGetForexScoreTool));
}
tracing::debug!("Registered {} built-in tools", self.count());
}
+4 -51
View File
@@ -446,14 +446,16 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
builtin.as_ref(),
exchange_proxy_url.is_some(),
);
let oauth_proxy_auth_token = crate::cli::oauth_defaults::oauth_proxy_auth_token();
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
Some(OAuthRefreshConfig {
token_url: oauth.token_url.clone(),
client_id,
client_secret,
exchange_proxy_url,
gateway_token: oauth_proxy_auth_token,
gateway_token,
secret_name: auth.secret_name.clone(),
provider: auth.provider.clone(),
})
@@ -889,11 +891,6 @@ mod tests {
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
@@ -985,7 +982,6 @@ mod tests {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
// google_oauth_token should fall back to built-in credentials
let caps = CapabilitiesFile {
@@ -1025,7 +1021,6 @@ mod tests {
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
@@ -1066,7 +1061,6 @@ mod tests {
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
let _client_secret_guard =
@@ -1101,47 +1095,6 @@ mod tests {
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
}
#[test]
fn test_resolve_oauth_refresh_config_hosted_proxy_prefers_dedicated_proxy_auth_token() {
use crate::tools::wasm::capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var(
"IRONCLAW_OAUTH_EXCHANGE_URL",
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var(
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
Some("shared-oauth-proxy-secret"),
);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
provider: Some("google".to_string()),
oauth: Some(OAuthConfigSchema {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
client_id_env: Some("GOOGLE_OAUTH_CLIENT_ID".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
assert_eq!(
config.gateway_token.as_deref(),
Some("shared-oauth-proxy-secret")
);
}
// ---------------------------------------------------------------
// Security regression tests
// ---------------------------------------------------------------
+5 -13
View File
@@ -62,8 +62,7 @@ pub struct OAuthRefreshConfig {
pub client_secret: Option<String>,
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
pub exchange_proxy_url: Option<String>,
/// OAuth proxy auth token for authenticating with the hosted OAuth proxy.
/// Kept as `gateway_token` for public API compatibility.
/// Gateway auth token for authenticating with the hosted OAuth proxy.
pub gateway_token: Option<String>,
/// Secret name of the access token (e.g., "google_oauth_token").
/// The refresh token lives at `{secret_name}_refresh_token`.
@@ -72,12 +71,6 @@ pub struct OAuthRefreshConfig {
pub provider: Option<String>,
}
impl OAuthRefreshConfig {
fn oauth_proxy_auth_token(&self) -> Option<&str> {
self.gateway_token.as_deref()
}
}
/// Pre-resolved credential for host-based injection.
///
/// Built before each WASM execution by decrypting secrets from the store.
@@ -1225,9 +1218,9 @@ async fn refresh_oauth_token(
let refresh_name = format!("{}_refresh_token", config.secret_name);
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
let Some(oauth_proxy_auth_token) = config.oauth_proxy_auth_token() else {
let Some(gateway_token) = config.gateway_token.as_deref() else {
tracing::warn!(
"OAuth refresh proxy is configured, but no OAuth proxy auth token is available"
"OAuth refresh proxy is configured, but no gateway auth token is available"
);
return false;
};
@@ -1242,7 +1235,7 @@ async fn refresh_oauth_token(
let token_response = match oauth_defaults::refresh_token_via_proxy(
oauth_defaults::ProxyRefreshTokenRequest {
proxy_url,
gateway_token: oauth_proxy_auth_token,
gateway_token,
token_url: &config.token_url,
client_id: &config.client_id,
client_secret: config.client_secret.as_deref(),
@@ -2711,8 +2704,7 @@ mod tests {
}
#[tokio::test]
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_oauth_proxy_auth_token()
{
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() {
use crate::secrets::{
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
};
-4
View File
@@ -428,10 +428,8 @@ mod tests {
host: "127.0.0.1".to_string(),
port: 3000,
auth_token: None,
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
});
c
}
@@ -442,10 +440,8 @@ mod tests {
host: host.to_string(),
port,
auth_token: None,
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(),
user_tokens: None,
});
c
}
-4
View File
@@ -271,7 +271,6 @@ async def ironclaw_server(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-web-sender",
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
@@ -371,7 +370,6 @@ async def hosted_oauth_refresh_server(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": OWNER_SCOPE_ID,
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
@@ -411,7 +409,6 @@ async def hosted_oauth_refresh_server(
yield {
"base_url": base_url,
"db_path": db_path,
"gateway_user_id": OWNER_SCOPE_ID,
"mock_llm_url": mock_llm_server,
}
except TimeoutError:
@@ -473,7 +470,6 @@ async def http_channel_server_without_secret(
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-tester",
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"CLI_ENABLED": "false",
+3 -40
View File
@@ -205,44 +205,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 5: routine_update_fail_delete_fallback
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_update_fail_delete_fallback() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json"
))
.expect("failed to load routine_update_fail_delete_fallback.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Try converting a routine trigger, then recover by deleting it")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let completed = rig.tool_calls_completed();
assert!(
completed.iter().any(|(n, ok)| n == "routine_update" && !ok),
"routine_update should fail in this regression path: {completed:?}"
);
assert!(
completed.iter().any(|(n, ok)| n == "routine_delete" && *ok),
"routine_delete should recover successfully via preserved routine identity: {completed:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 6: routine_manual_create_defaults_to_tools_enabled
// Test 5: routine_manual_create_defaults_to_tools_enabled
// -----------------------------------------------------------------------
#[tokio::test]
@@ -283,7 +246,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 7: routine_manual_create_explicit_no_tools
// Test 6: routine_manual_create_explicit_no_tools
// -----------------------------------------------------------------------
#[tokio::test]
@@ -324,7 +287,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 8: routine_history
// Test 7: routine_history
// -----------------------------------------------------------------------
#[tokio::test]
@@ -1,70 +0,0 @@
{
"model_name": "test-routine-update-fail-delete-fallback",
"expects": {
"tools_used": ["routine_create", "routine_update", "routine_delete"],
"tool_results_contain": {
"routine_update": "Cannot update schedule or timezone on a non-cron routine.",
"routine_delete": "temp-routine"
},
"min_responses": 1
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_fallback",
"name": "routine_create",
"arguments": {
"name": "temp-routine",
"trigger_type": "manual",
"prompt": "Temporary routine for fallback test."
}
}
],
"input_tokens": 120,
"output_tokens": 40
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_ru_fallback",
"name": "routine_update",
"arguments": {
"name": "temp-routine",
"schedule": "0 */10 * * * *"
}
}
],
"input_tokens": 200,
"output_tokens": 30
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rd_fallback",
"name": "routine_delete",
"arguments": {}
}
],
"input_tokens": 300,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I recovered from the failed update and cleaned up the original routine.",
"input_tokens": 380,
"output_tokens": 25
}
}
]
}
+14 -10
View File
@@ -42,8 +42,6 @@ const ALICE_USER_ID: &str = "alice";
const BOB_USER_ID: &str = "bob";
const OWNER_TOKEN: &str = "tok-owner-secret";
const OWNER_SCOPE_ID: &str = "owner-scope";
const GATEWAY_SENDER_ID: &str = "gateway-sender";
/// Build a MultiAuthState with two users.
fn two_user_auth() -> MultiAuthState {
let mut tokens = HashMap::new();
@@ -51,6 +49,7 @@ fn two_user_auth() -> MultiAuthState {
ALICE_TOKEN.to_string(),
UserIdentity {
user_id: ALICE_USER_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -58,6 +57,7 @@ fn two_user_auth() -> MultiAuthState {
BOB_TOKEN.to_string(),
UserIdentity {
user_id: BOB_USER_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()],
},
);
@@ -79,7 +79,10 @@ fn user_echo_app(auth: MultiAuthState) -> Router {
.route("/api/whoami/scopes", get(echo_user_with_scopes))
.route("/api/action", post(echo_user))
.route("/api/chat/events", get(echo_user)) // SSE endpoint (allows query token)
.layer(middleware::from_fn_with_state(auth, auth_middleware))
.layer(middleware::from_fn_with_state(
ironclaw::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
}
// ===========================================================================
@@ -544,7 +547,6 @@ fn gateway_state_has_multi_tenant_fields() {
prompt_queue: None,
scheduler: None,
owner_id: "fallback".to_string(),
default_sender_id: "fallback".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -558,10 +560,10 @@ fn gateway_state_has_multi_tenant_fields() {
startup_time: std::time::Instant::now(),
webhook_rate_limiter: RateLimiter::new(10, 60),
active_config: Default::default(),
secrets_store: None,
};
assert_eq!(state.owner_id, "fallback");
assert_eq!(state.default_sender_id, "fallback");
assert!(state.workspace_pool.is_none());
}
@@ -592,6 +594,7 @@ async fn start_owner_scoped_sender_server() -> (
OWNER_TOKEN.to_string(),
UserIdentity {
user_id: OWNER_SCOPE_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -599,6 +602,7 @@ async fn start_owner_scoped_sender_server() -> (
BOB_TOKEN.to_string(),
UserIdentity {
user_id: BOB_USER_ID.to_string(),
role: "member".to_string(),
workspace_read_scopes: Vec::new(),
},
);
@@ -618,7 +622,6 @@ async fn start_owner_scoped_sender_server() -> (
prompt_queue: None,
scheduler: None,
owner_id: OWNER_SCOPE_ID.to_string(),
default_sender_id: GATEWAY_SENDER_ID.to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -632,9 +635,10 @@ async fn start_owner_scoped_sender_server() -> (
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: Default::default(),
secrets_store: None,
});
let auth = MultiAuthState::multi(tokens);
let auth = MultiAuthState::multi(tokens).into();
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = start_server(addr, state.clone(), auth)
.await
@@ -769,7 +773,7 @@ async fn full_server_chat_send_rewrites_sender_only_for_owner_scope_rebind() {
.expect("Timed out waiting for owner message")
.expect("Agent channel closed");
assert_eq!(owner_msg.user_id, OWNER_SCOPE_ID);
assert_eq!(owner_msg.sender_id, GATEWAY_SENDER_ID);
assert_eq!(owner_msg.sender_id, OWNER_SCOPE_ID);
assert_eq!(owner_msg.content, "hello from owner");
let other_resp = client
@@ -1003,7 +1007,6 @@ async fn start_multi_user_server_with_db() -> (
prompt_queue: None,
scheduler: None,
owner_id: ALICE_USER_ID.to_string(),
default_sender_id: ALICE_USER_ID.to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -1017,10 +1020,11 @@ async fn start_multi_user_server_with_db() -> (
startup_time: std::time::Instant::now(),
webhook_rate_limiter: RateLimiter::new(10, 60),
active_config: Default::default(),
secrets_store: None,
});
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth)
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth.into())
.await
.expect("Failed to start server with DB");
+5 -5
View File
@@ -205,7 +205,6 @@ async fn start_test_server_with_provider(
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(llm_provider),
@@ -219,6 +218,7 @@ async fn start_test_server_with_provider(
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -226,7 +226,7 @@ async fn start_test_server_with_provider(
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth)
let bound_addr = start_server(addr, state.clone(), auth.into())
.await
.expect("Failed to start test server");
@@ -704,7 +704,6 @@ async fn test_no_llm_provider_returns_503() {
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, // No LLM!
@@ -718,6 +717,7 @@ async fn test_no_llm_provider_returns_503() {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -725,7 +725,7 @@ async fn test_no_llm_provider_returns_503() {
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state, auth).await.unwrap();
let bound_addr = start_server(addr, state, auth.into()).await.unwrap();
let url = format!("http://{}/v1/chat/completions", bound_addr);
let resp = client()
@@ -763,7 +763,7 @@ async fn test_chat_completions_body_too_large() {
post(ironclaw::channels::web::openai_compat::chat_completions_handler),
)
.route_layer(middleware::from_fn_with_state(
auth_state,
ironclaw::channels::web::auth::CombinedAuthState::from(auth_state),
ironclaw::channels::web::auth::auth_middleware,
))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
+2 -2
View File
@@ -227,7 +227,6 @@ impl GatewayWorkflowHarness {
prompt_queue: None,
scheduler: Some(scheduler_slot.clone()),
owner_id: user_id.clone(),
default_sender_id: user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(Arc::clone(&components.llm)),
@@ -241,6 +240,7 @@ impl GatewayWorkflowHarness {
routine_engine: Arc::clone(&routine_slot),
startup_time: Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
});
let mut agent = Agent::new(
@@ -299,7 +299,7 @@ impl GatewayWorkflowHarness {
let addr = start_server(
"127.0.0.1:0".parse().expect("valid localhost addr"),
Arc::clone(&gateway_state),
auth,
auth.into(),
)
.await
.expect("failed to start gateway server");
+2 -2
View File
@@ -52,7 +52,6 @@ async fn start_test_server() -> (
prompt_queue: None,
scheduler: None,
owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
@@ -66,6 +65,7 @@ async fn start_test_server() -> (
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
});
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -73,7 +73,7 @@ async fn start_test_server() -> (
"test-user".to_string(),
);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth)
let bound_addr = start_server(addr, state.clone(), auth.into())
.await
.expect("Failed to start test server");