Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions backend/app/api/endpoints/local_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async def sync_device_capabilities(
device_id=device_id,
skill_ids=request.skill_ids,
installed_skill_ids=request.installed_skill_ids,
installed_plugin_ids=request.installed_plugin_ids,
installed_mcp_ids=request.installed_mcp_ids,
mcp_ids=request.mcp_ids,
mode=request.mode,
Expand Down
1 change: 1 addition & 0 deletions backend/app/api/ws/device_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ async def _store_device_capabilities_state(
"revision": revision,
"digest": digest,
"skills": capabilities.get("skills", []),
"plugins": capabilities.get("plugins", []),
"mcps": capabilities.get("mcps", []),
"last_sync_at": capabilities.get("last_sync_at"),
},
Expand Down
6 changes: 6 additions & 0 deletions backend/app/schemas/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ class DeviceCapabilitySyncRequest(BaseModel):
default_factory=list,
description="InstalledMCP Kind IDs to resolve and sync.",
)
installed_plugin_ids: List[int] = Field(
default_factory=list,
description="InstalledPlugin Kind IDs to resolve and sync.",
)
mcp_ids: List[str] = Field(
default_factory=list,
description="Deprecated server-key based MCP IDs; use installed_mcp_ids.",
Expand Down Expand Up @@ -226,6 +230,7 @@ class DeviceCapabilitySyncResult(BaseModel):
success: bool
error: Optional[str] = None
skills: List[DeviceCapabilityItemResult] = Field(default_factory=list)
plugins: List[DeviceCapabilityItemResult] = Field(default_factory=list)
mcps: List[DeviceCapabilityItemResult] = Field(default_factory=list)
errors: List[Dict[str, Any]] = Field(default_factory=list)

Expand All @@ -237,6 +242,7 @@ class DeviceCapabilitySyncResponse(BaseModel):
device_id: str = ""
mode: Literal["merge", "replace"] = "merge"
skills: List[DeviceCapabilityItemResult] = Field(default_factory=list)
plugins: List[DeviceCapabilityItemResult] = Field(default_factory=list)
mcps: List[DeviceCapabilityItemResult] = Field(default_factory=list)
errors: List[Dict[str, Any]] = Field(default_factory=list)
synced: int = 0
Expand Down
124 changes: 119 additions & 5 deletions backend/app/services/device/capability_sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,17 @@ def build_desired_capabilities(
"""Build the full enabled capability set from user install records."""
self._validate_mode(mode)
skills = self._load_enabled_installed_skills(db, user_id=user_id)
plugins = self._load_enabled_installed_plugins(db, user_id=user_id)
mcps = self._load_enabled_installed_mcps(db, user_id=user_id)
logger.info(
"Built desired capabilities: user_id=%s mode=%s skill_names=%s mcp_names=%s",
"Built desired capabilities: user_id=%s mode=%s skill_names=%s plugin_names=%s mcp_names=%s",
user_id,
mode,
[item.get("name") for item in skills],
[item.get("name") for item in plugins],
[item.get("name") for item in mcps],
)
return {"mode": mode, "skills": skills, "mcps": mcps}
return {"mode": mode, "skills": skills, "plugins": plugins, "mcps": mcps}

def resolve_payload(
self,
Expand All @@ -67,6 +69,7 @@ def resolve_payload(
user: User,
skill_ids: list[int],
installed_skill_ids: Optional[list[int]] = None,
installed_plugin_ids: Optional[list[int]] = None,
installed_mcp_ids: Optional[list[int]] = None,
mcp_ids: Optional[list[str]] = None,
mode: str = "merge",
Expand All @@ -89,6 +92,14 @@ def resolve_payload(
strict=True,
),
}
plugins = self._resolve_plugin_payloads(
db,
user_id=user.id,
installed_plugin_ids=installed_plugin_ids or [],
strict=True,
)
if plugins:
payload["plugins"] = plugins
mcps = self._resolve_mcp_payloads(
db,
user_id=user.id,
Expand All @@ -110,10 +121,11 @@ async def sync_user_global_capabilities(
payload = self.build_desired_capabilities(db, user_id=user_id, mode=mode)
devices = await device_service.get_online_devices(db, user_id)
logger.info(
"Syncing global capabilities: user_id=%s mode=%s skills=%s mcps=%s online_devices=%s",
"Syncing global capabilities: user_id=%s mode=%s skills=%s plugins=%s mcps=%s online_devices=%s",
user_id,
mode,
len(payload.get("skills") or []),
len(payload.get("plugins") or []),
len(payload.get("mcps") or []),
len(devices),
)
Expand Down Expand Up @@ -148,6 +160,7 @@ async def sync_device_capabilities(
device_id: str,
skill_ids: list[int],
installed_skill_ids: Optional[Iterable[int]] = None,
installed_plugin_ids: Optional[Iterable[int]] = None,
installed_mcp_ids: Optional[Iterable[int]] = None,
mcp_ids: Optional[list[str]] = None,
mode: str = "merge",
Expand All @@ -164,6 +177,7 @@ async def sync_device_capabilities(
user=user,
skill_ids=skill_ids,
installed_skill_ids=list(installed_skill_ids or []),
installed_plugin_ids=list(installed_plugin_ids or []),
installed_mcp_ids=list(installed_mcp_ids or []),
mcp_ids=mcp_ids,
mode=mode,
Expand All @@ -184,6 +198,7 @@ async def sync_device_selected_capabilities(
device_id: str,
skill_ids: Optional[Iterable[int]] = None,
installed_skill_ids: Optional[Iterable[int]] = None,
installed_plugin_ids: Optional[Iterable[int]] = None,
installed_mcp_ids: Optional[Iterable[int]] = None,
mode: str = "replace",
) -> DeviceCapabilitySyncResponse:
Expand All @@ -195,6 +210,7 @@ async def sync_device_selected_capabilities(
device_id=device_id,
skill_ids=list(skill_ids or []),
installed_skill_ids=list(installed_skill_ids or []),
installed_plugin_ids=list(installed_plugin_ids or []),
installed_mcp_ids=list(installed_mcp_ids or []),
mode=mode,
)
Expand Down Expand Up @@ -223,12 +239,13 @@ async def sync_device_payload(

try:
logger.info(
"Sending capability sync: user_id=%s device_id=%s socket_id=%s mode=%s skill_names=%s mcp_names=%s",
"Sending capability sync: user_id=%s device_id=%s socket_id=%s mode=%s skill_names=%s plugin_names=%s mcp_names=%s",
user_id,
device_id,
socket_id,
payload.get("mode"),
[item.get("name") for item in payload.get("skills") or []],
[item.get("name") for item in payload.get("plugins") or []],
[item.get("name") for item in payload.get("mcps") or []],
)
response = await get_sio().call(
Expand Down Expand Up @@ -275,20 +292,23 @@ async def sync_device_payload(
success=False,
error=str(response.get("error") or "device rejected sync"),
skills=response.get("skills", []),
plugins=response.get("plugins", []),
mcps=response.get("mcps", []),
errors=response.get("errors", []),
)
logger.info(
"Capability sync delivered: user_id=%s device_id=%s skills=%s mcps=%s",
"Capability sync delivered: user_id=%s device_id=%s skills=%s plugins=%s mcps=%s",
user_id,
device_id,
len(response.get("skills") or []),
len(response.get("plugins") or []),
len(response.get("mcps") or []),
)
return DeviceCapabilitySyncResult(
device_id=device_id,
success=True,
skills=response.get("skills", []),
plugins=response.get("plugins", []),
mcps=response.get("mcps", []),
errors=response.get("errors", []),
)
Expand Down Expand Up @@ -339,6 +359,34 @@ def _load_enabled_installed_skills(
installed_skill_ids=enabled_ids,
)

def _load_enabled_installed_plugins(
self,
db: Session,
*,
user_id: int,
) -> list[dict[str, Any]]:
rows = (
db.query(Kind)
.filter(
and_(
Kind.user_id == user_id,
Kind.kind == "InstalledPlugin",
Kind.namespace == "default",
)
)
.all()
)
enabled_ids = [
row.id
for row in rows
if self._should_include_installed_plugin(user_id, row)
]
return self._resolve_plugin_payloads(
db,
user_id=user_id,
installed_plugin_ids=enabled_ids,
)

def _load_enabled_installed_mcps(
self,
db: Session,
Expand Down Expand Up @@ -431,6 +479,41 @@ def _resolve_skill_payloads(

return payloads

def _resolve_plugin_payloads(
self,
db: Session,
*,
user_id: int,
installed_plugin_ids: Optional[list[int]] = None,
strict: bool = False,
) -> list[dict[str, Any]]:
payloads: list[dict[str, Any]] = []
seen_ids: set[int] = set()
for installed_id in installed_plugin_ids or []:
installed = self._get_user_kind(
db,
user_id=user_id,
kind="InstalledPlugin",
kind_id=installed_id,
)
if not installed:
logger.warning(
"InstalledPlugin id not found while resolving sync payload: user_id=%s installed_id=%s",
user_id,
installed_id,
)
if strict:
raise DeviceCapabilityResolutionError(
f"InstalledPlugin not found or access denied: {installed_id}",
404,
)
continue
if installed.id in seen_ids:
continue
payloads.append(self._plugin_payload(installed))
seen_ids.add(installed.id)
return payloads

def _resolve_mcp_payloads(
self,
db: Session,
Expand Down Expand Up @@ -549,6 +632,20 @@ def _skill_payload(
),
}

def _plugin_payload(self, installed: Kind) -> dict[str, Any]:
spec = installed.json.get("spec", {})
source = spec.get("source") or {}
marketplace = spec.get("marketplace") or source.get("marketplace")
return {
"installed_plugin_id": installed.id,
"name": installed.name,
"display_name": spec.get("displayName") or installed.name,
"description": spec.get("description", ""),
"marketplace": marketplace,
"version": spec.get("version"),
"source": source,
}

def _aggregate_response(
self,
results: list[DeviceCapabilitySyncResult],
Expand All @@ -569,6 +666,7 @@ def _aggregate_response(
device_id=first.device_id if first else "",
mode=mode,
skills=first.skills if first else [],
plugins=first.plugins if first else [],
mcps=first.mcps if first else [],
errors=errors,
synced=synced,
Expand Down Expand Up @@ -610,6 +708,22 @@ def _should_include_installed_skill(self, user_id: int, row: Kind) -> bool:
)
return include

def _should_include_installed_plugin(self, user_id: int, row: Kind) -> bool:
spec = row.json.get("spec", {})
include = row.is_active and self._is_enabled_install(row)
logger.info(
"Desired-state InstalledPlugin candidate: user_id=%s installed_id=%s name=%s active=%s enabled=%s state=%s source=%s include=%s",
user_id,
row.id,
row.name,
row.is_active,
spec.get("enabled", True),
spec.get("installState", "installed"),
spec.get("source"),
include,
)
return include
Comment on lines +711 to +725

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging raw plugin source metadata.

spec["source"] is installation metadata and can carry private URLs or credentials for custom plugin sources. Logging the whole dict at info level leaks that data into backend logs; log stable identifiers such as marketplace/name/state instead.

🛡️ Proposed fix
     def _should_include_installed_plugin(self, user_id: int, row: Kind) -> bool:
         spec = row.json.get("spec", {})
         include = row.is_active and self._is_enabled_install(row)
         logger.info(
-            "Desired-state InstalledPlugin candidate: user_id=%s installed_id=%s name=%s active=%s enabled=%s state=%s source=%s include=%s",
+            "Desired-state InstalledPlugin candidate: user_id=%s installed_id=%s name=%s active=%s enabled=%s state=%s marketplace=%s include=%s",
             user_id,
             row.id,
             row.name,
             row.is_active,
             spec.get("enabled", True),
             spec.get("installState", "installed"),
-            spec.get("source"),
+            spec.get("marketplace")
+            or (spec.get("source") or {}).get("marketplace"),
             include,
         )
         return include

As per coding guidelines, backend/**/*.py: Backend encrypts Git tokens and API keys using AES-256-CBC.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _should_include_installed_plugin(self, user_id: int, row: Kind) -> bool:
spec = row.json.get("spec", {})
include = row.is_active and self._is_enabled_install(row)
logger.info(
"Desired-state InstalledPlugin candidate: user_id=%s installed_id=%s name=%s active=%s enabled=%s state=%s source=%s include=%s",
user_id,
row.id,
row.name,
row.is_active,
spec.get("enabled", True),
spec.get("installState", "installed"),
spec.get("source"),
include,
)
return include
def _should_include_installed_plugin(self, user_id: int, row: Kind) -> bool:
spec = row.json.get("spec", {})
include = row.is_active and self._is_enabled_install(row)
logger.info(
"Desired-state InstalledPlugin candidate: user_id=%s installed_id=%s name=%s active=%s enabled=%s state=%s marketplace=%s include=%s",
user_id,
row.id,
row.name,
row.is_active,
spec.get("enabled", True),
spec.get("installState", "installed"),
spec.get("marketplace")
or (spec.get("source") or {}).get("marketplace"),
include,
)
return include
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/device/capability_sync_service.py` around lines 711 -
725, The _should_include_installed_plugin function is logging raw installation
metadata (spec["source"]) which may contain private URLs/credentials; remove or
sanitize that field in the logger call and instead log stable, non-sensitive
identifiers (e.g. spec.get("marketplace"), row.name,
spec.get("installState")/spec.get("state") or a normalized status). Update the
logger.info in _should_include_installed_plugin to omit spec.get("source") (or
replace it with a redacted marker) and include only
marketplace/name/state/enable flags to avoid leaking sensitive plugin source
data.


def _should_include_installed_mcp(self, user_id: int, row: Kind) -> bool:
spec = row.json.get("spec", {})
include = row.is_active and self._is_enabled_install(row)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/services/device/cloud_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ async def get_status(
"update_available": update_available,
"client_ip": spec.get("clientIp"),
"cloud_config": spec.get("cloudConfig"),
"bind_shell": spec.get("bindShell", "claudecode"),
}

async def list_devices(
Expand Down Expand Up @@ -191,6 +192,7 @@ async def list_devices(
"update_available": update_available,
"client_ip": spec.get("clientIp"),
"cloud_config": spec.get("cloudConfig"),
"bind_shell": spec.get("bindShell", "claudecode"),
}
)

Expand Down
62 changes: 62 additions & 0 deletions backend/tests/api/ws/test_device_capabilities_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# SPDX-FileCopyrightText: 2026 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

from unittest.mock import AsyncMock

import pytest

from app.api.ws import device_namespace


@pytest.mark.asyncio
async def test_store_device_capabilities_state_preserves_plugin_report(monkeypatch):
stored = {}

async def fake_store(user_id, device_id, capabilities):
stored["user_id"] = user_id
stored["device_id"] = device_id
stored["capabilities"] = capabilities
return True

monkeypatch.setattr(
device_namespace.device_service,
"get_device_capabilities_state",
AsyncMock(return_value=None),
)
monkeypatch.setattr(
device_namespace.device_service,
"store_device_capabilities_state",
fake_store,
)

await device_namespace._store_device_capabilities_state(
1,
"device-1",
{
"revision": 2,
"digest": "sha256:test",
"full": True,
"skills": [{"name": "browser", "source": "local_user"}],
"mcps": [{"name": "docs", "source": "wegent"}],
"plugins": [
{
"name": "context7",
"marketplace": "claude-plugins-official",
"scope": "user",
"version": "1057d02c5307",
"source": "local_user",
}
],
},
)

assert stored["capabilities"]["plugins"] == [
{
"name": "context7",
"marketplace": "claude-plugins-official",
"scope": "user",
"version": "1057d02c5307",
"source": "local_user",
}
]
Loading
Loading