-
Notifications
You must be signed in to change notification settings - Fork 586
feat: local-agent bridge with pluggable runtime adapters #452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
dc91ed8
8dc1b1c
51fc3ef
2fad498
64cb73d
387a76d
dfe781f
25743ba
824a88f
c6e26ed
e9431ae
2594074
9cd7b74
e765f9f
618ec21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| """Add bridge_attached / bridge_detached to activity_action_enum. | ||
|
|
||
| Revision ID: add_bridge_activity_enum | ||
| Revises: add_bridge_adapter | ||
|
|
||
| session_dispatcher.py logs these two events when a bridge connects | ||
| or disconnects, but the enum never included them — so every | ||
| attach/detach produced an InvalidTextRepresentationError and the | ||
| row was dropped. This backfills the enum values; existing rows | ||
| are unaffected. | ||
| """ | ||
| from alembic import op | ||
|
|
||
|
|
||
| revision = 'add_bridge_activity_enum' | ||
| down_revision = 'add_bridge_adapter' | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.execute("ALTER TYPE activity_action_enum ADD VALUE IF NOT EXISTS 'bridge_attached'") | ||
| op.execute("ALTER TYPE activity_action_enum ADD VALUE IF NOT EXISTS 'bridge_detached'") | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| # PostgreSQL does not support removing values from an enum type. | ||
| pass |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| """Add bridge_adapter column to agents for per-agent local runtime selection. | ||
|
|
||
| When agent_type='openclaw', bridge_adapter picks which local runtime the | ||
| downloaded bridge installer + session.start.adapter will target: | ||
| 'claude_code' | 'openclaw' | 'hermes'. | ||
|
|
||
| Backfill policy: existing openclaw agents get 'claude_code' (the de-facto | ||
| default TOML was only enabling claude_code). | ||
|
|
||
| Revision ID: add_bridge_adapter | ||
| Revises: add_bridge_mode | ||
| Create Date: 2026-04-22 | ||
| """ | ||
| from alembic import op | ||
|
|
||
|
|
||
| revision = "add_bridge_adapter" | ||
| down_revision = "add_bridge_mode" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.execute( | ||
| "ALTER TABLE agents ADD COLUMN IF NOT EXISTS bridge_adapter VARCHAR(32)" | ||
| ) | ||
| op.execute( | ||
| "UPDATE agents SET bridge_adapter='claude_code' " | ||
| "WHERE agent_type='openclaw' AND bridge_adapter IS NULL" | ||
| ) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS bridge_adapter") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| """Add bridge_mode column to agents for local-agent bridge integration. | ||
|
|
||
| Revision ID: add_bridge_mode | ||
| Revises: increase_api_key_length | ||
| Create Date: 2026-04-21 | ||
| """ | ||
| from alembic import op | ||
|
|
||
|
|
||
| revision = "add_bridge_mode" | ||
| down_revision = "increase_api_key_length" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.execute( | ||
| "ALTER TABLE agents ADD COLUMN IF NOT EXISTS bridge_mode " | ||
| "VARCHAR(16) NOT NULL DEFAULT 'disabled'" | ||
| ) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS bridge_mode") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """Add remaining bridge session/tool action types to activity_action_enum. | ||
|
|
||
| Revision ID: add_bridge_session_enum | ||
| Revises: add_bridge_activity_enum | ||
|
|
||
| Beyond bridge_attached/bridge_detached (added in the prior migration), | ||
| the bridge code also logs per-session events and reverse-tool-call | ||
| events. The enum was missing all of them, so every bridge session | ||
| silently dropped its audit trail. | ||
|
|
||
| Values added: | ||
| - bridge_installer_download (agents.py download_bridge_installer) | ||
| - local_session_start (session_dispatcher) | ||
| - local_session_done | ||
| - local_session_error | ||
| - reverse_tool_call (bridge-initiated tool calls) | ||
| - reverse_tool_result | ||
| """ | ||
| from alembic import op | ||
|
|
||
|
|
||
| revision = 'add_bridge_session_enum' | ||
| down_revision = 'add_bridge_activity_enum' | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| _NEW_VALUES = ( | ||
| "bridge_installer_download", | ||
| "local_session_start", | ||
| "local_session_done", | ||
| "local_session_error", | ||
| "reverse_tool_call", | ||
| "reverse_tool_result", | ||
| ) | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| for v in _NEW_VALUES: | ||
| op.execute(f"ALTER TYPE activity_action_enum ADD VALUE IF NOT EXISTS '{v}'") | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| # PostgreSQL does not support removing values from an enum type. | ||
| pass |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |
| from datetime import datetime, timezone | ||
| from pathlib import Path | ||
|
|
||
| from fastapi import APIRouter, Depends, HTTPException, status | ||
| from fastapi import APIRouter, Depends, HTTPException, Request, Response, status | ||
| from sqlalchemy import select | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
|
|
@@ -241,6 +241,11 @@ async def create_agent( | |
| creator_id=current_user.id, | ||
| tenant_id=target_tenant_id, | ||
| agent_type=data.agent_type or "native", | ||
| bridge_adapter=( | ||
| (data.bridge_adapter or "claude_code") | ||
| if (data.agent_type or "native") == "openclaw" | ||
| else None | ||
| ), | ||
| primary_model_id=data.primary_model_id, | ||
| fallback_model_id=data.fallback_model_id, | ||
| max_tokens_per_day=data.max_tokens_per_day, | ||
|
|
@@ -289,6 +294,7 @@ async def create_agent( | |
| raw_key = f"oc-{secrets.token_urlsafe(32)}" | ||
| agent.api_key_hash = hashlib.sha256(raw_key.encode()).hexdigest() | ||
| agent.status = "idle" | ||
| agent.bridge_mode = "enabled" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
New OpenClaw agents are forced to Useful? React with 👍 / 👎. |
||
| await db.commit() | ||
| out = AgentOut.model_validate(agent).model_dump() | ||
| out["api_key"] = raw_key # Return once on creation | ||
|
|
@@ -477,6 +483,12 @@ async def update_agent( | |
|
|
||
| update_data = data.model_dump(exclude_unset=True) | ||
|
|
||
| # bridge_adapter: only meaningful for bridge-style agents. Silently | ||
| # drop the field for native agents instead of erroring, so generic | ||
| # bulk-update flows don't have to know the agent type. | ||
| if "bridge_adapter" in update_data and getattr(agent, "agent_type", "native") != "openclaw": | ||
| update_data.pop("bridge_adapter", None) | ||
|
|
||
| # expires_at: admin only | ||
| if "expires_at" in update_data: | ||
| if not is_admin: | ||
|
|
@@ -779,6 +791,130 @@ async def generate_or_reset_api_key( | |
| return {"api_key": raw_key, "message": "Key configured successfully."} | ||
|
|
||
|
|
||
| @router.post("/{agent_id}/bridge-installer") | ||
| async def download_bridge_installer( | ||
| agent_id: uuid.UUID, | ||
| request: Request, | ||
| platform: str, | ||
| current_user: User = Depends(get_current_user), | ||
| db: AsyncSession = Depends(get_db), | ||
| ): | ||
| """Generate a fresh API key and return a platform-specific bridge installer script. | ||
|
|
||
| Each download regenerates the agent's API key; previously-issued installers | ||
| (and any bridges still using the old key) will stop working. This is by | ||
| design — the key is the only secret in the installer, and short-lived | ||
| secrets limit blast radius if a user accidentally shares the file. | ||
| """ | ||
| from app.services.local_agent.installer_templates import ( | ||
| derive_ws_url, | ||
| render_installer, | ||
| ) | ||
| from app.config import get_settings | ||
|
|
||
| if platform not in ("windows", "macos", "linux"): | ||
| raise HTTPException(status_code=400, detail="platform must be windows, macos, or linux") | ||
|
|
||
| agent, _access = await check_agent_access(db, current_user, agent_id) | ||
| if not is_agent_creator(current_user, agent) and current_user.role not in ("platform_admin", "org_admin"): | ||
| raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator or admin can download bridge installers") | ||
| if getattr(agent, "agent_type", "native") != "openclaw": | ||
| raise HTTPException(status_code=400, detail="Bridge installer is only available for OpenClaw agents") | ||
|
|
||
| # Regenerate the key (same pattern as /{agent_id}/api-key). This invalidates | ||
| # any previously-downloaded installer. | ||
| raw_key = f"oc-{secrets.token_urlsafe(32)}" | ||
| agent.api_key_hash = hashlib.sha256(raw_key.encode()).hexdigest() | ||
|
|
||
| # Auto-enable bridge_mode if currently disabled — the user is clearly trying | ||
| # to set up a bridge, so the disabled mode would just reject their connection. | ||
| if getattr(agent, "bridge_mode", "disabled") == "disabled": | ||
| agent.bridge_mode = "enabled" | ||
|
|
||
| await db.commit() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Committing the regenerated key before calling Useful? React with 👍 / 👎. |
||
|
|
||
| # Resolve server URL. Prefer the configured PUBLIC_BASE_URL; fall back to | ||
| # the request's Host header (useful for dev / local testing). | ||
| settings = get_settings() | ||
| http_base = (settings.PUBLIC_BASE_URL or "").rstrip("/") | ||
| if not http_base: | ||
| forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme) | ||
| forwarded_host = request.headers.get("x-forwarded-host", request.headers.get("host", f"{request.url.hostname}:{request.url.port or 80}")) | ||
| http_base = f"{forwarded_proto}://{forwarded_host}" | ||
| ws_url = derive_ws_url(http_base) | ||
|
|
||
| try: | ||
| payload, filename, content_type = render_installer( | ||
| platform=platform, # type: ignore[arg-type] | ||
| server_url=ws_url, | ||
| api_key=raw_key, | ||
| agent_name=agent.name or str(agent.id), | ||
| adapter=getattr(agent, "bridge_adapter", None) or "claude_code", | ||
| ) | ||
| except FileNotFoundError as e: | ||
| # Bundled Windows exe missing — operator needs to build & drop it in. | ||
| raise HTTPException(status_code=503, detail=str(e)) from e | ||
|
|
||
| # Audit log (best-effort) | ||
| try: | ||
| from app.services.activity_logger import log_activity | ||
| await log_activity( | ||
| agent_id=agent.id, | ||
| action_type="bridge_installer_download", | ||
| summary=f"Bridge 安装器已下载 ({platform}),API Key 已重新生成", | ||
| detail={ | ||
| "platform": platform, | ||
| "user_id": str(current_user.id), | ||
| "server_url": ws_url, | ||
| "filename": filename, | ||
| }, | ||
| ) | ||
| except Exception: # noqa: BLE001 | ||
| pass | ||
|
|
||
| return Response( | ||
| content=payload, | ||
| media_type=content_type, | ||
| headers={ | ||
| "Content-Disposition": f'attachment; filename="{filename}"', | ||
| "X-Clawith-Server": ws_url, | ||
| "X-Clawith-Filename": filename, | ||
| }, | ||
| ) | ||
|
|
||
|
|
||
| @router.get("/{agent_id}/bridge-status") | ||
| async def get_bridge_status( | ||
| agent_id: uuid.UUID, | ||
| current_user: User = Depends(get_current_user), | ||
| db: AsyncSession = Depends(get_db), | ||
| ): | ||
| """Return live bridge connection status for this agent. | ||
|
|
||
| Used by the AgentDetail page to show an online/offline badge so | ||
| users don't have to discover bridge-offline state by failing to | ||
| chat. In-memory only (session_dispatcher._bridges), so correct | ||
| for the single-process backend; a Redis presence map would be | ||
| needed for multi-worker deploys. | ||
| """ | ||
| agent, _access = await check_agent_access(db, current_user, agent_id) | ||
| if getattr(agent, "agent_type", "native") != "openclaw": | ||
| return {"connected": False, "applicable": False} | ||
|
|
||
| from app.services.local_agent.session_dispatcher import dispatcher | ||
| info = dispatcher.get_bridge_info(str(agent_id)) | ||
| if info is None: | ||
| return {"connected": False, "applicable": True} | ||
| return { | ||
| "connected": True, | ||
| "applicable": True, | ||
| "bridge_version": info.get("bridge_version"), | ||
| "adapters": info.get("adapters") or [], | ||
| "connected_at": info.get("connected_at"), | ||
| "active_sessions": len(info.get("active_sessions") or []), | ||
| } | ||
|
|
||
|
|
||
| @router.get("/{agent_id}/gateway-messages") | ||
| async def list_gateway_messages( | ||
| agent_id: uuid.UUID, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The create flow persists
data.bridge_adapterdirectly for OpenClaw agents without constraining it to supported adapters. A client can store any string, which later causes chat dispatch to fail every turn with "Selected runtime ... is not available" while installer generation quietly falls back toclaude_code, creating a persistent runtime mismatch for that agent. Add server-side validation/enumeration at creation time to reject unknown adapter values.Useful? React with 👍 / 👎.