diff --git a/README.md b/README.md index 6b2976f..4bf9391 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ Contributions are welcome! oAI-Web takes real actions on your behalf — it can send emails, write files, make calendar changes, and post Telegram messages. Review your whitelist and permission settings carefully before use. Content you send is processed by your configured AI provider (Anthropic, OpenRouter, or OpenAI). oAI-Web is provided "as is" without warranty of any kind — the author accepts no responsibility for actions taken by the agent or any consequences thereof. See LICENSE for full terms. --- +--- **⭐ Star this project if you find it useful!** diff --git a/sbom.cdx.json b/sbom.cdx.json new file mode 100644 index 0000000..7041e65 --- /dev/null +++ b/sbom.cdx.json @@ -0,0 +1,574 @@ +{ + "components": [ + { + "bom-ref": "requirements-L24", + "description": "requirements line 24: aioimaplib>=1.0", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/aioimaplib/" + } + ], + "name": "aioimaplib", + "purl": "pkg:pypi/aioimaplib", + "type": "library" + }, + { + "bom-ref": "requirements-L9", + "description": "requirements line 9: anthropic==0.40.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/anthropic/" + } + ], + "name": "anthropic", + "purl": "pkg:pypi/anthropic@0.40.%2A", + "type": "library", + "version": "0.40.*" + }, + { + "bom-ref": "requirements-L33", + "description": "requirements line 33: apscheduler==3.10.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/apscheduler/" + } + ], + "name": "apscheduler", + "purl": "pkg:pypi/apscheduler@3.10.%2A", + "type": "library", + "version": "3.10.*" + }, + { + "bom-ref": "requirements-L36", + "description": "requirements line 36: argon2-cffi==23.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/argon2-cffi/" + } + ], + "name": "argon2-cffi", + "purl": "pkg:pypi/argon2-cffi@23.%2A", + "type": "library", + "version": "23.*" + }, + { + "bom-ref": "requirements-L41", + "description": "requirements line 41: asyncpg==0.31.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/asyncpg/" + } + ], + "name": "asyncpg", + "purl": "pkg:pypi/asyncpg@0.31.%2A", + "type": "library", + "version": "0.31.*" + }, + { + "bom-ref": "requirements-L28", + "description": "requirements line 28: beautifulsoup4==4.12.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/beautifulsoup4/" + } + ], + "name": "beautifulsoup4", + "purl": "pkg:pypi/beautifulsoup4@4.12.%2A", + "type": "library", + "version": "4.12.*" + }, + { + "bom-ref": "requirements-L19", + "description": "requirements line 19: caldav==1.3.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/caldav/" + } + ], + "name": "caldav", + "purl": "pkg:pypi/caldav@1.3.%2A", + "type": "library", + "version": "1.3.*" + }, + { + "bom-ref": "requirements-L13", + "description": "requirements line 13: cryptography==43.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/cryptography/" + } + ], + "name": "cryptography", + "purl": "pkg:pypi/cryptography@43.%2A", + "type": "library", + "version": "43.*" + }, + { + "bom-ref": "requirements-L2", + "description": "requirements line 2: fastapi==0.115.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/fastapi/" + } + ], + "name": "fastapi", + "purl": "pkg:pypi/fastapi@0.115.%2A", + "type": "library", + "version": "0.115.*" + }, + { + "bom-ref": "requirements-L29", + "description": "requirements line 29: feedparser==6.0.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/feedparser/" + } + ], + "name": "feedparser", + "purl": "pkg:pypi/feedparser@6.0.%2A", + "type": "library", + "version": "6.0.*" + }, + { + "bom-ref": "requirements-L27", + "description": "requirements line 27: httpx==0.27.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/httpx/" + } + ], + "name": "httpx", + "purl": "pkg:pypi/httpx@0.27.%2A", + "type": "library", + "version": "0.27.*" + }, + { + "bom-ref": "requirements-L23", + "description": "requirements line 23: imapclient==3.0.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/imapclient/" + } + ], + "name": "imapclient", + "purl": "pkg:pypi/imapclient@3.0.%2A", + "type": "library", + "version": "3.0.*" + }, + { + "bom-ref": "requirements-L4", + "description": "requirements line 4: jinja2==3.1.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/jinja2/" + } + ], + "name": "jinja2", + "purl": "pkg:pypi/jinja2@3.1.%2A", + "type": "library", + "version": "3.1.*" + }, + { + "bom-ref": "requirements-L42", + "description": "requirements line 42: mcp==1.26.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/mcp/" + } + ], + "name": "mcp", + "purl": "pkg:pypi/mcp@1.26.%2A", + "type": "library", + "version": "1.26.*" + }, + { + "bom-ref": "requirements-L10", + "description": "requirements line 10: openai==1.57.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/openai/" + } + ], + "name": "openai", + "purl": "pkg:pypi/openai@1.57.%2A", + "type": "library", + "version": "1.57.*" + }, + { + "bom-ref": "requirements-L30", + "description": "requirements line 30: playwright>=1.40", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/playwright/" + } + ], + "name": "playwright", + "purl": "pkg:pypi/playwright", + "type": "library" + }, + { + "bom-ref": "requirements-L37", + "description": "requirements line 37: pyotp>=2.9", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/pyotp/" + } + ], + "name": "pyotp", + "purl": "pkg:pypi/pyotp", + "type": "library" + }, + { + "bom-ref": "requirements-L45", + "description": "requirements line 45: python-dateutil==2.9.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/python-dateutil/" + } + ], + "name": "python-dateutil", + "purl": "pkg:pypi/python-dateutil@2.9.%2A", + "type": "library", + "version": "2.9.*" + }, + { + "bom-ref": "requirements-L16", + "description": "requirements line 16: python-dotenv==1.0.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/python-dotenv/" + } + ], + "name": "python-dotenv", + "purl": "pkg:pypi/python-dotenv@1.0.%2A", + "type": "library", + "version": "1.0.*" + }, + { + "bom-ref": "requirements-L5", + "description": "requirements line 5: python-multipart==0.0.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/python-multipart/" + } + ], + "name": "python-multipart", + "purl": "pkg:pypi/python-multipart@0.0.%2A", + "type": "library", + "version": "0.0.*" + }, + { + "bom-ref": "requirements-L46", + "description": "requirements line 46: pytz==2024.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/pytz/" + } + ], + "name": "pytz", + "purl": "pkg:pypi/pytz@2024.%2A", + "type": "library", + "version": "2024.*" + }, + { + "bom-ref": "requirements-L38", + "description": "requirements line 38: qrcode[pil]>=7.4", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/qrcode/" + } + ], + "name": "qrcode", + "properties": [ + { + "name": "cdx:python:package:required-extra", + "value": "pil" + } + ], + "purl": "pkg:pypi/qrcode", + "type": "library" + }, + { + "bom-ref": "requirements-L3", + "description": "requirements line 3: uvicorn[standard]==0.32.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/uvicorn/" + } + ], + "name": "uvicorn", + "properties": [ + { + "name": "cdx:python:package:required-extra", + "value": "standard" + } + ], + "purl": "pkg:pypi/uvicorn@0.32.%2A", + "type": "library", + "version": "0.32.*" + }, + { + "bom-ref": "requirements-L20", + "description": "requirements line 20: vobject==0.9.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/vobject/" + } + ], + "name": "vobject", + "purl": "pkg:pypi/vobject@0.9.%2A", + "type": "library", + "version": "0.9.*" + }, + { + "bom-ref": "requirements-L6", + "description": "requirements line 6: websockets==13.*", + "externalReferences": [ + { + "comment": "implicit dist url", + "type": "distribution", + "url": "https://pypi.org/simple/websockets/" + } + ], + "name": "websockets", + "purl": "pkg:pypi/websockets@13.%2A", + "type": "library", + "version": "13.*" + } + ], + "dependencies": [ + { + "ref": "requirements-L10" + }, + { + "ref": "requirements-L13" + }, + { + "ref": "requirements-L16" + }, + { + "ref": "requirements-L19" + }, + { + "ref": "requirements-L2" + }, + { + "ref": "requirements-L20" + }, + { + "ref": "requirements-L23" + }, + { + "ref": "requirements-L24" + }, + { + "ref": "requirements-L27" + }, + { + "ref": "requirements-L28" + }, + { + "ref": "requirements-L29" + }, + { + "ref": "requirements-L3" + }, + { + "ref": "requirements-L30" + }, + { + "ref": "requirements-L33" + }, + { + "ref": "requirements-L36" + }, + { + "ref": "requirements-L37" + }, + { + "ref": "requirements-L38" + }, + { + "ref": "requirements-L4" + }, + { + "ref": "requirements-L41" + }, + { + "ref": "requirements-L42" + }, + { + "ref": "requirements-L45" + }, + { + "ref": "requirements-L46" + }, + { + "ref": "requirements-L5" + }, + { + "ref": "requirements-L6" + }, + { + "ref": "requirements-L9" + } + ], + "metadata": { + "timestamp": "2026-04-15T07:29:58.838584+00:00", + "tools": { + "components": [ + { + "description": "CycloneDX Software Bill of Materials (SBOM) generator for Python projects and environments", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-bom/" + }, + { + "type": "documentation", + "url": "https://cyclonedx-bom-tool.readthedocs.io/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python/" + }, + { + "type": "website", + "url": "https://github.com/CycloneDX/cyclonedx-python/#readme" + } + ], + "group": "CycloneDX", + "licenses": [ + { + "license": { + "acknowledgement": "declared", + "id": "Apache-2.0" + } + } + ], + "name": "cyclonedx-py", + "type": "application", + "version": "7.3.0" + }, + { + "description": "Python library for CycloneDX", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx-python-library.readthedocs.io/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/#readme" + } + ], + "group": "CycloneDX", + "licenses": [ + { + "license": { + "acknowledgement": "declared", + "id": "Apache-2.0" + } + } + ], + "name": "cyclonedx-python-lib", + "type": "library", + "version": "11.7.0" + } + ] + } + }, + "serialNumber": "urn:uuid:2d68f514-7d51-45bc-957f-4df5affd9778", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/server/agent/agent.py b/server/agent/agent.py index a0fddd4..d2b3901 100644 --- a/server/agent/agent.py +++ b/server/agent/agent.py @@ -470,7 +470,7 @@ class Agent: confirmed = False # Confirmation flow (interactive sessions only) - if tool.requires_confirmation and task_id is None: + if await tool.should_confirm(**tc.arguments) and task_id is None: description = tool.confirmation_description(**tc.arguments) yield ConfirmationRequiredEvent( call_id=tc.id, @@ -697,12 +697,18 @@ class Agent: # Update in-memory history for multi-turn self._session_history[session_id] = messages - # Persist conversation to DB + # Persist conversation to DB (with accumulated usage for cost tracking) + from ..providers.models import compute_cost_usd as _compute_cost + _model_str = model or "" # full "provider:model" string for pricing lookup + _cost = _compute_cost(_model_str, total_usage.input_tokens, total_usage.output_tokens) if _model_str else None await _save_conversation( session_id=session_id, messages=messages, task_id=task_id, model=response.model or run_model or model or "", + input_tokens=total_usage.input_tokens, + output_tokens=total_usage.output_tokens, + cost_usd=_cost, ) yield DoneEvent( @@ -735,6 +741,9 @@ async def _save_conversation( messages: list[dict], task_id: str | None, model: str = "", + input_tokens: int = 0, + output_tokens: int = 0, + cost_usd: float | None = None, ) -> None: from ..context_vars import current_user as _cu user_id = _cu.get().id if _cu.get() else None @@ -745,26 +754,41 @@ async def _save_conversation( "SELECT id, title FROM conversations WHERE id = $1", session_id ) if existing: - # Only update title if still unset (don't overwrite a user-renamed title) + # Accumulate tokens across turns; only update title if still unset if not existing["title"]: title = _derive_title(messages) await pool.execute( - "UPDATE conversations SET messages = $1, ended_at = $2, title = $3, model = $4 WHERE id = $5", - messages, now, title, model or None, session_id, + """UPDATE conversations + SET messages = $1, ended_at = $2, title = $3, model = $4, + input_tokens = COALESCE(input_tokens, 0) + $5, + output_tokens = COALESCE(output_tokens, 0) + $6, + cost_usd = COALESCE(cost_usd, 0) + COALESCE($7, 0) + WHERE id = $8""", + messages, now, title, model or None, + input_tokens, output_tokens, cost_usd, session_id, ) else: await pool.execute( - "UPDATE conversations SET messages = $1, ended_at = $2, model = $3 WHERE id = $4", - messages, now, model or None, session_id, + """UPDATE conversations + SET messages = $1, ended_at = $2, model = $3, + input_tokens = COALESCE(input_tokens, 0) + $4, + output_tokens = COALESCE(output_tokens, 0) + $5, + cost_usd = COALESCE(cost_usd, 0) + COALESCE($6, 0) + WHERE id = $7""", + messages, now, model or None, + input_tokens, output_tokens, cost_usd, session_id, ) else: title = _derive_title(messages) await pool.execute( """ - INSERT INTO conversations (id, started_at, ended_at, messages, task_id, user_id, title, model) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO conversations + (id, started_at, ended_at, messages, task_id, user_id, title, model, + input_tokens, output_tokens, cost_usd) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) """, session_id, now, now, messages, task_id, user_id, title, model or None, + input_tokens, output_tokens, cost_usd, ) except Exception as e: logger.error(f"Failed to save conversation {session_id}: {e}") diff --git a/server/agents/runner.py b/server/agents/runner.py index 2c7b7a8..6016998 100644 --- a/server/agents/runner.py +++ b/server/agents/runner.py @@ -277,12 +277,17 @@ class AgentRunner: elif isinstance(event, ErrorEvent): final_text = f"Error: {event.message}" + run_model = agent_data.get("model") or "" + from ..providers.models import compute_cost_usd + cost = compute_cost_usd(run_model, input_tokens, output_tokens) if run_model else None await agent_store.finish_run( run_id, status="success", input_tokens=input_tokens, output_tokens=output_tokens, result=final_text, + model=run_model or None, + cost_usd=cost, ) logger.info( f"[agent-runner] Agent '{agent_data['name']}' run={run_id[:8]} completed OK" diff --git a/server/agents/tasks.py b/server/agents/tasks.py index f8a4a60..d0ed429 100644 --- a/server/agents/tasks.py +++ b/server/agents/tasks.py @@ -1,5 +1,20 @@ """ -agents/tasks.py — Agent and agent run CRUD operations (async). +agents/tasks.py — Agent and agent run CRUD operations (async, PostgreSQL). + +Agents are persistent, named, goal-oriented configurations that can be: + - Run manually (via the Agents UI or /api/agents/{id}/run) + - Scheduled with a cron expression (managed by AgentRunner + APScheduler) + - Triggered by email, Telegram, webhooks, or monitors + +agent_runs records every execution — input/output tokens, status, result text. +This is the source of truth for the Agents UI run history and token totals. + +Design note on allowed_tools: + - NULL in DB means "all tools" (unlimited access) + - [] (empty list) is falsy in Python — treated identically to NULL + - Non-empty list restricts the agent to exactly those tools + - This is enforced structurally: only declared schemas are sent to the model; + it is impossible for the model to call a tool it wasn't given a schema for. """ from __future__ import annotations @@ -161,6 +176,8 @@ async def finish_run( output_tokens: int = 0, result: str | None = None, error: str | None = None, + model: str | None = None, + cost_usd: float | None = None, ) -> dict | None: now = _now() pool = await get_pool() @@ -168,10 +185,12 @@ async def finish_run( """ UPDATE agent_runs SET ended_at = $1, status = $2, input_tokens = $3, - output_tokens = $4, result = $5, error = $6 - WHERE id = $7 + output_tokens = $4, result = $5, error = $6, + model = $7, cost_usd = $8 + WHERE id = $9 """, - now, status, input_tokens, output_tokens, result, error, run_id, + now, status, input_tokens, output_tokens, result, error, + model or None, cost_usd, run_id, ) return await get_run(run_id) diff --git a/server/database.py b/server/database.py index 9bd83dc..1e0161e 100644 --- a/server/database.py +++ b/server/database.py @@ -465,6 +465,29 @@ _MIGRATIONS: list[list[str]] = [ [ "ALTER TABLE webhook_targets ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE CASCADE", ], + # v28 — Browser trusted domains (per-user interaction pre-approval) + [ + """ + CREATE TABLE IF NOT EXISTS browser_approved_domains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + domain TEXT NOT NULL, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (owner_user_id, domain) + ) + """, + ], + # v29 — Track model per agent run for usage/cost overview + [ + "ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS model TEXT", + ], + # v30 — Track token usage and cost per chat conversation + [ + "ALTER TABLE conversations ADD COLUMN IF NOT EXISTS input_tokens INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE conversations ADD COLUMN IF NOT EXISTS output_tokens INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE conversations ADD COLUMN IF NOT EXISTS cost_usd REAL", + ], ] @@ -821,7 +844,15 @@ filesystem_whitelist_store = FilesystemWhitelistStore() # ─── Initialisation ─────────────────────────────────────────────────────────── async def _init_connection(conn: asyncpg.Connection) -> None: - """Register codecs on every new connection so asyncpg handles JSONB ↔ dict.""" + """ + Register codecs on every new connection so asyncpg handles JSONB ↔ dict automatically. + + Critical: asyncpg does NOT auto-serialize Python dicts to PostgreSQL JSONB. + Without this, inserting a dict into a JSONB column raises: + asyncpg.exceptions.UnsupportedClientFeatureError: ... + The codecs must be registered on every connection (not just once on the pool), + which is why this is passed as `init=` to create_pool(). + """ await conn.set_type_codec( "jsonb", encoder=json.dumps, diff --git a/server/inbox/listener.py b/server/inbox/listener.py index 0153db5..1262e3a 100644 --- a/server/inbox/listener.py +++ b/server/inbox/listener.py @@ -85,6 +85,14 @@ class EmailAccountListener: # ── Main loop ───────────────────────────────────────────────────────────── async def _run_loop(self) -> None: + """ + Outer retry loop. Restarts the inner trigger/handling loop on any error + with exponential backoff (5s → 10s → 20s → ... → 60s max). + + CancelledError is propagated cleanly (listener.stop() cancels the task). + Any other exception is logged and retried — typical causes: IMAP disconnect, + network timeout, credential rotation. Backoff resets on successful cycle. + """ backoff = 5 while True: try: diff --git a/server/main.py b/server/main.py index 86c7dab..3668fac 100644 --- a/server/main.py +++ b/server/main.py @@ -2,9 +2,38 @@ main.py — FastAPI application entry point. Provides: - - HTML pages: /, /agents, /audit, /settings, /login, /setup, /admin/users - - WebSocket: /ws/{session_id} (streaming agent responses) - - REST API: /api/* + - HTML pages: /, /chats, /agents, /models, /audit, /monitors, /files, + /settings, /login, /setup, /admin/users, /help + - WebSocket: /ws/{session_id} (streaming agent responses) + - REST API: /api/* (see server/web/routes.py) + - Brain MCP: /brain-mcp/sse (MCP protocol for 2nd Brain access) + +Startup order (lifespan): + 1. init_db() — run PostgreSQL migrations, create pool + 2. _refresh_brand_globals() — load brand name/logo from DB into Jinja2 globals + 3. _ensure_session_secret() — auto-generate HMAC signing secret if not set + 4. check user_count() — set _needs_setup flag (redirects to /setup if 0 users) + 5. cleanup_stale_runs() — mark any interrupted "running" agent_runs as "error" + 6. init_brain_db() — connect to brain PostgreSQL (pgvector) + 7. build_registry() — create ToolRegistry with all production tools + 8. discover_and_register_mcp_tools() — connect to configured MCP servers and add their tools + 9. Agent(registry=...) — create the singleton agent loop + 10. agent_runner.init/start() — load agent cron schedules into APScheduler, start scheduler + 11. page_monitor / rss_monitor — wire into shared APScheduler, load schedules + 12. _migrate_email_accounts() — one-time migration of old inbox:* credentials + 13. inbox_listener.start_all() — start IMAP listeners for all email accounts + 14. telegram_listener.start() — start Telegram long-polling listener + 15. _session_manager.run() — start Brain MCP session manager + +Key singletons (module-level, shared across all requests): + _agent: Agent instance — owns in-memory session history + _registry: ToolRegistry — the set of available tools + Both are initialised in lifespan() and referenced by the WebSocket handler. + +Auth middleware (_AuthMiddleware): + Runs on every request before route handlers. Validates the session cookie or + API key, stores the resolved CurrentUser in the current_user ContextVar. + Routes use _require_auth() / _require_admin() helpers to enforce access control. """ from __future__ import annotations @@ -716,6 +745,22 @@ async def setup_post(request: Request): # ── HTML pages ──────────────────────────────────────────────────────────────── +async def _user_can_view_usage(user) -> bool: + """Admins always; non-admins only if they have their own API key and aren't on admin keys.""" + if not user: + return False + if user.is_admin: + return True + from .database import user_settings_store + if await user_settings_store.get(user.id, "use_admin_keys"): + return False + return bool( + await user_settings_store.get(user.id, "anthropic_api_key") or + await user_settings_store.get(user.id, "openrouter_api_key") or + await user_settings_store.get(user.id, "openai_api_key") + ) + + async def _ctx(request: Request, **extra): """Build template context with current_user and active theme CSS injected.""" from .web.themes import get_theme_css, DEFAULT_THEME @@ -734,6 +779,7 @@ async def _ctx(request: Request, **extra): "current_user": user, "theme_css": theme_css, "needs_personality_setup": needs_personality_setup, + "can_view_usage": await _user_can_view_usage(user), **extra, } @@ -765,6 +811,15 @@ async def models_page(request: Request): return templates.TemplateResponse("models.html", await _ctx(request)) +@app.get("/usage", response_class=HTMLResponse) +async def usage_page(request: Request): + user = _get_current_user(request) + if not await _user_can_view_usage(user): + from fastapi.responses import RedirectResponse + return RedirectResponse("/", status_code=303) + return templates.TemplateResponse("usage.html", await _ctx(request)) + + @app.get("/audit", response_class=HTMLResponse) async def audit_page(request: Request): return templates.TemplateResponse("audit.html", await _ctx(request)) diff --git a/server/providers/models.py b/server/providers/models.py index a192d39..27db0e7 100644 --- a/server/providers/models.py +++ b/server/providers/models.py @@ -372,6 +372,50 @@ async def get_models_info( return results +def get_model_pricing(model_id: str) -> tuple[float | None, float | None]: + """ + Return (prompt_per_1m, completion_per_1m) in USD for the given model ID. + + Uses only in-memory data (hardcoded Anthropic/OpenAI + cached OpenRouter raw). + Returns (None, None) if pricing is unknown for this model. + + model_id format: "anthropic:claude-sonnet-4-6", "openrouter:openai/gpt-4o", "openai:gpt-4o" + """ + for m in _ANTHROPIC_MODEL_INFO: + if m["id"] == model_id: + p = m["pricing"] + return p["prompt_per_1m"], p["completion_per_1m"] + for m in _OPENAI_MODEL_INFO: + if m["id"] == model_id: + p = m["pricing"] + return p["prompt_per_1m"], p["completion_per_1m"] + # OpenRouter: strip "openrouter:" prefix to get the bare OR model id + if model_id.startswith("openrouter:"): + bare = model_id[len("openrouter:"):] + for m in _or_raw: + if m.get("id", "") == bare and not _is_free_openrouter(m): + pricing = m.get("pricing", {}) + try: + prompt = float(pricing.get("prompt", 0)) * 1_000_000 + completion = float(pricing.get("completion", 0)) * 1_000_000 + return prompt, completion + except (TypeError, ValueError): + return None, None + return None, None + + +def compute_cost_usd( + model_id: str, + input_tokens: int, + output_tokens: int, +) -> float | None: + """Compute estimated cost in USD for a completed run. Returns None if pricing unknown.""" + prompt_per_1m, completion_per_1m = get_model_pricing(model_id) + if prompt_per_1m is None or completion_per_1m is None: + return None + return (input_tokens / 1_000_000) * prompt_per_1m + (output_tokens / 1_000_000) * completion_per_1m + + async def get_access_tier( user_id: str | None = None, is_admin: bool = True, diff --git a/server/tools/base.py b/server/tools/base.py index 9427d9e..29345d5 100644 --- a/server/tools/base.py +++ b/server/tools/base.py @@ -62,6 +62,14 @@ class BaseTool(ABC): "input_schema": self.input_schema, } + async def should_confirm(self, **kwargs) -> bool: + """ + Return True if this specific call requires confirmation. + Override in subclasses for per-call logic (e.g. domain allow-lists). + Default: return self.requires_confirmation. + """ + return self.requires_confirmation + def confirmation_description(self, **kwargs) -> str: """ Human-readable description of the action shown to the user diff --git a/server/tools/browser_tool.py b/server/tools/browser_tool.py index fb75343..e0e0d34 100644 --- a/server/tools/browser_tool.py +++ b/server/tools/browser_tool.py @@ -1,9 +1,12 @@ """ tools/browser_tool.py — Playwright headless browser tool. -For JS-heavy pages that httpx can't render. Enforces the same Tier 1/2 -web whitelist as WebTool. Browser instance is lazy-initialized and shared -across calls. +Read operations (fetch_page, screenshot) never require confirmation. +Interactive operations (click, fill, select, press) require confirmation +unless the target domain is in the user's browser_approved_domains list. + +Sessions are stateful within a session_id: navigate with fetch_page first, +then use interactive ops without a url to act on the current page. Requires: playwright package + `playwright install chromium` """ @@ -13,7 +16,7 @@ import asyncio import logging from typing import ClassVar -from ..context_vars import current_task_id, web_tier2_enabled +from ..context_vars import current_task_id, current_session_id, web_tier2_enabled from ..security import assert_domain_tier1, sanitize_external_content from .base import BaseTool, ToolResult @@ -21,14 +24,39 @@ logger = logging.getLogger(__name__) _MAX_TEXT_CHARS = 25_000 _TIMEOUT_MS = 30_000 +_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + +_INTERACTIVE_OPS = {"click", "fill", "select", "press"} + + +async def _is_domain_approved(user_id: str, hostname: str) -> bool: + """Return True if hostname (or a parent domain) is in the user's approved list.""" + from ..database import get_pool + pool = await get_pool() + rows = await pool.fetch( + "SELECT domain FROM browser_approved_domains WHERE owner_user_id = $1", + user_id, + ) + hostname = hostname.lower() + for row in rows: + d = row["domain"].lower().lstrip("*.") + if hostname == d or hostname.endswith("." + d): + return True + return False class BrowserTool(BaseTool): name = "browser" description = ( - "Fetch web pages using a real headless browser (Chromium). " - "Use this for JS-heavy pages or single-page apps that the regular 'web' tool cannot read. " - "Operations: fetch_page (extract text content), screenshot (base64 PNG). " + "Headless Chromium browser for JS-heavy pages and web interactions. " + "Read ops: fetch_page (extract text), screenshot (PNG). " + "Interactive ops: click, fill (type into field), select (dropdown), press (keyboard key). " + "Interactive ops require confirmation unless the domain is in your Browser Trusted Domains list. " + "Page state is kept across calls within the same session — navigate with fetch_page first, " + "then use interactive ops (omit url to stay on the current page). " "Follows the same domain whitelist rules as the web tool." ) input_schema = { @@ -36,87 +64,129 @@ class BrowserTool(BaseTool): "properties": { "operation": { "type": "string", - "enum": ["fetch_page", "screenshot"], - "description": "fetch_page extracts text; screenshot returns a base64 PNG.", + "enum": ["fetch_page", "screenshot", "click", "fill", "select", "press"], + "description": ( + "fetch_page: extract page text. " + "screenshot: capture PNG. " + "click: click an element (selector required). " + "fill: type into a field (selector + value required). " + "select: choose a + +
+ + +
+ + + + + +