Compare commits

..
67 changed files with 1343 additions and 5652 deletions
Generated
-2
View File
@@ -3482,7 +3482,6 @@ dependencies = [
"wasmparser 0.220.1", "wasmparser 0.220.1",
"wasmtime", "wasmtime",
"wasmtime-wasi", "wasmtime-wasi",
"webpki-roots 0.26.11",
"zbus", "zbus",
"zip", "zip",
] ]
@@ -6992,7 +6991,6 @@ dependencies = [
"futures-util", "futures-util",
"http 1.4.0", "http 1.4.0",
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util",
"iri-string", "iri-string",
"pin-project-lite", "pin-project-lite",
"tower 0.5.3", "tower 0.5.3",
+1 -4
View File
@@ -57,7 +57,6 @@ refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
tokio-postgres-rustls = { version = "0.13", optional = true } tokio-postgres-rustls = { version = "0.13", optional = true }
rustls = { version = "0.23", optional = true, default-features = false } rustls = { version = "0.23", optional = true, default-features = false }
rustls-native-certs = { version = "0.8", optional = true } rustls-native-certs = { version = "0.8", optional = true }
webpki-roots = { version = "0.26", optional = true }
# Database - libSQL/Turso (optional embedded database) # Database - libSQL/Turso (optional embedded database)
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] } libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
@@ -96,7 +95,7 @@ termimad = "0.34"
# Channel integrations # Channel integrations
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
tower = "0.5" tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "catch-panic"] } tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
# Cron scheduling for routines # Cron scheduling for routines
cron = "0.13" cron = "0.13"
@@ -220,7 +219,6 @@ postgres = [
"dep:tokio-postgres-rustls", "dep:tokio-postgres-rustls",
"dep:rustls", "dep:rustls",
"dep:rustls-native-certs", "dep:rustls-native-certs",
"dep:webpki-roots",
"dep:postgres-types", "dep:postgres-types",
"dep:refinery", "dep:refinery",
"dep:pgvector", "dep:pgvector",
@@ -232,7 +230,6 @@ libsql = ["dep:libsql"]
integration = [] integration = []
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"] html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"] bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
demo = []
import = ["dep:json5", "libsql"] import = ["dep:json5", "libsql"]
[[test]] [[test]]
+9 -39
View File
@@ -1,78 +1,49 @@
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment). # 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: # Build:
# docker build --platform linux/amd64 -t ironclaw:latest . # docker build --platform linux/amd64 -t ironclaw:latest .
# #
# Run: # Run:
# docker run --env-file .env -p 3000:3000 ironclaw:latest # docker run --env-file .env -p 3000:3000 ironclaw:latest
# Stage 1: Install cargo-chef # Stage 1: Build
FROM rust:1.92-slim-bookworm AS chef FROM rust:1.92-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev cmake gcc g++ \ pkg-config libssl-dev cmake gcc g++ \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& rustup target add wasm32-wasip2 \ && rustup target add wasm32-wasip2 \
&& cargo install cargo-chef wasm-tools && cargo install wasm-tools
WORKDIR /app WORKDIR /app
# Stage 2: Generate the dependency recipe (changes only when Cargo.toml/lock change) # Copy manifests first for layer caching
FROM chef AS planner
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/ COPY crates/ crates/
# Copy source, build script, tests, and supporting directories
COPY build.rs build.rs COPY build.rs build.rs
COPY src/ src/ COPY src/ src/
COPY tests/ tests/ COPY tests/ tests/
COPY benches/ benches/
COPY migrations/ migrations/ COPY migrations/ migrations/
COPY registry/ registry/ COPY registry/ registry/
COPY channels-src/ channels-src/ COPY channels-src/ channels-src/
COPY wit/ wit/ COPY wit/ wit/
COPY providers.json providers.json 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 benches/ benches/
COPY migrations/ migrations/
COPY registry/ registry/
COPY channels-src/ channels-src/
COPY wit/ wit/
COPY providers.json providers.json
COPY skills/ skills/ RUN cargo build --release --bin ironclaw
RUN cargo build --release --features demo --bin ironclaw # Stage 2: Runtime
# Stage 5: Runtime
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \ ca-certificates libssl3 \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
COPY --from=builder /app/migrations /app/migrations COPY --from=builder /app/migrations /app/migrations
COPY --from=builder /app/skills /app/skills
# Non-root user # Non-root user
RUN useradd -m -u 1000 -s /bin/bash ironclaw RUN useradd -m -u 1000 -s /bin/bash ironclaw
@@ -81,6 +52,5 @@ USER ironclaw
EXPOSE 3000 EXPOSE 3000
ENV RUST_LOG=ironclaw=info ENV RUST_LOG=ironclaw=info
ENV SKILLS_DIR=/app/skills
ENTRYPOINT ["ironclaw"] ENTRYPOINT ["ironclaw"]
-571
View File
@@ -1,571 +0,0 @@
# 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` | |
-31
View File
@@ -1,31 +0,0 @@
-- 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);
-94
View File
@@ -1,94 +0,0 @@
---
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.
+9 -17
View File
@@ -400,21 +400,16 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
}; };
// Record cost and track token usage (global + per-user). // Record cost and track token usage (global + per-user).
// Use the provider's effective_model_name so cost attribution matches // When a model override is active, use the override name for attribution
// the model that actually served the request. When the override is // and let CostGuard look up pricing via costs::model_cost() instead of
// honoured (e.g. NearAI), this returns the override name; when the // using the default provider's cost_per_token (which reflects the wrong model).
// provider ignores overrides (e.g. Rig-based), it returns the active let (model_name, cost_per_token) = if let Some(ref ovr) = reason_ctx.model_override {
// model, keeping attribution accurate in both cases. (ovr.clone(), None)
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 { } else {
Some(self.agent.llm().cost_per_token()) (
self.agent.llm().active_model_name(),
Some(self.agent.llm().cost_per_token()),
)
}; };
let read_discount = self.agent.llm().cache_read_discount(); let read_discount = self.agent.llm().cache_read_discount();
let write_multiplier = self.agent.llm().cache_write_multiplier(); let write_multiplier = self.agent.llm().cache_write_multiplier();
@@ -1359,7 +1354,6 @@ mod tests {
max_tool_iterations: 50, max_tool_iterations: 50,
auto_approve_tools: false, auto_approve_tools: false,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0, max_tokens_per_job: 0,
multi_tenant: false, multi_tenant: false,
max_llm_concurrent_per_user: None, max_llm_concurrent_per_user: None,
@@ -2241,7 +2235,6 @@ mod tests {
max_tool_iterations, max_tool_iterations,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0, max_tokens_per_job: 0,
multi_tenant: false, multi_tenant: false,
max_llm_concurrent_per_user: None, max_llm_concurrent_per_user: None,
@@ -2369,7 +2362,6 @@ mod tests {
max_tool_iterations: max_iter, max_tool_iterations: max_iter,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0, max_tokens_per_job: 0,
multi_tenant: false, multi_tenant: false,
max_llm_concurrent_per_user: None, max_llm_concurrent_per_user: None,
+23 -25
View File
@@ -400,7 +400,7 @@ impl HeartbeatRunner {
} }
/// Send a notification about heartbeat findings. /// Send a notification about heartbeat findings.
async fn send_notification(&self, message: &str) { pub(crate) async fn send_notification(&self, message: &str) {
let Some(ref tx) = self.response_tx else { let Some(ref tx) = self.response_tx else {
tracing::debug!("No response channel configured for heartbeat notifications"); tracing::debug!("No response channel configured for heartbeat notifications");
return; return;
@@ -512,8 +512,8 @@ pub fn spawn_heartbeat(
}) })
} }
/// Spawn a multi-user heartbeat runner that cycles through all users who /// Spawn a multi-user heartbeat runner that cycles through all users that
/// have routines (enabled or not). Each tick, it queries the DB for distinct /// own 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 /// user_ids, creates a per-user workspace, and runs a heartbeat check for
/// each user concurrently. Per-user failure counts are tracked independently. /// each user concurrently. Per-user failure counts are tracked independently.
pub fn spawn_multi_user_heartbeat( pub fn spawn_multi_user_heartbeat(
@@ -574,10 +574,8 @@ pub fn spawn_multi_user_heartbeat(
} }
}; };
// Run user heartbeats (and hygiene) concurrently so one slow LLM // Run user heartbeats concurrently so one slow LLM call doesn't
// call doesn't block others. Cap concurrency to avoid flooding the // block others. Cap concurrency to avoid flooding the LLM provider.
// 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; const MAX_CONCURRENT_HEARTBEATS: usize = 8;
let mut join_set = tokio::task::JoinSet::new(); let mut join_set = tokio::task::JoinSet::new();
@@ -590,6 +588,23 @@ pub fn spawn_multi_user_heartbeat(
let workspace = Arc::new(Workspace::new_with_db(user_id, Arc::clone(store.db()))); 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. // Drain completed tasks to stay within the concurrency cap.
while join_set.len() >= MAX_CONCURRENT_HEARTBEATS { while join_set.len() >= MAX_CONCURRENT_HEARTBEATS {
if let Some(join_result) = join_set.join_next().await { if let Some(join_result) = join_set.join_next().await {
@@ -598,30 +613,13 @@ pub fn spawn_multi_user_heartbeat(
} }
let uid = user_id.clone(); let uid = user_id.clone();
// In multi-tenant mode, clear notify_user_id so that let cfg = config.clone();
// 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 hyg = hygiene_config.clone();
let llm_clone = llm.clone(); let llm_clone = llm.clone();
let tx = response_tx.clone(); let tx = response_tx.clone();
let admin = store.clone(); let admin = store.clone();
join_set.spawn(async move { 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); let mut runner = HeartbeatRunner::new(cfg, hyg, workspace, llm_clone);
if let Some(tx) = tx { if let Some(tx) = tx {
runner = runner.with_response_channel(tx); runner = runner.with_response_channel(tx);
+439 -2
View File
@@ -24,6 +24,8 @@ use std::time::Duration;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use crate::error::RoutineError; use crate::error::RoutineError;
@@ -52,6 +54,55 @@ pub struct Routine {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
const ROUTINE_VERIFICATION_STATE_KEY: &str = "_verification";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RoutineVerificationRecord {
current_fingerprint: String,
#[serde(default)]
verified_fingerprint: Option<String>,
#[serde(default)]
last_verified_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoutineVerificationStatus {
Verified,
Unverified,
}
impl RoutineVerificationStatus {
pub fn as_str(self) -> &'static str {
match self {
RoutineVerificationStatus::Verified => "verified",
RoutineVerificationStatus::Unverified => "unverified",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RoutineDisplayStatus {
Disabled,
Running,
Unverified,
Failing,
Attention,
Active,
}
impl RoutineDisplayStatus {
pub fn as_str(self) -> &'static str {
match self {
RoutineDisplayStatus::Disabled => "disabled",
RoutineDisplayStatus::Running => "running",
RoutineDisplayStatus::Unverified => "unverified",
RoutineDisplayStatus::Failing => "failing",
RoutineDisplayStatus::Attention => "attention",
RoutineDisplayStatus::Active => "active",
}
}
}
/// When a routine should fire. /// When a routine should fire.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
@@ -517,6 +568,155 @@ pub fn content_hash(content: &str) -> u64 {
hasher.finish() hasher.finish()
} }
fn routine_state_as_object(state: &Value) -> Map<String, Value> {
state.as_object().cloned().unwrap_or_default()
}
fn routine_verification_record(state: &Value) -> Option<RoutineVerificationRecord> {
state
.as_object()
.and_then(|obj| obj.get(ROUTINE_VERIFICATION_STATE_KEY))
.cloned()
.and_then(|value| serde_json::from_value(value).ok())
}
fn write_routine_verification_record(
state: &Value,
record: RoutineVerificationRecord,
) -> serde_json::Value {
let mut obj = routine_state_as_object(state);
if let Ok(value) = serde_json::to_value(record) {
obj.insert(ROUTINE_VERIFICATION_STATE_KEY.to_string(), value);
}
Value::Object(obj)
}
fn canonicalize_json_value(value: Value) -> Value {
match value {
Value::Array(items) => {
Value::Array(items.into_iter().map(canonicalize_json_value).collect())
}
Value::Object(obj) => {
let mut keys: Vec<String> = obj.keys().cloned().collect();
keys.sort();
let mut canonical = Map::new();
for key in keys {
if let Some(value) = obj.get(&key) {
canonical.insert(key, canonicalize_json_value(value.clone()));
}
}
Value::Object(canonical)
}
other => other,
}
}
pub fn routine_verification_fingerprint(routine: &Routine) -> String {
let canonical = canonicalize_json_value(serde_json::json!({
"trigger_type": routine.trigger.type_tag(),
"trigger": routine.trigger.to_config_json(),
"action_type": routine.action.type_tag(),
"action": routine.action.to_config_json(),
"guardrails": {
"cooldown_secs": routine.guardrails.cooldown.as_secs(),
"max_concurrent": routine.guardrails.max_concurrent,
"dedup_window_secs": routine.guardrails.dedup_window.map(|d| d.as_secs()),
},
}))
.to_string();
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
hex::encode(hasher.finalize())
}
pub fn reset_routine_verification_state(
state: &Value,
current_fingerprint: String,
) -> serde_json::Value {
let mut record = routine_verification_record(state).unwrap_or(RoutineVerificationRecord {
current_fingerprint: current_fingerprint.clone(),
verified_fingerprint: None,
last_verified_at: None,
});
record.current_fingerprint = current_fingerprint;
write_routine_verification_record(state, record)
}
pub fn apply_routine_verification_result(
state: &Value,
current_fingerprint: String,
status: RunStatus,
now: DateTime<Utc>,
) -> serde_json::Value {
if let Some(mut record) = routine_verification_record(state) {
record.current_fingerprint = current_fingerprint.clone();
if status == RunStatus::Ok {
record.verified_fingerprint = Some(current_fingerprint);
record.last_verified_at = Some(now);
}
write_routine_verification_record(state, record)
} else if status == RunStatus::Ok {
write_routine_verification_record(
state,
RoutineVerificationRecord {
current_fingerprint: current_fingerprint.clone(),
verified_fingerprint: Some(current_fingerprint),
last_verified_at: Some(now),
},
)
} else {
state.clone()
}
}
pub fn routine_verification_status(routine: &Routine) -> RoutineVerificationStatus {
let fingerprint = routine_verification_fingerprint(routine);
let verified =
routine_verification_record(&routine.state).map_or(routine.run_count > 0, |record| {
record.current_fingerprint == fingerprint
&& record.verified_fingerprint.as_deref() == Some(fingerprint.as_str())
});
if verified {
RoutineVerificationStatus::Verified
} else {
RoutineVerificationStatus::Unverified
}
}
pub fn routine_display_status(
routine: &Routine,
last_run_status: Option<RunStatus>,
) -> RoutineDisplayStatus {
routine_display_status_for_verification(
routine,
routine_verification_status(routine),
last_run_status,
)
}
pub fn routine_display_status_for_verification(
routine: &Routine,
verification_status: RoutineVerificationStatus,
last_run_status: Option<RunStatus>,
) -> RoutineDisplayStatus {
if !routine.enabled {
return RoutineDisplayStatus::Disabled;
}
if last_run_status == Some(RunStatus::Running) {
return RoutineDisplayStatus::Running;
}
if verification_status == RoutineVerificationStatus::Unverified {
return RoutineDisplayStatus::Unverified;
}
if routine.consecutive_failures > 0 {
return RoutineDisplayStatus::Failing;
}
if last_run_status == Some(RunStatus::Attention) {
return RoutineDisplayStatus::Attention;
}
RoutineDisplayStatus::Active
}
/// Normalize a cron expression to the 7-field format expected by the `cron` crate. /// Normalize a cron expression to the 7-field format expected by the `cron` crate.
/// ///
/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`. /// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
@@ -725,9 +925,14 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::agent::routine::{ use crate::agent::routine::{
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, MAX_TOOL_ROUNDS_LIMIT, NotifyConfig, Routine, RoutineAction, RoutineGuardrails,
describe_cron, next_cron_fire, normalize_cron_expression, RoutineVerificationStatus, RunStatus, Trigger, apply_routine_verification_result,
content_hash, describe_cron, next_cron_fire, normalize_cron_expression,
reset_routine_verification_state, routine_verification_fingerprint,
routine_verification_status,
}; };
use chrono::Utc;
use uuid::Uuid;
#[test] #[test]
fn test_trigger_roundtrip() { fn test_trigger_roundtrip() {
@@ -861,6 +1066,69 @@ mod tests {
assert_ne!(h1, h3); assert_ne!(h1, h3);
} }
#[test]
fn test_verification_fingerprint_is_digest_not_prompt_content() {
let routine = Routine {
id: Uuid::new_v4(),
name: "hashed".to_string(),
description: "hash test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "super-secret-routine-prompt".to_string(),
context_paths: Vec::new(),
max_tokens: 256,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
let fingerprint = routine_verification_fingerprint(&routine);
assert_eq!(fingerprint.len(), 64);
assert!(!fingerprint.contains("super-secret-routine-prompt"));
}
#[test]
fn test_system_event_fingerprint_is_stable_when_filter_insertion_order_differs() {
let mut first_filters = std::collections::HashMap::new();
first_filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
first_filters.insert("action".to_string(), "opened".to_string());
let mut second_filters = std::collections::HashMap::new();
second_filters.insert("action".to_string(), "opened".to_string());
second_filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
let mut first = make_verification_test_routine();
first.trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: first_filters,
};
let mut second = make_verification_test_routine();
second.trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue".to_string(),
filters: second_filters,
};
assert_eq!(
routine_verification_fingerprint(&first),
routine_verification_fingerprint(&second)
);
}
#[test] #[test]
fn test_next_cron_fire_valid() { fn test_next_cron_fire_valid() {
// Every minute should always have a next fire // Every minute should always have a next fire
@@ -1117,4 +1385,173 @@ mod tests {
_ => panic!("expected Lightweight"), _ => panic!("expected Lightweight"),
} }
} }
fn make_verification_test_routine() -> Routine {
Routine {
id: Uuid::new_v4(),
name: "verify-me".to_string(),
description: "verification test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "Check routine output".to_string(),
context_paths: Vec::new(),
max_tokens: 1024,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_reset_verification_state_marks_new_routine_unverified() {
let mut routine = make_verification_test_routine();
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_successful_run_verifies_current_fingerprint() {
let mut routine = make_verification_test_routine();
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = reset_routine_verification_state(&routine.state, fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Ok,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
#[test]
fn test_behavior_change_resets_prior_verification() {
let mut routine = make_verification_test_routine();
let original_fingerprint = routine_verification_fingerprint(&routine);
routine.state =
reset_routine_verification_state(&routine.state, original_fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
original_fingerprint,
RunStatus::Ok,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
if let RoutineAction::Lightweight { prompt, .. } = &mut routine.action {
*prompt = "Updated prompt".to_string();
}
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_failed_unverified_run_stays_unverified() {
let mut routine = make_verification_test_routine();
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = reset_routine_verification_state(&routine.state, fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Failed,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_schedule_change_resets_verification() {
let mut routine = make_verification_test_routine();
routine.trigger = Trigger::Cron {
schedule: "0 0 9 * * MON-FRI *".to_string(),
timezone: Some("UTC".to_string()),
};
let original_fingerprint = routine_verification_fingerprint(&routine);
routine.state =
reset_routine_verification_state(&routine.state, original_fingerprint.clone());
routine.state = apply_routine_verification_result(
&routine.state,
original_fingerprint,
RunStatus::Ok,
Utc::now(),
);
routine.trigger = Trigger::Cron {
schedule: "0 0 10 * * MON-FRI *".to_string(),
timezone: Some("UTC".to_string()),
};
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Unverified
);
}
#[test]
fn test_legacy_routine_with_runs_is_treated_as_verified_without_metadata() {
let mut routine = make_verification_test_routine();
routine.run_count = 3;
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
#[test]
fn test_failed_legacy_run_preserves_implicit_verification() {
let mut routine = make_verification_test_routine();
routine.run_count = 2;
let fingerprint = routine_verification_fingerprint(&routine);
routine.state = apply_routine_verification_result(
&routine.state,
fingerprint,
RunStatus::Failed,
Utc::now(),
);
assert_eq!(
routine_verification_status(&routine),
RoutineVerificationStatus::Verified
);
}
} }
+18 -4
View File
@@ -23,7 +23,8 @@ use uuid::Uuid;
use crate::agent::Scheduler; use crate::agent::Scheduler;
use crate::agent::routine::{ use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire, NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger,
apply_routine_verification_result, next_cron_fire, routine_verification_fingerprint,
}; };
use crate::channels::{IncomingMessage, OutgoingResponse}; use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::config::RoutineConfig; use crate::config::RoutineConfig;
@@ -621,7 +622,7 @@ impl RoutineEngine {
); );
// Load the routine to update consecutive_failures and send notification // Load the routine to update consecutive_failures and send notification
let routine = match self.store.get_routine(run.routine_id).await { let mut routine = match self.store.get_routine(run.routine_id).await {
Ok(Some(r)) => r, Ok(Some(r)) => r,
Ok(None) => { Ok(None) => {
tracing::warn!( tracing::warn!(
@@ -649,6 +650,12 @@ impl RoutineEngine {
}; };
let now = Utc::now(); let now = Utc::now();
routine.state = apply_routine_verification_result(
&routine.state,
routine_verification_fingerprint(&routine),
status,
now,
);
let next_fire = if let Trigger::Cron { let next_fire = if let Trigger::Cron {
ref schedule, ref schedule,
ref timezone, ref timezone,
@@ -1085,7 +1092,7 @@ struct EngineContext {
} }
/// Execute a routine run. Handles both lightweight and full_job modes. /// Execute a routine run. Handles both lightweight and full_job modes.
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) { async fn execute_routine(ctx: EngineContext, mut routine: Routine, run: RoutineRun) {
// Increment running count (atomic: survives panics in the execution below) // Increment running count (atomic: survives panics in the execution below)
ctx.running_count.fetch_add(1, Ordering::Relaxed); ctx.running_count.fetch_add(1, Ordering::Relaxed);
@@ -1143,8 +1150,15 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e); tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
} }
// Update routine runtime state
let now = Utc::now(); let now = Utc::now();
routine.state = apply_routine_verification_result(
&routine.state,
routine_verification_fingerprint(&routine),
status,
now,
);
// Update routine runtime state
let next_fire = if let Trigger::Cron { let next_fire = if let Trigger::Cron {
ref schedule, ref schedule,
ref timezone, ref timezone,
-15
View File
@@ -267,20 +267,6 @@ 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 // Transition job to in_progress
self.context_manager self.context_manager
.update_context(job_id, |ctx| { .update_context(job_id, |ctx| {
@@ -798,7 +784,6 @@ mod tests {
max_tool_iterations: 10, max_tool_iterations: 10,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job, max_tokens_per_job,
multi_tenant: false, multi_tenant: false,
max_llm_concurrent_per_user: None, max_llm_concurrent_per_user: None,
+7 -2
View File
@@ -336,12 +336,17 @@ impl AppBuilder {
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone()); ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
let ws = Arc::new(ws); let ws = Arc::new(ws);
// Detect multi-tenant mode: when the database has registered users, // Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured,
// each authenticated user needs their own workspace scope. Use // each authenticated user needs their own workspace scope. Use
// WorkspacePool (which implements WorkspaceResolver) to create // WorkspacePool (which implements WorkspaceResolver) to create
// per-user workspaces on demand instead of sharing the startup // per-user workspaces on demand instead of sharing the startup
// workspace across all users. // workspace across all users.
let is_multi_tenant = db.has_any_users().await.unwrap_or(false); let is_multi_tenant = self
.config
.channels
.gateway
.as_ref()
.is_some_and(|gw| gw.user_tokens.is_some());
if is_multi_tenant { if is_multi_tenant {
let pool = Arc::new(crate::channels::web::server::WorkspacePool::new( let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
+1 -29
View File
@@ -84,41 +84,13 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/routines` | List routines | | GET | `/api/routines` | List routines |
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/failing/runs_today) | | GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/unverified/failing/runs_today) |
| GET | `/api/routines/{id}` | Routine detail with recent run history | | GET | `/api/routines/{id}` | Routine detail with recent run history |
| POST | `/api/routines/{id}/trigger` | Manually trigger a routine | | POST | `/api/routines/{id}/trigger` | Manually trigger a routine |
| POST | `/api/routines/{id}/toggle` | Enable/disable a routine | | POST | `/api/routines/{id}/toggle` | Enable/disable a routine |
| DELETE | `/api/routines/{id}` | Delete a routine | | DELETE | `/api/routines/{id}` | Delete a routine |
| GET | `/api/routines/{id}/runs` | List runs for a specific 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 ### Settings
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
+20 -201
View File
@@ -5,7 +5,6 @@
//! handlers can extract it via `AuthenticatedUser`. //! handlers can extract it via `AuthenticatedUser`.
use std::collections::HashMap; use std::collections::HashMap;
use std::num::NonZeroUsize;
use axum::{ use axum::{
extract::{FromRequestParts, Request, State}, extract::{FromRequestParts, Request, State},
@@ -14,25 +13,18 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::sync::Arc;
use std::time::Instant;
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use tokio::sync::RwLock;
use crate::db::Database;
/// Identity resolved from a bearer token. /// Identity resolved from a bearer token.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct UserIdentity { pub struct UserIdentity {
pub user_id: String, pub user_id: String,
/// `admin` or `member`.
pub role: String,
/// Additional user scopes this identity can read from. /// Additional user scopes this identity can read from.
pub workspace_read_scopes: Vec<String>, pub workspace_read_scopes: Vec<String>,
} }
/// Hash a token with SHA-256 for constant-size, timing-safe storage. /// Hash a token with SHA-256 for constant-size, timing-safe storage.
pub fn hash_token(token: &str) -> [u8; 32] { fn hash_token(token: &str) -> [u8; 32] {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
hasher.finalize().into() hasher.finalize().into()
@@ -64,7 +56,6 @@ impl MultiAuthState {
hash, hash,
UserIdentity { UserIdentity {
user_id, user_id,
role: "admin".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
}, },
)], )],
@@ -73,11 +64,6 @@ impl MultiAuthState {
} }
/// Create a multi-user auth state from a map of tokens to identities. /// 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 { pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
.into_iter() .into_iter()
@@ -122,112 +108,6 @@ 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. /// Axum extractor that provides the authenticated user identity.
/// ///
/// Only available on routes behind `auth_middleware`. Extracts the /// Only available on routes behind `auth_middleware`. Extracts the
@@ -250,31 +130,6 @@ 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. /// Whether query-string token auth is allowed for this request.
/// ///
/// Only GET requests to streaming endpoints may use `?token=xxx`. This /// Only GET requests to streaming endpoints may use `?token=xxx`. This
@@ -311,65 +166,39 @@ fn query_token(request: &Request) -> Option<String> {
/// Auth middleware that validates bearer token from header or query param. /// Auth middleware that validates bearer token from header or query param.
/// ///
/// Tries env-var tokens first (constant-time, in-memory), then falls back /// SSE connections can't set headers from `EventSource`, so we also accept
/// to DB-backed token lookup if configured. SSE connections can't set /// `?token=xxx` as a query parameter, but only on SSE/WS endpoints.
/// 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 /// On successful authentication, inserts the matching `UserIdentity` into
/// request extensions for downstream extraction via `AuthenticatedUser`. /// request extensions for downstream extraction via `AuthenticatedUser`.
pub async fn auth_middleware( pub async fn auth_middleware(
State(auth): State<CombinedAuthState>, State(auth): State<MultiAuthState>,
headers: HeaderMap, headers: HeaderMap,
mut request: Request, mut request: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
// Extract the candidate token from header or query param. // Try Authorization header first.
let token = extract_token(&headers, &request); // RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
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") if let Some(auth_header) = headers.get("authorization")
&& let Ok(value) = auth_header.to_str() && let Ok(value) = auth_header.to_str()
&& value.len() > 7 && value.len() > 7
&& value[..7].eq_ignore_ascii_case("Bearer ") && value[..7].eq_ignore_ascii_case("Bearer ")
&& let Some(identity) = auth.authenticate(&value[7..])
{ {
return Some(value[7..].to_string()); request.extensions_mut().insert(identity.clone());
return next.run(request).await;
} }
// Fall back to query parameter for SSE/WS endpoints. // Fall back to query parameter, but only for SSE/WS endpoints.
if allows_query_token_auth(request) { if allows_query_token_auth(&request)
return query_token(request); && let Some(token) = query_token(&request)
&& let Some(identity) = auth.authenticate(&token)
{
request.extensions_mut().insert(identity.clone());
return next.run(request).await;
} }
None (StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
} }
#[cfg(test)] #[cfg(test)]
@@ -398,7 +227,6 @@ mod tests {
"tok-alice".to_string(), "tok-alice".to_string(),
UserIdentity { UserIdentity {
user_id: "alice".to_string(), user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
}, },
); );
@@ -406,7 +234,6 @@ mod tests {
"tok-bob".to_string(), "tok-bob".to_string(),
UserIdentity { UserIdentity {
user_id: "bob".to_string(), user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
}, },
); );
@@ -447,10 +274,7 @@ mod tests {
/// Router with streaming endpoints (query auth allowed) and regular /// Router with streaming endpoints (query auth allowed) and regular
/// endpoints (query auth rejected). /// endpoints (query auth rejected).
fn test_app(token: &str) -> Router { fn test_app(token: &str) -> Router {
let state = CombinedAuthState::from(MultiAuthState::single( let state = MultiAuthState::single(token.to_string(), "test-user".to_string());
token.to_string(),
"test-user".to_string(),
));
Router::new() Router::new()
.route("/api/chat/events", get(dummy_handler)) .route("/api/chat/events", get(dummy_handler))
.route("/api/logs/events", get(dummy_handler)) .route("/api/logs/events", get(dummy_handler))
@@ -662,7 +486,7 @@ mod tests {
/// Build a multi-user router where each token maps to a distinct identity. /// Build a multi-user router where each token maps to a distinct identity.
fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router { fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router {
let state = CombinedAuthState::from(MultiAuthState::multi(tokens)); let state = MultiAuthState::multi(tokens);
Router::new() Router::new()
.route("/api/chat/events", get(identity_handler)) .route("/api/chat/events", get(identity_handler))
.route("/api/chat/send", post(identity_handler)) .route("/api/chat/send", post(identity_handler))
@@ -676,7 +500,6 @@ mod tests {
"tok-alice".to_string(), "tok-alice".to_string(),
UserIdentity { UserIdentity {
user_id: "alice".to_string(), user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()], workspace_read_scopes: vec!["shared".to_string()],
}, },
); );
@@ -684,7 +507,6 @@ mod tests {
"tok-bob".to_string(), "tok-bob".to_string(),
UserIdentity { UserIdentity {
user_id: "bob".to_string(), user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()], workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
}, },
); );
@@ -821,10 +643,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_multi_user_empty_scopes_for_single_user() { async fn test_multi_user_empty_scopes_for_single_user() {
// Single-user mode creates identity with empty workspace_read_scopes. // Single-user mode creates identity with empty workspace_read_scopes.
let state = CombinedAuthState::from(MultiAuthState::single( let state = MultiAuthState::single("tok-only".to_string(), "solo".to_string());
"tok-only".to_string(),
"solo".to_string(),
));
let app = Router::new() let app = Router::new()
.route("/api/scopes", get(scopes_handler)) .route("/api/scopes", get(scopes_handler))
.layer(middleware::from_fn_with_state(state, auth_middleware)); .layer(middleware::from_fn_with_state(state, auth_middleware));
-3
View File
@@ -5,10 +5,7 @@
pub mod jobs; pub mod jobs;
pub mod memory; pub mod memory;
pub mod routines; pub mod routines;
pub mod secrets;
pub mod skills; pub mod skills;
pub mod tokens;
pub mod users;
// Modules not yet wired into server.rs router -- suppress dead_code until // Modules not yet wired into server.rs router -- suppress dead_code until
// they replace their inline counterparts. // they replace their inline counterparts.
+52 -9
View File
@@ -10,7 +10,10 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use crate::agent::routine::{Trigger, next_cron_fire}; use crate::agent::routine::{
RoutineDisplayStatus, RoutineVerificationStatus, Trigger, next_cron_fire,
routine_display_status_for_verification, routine_verification_status,
};
use crate::channels::web::auth::AuthenticatedUser; use crate::channels::web::auth::AuthenticatedUser;
use crate::channels::web::server::GatewayState; use crate::channels::web::server::GatewayState;
use crate::channels::web::types::*; use crate::channels::web::types::*;
@@ -30,7 +33,18 @@ pub async fn routines_list_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect(); let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let items: Vec<RoutineInfo> = routines
.iter()
.map(|routine| {
RoutineInfo::from_routine(routine, last_run_statuses.get(&routine.id).copied())
})
.collect();
Ok(Json(RoutineListResponse { routines: items })) Ok(Json(RoutineListResponse { routines: items }))
} }
@@ -49,13 +63,39 @@ pub async fn routines_summary_handler(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let total = routines.len() as u64; let total = routines.len() as u64;
let enabled = routines.iter().filter(|r| r.enabled).count() as u64; let mut enabled = 0u64;
let disabled = total - enabled; let mut disabled = 0u64;
let failing = routines let mut unverified = 0u64;
.iter() let mut failing = 0u64;
.filter(|r| r.consecutive_failures > 0)
.count() as u64; for routine in &routines {
let verification_status = routine_verification_status(routine);
if routine.enabled {
enabled += 1;
} else {
disabled += 1;
}
if verification_status == RoutineVerificationStatus::Unverified {
unverified += 1;
}
if routine_display_status_for_verification(
routine,
verification_status,
last_run_statuses.get(&routine.id).copied(),
) == RoutineDisplayStatus::Failing
{
failing += 1;
}
}
let today_start = chrono::Utc::now() let today_start = chrono::Utc::now()
.date_naive() .date_naive()
@@ -74,6 +114,7 @@ pub async fn routines_summary_handler(
total, total,
enabled, enabled,
disabled, disabled,
unverified,
failing, failing,
runs_today, runs_today,
})) }))
@@ -120,7 +161,7 @@ pub async fn routines_detail_handler(
job_id: run.job_id, job_id: run.job_id,
}) })
.collect(); .collect();
let routine_info = RoutineInfo::from_routine(&routine); let routine_info = RoutineInfo::from_routine(&routine, runs.first().map(|run| run.status));
Ok(Json(RoutineDetailResponse { Ok(Json(RoutineDetailResponse {
id: routine.id, id: routine.id,
@@ -138,6 +179,8 @@ pub async fn routines_detail_handler(
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()), next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
run_count: routine.run_count, run_count: routine.run_count,
consecutive_failures: routine.consecutive_failures, consecutive_failures: routine.consecutive_failures,
status: routine_info.status.clone(),
verification_status: routine_info.verification_status.clone(),
created_at: routine.created_at.to_rfc3339(), created_at: routine.created_at.to_rfc3339(),
recent_runs, recent_runs,
})) }))
-134
View File
@@ -1,134 +0,0 @@
//! 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
@@ -1,150 +0,0 @@
//! 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
@@ -1,406 +0,0 @@
//! 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,
})))
}
+65 -28
View File
@@ -18,7 +18,6 @@ pub mod auth;
pub(crate) mod handlers; pub(crate) mod handlers;
pub mod log_layer; pub mod log_layer;
pub mod openai_compat; pub mod openai_compat;
pub mod responses_api;
pub mod server; pub mod server;
pub mod sse; pub mod sse;
pub mod types; pub mod types;
@@ -56,7 +55,7 @@ use crate::workspace::Workspace;
use self::log_layer::{LogBroadcaster, LogLevelHandle}; use self::log_layer::{LogBroadcaster, LogLevelHandle};
use self::auth::{CombinedAuthState, DbAuthenticator, MultiAuthState}; use self::auth::MultiAuthState;
use self::server::GatewayState; use self::server::GatewayState;
use self::sse::SseManager; use self::sse::SseManager;
use self::types::AppEvent; use self::types::AppEvent;
@@ -65,8 +64,8 @@ use self::types::AppEvent;
pub struct GatewayChannel { pub struct GatewayChannel {
config: GatewayConfig, config: GatewayConfig,
state: Arc<GatewayState>, state: Arc<GatewayState>,
/// Combined auth state: env-var tokens + optional DB-backed tokens. /// Multi-user auth state (replaces bare auth_token).
auth: CombinedAuthState, auth: MultiAuthState,
} }
impl GatewayChannel { impl GatewayChannel {
@@ -74,7 +73,7 @@ impl GatewayChannel {
/// ///
/// If no auth token is configured, generates a random one and prints it. /// If no auth token is configured, generates a random one and prints it.
/// Builds a single-user `MultiAuthState` from the config. /// Builds a single-user `MultiAuthState` from the config.
pub fn new(config: GatewayConfig, owner_id: String) -> Self { pub fn new(config: GatewayConfig) -> Self {
let auth_token = config.auth_token.clone().unwrap_or_else(|| { let auth_token = config.auth_token.clone().unwrap_or_else(|| {
use rand::RngCore; use rand::RngCore;
use rand::rngs::OsRng; use rand::rngs::OsRng;
@@ -83,10 +82,7 @@ impl GatewayChannel {
bytes.iter().map(|b| format!("{b:02x}")).collect() bytes.iter().map(|b| format!("{b:02x}")).collect()
}); });
let auth = CombinedAuthState { let auth = MultiAuthState::single(auth_token, config.user_id.clone());
env_auth: MultiAuthState::single(auth_token, owner_id.clone()),
db_auth: None,
};
let state = Arc::new(GatewayState { let state = Arc::new(GatewayState {
msg_tx: tokio::sync::RwLock::new(None), msg_tx: tokio::sync::RwLock::new(None),
@@ -102,7 +98,8 @@ impl GatewayChannel {
job_manager: None, job_manager: None,
prompt_queue: None, prompt_queue: None,
scheduler: None, scheduler: None,
owner_id, owner_id: config.user_id.clone(),
default_sender_id: config.user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())), ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
llm_provider: None, llm_provider: None,
@@ -116,7 +113,62 @@ impl GatewayChannel {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: server::ActiveConfigSnapshot::default(), active_config: server::ActiveConfigSnapshot::default(),
secrets_store: None, });
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
};
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()),
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),
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(),
}); });
Self { Self {
@@ -144,6 +196,7 @@ impl GatewayChannel {
prompt_queue: self.state.prompt_queue.clone(), prompt_queue: self.state.prompt_queue.clone(),
scheduler: self.state.scheduler.clone(), scheduler: self.state.scheduler.clone(),
owner_id: self.state.owner_id.clone(), owner_id: self.state.owner_id.clone(),
default_sender_id: self.state.default_sender_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: self.state.ws_tracker.clone(), ws_tracker: self.state.ws_tracker.clone(),
llm_provider: self.state.llm_provider.clone(), llm_provider: self.state.llm_provider.clone(),
@@ -157,7 +210,6 @@ impl GatewayChannel {
routine_engine: Arc::clone(&self.state.routine_engine), routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time, startup_time: self.state.startup_time,
active_config: self.state.active_config.clone(), active_config: self.state.active_config.clone(),
secrets_store: self.state.secrets_store.clone(),
}; };
mutate(&mut new_state); mutate(&mut new_state);
self.state = Arc::new(new_state); self.state = Arc::new(new_state);
@@ -205,12 +257,6 @@ impl GatewayChannel {
self 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. /// Inject the container job manager for sandbox operations.
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self { pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
self.rebuild_state(|s| s.job_manager = Some(jm)); self.rebuild_state(|s| s.job_manager = Some(jm));
@@ -281,15 +327,6 @@ impl GatewayChannel {
self 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. /// Inject the per-user workspace pool for multi-user mode.
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self { pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
self.rebuild_state(|s| s.workspace_pool = Some(pool)); self.rebuild_state(|s| s.workspace_pool = Some(pool));
@@ -298,7 +335,7 @@ impl GatewayChannel {
/// Get the first auth token (for printing to console on startup). /// Get the first auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str { pub fn auth_token(&self) -> &str {
self.auth.env_auth.first_token().unwrap_or("") self.auth.first_token().unwrap_or("")
} }
/// Get a reference to the shared gateway state (for the agent to push SSE events). /// 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
+13 -92
View File
@@ -16,7 +16,7 @@ use axum::{
IntoResponse, IntoResponse,
sse::{Event, KeepAlive, Sse}, sse::{Event, KeepAlive, Sse},
}, },
routing::{get, post, put}, routing::{get, post},
}; };
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -31,7 +31,7 @@ use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage; use crate::channels::IncomingMessage;
use crate::channels::relay::DEFAULT_RELAY_NAME; use crate::channels::relay::DEFAULT_RELAY_NAME;
use crate::channels::web::auth::{ use crate::channels::web::auth::{
AuthenticatedUser, CombinedAuthState, UserIdentity, auth_middleware, AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
}; };
use crate::channels::web::handlers::jobs::{ use crate::channels::web::handlers::jobs::{
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler, job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
@@ -347,6 +347,8 @@ pub struct GatewayState {
pub prompt_queue: Option<PromptQueue>, pub prompt_queue: Option<PromptQueue>,
/// Durable owner scope for persistence and unauthenticated callback flows. /// Durable owner scope for persistence and unauthenticated callback flows.
pub owner_id: String, pub owner_id: String,
/// Default sender/routing identity for gateway-originated messages.
pub default_sender_id: String,
/// Shutdown signal sender. /// Shutdown signal sender.
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>, pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
/// WebSocket connection tracker. /// WebSocket connection tracker.
@@ -376,8 +378,6 @@ pub struct GatewayState {
pub startup_time: std::time::Instant, pub startup_time: std::time::Instant,
/// Snapshot of active (resolved) configuration for the frontend. /// Snapshot of active (resolved) configuration for the frontend.
pub active_config: ActiveConfigSnapshot, pub active_config: ActiveConfigSnapshot,
/// Secrets store for admin secret provisioning.
pub secrets_store: Option<Arc<dyn crate::secrets::SecretsStore + Send + Sync>>,
} }
/// Start the gateway HTTP server. /// Start the gateway HTTP server.
@@ -386,7 +386,7 @@ pub struct GatewayState {
pub async fn start_server( pub async fn start_server(
addr: SocketAddr, addr: SocketAddr,
state: Arc<GatewayState>, state: Arc<GatewayState>,
auth: CombinedAuthState, auth: MultiAuthState,
) -> Result<SocketAddr, crate::error::ChannelError> { ) -> Result<SocketAddr, crate::error::ChannelError> {
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
crate::error::ChannelError::StartupFailed { crate::error::ChannelError::StartupFailed {
@@ -512,57 +512,6 @@ pub async fn start_server(
"/api/settings/{key}", "/api/settings/{key}",
axum::routing::delete(settings_delete_handler), axum::routing::delete(settings_delete_handler),
) )
// User management (admin)
.route(
"/api/admin/users",
get(super::handlers::users::users_list_handler)
.post(super::handlers::users::users_create_handler),
)
.route(
"/api/admin/users/{id}",
get(super::handlers::users::users_detail_handler)
.patch(super::handlers::users::users_update_handler)
.delete(super::handlers::users::users_delete_handler),
)
.route(
"/api/admin/users/{id}/suspend",
post(super::handlers::users::users_suspend_handler),
)
.route(
"/api/admin/users/{id}/activate",
post(super::handlers::users::users_activate_handler),
)
// Admin secrets provisioning (per-user)
.route(
"/api/admin/users/{user_id}/secrets",
get(super::handlers::secrets::secrets_list_handler),
)
.route(
"/api/admin/users/{user_id}/secrets/{name}",
put(super::handlers::secrets::secrets_put_handler)
.delete(super::handlers::secrets::secrets_delete_handler),
)
// Usage reporting (admin)
.route(
"/api/admin/usage",
get(super::handlers::users::usage_stats_handler),
)
// User self-service profile
.route(
"/api/profile",
get(super::handlers::users::profile_get_handler)
.patch(super::handlers::users::profile_update_handler),
)
// Token management
.route(
"/api/tokens",
get(super::handlers::tokens::tokens_list_handler)
.post(super::handlers::tokens::tokens_create_handler),
)
.route(
"/api/tokens/{id}",
axum::routing::delete(super::handlers::tokens::tokens_revoke_handler),
)
// Gateway control plane // Gateway control plane
.route("/api/gateway/status", get(gateway_status_handler)) .route("/api/gateway/status", get(gateway_status_handler))
// OpenAI-compatible API // OpenAI-compatible API
@@ -571,15 +520,6 @@ pub async fn start_server(
post(super::openai_compat::chat_completions_handler), post(super::openai_compat::chat_completions_handler),
) )
.route("/v1/models", get(super::openai_compat::models_handler)) .route("/v1/models", get(super::openai_compat::models_handler))
// OpenAI Responses API (routes through the full agent loop)
.route(
"/v1/responses",
post(super::responses_api::create_response_handler),
)
.route(
"/v1/responses/{id}",
get(super::responses_api::get_response_handler),
)
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
auth_state.clone(), auth_state.clone(),
auth_middleware, auth_middleware,
@@ -622,7 +562,6 @@ pub async fn start_server(
axum::http::Method::GET, axum::http::Method::GET,
axum::http::Method::POST, axum::http::Method::POST,
axum::http::Method::PUT, axum::http::Method::PUT,
axum::http::Method::PATCH,
axum::http::Method::DELETE, axum::http::Method::DELETE,
]) ])
.allow_headers(AllowHeaders::list([ .allow_headers(AllowHeaders::list([
@@ -637,25 +576,6 @@ pub async fn start_server(
.merge(projects) .merge(projects)
.merge(protected) .merge(protected)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads)
.layer(tower_http::catch_panic::CatchPanicLayer::custom(
|panic_info: Box<dyn std::any::Any + Send + 'static>| {
let detail = if let Some(s) = panic_info.downcast_ref::<String>() {
s.clone()
} else if let Some(s) = panic_info.downcast_ref::<&str>() {
(*s).to_string()
} else {
"unknown panic".to_string()
};
tracing::error!("Handler panicked: {}", detail);
axum::http::Response::builder()
.status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
.header("content-type", "text/plain")
.body(axum::body::Body::from("Internal Server Error"))
.unwrap_or_else(|_| {
axum::http::Response::new(axum::body::Body::from("Internal Server Error"))
})
},
))
.layer(cors) .layer(cors)
.layer(SetResponseHeaderLayer::if_not_present( .layer(SetResponseHeaderLayer::if_not_present(
header::X_CONTENT_TYPE_OPTIONS, header::X_CONTENT_TYPE_OPTIONS,
@@ -1383,6 +1303,9 @@ async fn chat_send_handler(
} }
let mut msg = IncomingMessage::new("gateway", &user.user_id, &req.content); let mut msg = IncomingMessage::new("gateway", &user.user_id, &req.content);
if state.owner_id != state.default_sender_id && user.user_id == state.owner_id {
msg = msg.with_sender_id(&state.default_sender_id);
}
// Prefer timezone from JSON body, fall back to X-Timezone header // Prefer timezone from JSON body, fall back to X-Timezone header
let tz = req let tz = req
.timezone .timezone
@@ -1484,6 +1407,9 @@ async fn chat_approval_handler(
})?; })?;
let mut msg = IncomingMessage::new("gateway", &user.user_id, content); let mut msg = IncomingMessage::new("gateway", &user.user_id, content);
if state.owner_id != state.default_sender_id && user.user_id == state.owner_id {
msg = msg.with_sender_id(&state.default_sender_id);
}
if let Some(ref thread_id) = req.thread_id { if let Some(ref thread_id) = req.thread_id {
msg = msg.with_thread(thread_id); msg = msg.with_thread(thread_id);
@@ -3059,6 +2985,7 @@ mod tests {
job_manager: None, job_manager: None,
prompt_queue: None, prompt_queue: None,
owner_id: "test".to_string(), owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None, ws_tracker: None,
llm_provider: None, llm_provider: None,
@@ -3073,7 +3000,6 @@ mod tests {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(), active_config: ActiveConfigSnapshot::default(),
secrets_store: None,
}) })
} }
@@ -3140,7 +3066,6 @@ mod tests {
// without needing the full auth middleware layer. // without needing the full auth middleware layer.
req.extensions_mut().insert(UserIdentity { req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(), user_id: "test".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
}); });
@@ -3225,7 +3150,6 @@ mod tests {
// without needing the full auth middleware layer. // without needing the full auth middleware layer.
req.extensions_mut().insert(UserIdentity { req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(), user_id: "test".to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
}); });
@@ -3275,10 +3199,7 @@ mod tests {
let state = test_gateway_state(None); let state = test_gateway_state(None);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let auth = CombinedAuthState::from(crate::channels::web::auth::MultiAuthState::single( let auth = MultiAuthState::single("test-token".to_string(), "test".to_string());
"test-token".to_string(),
"test".to_string(),
));
let bound = start_server(addr, state.clone(), auth) let bound = start_server(addr, state.clone(), auth)
.await .await
.expect("server should start"); .expect("server should start");
+27 -151
View File
@@ -186,13 +186,6 @@ function authenticate() {
connectSSE(); connectSSE();
connectLogSSE(); connectLogSSE();
startGatewayStatusPolling(); 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(); checkTeeStatus();
loadThreads(); loadThreads();
loadMemoryTree(); loadMemoryTree();
@@ -4148,6 +4141,7 @@ function renderRoutinesSummary(s) {
+ summaryCard(I18n.t('routines.summary.total'), s.total, '') + summaryCard(I18n.t('routines.summary.total'), s.total, '')
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active') + summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '') + summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
+ summaryCard(I18n.t('routines.summary.unverified'), s.unverified, 'pending')
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed') + summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed'); + summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
} }
@@ -4166,6 +4160,8 @@ function renderRoutinesList(routines) {
tbody.innerHTML = routines.map((r) => { tbody.innerHTML = routines.map((r) => {
const statusClass = r.status === 'active' ? 'completed' const statusClass = r.status === 'active' ? 'completed'
: r.status === 'failing' ? 'failed' : r.status === 'failing' ? 'failed'
: r.status === 'attention' ? 'stuck'
: r.status === 'running' ? 'in_progress'
: 'pending'; : 'pending';
const toggleLabel = r.enabled ? 'Disable' : 'Enable'; const toggleLabel = r.enabled ? 'Disable' : 'Enable';
@@ -4173,6 +4169,9 @@ function renderRoutinesList(routines) {
const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw) const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw)
? ' title="' + escapeHtml(r.trigger_raw) + '"' ? ' title="' + escapeHtml(r.trigger_raw) + '"'
: ''; : '';
const runLabel = (r.verification_status === 'unverified' || r.status === 'unverified')
? 'Verify now'
: 'Run';
return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">' return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
+ '<td>' + escapeHtml(r.name) + '</td>' + '<td>' + escapeHtml(r.name) + '</td>'
@@ -4184,7 +4183,7 @@ function renderRoutinesList(routines) {
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>' + '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
+ '<td>' + '<td>'
+ '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> ' + '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> '
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">Run</button> ' + '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">' + runLabel + '</button> '
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>' + '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
+ '</td>' + '</td>'
+ '</tr>'; + '</tr>';
@@ -4213,12 +4212,12 @@ function renderRoutineDetail(routine) {
const detail = document.getElementById('routine-detail'); const detail = document.getElementById('routine-detail');
detail.style.display = 'block'; detail.style.display = 'block';
const statusClass = !routine.enabled ? 'pending' const statusClass = routine.status === 'active' ? 'completed'
: routine.consecutive_failures > 0 ? 'failed' : routine.status === 'failing' ? 'failed'
: 'completed'; : routine.status === 'attention' ? 'stuck'
const statusLabel = !routine.enabled ? 'disabled' : routine.status === 'running' ? 'in_progress'
: routine.consecutive_failures > 0 ? 'failing' : 'pending';
: 'active'; const statusLabel = routine.status || 'active';
let html = '<div class="job-detail-header">' let html = '<div class="job-detail-header">'
+ '<button class="btn-back" data-action="close-routine-detail">&larr; Back</button>' + '<button class="btn-back" data-action="close-routine-detail">&larr; Back</button>'
@@ -4243,6 +4242,20 @@ function renderRoutineDetail(routine) {
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>'; + '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
} }
if (routine.verification_status === 'unverified') {
let verificationCopy = 'Created or updated, but not yet verified with a successful run.';
if (routine.recent_runs && routine.recent_runs.length > 0) {
const latestRun = routine.recent_runs[0];
if (latestRun.status === 'failed') {
verificationCopy = 'The latest verification attempt failed. Review the run details and verify again after fixing it.';
} else if (latestRun.status === 'attention') {
verificationCopy = 'The latest verification attempt needs attention. Review the run details and verify again when ready.';
}
}
html += '<div class="job-description"><h3>Verification</h3>'
+ '<div class="job-description-body">' + escapeHtml(verificationCopy) + '</div></div>';
}
// Trigger config // Trigger config
if (routine.trigger_type === 'cron') { if (routine.trigger_type === 'cron') {
const summary = routine.trigger_summary || 'cron'; const summary = routine.trigger_summary || 'cron';
@@ -4346,142 +4359,6 @@ function formatRelativeTime(isoString) {
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days }); 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 --- // --- Gateway status widget ---
let gatewayStatusInterval = null; let gatewayStatusInterval = null;
@@ -5171,7 +5048,6 @@ function loadSettingsSubtab(subtab) {
else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); } else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
else if (subtab === 'mcp') loadMcpServers(); else if (subtab === 'mcp') loadMcpServers();
else if (subtab === 'skills') loadSkills(); else if (subtab === 'skills') loadSkills();
else if (subtab === 'users') loadUsers();
if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll(); if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
} }
+4 -2
View File
@@ -9,6 +9,8 @@ I18n.register('en', {
'auth.connect': 'Connect', 'auth.connect': 'Connect',
'auth.errorRequired': 'Token required', 'auth.errorRequired': 'Token required',
'auth.errorInvalid': 'Invalid token', 'auth.errorInvalid': 'Invalid token',
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
// Chat // Chat
'chat.inputPlaceholder': 'Message or / for commands...', 'chat.inputPlaceholder': 'Message or / for commands...',
@@ -42,8 +44,7 @@ I18n.register('en', {
'settings.channels': 'Channels', 'settings.channels': 'Channels',
'settings.networking': 'Networking', 'settings.networking': 'Networking',
'settings.mcp': 'MCP', 'settings.mcp': 'MCP',
'settings.users': 'Users',
// Status // Status
'status.connected': 'Connected', 'status.connected': 'Connected',
'status.disconnected': 'Disconnected', 'status.disconnected': 'Disconnected',
@@ -206,6 +207,7 @@ I18n.register('en', {
'routines.summary.total': 'Total', 'routines.summary.total': 'Total',
'routines.summary.enabled': 'Enabled', 'routines.summary.enabled': 'Enabled',
'routines.summary.disabled': 'Disabled', 'routines.summary.disabled': 'Disabled',
'routines.summary.unverified': 'Unverified',
'routines.summary.failing': 'Failing', 'routines.summary.failing': 'Failing',
'routines.summary.runsToday': 'Runs Today', 'routines.summary.runsToday': 'Runs Today',
+4 -2
View File
@@ -9,6 +9,8 @@ I18n.register('zh-CN', {
'auth.connect': '连接', 'auth.connect': '连接',
'auth.errorRequired': '请输入令牌', 'auth.errorRequired': '请输入令牌',
'auth.errorInvalid': '令牌无效', 'auth.errorInvalid': '令牌无效',
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
// 聊天 // 聊天
'chat.inputPlaceholder': '输入消息或 / 以使用命令...', 'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
@@ -42,8 +44,7 @@ I18n.register('zh-CN', {
'settings.channels': '频道', 'settings.channels': '频道',
'settings.networking': '网络', 'settings.networking': '网络',
'settings.mcp': 'MCP', 'settings.mcp': 'MCP',
'settings.users': '用户管理',
// 状态 // 状态
'status.connected': '已连接', 'status.connected': '已连接',
'status.disconnected': '已断开', 'status.disconnected': '已断开',
@@ -206,6 +207,7 @@ I18n.register('zh-CN', {
'routines.summary.total': '总计', 'routines.summary.total': '总计',
'routines.summary.enabled': '已启用', 'routines.summary.enabled': '已启用',
'routines.summary.disabled': '已禁用', 'routines.summary.disabled': '已禁用',
'routines.summary.unverified': '未验证',
'routines.summary.failing': '失败', 'routines.summary.failing': '失败',
'routines.summary.runsToday': '今日运行', 'routines.summary.runsToday': '今日运行',
+1 -24
View File
@@ -41,6 +41,7 @@
<button id="auth-connect-btn" data-i18n="auth.connect">Connect</button> <button id="auth-connect-btn" data-i18n="auth.connect">Connect</button>
</div> </div>
<div id="auth-error"></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>
</div> </div>
@@ -292,7 +293,6 @@
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button> <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="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="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> <button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
</div> </div>
<div class="settings-content"> <div class="settings-content">
@@ -390,29 +390,6 @@
</div> </div>
</div> </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> </div>
</div> </div>
-19
View File
@@ -5429,22 +5429,3 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
--text-muted: #a1a1aa; --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,6 +77,7 @@ impl TestGatewayBuilder {
job_manager: None, job_manager: None,
prompt_queue: None, prompt_queue: None,
owner_id: self.user_id.clone(), owner_id: self.user_id.clone(),
default_sender_id: self.user_id,
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: self.llm_provider, llm_provider: self.llm_provider,
@@ -91,7 +92,6 @@ impl TestGatewayBuilder {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(), 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" let addr: SocketAddr = "127.0.0.1:0"
.parse() .parse()
.expect("hard-coded address must parse"); // safety: constant literal .expect("hard-coded address must parse"); // safety: constant literal
let bound = start_server(addr, state.clone(), auth.into()).await?; let bound = start_server(addr, state.clone(), auth).await?;
Ok((bound, state)) Ok((bound, state))
} }
@@ -120,7 +120,7 @@ impl TestGatewayBuilder {
let addr: SocketAddr = "127.0.0.1:0" let addr: SocketAddr = "127.0.0.1:0"
.parse() .parse()
.expect("hard-coded address must parse"); // safety: constant literal .expect("hard-coded address must parse"); // safety: constant literal
let bound = start_server(addr, state.clone(), auth.into()).await?; let bound = start_server(addr, state.clone(), auth).await?;
Ok((bound, state)) Ok((bound, state))
} }
} }
+40 -158
View File
@@ -16,6 +16,7 @@ use axum::routing::{delete, get, post};
use tower::ServiceExt; use tower::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
use crate::channels::web::GatewayChannel;
use crate::channels::web::auth::{ use crate::channels::web::auth::{
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware, AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
}; };
@@ -23,6 +24,7 @@ use crate::channels::web::server::{
ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool, ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool,
}; };
use crate::channels::web::sse::SseManager; use crate::channels::web::sse::SseManager;
use crate::config::GatewayConfig;
// ── Helpers ──────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────
@@ -33,7 +35,6 @@ fn two_user_auth() -> MultiAuthState {
"tok-alice".to_string(), "tok-alice".to_string(),
UserIdentity { UserIdentity {
user_id: "alice".to_string(), user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()], workspace_read_scopes: vec!["shared".to_string()],
}, },
); );
@@ -41,7 +42,6 @@ fn two_user_auth() -> MultiAuthState {
"tok-bob".to_string(), "tok-bob".to_string(),
UserIdentity { UserIdentity {
user_id: "bob".to_string(), user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()], workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
}, },
); );
@@ -67,6 +67,7 @@ fn build_state(
job_manager: None, job_manager: None,
prompt_queue, prompt_queue,
owner_id: "test".to_string(), owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: None, ws_tracker: None,
llm_provider: None, llm_provider: None,
@@ -81,10 +82,43 @@ fn build_state(
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: ActiveConfigSnapshot::default(), 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. /// Create a libSQL-backed test database in a temporary directory.
/// ///
/// Returns the database and a `TempDir` guard — the database file is /// Returns the database and a `TempDir` guard — the database file is
@@ -191,7 +225,6 @@ mod workspace_pool {
); );
let identity = UserIdentity { let identity = UserIdentity {
user_id: "alice".to_string(), user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![], workspace_read_scopes: vec![],
}; };
let ws = pool.get_or_create(&identity).await; let ws = pool.get_or_create(&identity).await;
@@ -220,7 +253,6 @@ mod workspace_pool {
); );
let identity = UserIdentity { let identity = UserIdentity {
user_id: "alice".to_string(), user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![], workspace_read_scopes: vec![],
}; };
let ws = pool.get_or_create(&identity).await; let ws = pool.get_or_create(&identity).await;
@@ -244,7 +276,6 @@ mod workspace_pool {
); );
let identity = UserIdentity { let identity = UserIdentity {
user_id: "bob".to_string(), user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()], workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()],
}; };
let ws = pool.get_or_create(&identity).await; let ws = pool.get_or_create(&identity).await;
@@ -271,12 +302,10 @@ mod workspace_pool {
); );
let alice_id = UserIdentity { let alice_id = UserIdentity {
user_id: "alice".to_string(), user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![], workspace_read_scopes: vec![],
}; };
let bob_id = UserIdentity { let bob_id = UserIdentity {
user_id: "bob".to_string(), user_id: "bob".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec![], workspace_read_scopes: vec![],
}; };
@@ -308,7 +337,6 @@ mod workspace_pool {
); );
let identity = UserIdentity { let identity = UserIdentity {
user_id: "alice".to_string(), user_id: "alice".to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["token-scope".to_string()], workspace_read_scopes: vec!["token-scope".to_string()],
}; };
let ws = pool.get_or_create(&identity).await; let ws = pool.get_or_create(&identity).await;
@@ -349,10 +377,7 @@ mod jobs_isolation {
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler)) .route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
.route("/api/jobs/{id}/restart", post(jobs_restart_handler)) .route("/api/jobs/{id}/restart", post(jobs_restart_handler))
.route("/api/jobs/{id}/prompt", post(jobs_prompt_handler)) .route("/api/jobs/{id}/prompt", post(jobs_prompt_handler))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(auth, auth_middleware))
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state) .with_state(state)
} }
@@ -558,10 +583,7 @@ mod routines_isolation {
.route("/api/routines/{id}", get(routines_detail_handler)) .route("/api/routines/{id}", get(routines_detail_handler))
.route("/api/routines/{id}/toggle", post(routines_toggle_handler)) .route("/api/routines/{id}/toggle", post(routines_toggle_handler))
.route("/api/routines/{id}", delete(routines_delete_handler)) .route("/api/routines/{id}", delete(routines_delete_handler))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(auth, auth_middleware))
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state) .with_state(state)
} }
@@ -686,10 +708,7 @@ mod auth_enforcement {
.route("/api/logs/level", get(authed_handler).put(authed_handler)) .route("/api/logs/level", get(authed_handler).put(authed_handler))
// Gateway status // Gateway status
.route("/api/gateway/status", get(authed_handler)) .route("/api/gateway/status", get(authed_handler))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(auth, auth_middleware))
crate::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
.with_state(state) .with_state(state)
} }
@@ -812,140 +831,3 @@ mod auth_enforcement {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 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
}
}
}
+143 -8
View File
@@ -662,11 +662,15 @@ pub struct RoutineInfo {
pub run_count: u64, pub run_count: u64,
pub consecutive_failures: u32, pub consecutive_failures: u32,
pub status: String, pub status: String,
pub verification_status: String,
} }
impl RoutineInfo { impl RoutineInfo {
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display. /// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
pub fn from_routine(r: &crate::agent::routine::Routine) -> Self { pub fn from_routine(
r: &crate::agent::routine::Routine,
last_run_status: Option<crate::agent::routine::RunStatus>,
) -> Self {
let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger { let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger {
crate::agent::routine::Trigger::Cron { schedule, timezone } => ( crate::agent::routine::Trigger::Cron { schedule, timezone } => (
"cron".to_string(), "cron".to_string(),
@@ -710,13 +714,13 @@ impl RoutineInfo {
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job", crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
}; };
let status = if !r.enabled { let verification_status = crate::agent::routine::routine_verification_status(r);
"disabled" let status = crate::agent::routine::routine_display_status_for_verification(
} else if r.consecutive_failures > 0 { r,
"failing" verification_status,
} else { last_run_status,
"active" )
}; .as_str();
RoutineInfo { RoutineInfo {
id: r.id, id: r.id,
@@ -732,6 +736,7 @@ impl RoutineInfo {
run_count: r.run_count, run_count: r.run_count,
consecutive_failures: r.consecutive_failures, consecutive_failures: r.consecutive_failures,
status: status.to_string(), status: status.to_string(),
verification_status: verification_status.as_str().to_string(),
} }
} }
} }
@@ -746,6 +751,7 @@ pub struct RoutineSummaryResponse {
pub total: u64, pub total: u64,
pub enabled: u64, pub enabled: u64,
pub disabled: u64, pub disabled: u64,
pub unverified: u64,
pub failing: u64, pub failing: u64,
pub runs_today: u64, pub runs_today: u64,
} }
@@ -767,6 +773,8 @@ pub struct RoutineDetailResponse {
pub next_fire_at: Option<String>, pub next_fire_at: Option<String>,
pub run_count: u64, pub run_count: u64,
pub consecutive_failures: u32, pub consecutive_failures: u32,
pub status: String,
pub verification_status: String,
pub created_at: String, pub created_at: String,
pub recent_runs: Vec<RoutineRunInfo>, pub recent_runs: Vec<RoutineRunInfo>,
} }
@@ -823,6 +831,7 @@ pub struct HealthResponse {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
// ---- WsClientMessage deserialization tests ---- // ---- WsClientMessage deserialization tests ----
@@ -1173,4 +1182,130 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.get("channel").is_none()); assert!(parsed.get("channel").is_none());
} }
fn make_routine_for_status_tests() -> crate::agent::routine::Routine {
crate::agent::routine::Routine {
id: Uuid::new_v4(),
name: "status-check".to_string(),
description: "routine status test".to_string(),
user_id: "test-user".to_string(),
enabled: true,
trigger: crate::agent::routine::Trigger::Manual,
action: crate::agent::routine::RoutineAction::Lightweight {
prompt: "Check status".to_string(),
context_paths: Vec::new(),
max_tokens: 256,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: crate::agent::routine::RoutineGuardrails::default(),
notify: crate::agent::routine::NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_routine_info_marks_new_routine_unverified() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
let info = RoutineInfo::from_routine(&routine, None);
assert_eq!(info.status, "unverified");
assert_eq!(info.verification_status, "unverified");
}
#[test]
fn test_routine_info_preserves_verified_state_for_description_only_changes() {
let mut routine = make_routine_for_status_tests();
let fingerprint = crate::agent::routine::routine_verification_fingerprint(&routine);
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
fingerprint.clone(),
);
routine.state = crate::agent::routine::apply_routine_verification_result(
&routine.state,
fingerprint,
crate::agent::routine::RunStatus::Ok,
Utc::now(),
);
routine.description = "Updated description".to_string();
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "active");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_surfaces_running_before_unverified() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
let info =
RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Running));
assert_eq!(info.status, "running");
assert_eq!(info.verification_status, "unverified");
}
#[test]
fn test_routine_info_keeps_verified_state_when_disabled() {
let mut routine = make_routine_for_status_tests();
let fingerprint = crate::agent::routine::routine_verification_fingerprint(&routine);
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
fingerprint.clone(),
);
routine.state = crate::agent::routine::apply_routine_verification_result(
&routine.state,
fingerprint,
crate::agent::routine::RunStatus::Ok,
Utc::now(),
);
routine.enabled = false;
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "disabled");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_treats_legacy_run_history_as_verified() {
let mut routine = make_routine_for_status_tests();
routine.run_count = 2;
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
assert_eq!(info.status, "active");
assert_eq!(info.verification_status, "verified");
}
#[test]
fn test_routine_info_keeps_unverified_state_when_disabled() {
let mut routine = make_routine_for_status_tests();
routine.state = crate::agent::routine::reset_routine_verification_state(
&routine.state,
crate::agent::routine::routine_verification_fingerprint(&routine),
);
routine.enabled = false;
let info = RoutineInfo::from_routine(&routine, None);
assert_eq!(info.status, "disabled");
assert_eq!(info.verification_status, "unverified");
}
} }
+1 -1
View File
@@ -521,6 +521,7 @@ mod tests {
prompt_queue: None, prompt_queue: None,
scheduler: None, scheduler: None,
owner_id: "test".to_string(), owner_id: "test".to_string(),
default_sender_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, llm_provider: None,
@@ -534,7 +535,6 @@ mod tests {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(), active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
} }
} }
} }
+1 -2
View File
@@ -352,8 +352,7 @@ pub async fn run_routines_cli(
.await .await
.map_err(|e| anyhow::anyhow!("{e:#}"))?; .map_err(|e| anyhow::anyhow!("{e:#}"))?;
let user_id = let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string());
std::env::var("IRONCLAW_OWNER_ID").unwrap_or_else(|_| "default".to_string());
run_routines_command(routines_cmd.clone(), db, &user_id).await run_routines_command(routines_cmd.clone(), db, &user_id).await
} }
+5 -10
View File
@@ -1,6 +1,6 @@
use std::time::Duration; use std::time::Duration;
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env}; use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
use crate::error::ConfigError; use crate::error::ConfigError;
use crate::settings::Settings; use crate::settings::Settings;
@@ -31,13 +31,10 @@ pub struct AgentConfig {
pub auto_approve_tools: bool, pub auto_approve_tools: bool,
/// Default timezone for new sessions (IANA name, e.g. "America/New_York"). /// Default timezone for new sessions (IANA name, e.g. "America/New_York").
pub default_timezone: String, 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). /// Maximum tokens per job (0 = unlimited).
pub max_tokens_per_job: u64, pub max_tokens_per_job: u64,
/// Whether the deployment is multi-tenant (multiple users sharing one /// Whether the deployment is multi-tenant (multiple users sharing one
/// instance). Detected at runtime after DB initialization, not from config. /// instance). Auto-detected from GATEWAY_USER_TOKENS presence.
/// See app.rs startup logic.
pub multi_tenant: bool, pub multi_tenant: bool,
/// Maximum concurrent LLM calls per user. None = use default (4). /// Maximum concurrent LLM calls per user. None = use default (4).
pub max_llm_concurrent_per_user: Option<usize>, pub max_llm_concurrent_per_user: Option<usize>,
@@ -65,7 +62,6 @@ impl AgentConfig {
max_tool_iterations: 10, max_tool_iterations: 10,
auto_approve_tools: true, auto_approve_tools: true,
default_timezone: "UTC".to_string(), default_timezone: "UTC".to_string(),
max_jobs_per_user: None,
max_tokens_per_job: 0, max_tokens_per_job: 0,
multi_tenant: false, multi_tenant: false,
max_llm_concurrent_per_user: None, max_llm_concurrent_per_user: None,
@@ -126,14 +122,13 @@ impl AgentConfig {
} }
tz tz
}, },
max_jobs_per_user: parse_option_env("MAX_JOBS_PER_USER")?,
max_tokens_per_job: parse_optional_env( max_tokens_per_job: parse_optional_env(
"AGENT_MAX_TOKENS_PER_JOB", "AGENT_MAX_TOKENS_PER_JOB",
settings.agent.max_tokens_per_job, settings.agent.max_tokens_per_job,
)?, )?,
// Multi-tenant mode is detected at runtime after DB initialization, // Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
// not from config. See app.rs startup logic. // knob — multi-tenant mode is always implied by configuring user tokens.
multi_tenant: false, multi_tenant: optional_env("GATEWAY_USER_TOKENS")?.is_some(),
max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?, max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?,
max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?, max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?,
}) })
+64 -2
View File
@@ -1,11 +1,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use secrecy::SecretString;
use serde::Deserialize;
use crate::bootstrap::ironclaw_base_dir; use crate::bootstrap::ironclaw_base_dir;
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env}; use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
use crate::error::ConfigError; use crate::error::ConfigError;
use crate::settings::Settings; use crate::settings::Settings;
use secrecy::SecretString;
/// Channel configurations. /// Channel configurations.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -43,6 +45,7 @@ pub struct GatewayConfig {
pub port: u16, pub port: u16,
/// Bearer token for authentication. Random hex generated at startup if unset. /// Bearer token for authentication. Random hex generated at startup if unset.
pub auth_token: Option<String>, pub auth_token: Option<String>,
pub user_id: String,
/// Additional user scopes for workspace reads. /// Additional user scopes for workspace reads.
/// ///
/// When set, the workspace will be able to read (search, read, list) from /// When set, the workspace will be able to read (search, read, list) from
@@ -51,6 +54,18 @@ pub struct GatewayConfig {
pub workspace_read_scopes: Vec<String>, pub workspace_read_scopes: Vec<String>,
/// Memory layer definitions (JSON in env var, or from external config). /// Memory layer definitions (JSON in env var, or from external config).
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>, 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). /// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
@@ -117,6 +132,10 @@ impl ChannelsConfig {
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?; let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if 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> = let memory_layers: Vec<crate::workspace::layer::MemoryLayer> =
match optional_env("MEMORY_LAYERS")? { match optional_env("MEMORY_LAYERS")? {
Some(json_str) => { Some(json_str) => {
@@ -125,7 +144,7 @@ impl ChannelsConfig {
message: format!("must be valid JSON array of layer objects: {e}"), message: format!("must be valid JSON array of layer objects: {e}"),
})? })?
} }
None => crate::workspace::layer::MemoryLayer::default_for_user(owner_id), None => crate::workspace::layer::MemoryLayer::default_for_user(&user_id),
}; };
// Validate layer names and scopes // Validate layer names and scopes
@@ -177,6 +196,41 @@ 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")? let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
.map(|s| { .map(|s| {
s.split(',') s.split(',')
@@ -204,8 +258,10 @@ impl ChannelsConfig {
)?, )?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")? auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()), .or_else(|| cs.gateway_auth_token.clone()),
user_id,
workspace_read_scopes, workspace_read_scopes,
memory_layers, memory_layers,
user_tokens,
}) })
} else { } else {
None None
@@ -360,12 +416,15 @@ mod tests {
host: "127.0.0.1".to_string(), host: "127.0.0.1".to_string(),
port: 3000, port: 3000,
auth_token: Some("tok-abc".to_string()), auth_token: Some("tok-abc".to_string()),
user_id: "default".to_string(),
workspace_read_scopes: vec![], workspace_read_scopes: vec![],
memory_layers: vec![], memory_layers: vec![],
user_tokens: None,
}; };
assert_eq!(cfg.host, "127.0.0.1"); assert_eq!(cfg.host, "127.0.0.1");
assert_eq!(cfg.port, 3000); assert_eq!(cfg.port, 3000);
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc")); assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
assert_eq!(cfg.user_id, "default");
} }
#[test] #[test]
@@ -374,8 +433,10 @@ mod tests {
host: "0.0.0.0".to_string(), host: "0.0.0.0".to_string(),
port: 3001, port: 3001,
auth_token: None, auth_token: None,
user_id: "anon".to_string(),
workspace_read_scopes: vec![], workspace_read_scopes: vec![],
memory_layers: vec![], memory_layers: vec![],
user_tokens: None,
}; };
assert!(cfg.auth_token.is_none()); assert!(cfg.auth_token.is_none());
} }
@@ -502,6 +563,7 @@ mod tests {
assert_eq!(gateway.host, "127.0.0.3"); assert_eq!(gateway.host, "127.0.0.3");
assert_eq!(gateway.port, 9191); assert_eq!(gateway.port, 9191);
assert_eq!(gateway.auth_token.as_deref(), Some("tok")); assert_eq!(gateway.auth_token.as_deref(), Some("tok"));
assert_eq!(gateway.user_id, "owner-scope");
let signal = cfg.signal.expect("signal config"); let signal = cfg.signal.expect("signal config");
assert_eq!(signal.account, "+15551234567"); assert_eq!(signal.account, "+15551234567");
+8 -3
View File
@@ -21,8 +21,8 @@ pub struct HeartbeatConfig {
pub quiet_hours_end: Option<u32>, pub quiet_hours_end: Option<u32>,
/// Timezone for fire_at and quiet hours evaluation (IANA name). /// Timezone for fire_at and quiet hours evaluation (IANA name).
pub timezone: Option<String>, pub timezone: Option<String>,
/// When true, cycle through all users with routines. Set explicitly via /// When true, cycle through all users with routines. Auto-detected from
/// HEARTBEAT_MULTI_TENANT or detected at runtime after DB initialization. /// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT.
pub multi_tenant: bool, pub multi_tenant: bool,
} }
@@ -105,7 +105,12 @@ impl HeartbeatConfig {
} }
tz tz
}, },
multi_tenant: parse_bool_env("HEARTBEAT_MULTI_TENANT", false)?, // 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(),
)?,
}) })
} }
} }
-1
View File
@@ -12,7 +12,6 @@ mod routines;
mod sandbox; mod sandbox;
mod settings; mod settings;
mod tool_failures; mod tool_failures;
mod users;
mod workspace; mod workspace;
use std::path::Path; use std::path::Path;
+114 -20
View File
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use libsql::params; use libsql::{params, params_from_iter};
use uuid::Uuid; use uuid::Uuid;
use super::{ use super::{
@@ -471,25 +471,33 @@ impl RoutineStore for LibSqlBackend {
} }
let conn = self.connect().await?; let conn = self.connect().await?;
let requested_rows = (1..=routine_ids.len())
// SQLite doesn't support ANY($1), so we query all latest runs and filter in memory. .map(|i| format!("(?{i})"))
// Uses a subquery to pick only the most recent run per routine. .collect::<Vec<_>>()
.join(", ");
let requested_ids = routine_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>();
let sql = format!(
"WITH requested(routine_id) AS (VALUES {requested_rows})
SELECT r1.routine_id, r1.status
FROM routine_runs r1
JOIN (
SELECT rr.routine_id, MAX(rr.started_at) AS max_started_at
FROM routine_runs rr
JOIN requested req ON req.routine_id = rr.routine_id
GROUP BY rr.routine_id
) latest
ON latest.routine_id = r1.routine_id
AND latest.max_started_at = r1.started_at"
);
let mut rows = conn let mut rows = conn
.query( .query(&sql, params_from_iter(requested_ids))
"SELECT routine_id, status FROM routine_runs r1
WHERE started_at = (
SELECT MAX(started_at) FROM routine_runs r2
WHERE r2.routine_id = r1.routine_id
)
GROUP BY routine_id",
params![],
)
.await .await
.map_err(|e| { .map_err(|e| {
DatabaseError::Query(format!("Failed to batch get last run status: {}", e)) DatabaseError::Query(format!("Failed to batch get last run status: {}", e))
})?; })?;
let routine_id_set: HashSet<Uuid> = routine_ids.iter().copied().collect();
let mut statuses = HashMap::new(); let mut statuses = HashMap::new();
while let Some(row) = rows while let Some(row) = rows
@@ -501,11 +509,9 @@ impl RoutineStore for LibSqlBackend {
let id = Uuid::parse_str(&id_str) let id = Uuid::parse_str(&id_str)
.map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?; .map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?;
if routine_id_set.contains(&id) { let status_str: String = get_text(&row, 1);
let status_str: String = get_text(&row, 1); if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() {
if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() { statuses.insert(id, status);
statuses.insert(id, status);
}
} }
} }
@@ -594,3 +600,91 @@ impl RoutineStore for LibSqlBackend {
Ok(runs) Ok(runs)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, Trigger,
};
use crate::db::{Database, RoutineStore};
fn test_routine(user_id: &str, name: &str) -> Routine {
Routine {
id: Uuid::new_v4(),
name: name.to_string(),
description: "test routine".to_string(),
user_id: user_id.to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "test".to_string(),
context_paths: Vec::new(),
max_tokens: 128,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails::default(),
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn test_run(routine_id: Uuid, status: RunStatus, started_at: DateTime<Utc>) -> RoutineRun {
RoutineRun {
id: Uuid::new_v4(),
routine_id,
trigger_type: "manual".to_string(),
trigger_detail: None,
started_at,
completed_at: None,
status,
result_summary: None,
tokens_used: None,
job_id: None,
created_at: started_at,
}
}
#[tokio::test]
async fn batch_get_last_run_status_is_scoped_to_requested_routines() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("routine-status.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();
let requested = test_routine("user-1", "requested");
let other = test_routine("user-1", "other");
backend.create_routine(&requested).await.unwrap();
backend.create_routine(&other).await.unwrap();
let now = Utc::now();
backend
.create_routine_run(&test_run(requested.id, RunStatus::Ok, now))
.await
.unwrap();
backend
.create_routine_run(&test_run(
other.id,
RunStatus::Failed,
now + chrono::Duration::seconds(1),
))
.await
.unwrap();
let statuses = backend
.batch_get_last_run_status(&[requested.id])
.await
.unwrap();
assert_eq!(statuses.len(), 1);
assert_eq!(statuses.get(&requested.id), Some(&RunStatus::Ok));
assert!(!statuses.contains_key(&other.id));
}
}
-735
View File
@@ -1,735 +0,0 @@
//! 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,36 +579,6 @@ 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-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')); ('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. /// Incremental migrations applied after the base schema.
@@ -753,38 +723,6 @@ CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event'); WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
PRAGMA foreign_keys=ON; 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,43 +309,6 @@ async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), Databas
Ok(()) 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 ==================== // ==================== Sub-traits ====================
// //
// Each sub-trait groups related persistence methods. The `Database` supertrait // Each sub-trait groups related persistence methods. The `Database` supertrait
@@ -798,81 +761,6 @@ 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. /// Backend-agnostic database supertrait.
/// ///
/// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers /// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers
@@ -886,7 +774,6 @@ pub trait Database:
+ ToolFailureStore + ToolFailureStore
+ SettingsStore + SettingsStore
+ WorkspaceStore + WorkspaceStore
+ UserStore
+ Send + Send
+ Sync + Sync
{ {
+2 -90
View File
@@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus};
use crate::config::DatabaseConfig; use crate::config::DatabaseConfig;
use crate::context::{ActionRecord, JobContext, JobState}; use crate::context::{ActionRecord, JobContext, JobState};
use crate::db::{ use crate::db::{
ApiTokenRecord, ConversationStore, Database, JobStore, RoutineStore, SandboxStore, ConversationStore, Database, JobStore, RoutineStore, SandboxStore, SettingsStore,
SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore, ToolFailureStore, WorkspaceStore,
}; };
use crate::error::{DatabaseError, WorkspaceError}; use crate::error::{DatabaseError, WorkspaceError};
use crate::history::{ use crate::history::{
@@ -786,91 +786,3 @@ impl WorkspaceStore for PgBackend {
.await .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
}
}
+7 -18
View File
@@ -2,8 +2,7 @@
//! //!
//! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector //! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector
//! based on the configured [`SslMode`]. Uses `rustls` with system root //! based on the configured [`SslMode`]. Uses `rustls` with system root
//! certificates, falling back to Mozilla's bundled roots via `webpki-roots` //! certificates — the same TLS stack that `reqwest` already uses for HTTP.
//! when the system store is empty (common in minimal container images).
use deadpool_postgres::{Pool, Runtime}; use deadpool_postgres::{Pool, Runtime};
use thiserror::Error; use thiserror::Error;
@@ -20,15 +19,9 @@ pub enum CreatePoolError {
TlsConfig(#[from] rustls::Error), TlsConfig(#[from] rustls::Error),
} }
/// Build a rustls-based TLS connector. /// Build a rustls-based TLS connector using the platform's root certificate store.
///
/// 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> { fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
let mut root_store = rustls::RootCertStore::empty(); let mut root_store = rustls::RootCertStore::empty();
// Try native certs first.
let native = rustls_native_certs::load_native_certs(); let native = rustls_native_certs::load_native_certs();
for e in &native.errors { for e in &native.errors {
tracing::warn!("error loading system root certs: {e}"); tracing::warn!("error loading system root certs: {e}");
@@ -38,16 +31,11 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
tracing::warn!("skipping invalid system root cert: {e}"); 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() { if root_store.is_empty() {
tracing::info!( tracing::error!("no system root certificates found -- TLS connections will fail");
"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 ring crypto provider (same one reqwest uses). // Pick the same ring provider reqwest already uses so postgres TLS setup stays deterministic.
let config = rustls::ClientConfig::builder_with_provider( let config = rustls::ClientConfig::builder_with_provider(
rustls::crypto::ring::default_provider().into(), rustls::crypto::ring::default_provider().into(),
) )
@@ -60,7 +48,7 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
/// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector. /// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector.
/// ///
/// - `Disable` → plain TCP (no TLS) /// - `Disable` → plain TCP (no TLS)
/// - `Prefer` / `Require` → rustls with system or bundled root certificates /// - `Prefer` / `Require` → rustls with system root certificates
/// ///
/// **Note:** `Prefer` and `Require` currently behave identically — both /// **Note:** `Prefer` and `Require` currently behave identically — both
/// provide a TLS connector and will fail if the server rejects the TLS /// provide a TLS connector and will fail if the server rejects the TLS
@@ -93,6 +81,7 @@ mod tests {
fn create_pool_disable_mode() { fn create_pool_disable_mode() {
let mut config = deadpool_postgres::Config::new(); let mut config = deadpool_postgres::Config::new();
config.url = Some("postgres://localhost/test".to_string()); 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); let pool = create_pool(&config, SslMode::Disable);
assert!(pool.is_ok()); assert!(pool.is_ok());
} }
-404
View File
@@ -2279,410 +2279,6 @@ 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+5 -55
View File
@@ -601,14 +601,11 @@ fn build_rig_request(
/// Inject a per-request model override into the rig request's `additional_params`. /// 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 /// Rig-core bakes the model name at construction time inside each provider's
/// `CompletionModel` implementation. This helper inserts a top-level `"model"` /// `CompletionModel` implementation. The actual HTTP request body includes a
/// key into `additional_params`, which rig-core flattens into the provider's /// `model` field set by the provider. Rig-core's `#[serde(flatten)]` on
/// request payload via `#[serde(flatten)]`. /// `additional_params` emits these fields AFTER the provider's own fields.
/// /// Most API servers (Python, Go) use last-key-wins when deserializing
/// Whether the override takes effect depends on the downstream API server's /// duplicate JSON keys, so the injected `model` value takes effect.
/// 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>) { fn inject_model_override(rig_req: &mut RigRequest, model_override: Option<&str>) {
let Some(model) = model_override else { let Some(model) = model_override else {
return; return;
@@ -1518,51 +1515,4 @@ mod tests {
"different raw IDs should produce different hashed IDs" "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());
}
} }
+27 -50
View File
@@ -591,7 +591,27 @@ async fn async_main() -> anyhow::Result<()> {
let mut gateway_url: Option<String> = None; let mut gateway_url: Option<String> = None;
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None; let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
if let Some(ref gw_config) = config.channels.gateway { if let Some(ref gw_config) = config.channels.gateway {
let mut gw = GatewayChannel::new(gw_config.clone(), config.owner_id.clone()); // 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());
gw = gw.with_llm_provider(Arc::clone(&components.llm)); gw = gw.with_llm_provider(Arc::clone(&components.llm));
if let Some(ref ws) = components.workspace { if let Some(ref ws) = components.workspace {
gw = gw.with_workspace(Arc::clone(ws)); gw = gw.with_workspace(Arc::clone(ws));
@@ -630,54 +650,6 @@ async fn async_main() -> anyhow::Result<()> {
} }
if let Some(ref d) = components.db { if let Some(ref d) = components.db {
gw = gw.with_store(Arc::clone(d)); 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 { if let Some(ref jm) = container_job_manager {
gw = gw.with_job_manager(Arc::clone(jm)); gw = gw.with_job_manager(Arc::clone(jm));
@@ -819,7 +791,12 @@ async fn async_main() -> anyhow::Result<()> {
.await; .await;
// Default user ID for extension operations (single-user mode). // Default user ID for extension operations (single-user mode).
let ext_user_id = config.owner_id.clone(); let ext_user_id = config
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string());
// Wire up channel runtime for hot-activation of WASM channels. // Wire up channel runtime for hot-activation of WASM channels.
if let Some(ref ext_mgr) = components.extension_manager if let Some(ref ext_mgr) = components.extension_manager
+5
View File
@@ -269,6 +269,10 @@ pub struct ChannelSettings {
#[serde(default)] #[serde(default)]
pub gateway_auth_token: Option<String>, pub gateway_auth_token: Option<String>,
/// Web gateway user ID.
#[serde(default)]
pub gateway_user_id: Option<String>,
/// Whether the CLI channel is enabled. /// Whether the CLI channel is enabled.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub cli_enabled: bool, pub cli_enabled: bool,
@@ -338,6 +342,7 @@ impl Default for ChannelSettings {
gateway_host: None, gateway_host: None,
gateway_port: None, gateway_port: None,
gateway_auth_token: None, gateway_auth_token: None,
gateway_user_id: None,
cli_enabled: true, cli_enabled: true,
signal_enabled: false, signal_enabled: false,
signal_http_url: None, signal_http_url: None,
+1 -42
View File
@@ -330,62 +330,35 @@ impl TenantScope {
/// Add a message to a conversation owned by this tenant. /// Add a message to a conversation owned by this tenant.
/// ///
/// Returns `NotFound` if the conversation does not belong to this user. /// Verifies the conversation belongs to this user before adding.
pub async fn add_conversation_message( pub async fn add_conversation_message(
&self, &self,
conversation_id: Uuid, conversation_id: Uuid,
role: &str, role: &str,
content: &str, content: &str,
) -> Result<Uuid, DatabaseError> { ) -> 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 self.inner
.add_conversation_message(conversation_id, role, content) .add_conversation_message(conversation_id, role, content)
.await .await
} }
/// Touch a conversation timestamp. Returns `NotFound` if not owned by this user.
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> { 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 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( pub async fn list_conversation_messages(
&self, &self,
conversation_id: Uuid, conversation_id: Uuid,
) -> Result<Vec<ConversationMessage>, DatabaseError> { ) -> 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 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( pub async fn list_conversation_messages_paginated(
&self, &self,
conversation_id: Uuid, conversation_id: Uuid,
before: Option<DateTime<Utc>>, before: Option<DateTime<Utc>>,
limit: i64, limit: i64,
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> { ) -> 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 self.inner
.list_conversation_messages_paginated(conversation_id, before, limit) .list_conversation_messages_paginated(conversation_id, before, limit)
.await .await
@@ -401,35 +374,21 @@ impl TenantScope {
.await .await
} }
/// Update metadata on a conversation. Returns `NotFound` if not owned by this user.
pub async fn update_conversation_metadata_field( pub async fn update_conversation_metadata_field(
&self, &self,
id: Uuid, id: Uuid,
key: &str, key: &str,
value: &serde_json::Value, value: &serde_json::Value,
) -> Result<(), DatabaseError> { ) -> Result<(), DatabaseError> {
if !self.conversation_belongs_to_user(id).await? {
return Err(DatabaseError::NotFound {
entity: "conversation".to_string(),
id: id.to_string(),
});
}
self.inner self.inner
.update_conversation_metadata_field(id, key, value) .update_conversation_metadata_field(id, key, value)
.await .await
} }
/// Get conversation metadata. Returns `NotFound` if not owned by this user.
pub async fn get_conversation_metadata( pub async fn get_conversation_metadata(
&self, &self,
id: Uuid, id: Uuid,
) -> Result<Option<serde_json::Value>, DatabaseError> { ) -> 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 self.inner.get_conversation_metadata(id).await
} }
} }
-563
View File
@@ -1,563 +0,0 @@
//! 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,7 +1,5 @@
//! Built-in tools that come with the agent. //! Built-in tools that come with the agent.
#[cfg(feature = "demo")]
mod abound;
mod echo; mod echo;
pub mod extension_tools; pub mod extension_tools;
mod file; mod file;
@@ -19,11 +17,6 @@ pub mod skill_tools;
mod time; mod time;
mod tool_info; mod tool_info;
#[cfg(feature = "demo")]
pub use abound::{
AboundCreateNotificationTool, AboundGetAccountInfoTool, AboundGetExchangeRateTool,
AboundGetForexScoreTool, AboundSendWireTool,
};
pub use echo::EchoTool; pub use echo::EchoTool;
pub use extension_tools::{ pub use extension_tools::{
ExtensionInfoTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ExtensionInfoTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool,
+76 -9
View File
@@ -20,7 +20,8 @@ use uuid::Uuid;
use crate::agent::routine::{ use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
normalize_cron_expression, normalize_cron_expression, reset_routine_verification_state, routine_verification_fingerprint,
routine_verification_status,
}; };
use crate::agent::routine_engine::RoutineEngine; use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext; use crate::context::JobContext;
@@ -414,12 +415,29 @@ fn routine_create_tool_summary() -> ToolDiscoverySummary {
"Set execution.use_tools=false to keep a new lightweight routine text-only.".into(), "Set execution.use_tools=false to keep a new lightweight routine text-only.".into(),
"Omitting delivery.user falls back to the owner's last-seen notification target.".into(), "Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
"advanced.cooldown_secs defaults to 300.".into(), "advanced.cooldown_secs defaults to 300.".into(),
"Creating a routine only saves the configuration. It does not prove the routine can execute successfully.".into(),
"After routine_create, tell the user the routine is unverified and offer to test it now unless they asked not to.".into(),
"Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(), "Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
], ],
examples: routine_create_examples(), examples: routine_create_examples(),
} }
} }
fn verification_result_payload(routine: &Routine, verification_reset: bool) -> Value {
let verification_status = routine_verification_status(routine);
serde_json::json!({
"verification_status": verification_status.as_str(),
"verification_reset": verification_reset,
"verification_hint": if verification_reset {
"The routine configuration changed and should be re-tested before being treated as reliable."
} else if verification_status == crate::agent::routine::RoutineVerificationStatus::Verified {
"The current routine configuration has already been verified with a successful run."
} else {
"The routine has been saved, but it has not been verified yet. Offer to test it now."
}
})
}
fn routine_create_schema(include_compatibility_aliases: bool) -> Value { fn routine_create_schema(include_compatibility_aliases: bool) -> Value {
let mut schema = serde_json::json!({ let mut schema = serde_json::json!({
"type": "object", "type": "object",
@@ -1063,7 +1081,8 @@ impl Tool for RoutineCreateTool {
fn description(&self) -> &str { fn description(&self) -> &str {
"Create a new routine (scheduled or event-driven task). \ "Create a new routine (scheduled or event-driven task). \
Supports cron schedules, event pattern matching, system events, and manual triggers. \ Supports cron schedules, event pattern matching, system events, and manual triggers. \
Use this when the user wants something to happen periodically or reactively." Use this when the user wants something to happen periodically or reactively. \
Creation saves the routine, but does not verify that it will execute successfully."
} }
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement { fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
@@ -1108,7 +1127,7 @@ impl Tool for RoutineCreateTool {
None None
}; };
let routine = Routine { let mut routine = Routine {
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: normalized.name.clone(), name: normalized.name.clone(),
description: normalized.description.clone(), description: normalized.description.clone(),
@@ -1134,6 +1153,10 @@ impl Tool for RoutineCreateTool {
created_at: Utc::now(), created_at: Utc::now(),
updated_at: Utc::now(), updated_at: Utc::now(),
}; };
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
self.store self.store
.create_routine(&routine) .create_routine(&routine)
@@ -1148,12 +1171,14 @@ impl Tool for RoutineCreateTool {
self.engine.refresh_event_cache().await; self.engine.refresh_event_cache().await;
} }
let verification = verification_result_payload(&routine, false);
let result = serde_json::json!({ let result = serde_json::json!({
"id": routine.id.to_string(), "id": routine.id.to_string(),
"name": routine.name, "name": routine.name.clone(),
"trigger_type": routine.trigger.type_tag(), "trigger_type": routine.trigger.type_tag(),
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()), "next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
"status": "created", "status": "created",
"verification": verification,
}); });
Ok(ToolOutput::success(result, start.elapsed())) Ok(ToolOutput::success(result, start.elapsed()))
@@ -1206,10 +1231,24 @@ impl Tool for RoutineListTool {
.list_routines(&ctx.user_id) .list_routines(&ctx.user_id)
.await .await
.map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?; .map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?;
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
let last_run_statuses = self
.store
.batch_get_last_run_status(&routine_ids)
.await
.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to read routine statuses: {e}"))
})?;
let list: Vec<serde_json::Value> = routines let list: Vec<serde_json::Value> = routines
.iter() .iter()
.map(|r| { .map(|r| {
let verification_status = routine_verification_status(r);
let status = crate::agent::routine::routine_display_status_for_verification(
r,
verification_status,
last_run_statuses.get(&r.id).copied(),
);
serde_json::json!({ serde_json::json!({
"id": r.id.to_string(), "id": r.id.to_string(),
"name": r.name, "name": r.name,
@@ -1221,6 +1260,8 @@ impl Tool for RoutineListTool {
"next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()), "next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()),
"run_count": r.run_count, "run_count": r.run_count,
"consecutive_failures": r.consecutive_failures, "consecutive_failures": r.consecutive_failures,
"status": status.as_str(),
"verification_status": verification_status.as_str(),
}) })
}) })
.collect(); .collect();
@@ -1259,7 +1300,8 @@ impl Tool for RoutineUpdateTool {
fn description(&self) -> &str { fn description(&self) -> &str {
"Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \ "Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \
Pass the routine name and only the fields you want to change. This does not convert trigger types." Pass the routine name and only the fields you want to change. This does not convert trigger types. \
Behavior-changing edits should leave the routine marked unverified until it is tested again."
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
@@ -1282,6 +1324,9 @@ impl Tool for RoutineUpdateTool {
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
let original_fingerprint = routine_verification_fingerprint(&routine);
let mut verification_reset = false;
// Apply updates // Apply updates
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) { if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
routine.enabled = enabled; routine.enabled = enabled;
@@ -1293,8 +1338,18 @@ impl Tool for RoutineUpdateTool {
if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) { if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) {
match &mut routine.action { match &mut routine.action {
RoutineAction::Lightweight { prompt: p, .. } => *p = prompt.to_string(), RoutineAction::Lightweight { prompt: p, .. } => {
RoutineAction::FullJob { description: d, .. } => *d = prompt.to_string(), if p != prompt {
verification_reset = true;
*p = prompt.to_string();
}
}
RoutineAction::FullJob { description: d, .. } => {
if d != prompt {
verification_reset = true;
*d = prompt.to_string();
}
}
} }
} }
@@ -1325,12 +1380,16 @@ impl Tool for RoutineUpdateTool {
if let Some((old_schedule, old_tz)) = existing_cron { if let Some((old_schedule, old_tz)) = existing_cron {
let effective_schedule = new_schedule.as_deref().unwrap_or(&old_schedule); let effective_schedule = new_schedule.as_deref().unwrap_or(&old_schedule);
let effective_tz = new_timezone.or(old_tz); let effective_tz = new_timezone.clone().or(old_tz.clone());
// Validate // Validate
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| { next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
ToolError::InvalidParameters(format!("invalid cron schedule: {e}")) ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
})?; })?;
if effective_schedule != old_schedule || effective_tz != old_tz {
verification_reset = true;
}
routine.trigger = Trigger::Cron { routine.trigger = Trigger::Cron {
schedule: effective_schedule.to_string(), schedule: effective_schedule.to_string(),
timezone: effective_tz.clone(), timezone: effective_tz.clone(),
@@ -1344,6 +1403,12 @@ impl Tool for RoutineUpdateTool {
} }
} }
let updated_fingerprint = routine_verification_fingerprint(&routine);
if updated_fingerprint != original_fingerprint {
verification_reset = true;
routine.state = reset_routine_verification_state(&routine.state, updated_fingerprint);
}
self.store self.store
.update_routine(&routine) .update_routine(&routine)
.await .await
@@ -1352,12 +1417,14 @@ impl Tool for RoutineUpdateTool {
// Refresh event cache in case trigger changed // Refresh event cache in case trigger changed
self.engine.refresh_event_cache().await; self.engine.refresh_event_cache().await;
let verification = verification_result_payload(&routine, verification_reset);
let result = serde_json::json!({ let result = serde_json::json!({
"name": routine.name, "name": routine.name.clone(),
"enabled": routine.enabled, "enabled": routine.enabled,
"trigger_type": routine.trigger.type_tag(), "trigger_type": routine.trigger.type_tag(),
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()), "next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
"status": "updated", "status": "updated",
"verification": verification,
}); });
Ok(ToolOutput::success(result, start.elapsed())) Ok(ToolOutput::success(result, start.elapsed()))
-15
View File
@@ -16,11 +16,6 @@ use crate::skills::registry::SkillRegistry;
use crate::tools::builder::{ use crate::tools::builder::{
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder, BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
}; };
#[cfg(feature = "demo")]
use crate::tools::builtin::{
AboundCreateNotificationTool, AboundGetAccountInfoTool, AboundGetExchangeRateTool,
AboundGetForexScoreTool, AboundSendWireTool,
};
use crate::tools::builtin::{ use crate::tools::builtin::{
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool, ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool, JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
@@ -253,16 +248,6 @@ impl ToolRegistry {
} }
self.register_sync(Arc::new(http)); 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()); tracing::debug!("Registered {} built-in tools", self.count());
} }
+4
View File
@@ -428,8 +428,10 @@ mod tests {
host: "127.0.0.1".to_string(), host: "127.0.0.1".to_string(),
port: 3000, port: 3000,
auth_token: None, auth_token: None,
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(), memory_layers: Vec::new(),
user_tokens: None,
}); });
c c
} }
@@ -440,8 +442,10 @@ mod tests {
host: host.to_string(), host: host.to_string(),
port, port,
auth_token: None, auth_token: None,
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
memory_layers: Vec::new(), memory_layers: Vec::new(),
user_tokens: None,
}); });
c c
} }
+4
View File
@@ -271,6 +271,7 @@ async def ironclaw_server(
"GATEWAY_HOST": "127.0.0.1", "GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port), "GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN, "GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-web-sender",
"HTTP_HOST": "127.0.0.1", "HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port), "HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET, "HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
@@ -370,6 +371,7 @@ async def hosted_oauth_refresh_server(
"GATEWAY_HOST": "127.0.0.1", "GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port), "GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN, "GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": OWNER_SCOPE_ID,
"HTTP_HOST": "127.0.0.1", "HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port), "HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET, "HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
@@ -409,6 +411,7 @@ async def hosted_oauth_refresh_server(
yield { yield {
"base_url": base_url, "base_url": base_url,
"db_path": db_path, "db_path": db_path,
"gateway_user_id": OWNER_SCOPE_ID,
"mock_llm_url": mock_llm_server, "mock_llm_url": mock_llm_server,
} }
except TimeoutError: except TimeoutError:
@@ -470,6 +473,7 @@ async def http_channel_server_without_secret(
"GATEWAY_HOST": "127.0.0.1", "GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port), "GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN, "GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-tester",
"HTTP_HOST": "127.0.0.1", "HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port), "HTTP_PORT": str(http_port),
"CLI_ENABLED": "false", "CLI_ENABLED": "false",
@@ -29,7 +29,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the any-channel-bug-watcher routine for bug messages.", "content": "Created the any-channel-bug-watcher routine for bug messages, but it is not verified yet. It should stay unverified until it has a successful run.",
"input_tokens": 170, "input_tokens": 170,
"output_tokens": 18 "output_tokens": 18
} }
@@ -30,7 +30,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the telegram-bug-watcher routine for Telegram bug messages.", "content": "Created the telegram-bug-watcher routine for Telegram bug messages, but it is not verified yet. I can test it the next time you want to fire it.",
"input_tokens": 180, "input_tokens": 180,
"output_tokens": 20 "output_tokens": 20
} }
@@ -37,7 +37,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the **morning-tech-news** routine with manual trigger and full_job mode. The `message` and `http` tools are pre-authorized.", "content": "Created the **morning-tech-news** routine with manual trigger and full_job mode. The `message` and `http` tools are available, but the routine is not verified yet.",
"input_tokens": 200, "input_tokens": 200,
"output_tokens": 50 "output_tokens": 50
} }
@@ -57,7 +57,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the weekday-digest routine with a grouped cron request and listed the active routines.", "content": "Created the weekday-digest routine with a grouped cron request and listed the routines. It is not verified yet, so it should stay unverified until it has a successful run.",
"input_tokens": 250, "input_tokens": 250,
"output_tokens": 24 "output_tokens": 24
} }
+1 -1
View File
@@ -52,7 +52,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "I created a daily-check routine that runs at 9 AM every day. The routine list shows it as active.", "content": "I created the daily-check routine, but it is not verified yet. It is scheduled for 9 AM every day, and the routine list should show it as unverified until it has a successful run.",
"input_tokens": 300, "input_tokens": 300,
"output_tokens": 25 "output_tokens": 25
} }
+1 -1
View File
@@ -41,7 +41,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "The history-test routine was created. Its run history is empty since it hasn't been triggered yet.", "content": "The history-test routine was created, but it is not verified yet. Its run history is empty since it hasn't been triggered yet.",
"input_tokens": 300, "input_tokens": 300,
"output_tokens": 25 "output_tokens": 25
} }
+1 -1
View File
@@ -27,7 +27,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the manual-triage routine. It will only run when explicitly fired.", "content": "Created the manual-triage routine, but it is not verified yet. It will only run when explicitly fired, so I can test it for you when you're ready.",
"input_tokens": 140, "input_tokens": 140,
"output_tokens": 18 "output_tokens": 18
} }
@@ -30,7 +30,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created the manual-triage-no-tools routine. It will only run when explicitly fired and stay text-only.", "content": "Created the manual-triage-no-tools routine, but it is not verified yet. It will only run when explicitly fired and stay text-only until you decide to test it.",
"input_tokens": 140, "input_tokens": 140,
"output_tokens": 18 "output_tokens": 18
} }
+1 -1
View File
@@ -59,7 +59,7 @@
{ {
"response": { "response": {
"type": "text", "type": "text",
"content": "Created, updated, and then deleted the temp-routine successfully.", "content": "Created, updated, and then deleted the temp-routine successfully. The update would have left it unverified until it was tested again.",
"input_tokens": 400, "input_tokens": 400,
"output_tokens": 20 "output_tokens": 20
} }
+112
View File
@@ -16,6 +16,7 @@ mod tests {
use chrono::Utc; use chrono::Utc;
use ironclaw::agent::routine::{ use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
reset_routine_verification_state, routine_verification_fingerprint,
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -338,4 +339,115 @@ mod tests {
harness.shutdown().await; harness.shutdown().await;
mock.shutdown().await; mock.shutdown().await;
} }
#[tokio::test]
async fn routines_api_surfaces_unverified_status_for_new_routine() {
let mock = MockOpenAiServerBuilder::new()
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
.start()
.await;
let harness =
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
.await;
let mut routine = Routine {
id: Uuid::new_v4(),
name: "wf-unverified".to_string(),
description: "Unverified status regression test".to_string(),
user_id: harness.user_id.clone(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::Lightweight {
prompt: "Check verification status".to_string(),
context_paths: Vec::new(),
max_tokens: 512,
use_tools: false,
max_tool_rounds: 1,
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
routine.state = reset_routine_verification_state(
&routine.state,
routine_verification_fingerprint(&routine),
);
harness
.db
.create_routine(&routine)
.await
.expect("create routine");
let mut disabled_routine = routine.clone();
disabled_routine.id = Uuid::new_v4();
disabled_routine.name = "wf-unverified-disabled".to_string();
disabled_routine.enabled = false;
disabled_routine.state = reset_routine_verification_state(
&disabled_routine.state,
routine_verification_fingerprint(&disabled_routine),
);
harness
.db
.create_routine(&disabled_routine)
.await
.expect("create disabled routine");
let list = harness.list_routines().await;
let routine_id = routine.id.to_string();
let listed = list["routines"]
.as_array()
.expect("routines array")
.iter()
.find(|item| item["id"].as_str() == Some(routine_id.as_str()))
.expect("routine should be listed");
assert_eq!(listed["status"].as_str(), Some("unverified"));
assert_eq!(listed["verification_status"].as_str(), Some("unverified"));
let summary = harness
.client
.get(format!("{}/api/routines/summary", harness.base_url()))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("summary request failed")
.error_for_status()
.expect("summary non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid summary response");
assert_eq!(summary["unverified"].as_u64(), Some(2));
let detail = harness
.client
.get(format!(
"{}/api/routines/{}",
harness.base_url(),
routine_id
))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("detail request failed")
.error_for_status()
.expect("detail non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid detail response");
assert_eq!(detail["status"].as_str(), Some("unverified"));
assert_eq!(detail["verification_status"].as_str(), Some("unverified"));
harness.shutdown().await;
mock.shutdown().await;
}
} }
+10 -14
View File
@@ -42,6 +42,8 @@ const ALICE_USER_ID: &str = "alice";
const BOB_USER_ID: &str = "bob"; const BOB_USER_ID: &str = "bob";
const OWNER_TOKEN: &str = "tok-owner-secret"; const OWNER_TOKEN: &str = "tok-owner-secret";
const OWNER_SCOPE_ID: &str = "owner-scope"; const OWNER_SCOPE_ID: &str = "owner-scope";
const GATEWAY_SENDER_ID: &str = "gateway-sender";
/// Build a MultiAuthState with two users. /// Build a MultiAuthState with two users.
fn two_user_auth() -> MultiAuthState { fn two_user_auth() -> MultiAuthState {
let mut tokens = HashMap::new(); let mut tokens = HashMap::new();
@@ -49,7 +51,6 @@ fn two_user_auth() -> MultiAuthState {
ALICE_TOKEN.to_string(), ALICE_TOKEN.to_string(),
UserIdentity { UserIdentity {
user_id: ALICE_USER_ID.to_string(), user_id: ALICE_USER_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
}, },
); );
@@ -57,7 +58,6 @@ fn two_user_auth() -> MultiAuthState {
BOB_TOKEN.to_string(), BOB_TOKEN.to_string(),
UserIdentity { UserIdentity {
user_id: BOB_USER_ID.to_string(), user_id: BOB_USER_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: vec!["shared".to_string()], workspace_read_scopes: vec!["shared".to_string()],
}, },
); );
@@ -79,10 +79,7 @@ fn user_echo_app(auth: MultiAuthState) -> Router {
.route("/api/whoami/scopes", get(echo_user_with_scopes)) .route("/api/whoami/scopes", get(echo_user_with_scopes))
.route("/api/action", post(echo_user)) .route("/api/action", post(echo_user))
.route("/api/chat/events", get(echo_user)) // SSE endpoint (allows query token) .route("/api/chat/events", get(echo_user)) // SSE endpoint (allows query token)
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(auth, auth_middleware))
ironclaw::channels::web::auth::CombinedAuthState::from(auth),
auth_middleware,
))
} }
// =========================================================================== // ===========================================================================
@@ -547,6 +544,7 @@ fn gateway_state_has_multi_tenant_fields() {
prompt_queue: None, prompt_queue: None,
scheduler: None, scheduler: None,
owner_id: "fallback".to_string(), owner_id: "fallback".to_string(),
default_sender_id: "fallback".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, llm_provider: None,
@@ -560,10 +558,10 @@ fn gateway_state_has_multi_tenant_fields() {
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
webhook_rate_limiter: RateLimiter::new(10, 60), webhook_rate_limiter: RateLimiter::new(10, 60),
active_config: Default::default(), active_config: Default::default(),
secrets_store: None,
}; };
assert_eq!(state.owner_id, "fallback"); assert_eq!(state.owner_id, "fallback");
assert_eq!(state.default_sender_id, "fallback");
assert!(state.workspace_pool.is_none()); assert!(state.workspace_pool.is_none());
} }
@@ -594,7 +592,6 @@ async fn start_owner_scoped_sender_server() -> (
OWNER_TOKEN.to_string(), OWNER_TOKEN.to_string(),
UserIdentity { UserIdentity {
user_id: OWNER_SCOPE_ID.to_string(), user_id: OWNER_SCOPE_ID.to_string(),
role: "admin".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
}, },
); );
@@ -602,7 +599,6 @@ async fn start_owner_scoped_sender_server() -> (
BOB_TOKEN.to_string(), BOB_TOKEN.to_string(),
UserIdentity { UserIdentity {
user_id: BOB_USER_ID.to_string(), user_id: BOB_USER_ID.to_string(),
role: "member".to_string(),
workspace_read_scopes: Vec::new(), workspace_read_scopes: Vec::new(),
}, },
); );
@@ -622,6 +618,7 @@ async fn start_owner_scoped_sender_server() -> (
prompt_queue: None, prompt_queue: None,
scheduler: None, scheduler: None,
owner_id: OWNER_SCOPE_ID.to_string(), owner_id: OWNER_SCOPE_ID.to_string(),
default_sender_id: GATEWAY_SENDER_ID.to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, llm_provider: None,
@@ -635,10 +632,9 @@ async fn start_owner_scoped_sender_server() -> (
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: Default::default(), active_config: Default::default(),
secrets_store: None,
}); });
let auth = MultiAuthState::multi(tokens).into(); let auth = MultiAuthState::multi(tokens);
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = start_server(addr, state.clone(), auth) let bound = start_server(addr, state.clone(), auth)
.await .await
@@ -773,7 +769,7 @@ async fn full_server_chat_send_rewrites_sender_only_for_owner_scope_rebind() {
.expect("Timed out waiting for owner message") .expect("Timed out waiting for owner message")
.expect("Agent channel closed"); .expect("Agent channel closed");
assert_eq!(owner_msg.user_id, OWNER_SCOPE_ID); assert_eq!(owner_msg.user_id, OWNER_SCOPE_ID);
assert_eq!(owner_msg.sender_id, OWNER_SCOPE_ID); assert_eq!(owner_msg.sender_id, GATEWAY_SENDER_ID);
assert_eq!(owner_msg.content, "hello from owner"); assert_eq!(owner_msg.content, "hello from owner");
let other_resp = client let other_resp = client
@@ -1007,6 +1003,7 @@ async fn start_multi_user_server_with_db() -> (
prompt_queue: None, prompt_queue: None,
scheduler: None, scheduler: None,
owner_id: ALICE_USER_ID.to_string(), owner_id: ALICE_USER_ID.to_string(),
default_sender_id: ALICE_USER_ID.to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, llm_provider: None,
@@ -1020,11 +1017,10 @@ async fn start_multi_user_server_with_db() -> (
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
webhook_rate_limiter: RateLimiter::new(10, 60), webhook_rate_limiter: RateLimiter::new(10, 60),
active_config: Default::default(), active_config: Default::default(),
secrets_store: None,
}); });
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth.into()) let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth)
.await .await
.expect("Failed to start server with DB"); .expect("Failed to start server with DB");
+5 -5
View File
@@ -205,6 +205,7 @@ async fn start_test_server_with_provider(
prompt_queue: None, prompt_queue: None,
scheduler: None, scheduler: None,
owner_id: "test-user".to_string(), owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(llm_provider), llm_provider: Some(llm_provider),
@@ -218,7 +219,6 @@ async fn start_test_server_with_provider(
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
}); });
let auth = ironclaw::channels::web::auth::MultiAuthState::single( let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -226,7 +226,7 @@ async fn start_test_server_with_provider(
"test-user".to_string(), "test-user".to_string(),
); );
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth.into()) let bound_addr = start_server(addr, state.clone(), auth)
.await .await
.expect("Failed to start test server"); .expect("Failed to start test server");
@@ -704,6 +704,7 @@ async fn test_no_llm_provider_returns_503() {
prompt_queue: None, prompt_queue: None,
scheduler: None, scheduler: None,
owner_id: "test-user".to_string(), owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, // No LLM! llm_provider: None, // No LLM!
@@ -717,7 +718,6 @@ async fn test_no_llm_provider_returns_503() {
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
}); });
let auth = ironclaw::channels::web::auth::MultiAuthState::single( let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -725,7 +725,7 @@ async fn test_no_llm_provider_returns_503() {
"test-user".to_string(), "test-user".to_string(),
); );
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state, auth.into()).await.unwrap(); let bound_addr = start_server(addr, state, auth).await.unwrap();
let url = format!("http://{}/v1/chat/completions", bound_addr); let url = format!("http://{}/v1/chat/completions", bound_addr);
let resp = client() let resp = client()
@@ -763,7 +763,7 @@ async fn test_chat_completions_body_too_large() {
post(ironclaw::channels::web::openai_compat::chat_completions_handler), post(ironclaw::channels::web::openai_compat::chat_completions_handler),
) )
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
ironclaw::channels::web::auth::CombinedAuthState::from(auth_state), auth_state,
ironclaw::channels::web::auth::auth_middleware, ironclaw::channels::web::auth::auth_middleware,
)) ))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
+2 -2
View File
@@ -227,6 +227,7 @@ impl GatewayWorkflowHarness {
prompt_queue: None, prompt_queue: None,
scheduler: Some(scheduler_slot.clone()), scheduler: Some(scheduler_slot.clone()),
owner_id: user_id.clone(), owner_id: user_id.clone(),
default_sender_id: user_id.clone(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: Some(Arc::clone(&components.llm)), llm_provider: Some(Arc::clone(&components.llm)),
@@ -240,7 +241,6 @@ impl GatewayWorkflowHarness {
routine_engine: Arc::clone(&routine_slot), routine_engine: Arc::clone(&routine_slot),
startup_time: Instant::now(), startup_time: Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
}); });
let mut agent = Agent::new( let mut agent = Agent::new(
@@ -299,7 +299,7 @@ impl GatewayWorkflowHarness {
let addr = start_server( let addr = start_server(
"127.0.0.1:0".parse().expect("valid localhost addr"), "127.0.0.1:0".parse().expect("valid localhost addr"),
Arc::clone(&gateway_state), Arc::clone(&gateway_state),
auth.into(), auth,
) )
.await .await
.expect("failed to start gateway server"); .expect("failed to start gateway server");
+2 -2
View File
@@ -52,6 +52,7 @@ async fn start_test_server() -> (
prompt_queue: None, prompt_queue: None,
scheduler: None, scheduler: None,
owner_id: "test-user".to_string(), owner_id: "test-user".to_string(),
default_sender_id: "test-user".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None), shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())), ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None, llm_provider: None,
@@ -65,7 +66,6 @@ async fn start_test_server() -> (
routine_engine: Arc::new(tokio::sync::RwLock::new(None)), routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(), startup_time: std::time::Instant::now(),
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(), active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
secrets_store: None,
}); });
let auth = ironclaw::channels::web::auth::MultiAuthState::single( let auth = ironclaw::channels::web::auth::MultiAuthState::single(
@@ -73,7 +73,7 @@ async fn start_test_server() -> (
"test-user".to_string(), "test-user".to_string(),
); );
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap(); let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let bound_addr = start_server(addr, state.clone(), auth.into()) let bound_addr = start_server(addr, state.clone(), auth)
.await .await
.expect("Failed to start test server"); .expect("Failed to start test server");