Skip to content
Open
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
37 changes: 37 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
---
name: OpenCLI Admin design system
---

# Design

## Source of truth
Expand Down Expand Up @@ -400,3 +404,36 @@
- [ ] Workflow UX / `manifest.presentation` 的 experience descriptor 首版字段名和版本如何定义,使内建专用编辑器可声明但不把任意插件前端带入平台?
- [ ] Workflow UX / 跨工作流复制首版是否仅支持同一 OpenCLI 实例,还是同时定义跨实例剪贴板格式与兼容性报告?
- [ ] Workbench engines / Perspective + DuckDB-Wasm 与 OpenTelemetry + Langfuse 的首个生产适配器边界、数据量阈值和许可证复核何时进入 ADR?当前页面只验证 OpenCLI 内的信息架构与真实数据交互。

## Product Context

OpenCLI Admin 是面向自托管操作者的研究、采集和工作流控制台。界面必须优先表达真实状态、明确权限边界,并为失败提供可执行的恢复路径。

## Overview

产品采用桌面优先、信息密集、渐进披露的控制台体验。首次部署必须能够完成安全初始化,后续高频操作不暴露底层部署凭据。

## Colors

沿用 `docs/DESIGN_SYSTEM.md` 与 `frontend/app/globals.css` 的语义色;橙色用于主要操作和品牌信号,状态不能只依赖颜色表达。

## Typography

正文使用现有界面字体栈,运行标识、端口和令牌类内容使用等宽字体。紧凑布局不得牺牲标签、错误信息和正文可读性。

## Layout

控制台保持现有侧栏、内容区和检查器边界。认证界面在窄屏采用单列,在桌面保留产品背景与固定宽度表单,并保证表单顺序稳定。

## Do's and Don'ts

- Do:先显示用户要完成的任务,再按需揭示实现细节和恢复入口。
- Do:为加载、空、错误、阻塞和成功状态提供明确文案与下一步。
- Don't:把环境变量、OIDC 术语或 Fleet Token 当作新用户必须理解的产品概念。
- Don't:使用装饰动画遮盖状态变化,或把不可用能力表现为可执行。

## Source Decisions

- Adopted:现有 Dark Ops Console 视觉体系、shadcn 组件、渐进披露和可恢复状态原则。
- Rejected:要求首次用户先配置外部身份提供方或从 `.env` 中寻找日常登录凭据的流程。
- Active change:`openspec/changes/local-admin-onboarding`。
41 changes: 41 additions & 0 deletions MOTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
schema: design-pipeline.motion-foundation.v0.1
name: OpenCLI operator motion language
posture: minimal
primitiveRegistry: design-pipeline.motion-primitives.v1
---

## Motion Thesis

Motion confirms a completed operator action or a changed system state. It never delays access to credentials, recovery, or operational data.

## Motion Principles

- Keep authentication transitions short, interruptible, and secondary to the active form state.
- Never move focused controls or change their order while the user is typing.
- Prefer opacity and color feedback over layout movement for repeated operational use.

## Motion Vocabulary

- primitive: reveal.trim-line
- Use only for a non-blocking transition between login states.

## Procedural Motion

No procedural motion is used for authentication or recovery surfaces.

## Runtime Policy

CSS transitions are the default adapter for small state changes. The existing Motion React adapter may preserve the selected primitive where it is already loaded; no new animation runtime is introduced.

## Reduced Motion

When `prefers-reduced-motion` is enabled, state changes use immediate opacity changes and do not animate position, scale, or background effects.

Fallback: every animated confirmation has an immediate static state change with the same text and focus result.
Comment on lines +23 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Make the reduced-motion contract consistent.

MOTION.md requires an immediate state change for reduced motion and says that authentication uses no procedural motion. The feature specification permits a Motion React adapter but does not require an immediate static reduced-motion state. This can produce an animated reduced-motion path.

  • MOTION.md#L23-L35: Define whether reveal.trim-line is permitted on authentication surfaces and state that reduced motion has no animation.
  • openspec/changes/local-admin-onboarding/motion.md#L5-L8: Require the immediate static text and focus result when reduced motion is enabled.
📍 Affects 2 files
  • MOTION.md#L23-L35 (this comment)
  • openspec/changes/local-admin-onboarding/motion.md#L5-L8
🤖 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 `@MOTION.md` around lines 23 - 35, Make the reduced-motion contract consistent
across MOTION.md (lines 23-35) and
openspec/changes/local-admin-onboarding/motion.md (lines 5-8): explicitly state
whether reveal.trim-line is allowed on authentication surfaces, require no
animation when reduced motion is enabled, and require the immediate static text
and focus result in the onboarding motion specification.


## Source Decisions

- Adopted: the existing login surface's short, non-blocking confirmation transitions; this keeps the new authentication states consistent with repeated console use.
- Rejected: decorative background and position animation for password and recovery states; these make an access-critical form less legible and are not required for the operator workflow.
- Authored for `openspec/changes/local-admin-onboarding`; no external motion implementation or visual reference is adopted.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@ Invoke-WebRequest https://raw.githubusercontent.com/2233admin/opencli-Razormind/

安装完成后,终端会打印:

- `BOOTSTRAP_ADMIN_TOKEN`:首次进入管理界面使用
- `BOOTSTRAP_ADMIN_TOKEN`:首次创建本地管理员及紧急恢复时使用
- `API_AUTH_TOKEN`:Fleet、Agent、API 和 MCP 访问使用。

两者同时保存在安装目录的 `.env`。不要公开 noVNC、令牌或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。
首次进入控制台时设置本地管理员密码并输入一次 `BOOTSTRAP_ADMIN_TOKEN`;后续直接使用管理员密码登录。令牌同时保存在安装目录的 `.env`,仅供恢复使用。不要公开 noVNC、令牌或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。

## 正常的研究流程

Expand Down
2 changes: 2 additions & 0 deletions backend/api/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
geo_acquisition,
identity,
image_studio,
local_auth,
model_defaults,
nodes,
notifications,
Expand Down Expand Up @@ -80,6 +81,7 @@
v1_router.include_router(dashboard.router)
v1_router.include_router(system.router)
v1_router.include_router(identity.router)
v1_router.include_router(local_auth.router)
v1_router.include_router(workspaces.router)
v1_router.include_router(workspace_sources.router)
v1_router.include_router(project_source_bindings.router)
81 changes: 81 additions & 0 deletions backend/api/v1/local_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""First-run local administrator setup and password login."""

from hmac import compare_digest
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from backend.config import get_settings
from backend.database import get_db
from backend.models.identity import LocalAdmin
from backend.schemas.common import ApiResponse
from backend.security.local_auth import (
hash_password,
issue_session,
login_attempt_limiter,
verify_password,
)

router = APIRouter(prefix="/auth/local", tags=["auth"])


class PasswordInput(BaseModel):
password: str = Field(min_length=12, max_length=256)


class SetupInput(PasswordInput):
bootstrap_token: str = Field(min_length=1, max_length=1024)


async def _admin(db: AsyncSession) -> LocalAdmin | None:
return (await db.execute(select(LocalAdmin))).scalar_one_or_none()


@router.get("/status", response_model=ApiResponse[dict])
async def local_status(db: Annotated[AsyncSession, Depends(get_db)]) -> ApiResponse:
return ApiResponse.ok({"configured": await _admin(db) is not None})


@router.post("/setup", response_model=ApiResponse[dict])
async def setup_local_admin(
body: SetupInput,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
) -> ApiResponse:
if await _admin(db) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Local administrator is already configured")
expected = get_settings().bootstrap_admin_token
client_id = request.client.host if request.client else "unknown"
login_attempt_limiter.check(client_id)
if not expected or not compare_digest(body.bootstrap_token, expected):
login_attempt_limiter.record_failure(client_id)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid recovery credential")
db.add(LocalAdmin(id="local-admin", password_hash=hash_password(body.password)))
try:
await db.flush()
except IntegrityError as exc:
raise HTTPException(
status.HTTP_409_CONFLICT, "Local administrator is already configured"
) from exc
login_attempt_limiter.reset(client_id)
return ApiResponse.ok({"access_token": issue_session()})


@router.post("/login", response_model=ApiResponse[dict])
async def local_login(
body: PasswordInput,
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
) -> ApiResponse:
client_id = request.client.host if request.client else "unknown"
login_attempt_limiter.check(client_id)
admin = await _admin(db)
if admin is None or not verify_password(body.password, admin.password_hash):
login_attempt_limiter.record_failure(client_id)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid administrator password")
login_attempt_limiter.reset(client_id)
return ApiResponse.ok({"access_token": issue_session()})
3 changes: 3 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ class Settings(BaseSettings):
# Empty (default) = auth disabled — dev posture, which the startup bind
# guard only allows on a localhost bind. Env: API_AUTH_TOKEN.
api_auth_token: str = ""
# Emergency first-run/recovery credential. Local administrator setup
# verifies this value but never persists it as a daily login secret.
bootstrap_admin_token: str = ""

# CLI channel binary allowlist (ADR-0005, audit P0-4). The cli channel is
# an arbitrary-binary-execution surface, so it only runs binaries the
Expand Down
27 changes: 27 additions & 0 deletions backend/migrations/versions/z7a8b9c0d1e2_add_local_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""add local administrator credential

Revision ID: z7a8b9c0d1e2
Revises: k8l9m0n1o2p3
"""
import sqlalchemy as sa
from alembic import op

revision = "z7a8b9c0d1e2"
down_revision = "k8l9m0n1o2p3"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"local_admin_credentials",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)


def downgrade() -> None:
op.drop_table("local_admin_credentials")
2 changes: 2 additions & 0 deletions backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from backend.models.cookie_jar import CookieJarEntry
from backend.models.edge_node import EdgeNode, EdgeNodeEvent
from backend.models.identity import (
LocalAdmin,
ServiceIdentity,
Team,
TeamMembership,
Expand Down Expand Up @@ -95,6 +96,7 @@
"Team",
"TeamMembership",
"ServiceIdentity",
"LocalAdmin",
"OperationsWorkItem",
"OperationsAgentIdentity",
"AgentPermissionProfile",
Expand Down
6 changes: 6 additions & 0 deletions backend/models/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ class User(TimestampMixin):
disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)


class LocalAdmin(TimestampMixin):
__tablename__ = "local_admin_credentials"

password_hash: Mapped[str] = mapped_column(String(255), nullable=False)


class Workspace(TimestampMixin):
__tablename__ = "workspaces"

Expand Down
31 changes: 29 additions & 2 deletions backend/security/fleet_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
- ``/docs``, ``/redoc``, ``/openapi.json`` — outside the ``/api`` prefix.
They disclose the API *schema* but no data; issue 04's scope is "every
/api route". Tighten separately if schema disclosure becomes a concern.
- ``/api/v1/auth/local/status``, ``/setup``, and ``/login`` — the minimum
unauthenticated surface required to establish a local administrator session.
Setup still requires the Bootstrap credential, login is rate-limited, and
status returns only a boolean.

Websocket endpoints under ``/api`` (the agent reverse channel in
api/v1/nodes.py and api/v1/browsers.py) are guarded by this same middleware.
Expand Down Expand Up @@ -73,9 +77,17 @@
from starlette.websockets import WebSocketClose

from backend.config import get_settings
from backend.security.local_auth import is_local_session

#: Path prefixes guarded by :class:`FleetAuthMiddleware`.
PROTECTED_PREFIXES = ("/api", "/mcp")
PUBLIC_LOCAL_AUTH_PATHS = frozenset(
{
"/api/v1/auth/local/status",
"/api/v1/auth/local/setup",
"/api/v1/auth/local/login",
}
)

_LOCALHOST_HOSTS = frozenset({"localhost", "::1"})

Expand Down Expand Up @@ -156,6 +168,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
):
await self.app(scope, receive, send)
return
if scope["type"] == "http" and scope["path"] in PUBLIC_LOCAL_AUTH_PATHS:
await self.app(scope, receive, send)
return

# Read per request: get_settings() is lru_cached (cheap), but
# api/v1/system.py may cache_clear() it at runtime after a config
Expand All @@ -179,8 +194,20 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
return

headers = Headers(scope=scope)
credential = headers.get("x-api-token", "") or _bearer_credential(headers)
if credential and _token_matches(credential, token):
credentials = (
headers.get("x-api-token", ""),
_bearer_credential(headers),
)
bootstrap = get_settings().bootstrap_admin_token
if any(
credential
and (
_token_matches(credential, token)
or (bootstrap and _token_matches(credential, bootstrap))
or is_local_session(credential)
)
for credential in credentials
):
await self.app(scope, receive, send)
return

Expand Down
9 changes: 9 additions & 0 deletions backend/security/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from fastapi import HTTPException, Request, status
from jose import JWTError, jwt

from backend.security.local_auth import is_local_session


@dataclass(frozen=True)
class IdentitySettings:
Expand Down Expand Up @@ -138,6 +140,13 @@ async def get_request_identity(request: Request) -> RequestIdentity:
is_platform_admin=True,
auth_method="bootstrap",
)
if is_local_session(token):
return RequestIdentity(
subject="local-admin",
name="Local Administrator",
is_platform_admin=True,
auth_method="local",
)
return await oidc.verify(token)

return get_request_identity
Expand Down
Loading
Loading