Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions backend/app/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
from app.api.endpoints.internal import (
callback_router,
chat_storage_router,
devices_router as internal_devices_router,
services_router,
skills_router,
subscriptions_router,
Expand Down Expand Up @@ -175,6 +176,9 @@

api_router.include_router(skills_router, prefix="/internal", tags=["internal-skills"])
api_router.include_router(tables_router, prefix="/internal", tags=["internal-tables"])
api_router.include_router(
internal_devices_router, prefix="/internal", tags=["internal-devices"]
)
api_router.include_router(
internal_bots_router, prefix="/internal", tags=["internal-bots"]
)
Expand Down
77 changes: 77 additions & 0 deletions backend/app/api/endpoints/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,42 @@ class DeviceUpgradeResponse(BaseModel):
message: str = Field(..., description="Human-readable status message")


class DeviceSandboxExecRequest(BaseModel):
"""Request model for executing a command on a user device."""

command: str = Field(..., min_length=1, description="Command to execute")
working_dir: str = Field(
default="/home/user",
description="Working directory for command execution",
)
timeout_seconds: int = Field(
default=300,
ge=1,
le=1800,
description="Command timeout in seconds",
)
required_capability: Optional[str] = Field(
default=None,
description="Optional device capability required for routing",
)
device_id: Optional[str] = Field(
default=None,
description="Optional explicit device ID override",
)


class DeviceSandboxExecResponse(BaseModel):
"""Response model for a device-backed command execution."""

success: bool = Field(..., description="Whether the command succeeded")
stdout: str = Field(default="", description="Standard output")
stderr: str = Field(default="", description="Standard error")
exit_code: int = Field(..., description="Process exit code")
execution_time: float = Field(..., description="Execution time in seconds")
device_id: str = Field(..., description="Device that executed the command")
backend: str = Field(default="device", description="Execution backend identifier")


@router.get("", response_model=DeviceListResponse)
async def get_all_devices(
db: Session = Depends(get_db),
Expand Down Expand Up @@ -162,6 +198,47 @@ async def delete_device(
return {"message": f"Device '{device_id}' deleted"}


@router.post("/sandbox/exec", response_model=DeviceSandboxExecResponse)
async def execute_device_sandbox_command(
request: DeviceSandboxExecRequest,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
) -> DeviceSandboxExecResponse:
"""
Execute a command on an online user device through the existing device channel.

The backend selects a compatible online device, forwards the command over
`/local-executor`, and returns the device's execution result.
"""
from app.services.device_sandbox_service import (
DeviceSandboxError,
device_sandbox_service,
)

try:
result = await device_sandbox_service.execute_command(
db=db,
user_id=current_user.id,
command=request.command,
working_dir=request.working_dir,
timeout_seconds=request.timeout_seconds,
required_capability=request.required_capability,
device_id=request.device_id,
)
except DeviceSandboxError as exc:
logger.warning(
"[Device Sandbox] Command rejected: user_id=%s, error=%s",
current_user.id,
exc,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc

return DeviceSandboxExecResponse(**result)


@router.post("/{device_id}/upgrade", response_model=DeviceUpgradeResponse)
async def trigger_device_upgrade(
device_id: str,
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/endpoints/internal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .bots import router as bots_router
from .callback import router as callback_router
from .chat_storage import router as chat_storage_router
from .devices import router as devices_router
from .services import router as services_router
from .skills import router as skills_router
from .subscriptions import router as subscriptions_router
Expand All @@ -23,6 +24,7 @@
"bots_router",
"callback_router",
"chat_storage_router",
"devices_router",
"services_router",
"skills_router",
"subscriptions_router",
Expand Down
69 changes: 69 additions & 0 deletions backend/app/api/endpoints/internal/devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# SPDX-FileCopyrightText: 2025 Weibo, Inc.
#
# SPDX-License-Identifier: Apache-2.0

"""Internal device APIs for service-to-service communication."""

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session

from app.api.dependencies import get_db
from app.api.endpoints.devices import DeviceSandboxExecResponse

router = APIRouter(prefix="/devices", tags=["internal-devices"])


class InternalDeviceSandboxExecRequest(BaseModel):
"""Internal request model for device-backed command execution."""

user_id: int = Field(..., ge=1, description="Owner user ID")
command: str = Field(..., min_length=1, description="Command to execute")
working_dir: str = Field(
default="/home/user",
description="Working directory for command execution",
)
timeout_seconds: int = Field(
default=300,
ge=1,
le=1800,
description="Command timeout in seconds",
)
required_capability: str | None = Field(
default=None,
description="Optional device capability required for routing",
)
device_id: str | None = Field(
default=None,
description="Optional explicit device ID override",
)


@router.post("/sandbox/exec", response_model=DeviceSandboxExecResponse)
async def execute_device_sandbox_command_internal(
request: InternalDeviceSandboxExecRequest,
db: Session = Depends(get_db),
) -> DeviceSandboxExecResponse:
Comment on lines +154 to +158
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 | 🔴 Critical

Protect this internal exec endpoint with explicit internal authz/authn.

Line 42 exposes remote command execution with caller-controlled request.user_id. Combined with backend/app/api/api.py (Lines 178-181) including this router without route-level dependencies, this is a privilege-escalation path if /internal is reachable. Add a strict internal-service auth dependency and enforce impersonation policy before executing.

🔐 Example hardening sketch
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, status
+# from app.api.dependencies import require_internal_service_auth
...
-router = APIRouter(prefix="/devices", tags=["internal-devices"])
+router = APIRouter(
+    prefix="/devices",
+    tags=["internal-devices"],
+    # dependencies=[Depends(require_internal_service_auth)],
+)
🧰 Tools
🪛 Ruff (0.15.6)

[warning] 45-45: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/endpoints/internal/devices.py` around lines 42 - 46, The
execute_device_sandbox_command_internal endpoint currently allows
caller-controlled request.user_id; protect it by adding an explicit internal
auth dependency (e.g., add a Depends(get_internal_service_auth) or
Depends(verify_internal_service) to the execute_device_sandbox_command_internal
signature) and enforce an impersonation check before running sandbox commands:
use a policy function (e.g., can_impersonate(calling_service, request.user_id)
or enforce_impersonation_policy(auth_principal, request.user_id)) and return 403
if not allowed; keep the existing db: Session = Depends(get_db) and request:
InternalDeviceSandboxExecRequest but ensure the new auth dependency supplies the
caller identity used by the impersonation check.

"""Execute a command on a user's device for internal trusted services."""
Comment on lines +155 to +159
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.

🛠️ Refactor suggestion | 🟠 Major

Add OpenTelemetry tracing on the async endpoint handler.

Line 43 defines an async API handler without the required trace decorator/span metadata, which leaves this execution path under-instrumented.

As per coding guidelines, "Use @trace_async(span_name, tracer_name, extract_attributes) decorator to trace entire async functions in OpenTelemetry".

🧰 Tools
🪛 Ruff (0.15.6)

[warning] 45-45: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/endpoints/internal/devices.py` around lines 43 - 47, The
async handler execute_device_sandbox_command_internal is missing OpenTelemetry
tracing; add the trace_async decorator above the function using the project's
convention: `@trace_async`(span_name, tracer_name, extract_attributes). Import
trace_async if needed and choose a clear span_name like
"execute_device_sandbox_command_internal" and the appropriate tracer_name and
extract_attributes function used elsewhere (match other endpoints' usage) so the
entire async function is traced.

from app.services.device_sandbox_service import (
DeviceSandboxError,
device_sandbox_service,
)

try:
result = await device_sandbox_service.execute_command(
db=db,
user_id=request.user_id,
command=request.command,
working_dir=request.working_dir,
timeout_seconds=request.timeout_seconds,
required_capability=request.required_capability,
device_id=request.device_id,
)
except DeviceSandboxError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc

return DeviceSandboxExecResponse(**result)
3 changes: 3 additions & 0 deletions backend/app/api/ws/device_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def _register_device(
client_ip: Optional[str] = None,
device_type: Optional[str] = None,
bind_shell: Optional[str] = None,
capabilities: Optional[list[str]] = None,
) -> tuple[bool, Optional[str]]:
"""
Register or update device CRD in database.
Expand All @@ -189,6 +190,7 @@ def _register_device(
client_ip=client_ip,
device_type=device_type,
bind_shell=bind_shell,
capabilities=capabilities,
)
return True, None
except Exception as e:
Expand Down Expand Up @@ -759,6 +761,7 @@ async def on_device_register(self, sid: str, data: dict) -> dict:
payload.client_ip,
payload.device_type.value,
payload.bind_shell.value,
payload.capabilities,
)
if not success:
return {"error": f"Registration failed: {error}"}
Expand Down
Loading
Loading