Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
dc91ed8
feat: local-agent bridge with pluggable runtime adapters
TatsuKo-Tsukimi Apr 22, 2026
8dc1b1c
fix: add bridge_attached/bridge_detached to activity_action_enum
TatsuKo-Tsukimi Apr 22, 2026
51fc3ef
feat: bridge online/offline badge on AgentDetail + fill remaining enu…
TatsuKo-Tsukimi Apr 22, 2026
2fad498
feat: allow editing bridge_adapter post-creation with redownload prompt
TatsuKo-Tsukimi Apr 22, 2026
64cb73d
feat: surface bridge adapter mismatch proactively instead of on chat …
TatsuKo-Tsukimi Apr 22, 2026
387a76d
fix(security): close Codex-flagged bridge auth gaps
TatsuKo-Tsukimi Apr 22, 2026
dfe781f
test: add bridge-scoped tests for installer, schema, and enum migrations
TatsuKo-Tsukimi Apr 22, 2026
25743ba
fix(bridge): address Codex P1s — rotation rollback + subprocess timeo…
TatsuKo-Tsukimi Apr 22, 2026
824a88f
test: add endpoint coverage for bridge_adapter plumbing
TatsuKo-Tsukimi Apr 22, 2026
c6e26ed
feat(bridge): decouple installer download from API key rotation
TatsuKo-Tsukimi Apr 22, 2026
e9431ae
docs: add per-machine bridge design doc
TatsuKo-Tsukimi Apr 22, 2026
2594074
feat(frontend): split runtime selector into two-tier platform/local UX
TatsuKo-Tsukimi Apr 22, 2026
9cd7b74
feat(bridge): real OpenClaw adapter via ACP stdio subprocess
TatsuKo-Tsukimi Apr 22, 2026
e765f9f
fix(bridge): revoke attached bridge when API key is rotated
TatsuKo-Tsukimi Apr 22, 2026
618ec21
fix(backend): guard ss-local config with isfile() so missing mount st…
TatsuKo-Tsukimi Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ backend/agent_data/
ss-nodes.json
.data/

# Bundled bridge binary (built separately via bridge/clawith-bridge.spec)
backend/app/static/bridge/clawith-bridge.exe

# Ignore Antigravity / Claude Code agent configurations
.agent/
.agents/
Expand Down
28 changes: 28 additions & 0 deletions backend/alembic/versions/add_bridge_activity_enum.py
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
34 changes: 34 additions & 0 deletions backend/alembic/versions/add_bridge_adapter.py
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")
24 changes: 24 additions & 0 deletions backend/alembic/versions/add_bridge_mode.py
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")
45 changes: 45 additions & 0 deletions backend/alembic/versions/add_bridge_session_enum.py
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
138 changes: 137 additions & 1 deletion backend/app/api/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment on lines +244 to +247
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate bridge adapter before persisting OpenClaw agents

The create flow persists data.bridge_adapter directly 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 to claude_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 👍 / 👎.

),
primary_model_id=data.primary_model_id,
fallback_model_id=data.fallback_model_id,
max_tokens_per_day=data.max_tokens_per_day,
Expand Down Expand Up @@ -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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Default new OpenClaw agents to fallback-capable bridge mode

New OpenClaw agents are forced to bridge_mode="enabled" at creation, but the chat websocket path rejects requests when no bridge is connected instead of queueing to gateway fallback (the fallback path is only used for auto/disabled). This makes fresh agents fail chat out of the box until operators manually install/connect a bridge, even though creation still returns API-key-based onboarding.

Useful? React with 👍 / 👎.

await db.commit()
out = AgentOut.model_validate(agent).model_dump()
out["api_key"] = raw_key # Return once on creation
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Defer API key rotation until installer payload is built

Committing the regenerated key before calling render_installer means a failed download (for example, Windows binary missing and render_installer raising FileNotFoundError) still invalidates the currently running bridge token. In that failure path the API returns 503, but the old key is already revoked, so operators lose connectivity without receiving a usable installer. Move the commit after successful payload generation (or roll back on generation errors) so failed downloads are side-effect free.

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,
Expand Down
Loading