Compare commits

..
2 Commits
Author SHA1 Message Date
q792602257andClaude Opus 4.6 9afbdc91fa style(cloud): reformat modules and restore except-tuple parentheses
Tests / Test failed: 4, passed: 744
Apply consistent line-length formatting across governance, plugins, the
SDK routers (governance_api, user_api), user_auth, and migrations
0003/0005/0006.

Also restore the parentheses on two except clauses that had been dropped
into invalid Python 3 `except A, B:` syntax: plugins._coerce_to_manifest
(KeyError, ValueError) and PasswordHasher.verify (InvalidHashError,
VerificationError). Both modules now import cleanly again.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:30:13 +08:00
q792602257andClaude Opus 4.6 c0a653fa3f feat(host-agent): serve built console via SPA-aware static mount
Add an optional single-process mode where the backend serves the built
console bundle itself, so operators don't need a separate `npm run dev`
for edge/dev setups. When RUNTIME_CONSOLE_STATIC_DIR points at the
console dist directory, the app mounts a SpaStaticFiles handler at /ui/
(with 404 fallback to index.html for client-side routing) and redirects
/ to /ui/. The console build uses an empty VITE_API_BASE_URL for relative
API paths (same-origin, no CORS), and Vite's base is set to /ui/ so
assets resolve under the mount. /console/* JSON API is unchanged and is
shared by both serve modes.

api.ts now treats an explicitly-empty VITE_API_BASE_URL as "use relative
paths" instead of falling back to the dev default, which previously
forced absolute URLs even in same-origin builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:29:58 +08:00
12 changed files with 177 additions and 42 deletions
+40
View File
@@ -1,3 +1,5 @@
import os
from pathlib import Path
from typing import Any
from api.console import create_console_router
@@ -25,7 +27,27 @@ def create_app(
) -> Any:
from fastapi import BackgroundTasks, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.types import Scope
class SpaStaticFiles(StaticFiles):
"""``StaticFiles`` variant that falls back to ``index.html`` for SPA routes.
Mirrors ``apps/cloud-api/cloud_api/app.py``'s implementation: an unknown
path like ``/ui/tasks/abc`` would otherwise 404 instead of letting the
SPA's client-side router handle it.
"""
async def get_response(self, path: str, scope: Scope) -> Any:
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404 and path != "index.html":
return await super().get_response("index.html", scope)
raise
device_manager = manager or DEFAULT_MANAGER
store = metadata_store or TaskMetadataStore()
@@ -127,6 +149,24 @@ def create_app(
)
)
console_static_dir = os.environ.get("RUNTIME_CONSOLE_STATIC_DIR")
if console_static_dir:
dist_dir = Path(console_static_dir)
if not dist_dir.is_dir():
raise ValueError(
f"RUNTIME_CONSOLE_STATIC_DIR is not a directory: {dist_dir}"
)
@app.get("/", include_in_schema=False)
async def _redirect_to_console() -> RedirectResponse:
return RedirectResponse(url="/ui/")
app.mount(
"/ui",
SpaStaticFiles(directory=str(dist_dir), html=True),
name="console",
)
return app
+16
View File
@@ -28,3 +28,19 @@ for local frontend development.
```bash
npm run build
```
## Same-Origin, Single-Process Mode
For an edge/dev setup where running a separate `npm run dev` process is too heavy,
the backend can serve the built console directly from the same process:
```bash
VITE_API_BASE_URL= npm run build
RUNTIME_CONSOLE_STATIC_DIR=$(pwd)/dist uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
```
`VITE_API_BASE_URL=` (empty) makes the build use relative API paths so it works
same-origin without CORS. The console is then served at `/ui/` (with `/`
redirecting there); `/console/*` remains the JSON API used by both this mode
and local `npm run dev`. Rebuild (`npm run build`) after frontend changes —
this mode does not hot-reload.
+3 -4
View File
@@ -7,10 +7,9 @@ import type {
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
export const API_BASE_URL = (configuredBaseUrl || "http://127.0.0.1:8000").replace(
/\/$/,
"",
);
export const API_BASE_URL = (
configuredBaseUrl !== undefined ? configuredBaseUrl : "http://127.0.0.1:8000"
).replace(/\/$/, "");
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
+1
View File
@@ -3,4 +3,5 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
base: "/ui/",
});
+7 -4
View File
@@ -91,8 +91,7 @@ def enforce_user_submission_policy(
if not policy.submission_enabled:
raise TaskSubmissionPolicyError("task submission is disabled for this user")
restricted = (
policy.allowed_host_ids is not None
or policy.allowed_device_targets is not None
policy.allowed_host_ids is not None or policy.allowed_device_targets is not None
)
if not restricted:
return
@@ -104,8 +103,12 @@ def enforce_user_submission_policy(
):
raise TaskSubmissionPolicyError("target host is not permitted")
if policy.allowed_device_targets is not None:
if target_device_id is None or (
if (
target_device_id is None
or (
target_host_id,
target_device_id,
) not in policy.allowed_device_targets:
)
not in policy.allowed_device_targets
):
raise TaskSubmissionPolicyError("target device is not permitted")
@@ -24,7 +24,9 @@ def upgrade() -> None:
sa.Column("display_name", sa.String(), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("role", sa.String(), nullable=False),
sa.Column("enabled", sa.Integer(), nullable=False, server_default=sa.text("1")),
sa.Column(
"enabled", sa.Integer(), nullable=False, server_default=sa.text("1")
),
sa.Column(
"must_change_password",
sa.Integer(),
@@ -103,7 +105,12 @@ def upgrade() -> None:
sa.Column("action", sa.String(), nullable=False),
sa.Column("outcome", sa.String(), nullable=False),
sa.Column("correlation_id", sa.String(), nullable=True),
sa.Column("metadata_json", sa.Text(), nullable=False, server_default=sa.text("'{}'")),
sa.Column(
"metadata_json",
sa.Text(),
nullable=False,
server_default=sa.text("'{}'"),
),
)
op.create_index(
"ix_cloud_auth_audit_events_occurred_at",
@@ -16,7 +16,12 @@ def upgrade() -> None:
op.create_table(
"cloud_token_reservations",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("host_id", sa.String(), sa.ForeignKey("host_registrations.host_id", ondelete="CASCADE"), nullable=False),
sa.Column(
"host_id",
sa.String(),
sa.ForeignKey("host_registrations.host_id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("usage_day", sa.String(), nullable=False),
sa.Column("reserved_tokens", sa.Integer(), nullable=False),
sa.Column("task_id", sa.String(), nullable=True),
@@ -24,12 +29,25 @@ def upgrade() -> None:
sa.Column("created_at", sa.String(), nullable=False),
sa.Column("expires_at", sa.String(), nullable=False),
)
op.create_index("ix_cloud_token_reservations_host_day", "cloud_token_reservations", ["host_id", "usage_day"])
op.create_index("ix_cloud_token_reservations_expires_at", "cloud_token_reservations", ["expires_at"])
op.create_index(
"ix_cloud_token_reservations_host_day",
"cloud_token_reservations",
["host_id", "usage_day"],
)
op.create_index(
"ix_cloud_token_reservations_expires_at",
"cloud_token_reservations",
["expires_at"],
)
op.create_table(
"cloud_token_usage_events",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("host_id", sa.String(), sa.ForeignKey("host_registrations.host_id", ondelete="CASCADE"), nullable=False),
sa.Column(
"host_id",
sa.String(),
sa.ForeignKey("host_registrations.host_id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("usage_day", sa.String(), nullable=False),
sa.Column("task_id", sa.String(), nullable=True),
sa.Column("attempt", sa.Integer(), nullable=True),
@@ -40,14 +58,30 @@ def upgrade() -> None:
sa.Column("total_tokens", sa.Integer(), nullable=False),
sa.Column("occurred_at", sa.String(), nullable=False),
)
op.create_index("ix_cloud_token_usage_events_host_day", "cloud_token_usage_events", ["host_id", "usage_day"])
op.create_index("ix_cloud_token_usage_events_occurred_at", "cloud_token_usage_events", ["occurred_at"])
op.create_index(
"ix_cloud_token_usage_events_host_day",
"cloud_token_usage_events",
["host_id", "usage_day"],
)
op.create_index(
"ix_cloud_token_usage_events_occurred_at",
"cloud_token_usage_events",
["occurred_at"],
)
def downgrade() -> None:
op.drop_index("ix_cloud_token_usage_events_occurred_at", table_name="cloud_token_usage_events")
op.drop_index("ix_cloud_token_usage_events_host_day", table_name="cloud_token_usage_events")
op.drop_index(
"ix_cloud_token_usage_events_occurred_at", table_name="cloud_token_usage_events"
)
op.drop_index(
"ix_cloud_token_usage_events_host_day", table_name="cloud_token_usage_events"
)
op.drop_table("cloud_token_usage_events")
op.drop_index("ix_cloud_token_reservations_expires_at", table_name="cloud_token_reservations")
op.drop_index("ix_cloud_token_reservations_host_day", table_name="cloud_token_reservations")
op.drop_index(
"ix_cloud_token_reservations_expires_at", table_name="cloud_token_reservations"
)
op.drop_index(
"ix_cloud_token_reservations_host_day", table_name="cloud_token_reservations"
)
op.drop_table("cloud_token_reservations")
@@ -15,7 +15,9 @@ depends_on = None
def upgrade() -> None:
op.add_column(
"host_registrations",
sa.Column("planner_transport", sa.String(), nullable=False, server_default="direct"),
sa.Column(
"planner_transport", sa.String(), nullable=False, server_default="direct"
),
)
+7 -5
View File
@@ -142,9 +142,7 @@ class PluginRegistry:
self.register(manifest)
result.registered.append(manifest)
except _PLUGIN_REGISTRATION_FAILURES as exc:
result.errors.append(
f"entry-point plugin {manifest.name!r}: {exc}"
)
result.errors.append(f"entry-point plugin {manifest.name!r}: {exc}")
if scan_path is not None:
for manifest in self.discover_manifest_files(scan_path):
try:
@@ -167,8 +165,12 @@ class PluginRegistry:
for entry_point in entry_points:
try:
loaded = entry_point.load()
except Exception as exc: # pragma: no cover - exercised via fake eps in tests
logger.warning("entry point %r failed to load: %s", entry_point.name, exc)
except (
Exception
) as exc: # pragma: no cover - exercised via fake eps in tests
logger.warning(
"entry point %r failed to load: %s", entry_point.name, exc
)
continue
manifest = _coerce_to_manifest(loaded)
if manifest is not None:
@@ -78,7 +78,10 @@ def create_governance_router(*, repository, auth_provider: AuthProvider) -> APIR
submission_enabled=payload.submission_enabled,
allowed_host_ids=_unique_strings(payload.allowed_host_ids),
allowed_device_targets=(
tuple((item.host_id, item.device_id) for item in payload.allowed_device_targets)
tuple(
(item.host_id, item.device_id)
for item in payload.allowed_device_targets
)
if payload.allowed_device_targets is not None
else None
),
+33 -11
View File
@@ -103,7 +103,9 @@ def create_user_auth_router(
)
@router.post("/auth/login", response_model=UserResponse)
def login(payload: LoginRequest, request: Request, response: Response) -> UserResponse:
def login(
payload: LoginRequest, request: Request, response: Response
) -> UserResponse:
try:
result = user_auth_service.login(
username=payload.username,
@@ -142,7 +144,9 @@ def create_user_auth_router(
user_id, _ = _require_session(principal)
user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined]
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized"
)
return _user_response(user)
@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
@@ -181,7 +185,9 @@ def create_user_auth_router(
detail="invalid username or password",
) from exc
except UserValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
_clear_cookies(response)
response.status_code = status.HTTP_204_NO_CONTENT
return response
@@ -200,7 +206,9 @@ def create_user_auth_router(
offset=offset,
)
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED
)
def create_user(payload: UserCreateRequest, request: Request) -> UserResponse:
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
_require_csrf(request, principal)
@@ -212,7 +220,9 @@ def create_user_auth_router(
password=payload.password,
)
except (UserValidationError, UserConflictError) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
user_auth_service.record_admin_action(
actor_principal_id=principal.id,
target_user_id=user.id,
@@ -243,11 +253,17 @@ def create_user_auth_router(
updated_at=utc_now(),
)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="user not found"
) from exc
except LastAdministratorConflictError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except UserValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
user_auth_service.record_admin_action(
actor_principal_id=principal.id,
target_user_id=user.id,
@@ -273,9 +289,13 @@ def create_user_auth_router(
correlation_id=current_correlation_id(),
)
except KeyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="user not found"
) from exc
except UserValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
return _user_response(user)
@router.delete("/users/{user_id}/sessions", status_code=status.HTTP_204_NO_CONTENT)
@@ -284,7 +304,9 @@ def create_user_auth_router(
_require_csrf(request, principal)
user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined]
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="user not found"
)
user_auth_service.repository.revoke_user_sessions( # type: ignore[attr-defined]
user_id,
revoked_at=utc_now(),
+8 -2
View File
@@ -201,7 +201,9 @@ def utc_now() -> datetime:
return datetime.now(UTC)
def csrf_matches(*, csrf_cookie: str | None, csrf_header: str | None, session: UserSession) -> bool:
def csrf_matches(
*, csrf_cookie: str | None, csrf_header: str | None, session: UserSession
) -> bool:
if not csrf_cookie or not csrf_header:
return False
if not compare_digest(csrf_cookie, csrf_header):
@@ -271,7 +273,11 @@ class UserAuthService:
normalized,
client_bucket,
)
if throttle is not None and throttle.blocked_until and throttle.blocked_until > now:
if (
throttle is not None
and throttle.blocked_until
and throttle.blocked_until > now
):
self.password_hasher.verify_dummy(password)
self._audit(
action="login",