From 6c65f69dfd5b71e82c6dd3b61f50ea060d563b17 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:52:31 +0800 Subject: [PATCH 1/7] Add local administrator onboarding --- DESIGN.md | 37 +++++ MOTION.md | 41 +++++ README.md | 4 +- backend/api/v1/__init__.py | 2 + backend/api/v1/local_auth.py | 81 ++++++++++ backend/config.py | 3 + .../versions/z7a8b9c0d1e2_add_local_admin.py | 27 ++++ backend/models/__init__.py | 2 + backend/models/identity.py | 6 + backend/security/fleet_auth.py | 22 ++- backend/security/identity.py | 9 ++ backend/security/local_auth.py | 136 ++++++++++++++++ ...-administrator-password-after-bootstrap.md | 38 +++++ frontend/app/login/page.tsx | 95 +++-------- frontend/components/auth/auth-provider.tsx | 67 +++++++- .../components/auth/local-admin-access.tsx | 153 ++++++++++++++++++ frontend/lib/api/endpoints.ts | 16 ++ frontend/lib/auth/session.ts | 15 +- frontend/lib/auth/types.ts | 2 +- .../scripts/check-login-theme-regressions.mjs | 13 +- .../changes/local-admin-onboarding/brief.md | 8 + .../changes/local-admin-onboarding/design.md | 10 ++ .../local-admin-onboarding/directions.md | 13 ++ .../changes/local-admin-onboarding/motion.md | 8 + openspec/changes/local-admin-onboarding/qa.md | 30 ++++ .../changes/local-admin-onboarding/tasks.md | 9 ++ scripts/install.ps1 | 3 +- scripts/install.sh | 3 +- tests/unit/security/test_local_auth.py | 95 +++++++++++ tests/unit/test_identity_models.py | 5 +- 30 files changed, 858 insertions(+), 95 deletions(-) create mode 100644 MOTION.md create mode 100644 backend/api/v1/local_auth.py create mode 100644 backend/migrations/versions/z7a8b9c0d1e2_add_local_admin.py create mode 100644 backend/security/local_auth.py create mode 100644 docs/adr/0043-use-local-administrator-password-after-bootstrap.md create mode 100644 frontend/components/auth/local-admin-access.tsx create mode 100644 openspec/changes/local-admin-onboarding/brief.md create mode 100644 openspec/changes/local-admin-onboarding/design.md create mode 100644 openspec/changes/local-admin-onboarding/directions.md create mode 100644 openspec/changes/local-admin-onboarding/motion.md create mode 100644 openspec/changes/local-admin-onboarding/qa.md create mode 100644 openspec/changes/local-admin-onboarding/tasks.md create mode 100644 tests/unit/security/test_local_auth.py diff --git a/DESIGN.md b/DESIGN.md index 1d85ab0..a652485 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,3 +1,7 @@ +--- +name: OpenCLI Admin design system +--- + # Design ## Source of truth @@ -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`。 diff --git a/MOTION.md b/MOTION.md new file mode 100644 index 0000000..6965147 --- /dev/null +++ b/MOTION.md @@ -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. + +## 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. diff --git a/README.md b/README.md index b93b5cf..02370c9 100644 --- a/README.md +++ b/README.md @@ -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 隧道。 ## 正常的研究流程 diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py index e42907e..5c687b6 100644 --- a/backend/api/v1/__init__.py +++ b/backend/api/v1/__init__.py @@ -16,6 +16,7 @@ geo_acquisition, identity, image_studio, + local_auth, model_defaults, nodes, notifications, @@ -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) diff --git a/backend/api/v1/local_auth.py b/backend/api/v1/local_auth.py new file mode 100644 index 0000000..785cd05 --- /dev/null +++ b/backend/api/v1/local_auth.py @@ -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()}) diff --git a/backend/config.py b/backend/config.py index 5b19992..f9aee35 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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 diff --git a/backend/migrations/versions/z7a8b9c0d1e2_add_local_admin.py b/backend/migrations/versions/z7a8b9c0d1e2_add_local_admin.py new file mode 100644 index 0000000..c144cea --- /dev/null +++ b/backend/migrations/versions/z7a8b9c0d1e2_add_local_admin.py @@ -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") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 0da337e..d201945 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -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, @@ -95,6 +96,7 @@ "Team", "TeamMembership", "ServiceIdentity", + "LocalAdmin", "OperationsWorkItem", "OperationsAgentIdentity", "AgentPermissionProfile", diff --git a/backend/models/identity.py b/backend/models/identity.py index 317a1f1..3a8dfb5 100644 --- a/backend/models/identity.py +++ b/backend/models/identity.py @@ -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" diff --git a/backend/security/fleet_auth.py b/backend/security/fleet_auth.py index 9464238..e9bf689 100644 --- a/backend/security/fleet_auth.py +++ b/backend/security/fleet_auth.py @@ -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. @@ -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"}) @@ -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 @@ -180,7 +195,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: headers = Headers(scope=scope) credential = headers.get("x-api-token", "") or _bearer_credential(headers) - if credential and _token_matches(credential, token): + bootstrap = get_settings().bootstrap_admin_token + if credential and ( + _token_matches(credential, token) + or (bootstrap and _token_matches(credential, bootstrap)) + or is_local_session(credential) + ): await self.app(scope, receive, send) return diff --git a/backend/security/identity.py b/backend/security/identity.py index 6b1a0f8..eadc4db 100644 --- a/backend/security/identity.py +++ b/backend/security/identity.py @@ -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: @@ -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 diff --git a/backend/security/local_auth.py b/backend/security/local_auth.py new file mode 100644 index 0000000..dd9a452 --- /dev/null +++ b/backend/security/local_auth.py @@ -0,0 +1,136 @@ +"""Local single-admin authentication for self-hosted first-run deployments.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import time +from collections import defaultdict, deque +from datetime import UTC, datetime, timedelta +from threading import Lock + +from fastapi import HTTPException, status +from jose import JWTError, jwt + +from backend.config import get_settings + +_ALGORITHM = "HS256" +_SUBJECT = "local-admin" +_SCRYPT_N = 2**14 +_SCRYPT_R = 8 +_SCRYPT_P = 1 + + +class LoginAttemptLimiter: + """Small per-process guard against rapid password guessing.""" + + def __init__( + self, max_attempts: int = 10, window_seconds: int = 60, max_clients: int = 1024 + ) -> None: + self.max_attempts = max_attempts + self.window_seconds = window_seconds + self.max_clients = max_clients + self._attempts: dict[str, deque[float]] = defaultdict(deque) + self._lock = Lock() + + def check(self, client_id: str) -> None: + now = time.monotonic() + with self._lock: + attempts = self._attempts.get(client_id) + if not attempts: + return + while attempts and now - attempts[0] >= self.window_seconds: + attempts.popleft() + if not attempts: + self._attempts.pop(client_id, None) + return + if len(attempts) >= self.max_attempts: + retry_after = max(1, int(self.window_seconds - (now - attempts[0]))) + raise HTTPException( + status.HTTP_429_TOO_MANY_REQUESTS, + "Too many authentication attempts", + headers={"Retry-After": str(retry_after)}, + ) + + def record_failure(self, client_id: str) -> None: + with self._lock: + if client_id not in self._attempts and len(self._attempts) >= self.max_clients: + self._attempts.pop(next(iter(self._attempts))) + self._attempts[client_id].append(time.monotonic()) + + def reset(self, client_id: str) -> None: + with self._lock: + self._attempts.pop(client_id, None) + + +login_attempt_limiter = LoginAttemptLimiter() + + +def validate_password(password: str) -> str: + if len(password) < 12: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "Password must be at least 12 characters", + ) + if len(password) > 256: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Password is too long") + return password + + +def hash_password(password: str) -> str: + encoded = validate_password(password).encode("utf-8") + salt = secrets.token_bytes(16) + digest = hashlib.scrypt( + encoded, salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32 + ) + return "$".join( + ( + "scrypt", + str(_SCRYPT_N), + str(_SCRYPT_R), + str(_SCRYPT_P), + base64.urlsafe_b64encode(salt).decode("ascii"), + base64.urlsafe_b64encode(digest).decode("ascii"), + ) + ) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + scheme, n, r, p, salt, expected = password_hash.split("$", 5) + if scheme != "scrypt" or (int(n), int(r), int(p)) != ( + _SCRYPT_N, + _SCRYPT_R, + _SCRYPT_P, + ): + return False + digest = hashlib.scrypt( + password.encode("utf-8"), + salt=base64.urlsafe_b64decode(salt), + n=_SCRYPT_N, + r=_SCRYPT_R, + p=_SCRYPT_P, + dklen=32, + ) + except (ValueError, TypeError): + return False + return hmac.compare_digest(base64.urlsafe_b64encode(digest).decode("ascii"), expected) + + +def issue_session() -> str: + now = datetime.now(UTC) + return jwt.encode( + {"sub": _SUBJECT, "typ": "local-admin", "iat": now, "exp": now + timedelta(hours=12)}, + get_settings().secret_key, + algorithm=_ALGORITHM, + ) + + +def is_local_session(token: str) -> bool: + try: + claims = jwt.decode(token, get_settings().secret_key, algorithms=[_ALGORITHM]) + except JWTError: + return False + return claims.get("sub") == _SUBJECT and claims.get("typ") == "local-admin" diff --git a/docs/adr/0043-use-local-administrator-password-after-bootstrap.md b/docs/adr/0043-use-local-administrator-password-after-bootstrap.md new file mode 100644 index 0000000..4074520 --- /dev/null +++ b/docs/adr/0043-use-local-administrator-password-after-bootstrap.md @@ -0,0 +1,38 @@ +--- +status: accepted +--- + +# Use a Local Administrator Password After Bootstrap + +## Context + +ADR-0005 protects the fleet API with a static token and deliberately avoids a multi-user identity system. That token is appropriate for agents and API clients, but requiring an operator to retrieve and paste deployment credentials for every browser session makes a fresh self-hosted console difficult to enter. OIDC cannot be the only human login path because a single-operator installation may not have an identity provider. + +## Decision + +Keep the single-operator model and add one persistent local administrator credential: + +- First-run setup requires the existing `BOOTSTRAP_ADMIN_TOKEN` and can create only the fixed `local-admin` record. +- The operator chooses a password of at least 12 characters. The backend stores only a salted scrypt hash with fixed, reviewed work parameters. +- Successful setup or password login returns a server-signed, 12-hour local administrator session. +- A valid local session crosses the Fleet middleware for browser API requests and resolves to the existing platform-administrator identity. +- Bootstrap remains valid as an explicit recovery credential; it is not the primary daily login control. +- OIDC remains optional and independent. Static Fleet tokens remain the machine-client credential. +- Only local-auth status, setup, and login are public API paths. Failed credential attempts are rate-limited per client. + +This decision does not add registration, invitations, multiple local accounts, workspace-specific local roles, or password-reset email. + +## Consequences + +- A new self-hosted deployment can establish a human login without configuring OIDC. +- Daily browser access no longer exposes deployment tokens in the main login flow. +- Compromise of the signing key can forge local sessions, so production deployments must continue generating a strong `SECRET_KEY`. +- Rate limiting is process-local; deployments with multiple API workers should enforce an additional shared limit at the reverse proxy. +- Losing both the local password and Bootstrap credential still requires operator access to the deployment environment. + +## Rejected Alternatives + +- Keep Bootstrap as the daily login: preserves an opaque operational-token workflow and increases routine exposure of a high-privilege recovery secret. +- Allow the first visitor to create an administrator without a credential: permits remote takeover when a new console is network reachable. +- Require OIDC before first use: makes the default single-operator deployment depend on external identity infrastructure. +- Add a general user database and password-reset system: exceeds the single-operator threat model and creates unnecessary identity lifecycle scope. diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 1644793..227813a 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { Droplets, Grid3X3, KeyRound, LoaderCircle, ShieldCheck, SquareTerminal } from 'lucide-react' +import { Droplets, Grid3X3, LoaderCircle, ShieldCheck, SquareTerminal } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { useRouter, useSearchParams } from 'next/navigation' import { Suspense, useEffect, useState } from 'react' @@ -9,6 +9,7 @@ import { toast } from 'sonner' import FaultyTerminal from '@/components/FaultyTerminal' import Dither from '@/components/Dither' import { useAuth } from '@/components/auth/auth-provider' +import { LocalAdminAccess } from '@/components/auth/local-admin-access' import { PixelLiquidBg } from '@/components/unlumen-ui/pixel-liquid-bg' import { Button } from '@/components/ui/button' import { @@ -19,7 +20,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' -import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field' +import { Field, FieldDescription, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import RevealText from '@/components/ui/smoothui/reveal-text' @@ -137,12 +138,10 @@ function LoginForm() { oidcEnabled, developmentLoginEnabled, signInWithOidc, - signInWithBootstrap, enterDevelopmentMode, } = useAuth() - const [identityToken, setIdentityToken] = useState('') const [fleetToken, setFleetToken] = useState('') - const [submitting, setSubmitting] = useState<'oidc' | 'bootstrap' | 'development' | null>(null) + const [submitting, setSubmitting] = useState<'oidc' | 'development' | null>(null) const [reduceMotion, setReduceMotion] = useState(true) const [backdrop, setBackdrop] = useState('liquid') const [headlineWord, setHeadlineWord] = useState(0) @@ -185,19 +184,6 @@ function LoginForm() { } } - async function handleBootstrapLogin(event: React.FormEvent) { - event.preventDefault() - setSubmitting('bootstrap') - try { - await signInWithBootstrap(identityToken, optionalFleetToken) - toast.success('管理员身份验证成功') - router.replace(returnTo) - } catch (error) { - toast.error(error instanceof Error ? error.message : '身份验证失败') - setSubmitting(null) - } - } - function handleDevelopmentLogin() { setSubmitting('development') try { @@ -211,7 +197,7 @@ function LoginForm() { return ( 登录控制台 - 使用组织账号登录;Bootstrap Admin 仅用于首次部署和紧急恢复。 + 首次部署创建本地管理员;组织登录可在需要时接入。 - {oidcEnabled ? ( + router.replace(returnTo)} /> + + {oidcEnabled ? <> +
+ + 组织登录 + +
- ) : ( -
- 当前未配置组织登录。请配置 OIDC issuer、client ID 和授权端点。 -
- )} - -
- - 紧急管理员访问 - -
+ : null} -
- - - 管理员身份令牌 - setIdentityToken(event.target.value)} - autoComplete="off" - /> - 验证成功后仅保存在当前标签页会话中。 - - + {oidcEnabled || developmentLoginEnabled ? ( +
+ + Fleet 连接设置 + + Fleet API 令牌(可选) - 后端启用 Fleet Auth 时填写;留空沿用部署配置或浏览器中已有值。 + 仅在组织登录或开发模式需要单独通过 Fleet Auth 时填写。 - - +
+ ) : null} +
- {developmentLoginEnabled ? ( + + ) + } + + const configured = localAdminStatus === 'configured' + + return ( +
+
+

+ {recoveryMode ? '紧急恢复' : configured ? '本地管理员登录' : '创建本地管理员'} +

+

+ {recoveryMode + ? '使用部署环境中保存的 Bootstrap Admin 令牌进入控制台。' + : configured + ? '使用首次部署时设置的管理员密码。' + : '首次部署只需完成一次;以后直接使用管理员密码。'} +

+
+ + {!configured || recoveryMode ? ( + + + {recoveryMode ? '紧急恢复令牌' : '首次部署令牌'} + + setBootstrapToken(event.target.value)} + autoComplete="off" + required + /> + + {recoveryMode + ? '使用部署目录 .env 中的 BOOTSTRAP_ADMIN_TOKEN。' + : '安装程序会在完成部署后直接打印该令牌。'} + + + ) : null} + {!recoveryMode ? + 管理员密码 + setPassword(event.target.value)} + autoComplete={configured ? 'current-password' : 'new-password'} + minLength={12} + maxLength={256} + required + /> + 至少 12 个字符。 + : null} + {!configured && !recoveryMode ? ( + + 确认管理员密码 + setConfirmation(event.target.value)} + autoComplete="new-password" + minLength={12} + maxLength={256} + required + /> + + ) : null} + + + {configured ? ( + + ) : null} +
+ ) +} diff --git a/frontend/lib/api/endpoints.ts b/frontend/lib/api/endpoints.ts index cfe9742..dcfa7dd 100644 --- a/frontend/lib/api/endpoints.ts +++ b/frontend/lib/api/endpoints.ts @@ -91,6 +91,22 @@ export const resetWorkspaceSettings = () => export const getCurrentIdentity = () => apiClient.get>('/auth/me').then((r) => r.data.data) +export const getLocalAuthStatus = () => + apiClient.get>('/auth/local/status').then((r) => r.data.data) + +export const loginLocalAdmin = (password: string) => + apiClient + .post>('/auth/local/login', { password }) + .then((r) => r.data.data.access_token) + +export const setupLocalAdmin = (bootstrapToken: string, password: string) => + apiClient + .post>('/auth/local/setup', { + bootstrap_token: bootstrapToken, + password, + }) + .then((r) => r.data.data.access_token) + export const listMyWorkspaces = () => apiClient.get>('/workspaces').then((r) => r.data.data) diff --git a/frontend/lib/auth/session.ts b/frontend/lib/auth/session.ts index d0cd8b5..1dce047 100644 --- a/frontend/lib/auth/session.ts +++ b/frontend/lib/auth/session.ts @@ -1,4 +1,4 @@ -const BOOTSTRAP_TOKEN_KEY = 'opencli.bootstrapIdentityToken' +const IDENTITY_TOKEN_KEY = 'opencli.identityToken' const DEVELOPMENT_SESSION_KEY = 'opencli.developmentSession' let runtimeIdentityToken = '' @@ -23,26 +23,26 @@ function safeSessionSet(key: string, value: string): void { } export function getIdentityAccessToken(): string { - return runtimeIdentityToken || safeSessionGet(BOOTSTRAP_TOKEN_KEY) + return runtimeIdentityToken || safeSessionGet(IDENTITY_TOKEN_KEY) } export function setRuntimeIdentityToken(token: string): void { runtimeIdentityToken = token.trim() } -export function getBootstrapIdentityToken(): string { - return safeSessionGet(BOOTSTRAP_TOKEN_KEY) +export function getPersistedIdentityToken(): string { + return safeSessionGet(IDENTITY_TOKEN_KEY) } -export function persistBootstrapIdentityToken(token: string): void { +export function persistIdentityToken(token: string): void { const trimmed = token.trim() runtimeIdentityToken = trimmed - safeSessionSet(BOOTSTRAP_TOKEN_KEY, trimmed) + safeSessionSet(IDENTITY_TOKEN_KEY, trimmed) } export function clearIdentityToken(): void { runtimeIdentityToken = '' - safeSessionSet(BOOTSTRAP_TOKEN_KEY, '') + safeSessionSet(IDENTITY_TOKEN_KEY, '') } export function hasDevelopmentSession(): boolean { @@ -60,4 +60,3 @@ export function isDevelopmentLoginAllowed(): boolean { process.env.NEXT_PUBLIC_ALLOW_UNAUTHENTICATED_DEV !== 'false' ) } - diff --git a/frontend/lib/auth/types.ts b/frontend/lib/auth/types.ts index da65ef3..24f7a66 100644 --- a/frontend/lib/auth/types.ts +++ b/frontend/lib/auth/types.ts @@ -5,7 +5,7 @@ export type AuthIdentity = { username: string | null picture: string | null is_platform_admin: boolean - auth_method: 'oidc' | 'bootstrap' | 'development' | string + auth_method: 'oidc' | 'local' | 'bootstrap' | 'development' | string } export type AuthStatus = 'loading' | 'authenticated' | 'anonymous' diff --git a/frontend/scripts/check-login-theme-regressions.mjs b/frontend/scripts/check-login-theme-regressions.mjs index 40eddf7..ec73648 100644 --- a/frontend/scripts/check-login-theme-regressions.mjs +++ b/frontend/scripts/check-login-theme-regressions.mjs @@ -14,13 +14,20 @@ test('login keeps the liquid, terminal, and pixel theme switcher', async () => { assert.match(login, / { - const login = await read('app/login/page.tsx') +test('login keeps local setup, recovery, OIDC, development, and reduced-motion paths', async () => { + const [login, localAdmin] = await Promise.all([ + read('app/login/page.tsx'), + read('components/auth/local-admin-access.tsx'), + ]) assert.match(login, /signInWithOidc/) - assert.match(login, /signInWithBootstrap/) assert.match(login, /enterDevelopmentMode/) assert.match(login, /prefers-reduced-motion: reduce/) + assert.match(localAdmin, /setupLocalAdmin/) + assert.match(localAdmin, /signInWithLocal/) + assert.match(localAdmin, /signInWithBootstrap/) + assert.match(localAdmin, /localAdminStatus/) + assert.match(localAdmin, /refreshLocalAdminStatus/) }) test('auth defaults return to the project list instead of a contextless workflow', async () => { diff --git a/openspec/changes/local-admin-onboarding/brief.md b/openspec/changes/local-admin-onboarding/brief.md new file mode 100644 index 0000000..d7b4e3d --- /dev/null +++ b/openspec/changes/local-admin-onboarding/brief.md @@ -0,0 +1,8 @@ +# Brief + +- Goal: make a fresh self-hosted deployment usable without an external identity provider or manual `.env` token lookup. +- Audience: a single operator deploying OpenCLI for the first time. +- Surface: installer output, login page, local-auth API, and existing API authorization boundary. +- Constraint: first administrator creation must remain safe when the console is network reachable. +- Non-goal: multi-user registration, invitations, password reset, or replacing OIDC. +- Acceptance: installer prints a first-run credential and clear setup instructions; that credential creates one local administrator; later password login opens the console without a Fleet token; Bootstrap remains recovery-only. diff --git a/openspec/changes/local-admin-onboarding/design.md b/openspec/changes/local-admin-onboarding/design.md new file mode 100644 index 0000000..ac3a955 --- /dev/null +++ b/openspec/changes/local-admin-onboarding/design.md @@ -0,0 +1,10 @@ +# Local administrator onboarding design + +Selected direction: C from `directions.md`. + +- Login hierarchy: local administrator password is the default when local setup exists; OIDC is the primary choice only when configured; recovery is a disclosure, not a peer action. +- Fresh state: explain that setup uses the Bootstrap credential printed by the installer. The setup form contains that credential, a password, and confirmation, with visible password-manager-compatible labels. +- Error state: invalid setup or login credentials state the recovery action without echoing secret values. +- Abuse boundary: setup and login failures are rate-limited per client; malformed stored password hashes cannot select an attacker-controlled scrypt work factor. +- Accessibility: labelled password fields, matching error messages, keyboard-first form submission, no hidden focus changes, and reduced-motion-safe state transitions. +- Existing project `DESIGN.md` remains the visual authority. diff --git a/openspec/changes/local-admin-onboarding/directions.md b/openspec/changes/local-admin-onboarding/directions.md new file mode 100644 index 0000000..ecf273c --- /dev/null +++ b/openspec/changes/local-admin-onboarding/directions.md @@ -0,0 +1,13 @@ +# Directions + +## A. Expose Bootstrap on the main login form + +Fastest implementation, but preserves the opaque-token onboarding failure. Rejected. + +## B. Unauthenticated create-admin form + +Feels simple on localhost but permits the first remote visitor to claim the administrator role. Rejected. + +## C. One-time bootstrap step, then local password login + +The installer prints the existing Bootstrap credential beside direct setup instructions. The login page uses it only while creating the local administrator, then switches to a password-first local path; OIDC remains available when configured and recovery is secondary. Selected because it preserves the existing safe trust handoff without making a deployment token the daily login experience. diff --git a/openspec/changes/local-admin-onboarding/motion.md b/openspec/changes/local-admin-onboarding/motion.md new file mode 100644 index 0000000..1f7f3ba --- /dev/null +++ b/openspec/changes/local-admin-onboarding/motion.md @@ -0,0 +1,8 @@ +# Local administrator onboarding motion + +Foundation: `MOTION.md`. + +- Selected primitive: `reveal.trim-line`. +- Trigger: a successful setup or local login response. +- Purpose: acknowledge the state transition before navigation; it does not block interaction. +- Adapter: existing Motion React only where already present; reduced-motion uses no position or scale animation. diff --git a/openspec/changes/local-admin-onboarding/qa.md b/openspec/changes/local-admin-onboarding/qa.md new file mode 100644 index 0000000..8dd0aa7 --- /dev/null +++ b/openspec/changes/local-admin-onboarding/qa.md @@ -0,0 +1,30 @@ +# QA + +## Automated evidence + +- Backend local-auth, identity, and model tests: pass. +- Ruff on changed Python authentication files: pass. +- TypeScript type-check: pass. +- Targeted ESLint on changed authentication UI files: pass. +- Login theme and authentication-path regression script: pass. +- Next.js production build: pass. +- Alembic graph: one head, including the local administrator migration. +- Design and motion foundation checks: ready. + +## Covered behavior + +- Fresh installation reports that no local administrator exists. +- A valid Bootstrap credential creates the only local administrator. +- Invalid Bootstrap credentials and passwords are rejected. +- Repeated failed attempts are rate-limited. +- A signed local session crosses Fleet auth and resolves to a platform administrator identity. +- A second setup attempt is rejected. +- Daily login uses the local administrator password. +- Recovery remains hidden behind a secondary disclosure. +- OIDC remains optional, including its advanced Fleet-token setting. + +## Known validation gap + +No screenshot baseline or browser E2E harness exists for the login page. The production frontend build and source-level login regression script cover this change; interactive visual review remains appropriate during PR review. + +A clean Compose image build was attempted after the application checks, but the local Docker Desktop BuildKit store became read-only while committing downloaded base-image layers (`metadata_v2.db: read-only file system`). The failure occurred before project compilation inside Docker and is host-storage related. After clearing the failed build cache, the installed release stack was restarted and reverified healthy. A clean Docker builder should repeat the image build during CI or PR review. diff --git a/openspec/changes/local-admin-onboarding/tasks.md b/openspec/changes/local-admin-onboarding/tasks.md new file mode 100644 index 0000000..4ec2a3e --- /dev/null +++ b/openspec/changes/local-admin-onboarding/tasks.md @@ -0,0 +1,9 @@ +# Tasks + +- [x] Add persistent local administrator credential and migration. +- [x] Add one-time setup and local-login endpoints with signed local sessions. +- [x] Rate-limit failed local-auth attempts and guard concurrent first-run setup. +- [x] Allow valid local sessions through the Fleet boundary while retaining static-token support for agents. +- [x] Update installer guidance and login/setup UI. +- [x] Add backend and frontend regression coverage. +- [x] Run targeted validation and prepare the draft PR. diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f5e1f38..ed28f24 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -117,4 +117,5 @@ Write-Host "OpenCLI Admin $Version is ready." Write-Host "URL: http://localhost:$frontendPort" Write-Host "BOOTSTRAP_ADMIN_TOKEN: $bootstrapToken" Write-Host "API_AUTH_TOKEN: $apiToken" -Write-Host "Use BOOTSTRAP_ADMIN_TOKEN in the first login field and API_AUTH_TOKEN in the optional fleet field. Both are stored in $envPath" +Write-Host "First run: open the URL, create a local administrator password, and enter BOOTSTRAP_ADMIN_TOKEN once." +Write-Host "After setup, sign in with the administrator password. Recovery tokens remain stored in $envPath" diff --git a/scripts/install.sh b/scripts/install.sh index a79c9a3..da40154 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -93,4 +93,5 @@ printf '\nOpenCLI Admin %s is ready.\n' "$VERSION" printf 'URL: http://localhost:%s\n' "${FRONTEND_PORT:-3010}" printf 'BOOTSTRAP_ADMIN_TOKEN: %s\n' "$bootstrap_token" printf 'API_AUTH_TOKEN: %s\n' "$api_token" -printf 'Use BOOTSTRAP_ADMIN_TOKEN in the first login field and API_AUTH_TOKEN in the optional fleet field. Both are stored in %s/.env\n' "$INSTALL_DIR" +printf 'First run: open the URL, create a local administrator password, and enter BOOTSTRAP_ADMIN_TOKEN once.\n' +printf 'After setup, sign in with the administrator password. Recovery tokens remain stored in %s/.env\n' "$INSTALL_DIR" diff --git a/tests/unit/security/test_local_auth.py b/tests/unit/security/test_local_auth.py new file mode 100644 index 0000000..ab88087 --- /dev/null +++ b/tests/unit/security/test_local_auth.py @@ -0,0 +1,95 @@ +import pytest +from fastapi import HTTPException + +from backend.config import get_settings +from backend.security.local_auth import LoginAttemptLimiter, verify_password + + +@pytest.fixture(autouse=True) +def _clear_settings_cache(): + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.mark.asyncio +async def test_first_run_setup_creates_local_admin_and_session(client, monkeypatch): + monkeypatch.setenv("BOOTSTRAP_ADMIN_TOKEN", "first-run-secret") + monkeypatch.setenv("SECRET_KEY", "test-session-signing-key") + monkeypatch.setenv("API_AUTH_TOKEN", "fleet-secret") + get_settings.cache_clear() + + status = await client.get("/api/v1/auth/local/status") + assert status.status_code == 200 + assert status.json()["data"] == {"configured": False} + assert (await client.get("/api/v1/auth/local/status/extra")).status_code == 401 + + denied = await client.post( + "/api/v1/auth/local/setup", + json={"bootstrap_token": "wrong", "password": "long-enough-password"}, + ) + assert denied.status_code == 401 + + setup = await client.post( + "/api/v1/auth/local/setup", + json={"bootstrap_token": "first-run-secret", "password": "long-enough-password"}, + ) + assert setup.status_code == 200 + token = setup.json()["data"]["access_token"] + + identity = await client.get( + "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"} + ) + assert identity.status_code == 200 + assert identity.json()["data"]["auth_method"] == "local" + assert identity.json()["data"]["is_platform_admin"] is True + + +@pytest.mark.asyncio +async def test_local_admin_login_and_second_setup_are_guarded(client, monkeypatch): + monkeypatch.setenv("BOOTSTRAP_ADMIN_TOKEN", "first-run-secret") + monkeypatch.setenv("SECRET_KEY", "test-session-signing-key") + get_settings.cache_clear() + payload = {"bootstrap_token": "first-run-secret", "password": "long-enough-password"} + assert (await client.post("/api/v1/auth/local/setup", json=payload)).status_code == 200 + assert (await client.post("/api/v1/auth/local/setup", json=payload)).status_code == 409 + + denied = await client.post( + "/api/v1/auth/local/login", json={"password": "incorrect-password"} + ) + assert denied.status_code == 401 + accepted = await client.post( + "/api/v1/auth/local/login", json={"password": "long-enough-password"} + ) + assert accepted.status_code == 200 + assert accepted.json()["data"]["access_token"] + + +def test_login_attempt_limiter_blocks_and_can_reset(): + limiter = LoginAttemptLimiter(max_attempts=2, window_seconds=60) + limiter.record_failure("client") + limiter.record_failure("client") + + with pytest.raises(HTTPException) as exc_info: + limiter.check("client") + assert exc_info.value.status_code == 429 + assert exc_info.value.headers["Retry-After"] + + limiter.reset("client") + limiter.check("client") + + +def test_login_attempt_limiter_bounds_tracked_clients(): + limiter = LoginAttemptLimiter(max_attempts=1, window_seconds=60, max_clients=1) + limiter.record_failure("first") + limiter.check("new-client") + limiter.record_failure("second") + + limiter.check("first") + with pytest.raises(HTTPException): + limiter.check("second") + + +def test_password_hash_rejects_untrusted_work_factor(): + forged = "scrypt$1073741824$8$1$c2FsdA==$ZGlnZXN0" + assert verify_password("long-enough-password", forged) is False diff --git a/tests/unit/test_identity_models.py b/tests/unit/test_identity_models.py index 4dacf23..55e3e37 100644 --- a/tests/unit/test_identity_models.py +++ b/tests/unit/test_identity_models.py @@ -2,7 +2,7 @@ from sqlalchemy.exc import IntegrityError from backend.database import Base -from backend.models import User, Workspace, WorkspaceMembership, WorkspaceRole +from backend.models import LocalAdmin, User, Workspace, WorkspaceMembership, WorkspaceRole def test_identity_models_are_registered_for_migrations(): @@ -13,8 +13,11 @@ def test_identity_models_are_registered_for_migrations(): "teams", "team_memberships", "service_identities", + "local_admin_credentials", } <= set(Base.metadata.tables) + assert LocalAdmin.__tablename__ == "local_admin_credentials" + @pytest.mark.asyncio async def test_workspace_membership_role_and_uniqueness(db_session): From 9873519e058251ba9d47cc29209c35beca81aa16 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:26:26 +0800 Subject: [PATCH 2/7] accept local sessions with stale fleet headers --- backend/security/fleet_auth.py | 17 ++++++++++++----- tests/unit/security/test_local_auth.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/backend/security/fleet_auth.py b/backend/security/fleet_auth.py index e9bf689..923b68b 100644 --- a/backend/security/fleet_auth.py +++ b/backend/security/fleet_auth.py @@ -194,12 +194,19 @@ 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) + credentials = ( + headers.get("x-api-token", ""), + _bearer_credential(headers), + ) bootstrap = get_settings().bootstrap_admin_token - if credential and ( - _token_matches(credential, token) - or (bootstrap and _token_matches(credential, bootstrap)) - or is_local_session(credential) + 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 diff --git a/tests/unit/security/test_local_auth.py b/tests/unit/security/test_local_auth.py index ab88087..c1152c9 100644 --- a/tests/unit/security/test_local_auth.py +++ b/tests/unit/security/test_local_auth.py @@ -44,6 +44,16 @@ async def test_first_run_setup_creates_local_admin_and_session(client, monkeypat assert identity.json()["data"]["auth_method"] == "local" assert identity.json()["data"]["is_platform_admin"] is True + identity_with_stale_fleet_token = await client.get( + "/api/v1/auth/me", + headers={ + "Authorization": f"Bearer {token}", + "X-API-Token": "stale-browser-token", + }, + ) + assert identity_with_stale_fleet_token.status_code == 200 + assert identity_with_stale_fleet_token.json()["data"]["auth_method"] == "local" + @pytest.mark.asyncio async def test_local_admin_login_and_second_setup_are_guarded(client, monkeypatch): From cde5ff80c2471501b41d55090b4761979c12a272 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:22:51 +0800 Subject: [PATCH 3/7] add observable agent execution experience --- backend/api/v1/chat.py | 184 ++++++++++++++++- frontend/app/(app)/operations-agents/page.tsx | 11 +- .../components/shell/global-agent-dock.tsx | 186 ++++++++++++++++-- .../agent-execution-experience/brief.md | 28 +++ .../agent-execution-experience/design.md | 22 +++ .../agent-execution-experience/motion.md | 5 + .../changes/agent-execution-experience/qa.md | 14 ++ .../agent-execution-experience/tasks.md | 11 ++ 8 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/agent-execution-experience/brief.md create mode 100644 openspec/changes/agent-execution-experience/design.md create mode 100644 openspec/changes/agent-execution-experience/motion.md create mode 100644 openspec/changes/agent-execution-experience/qa.md create mode 100644 openspec/changes/agent-execution-experience/tasks.md diff --git a/backend/api/v1/chat.py b/backend/api/v1/chat.py index ee4f636..edfe922 100644 --- a/backend/api/v1/chat.py +++ b/backend/api/v1/chat.py @@ -11,12 +11,15 @@ v1 薄闭环: 唯一写动作 = 启停 source。验证通后按同模式扩 trigger_task / update_schedule。 """ +import asyncio import json import logging import re -from typing import Any, Literal, Optional +from contextvars import ContextVar +from typing import Any, Awaitable, Callable, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -35,6 +38,40 @@ MAX_TOOL_STEPS = 5 +ActivitySink = Callable[[dict[str, Any]], Awaitable[None]] +_activity_sink: ContextVar[ActivitySink | None] = ContextVar("chat_activity_sink", default=None) + +_PUBLIC_TOOL_LABELS = { + "list_sources": ("检查数据源", "数据源"), + "list_schedules": ("检查调度计划", "调度计划"), + "list_tasks": ("检查最近任务", "采集任务"), + "list_providers": ("检查模型连接", "模型提供商"), + "toggle_source": ("变更数据源状态", "数据源"), + "trigger_task": ("启动采集任务", "数据源"), + "update_schedule": ("更新调度计划", "调度计划"), + "update_provider": ("更新模型配置", "模型提供商"), +} + + +async def _emit_activity(event_type: str, label: str, detail: str, **extra: Any) -> None: + sink = _activity_sink.get() + if sink is not None: + await sink({"type": event_type, "label": label, "detail": detail, **extra}) + + +def _tool_public_description(name: str, args: dict[str, Any]) -> tuple[str, str, str | None]: + label, target_type = _PUBLIC_TOOL_LABELS.get(name, ("执行操作", "系统对象")) + target_id = next((str(args[key]) for key in ("source_id", "schedule_id", "provider_id") if args.get(key)), None) + return label, target_type, target_id + + +def _result_public_summary(result: Any) -> str: + if isinstance(result, list): + return f"找到 {len(result)} 项可用信息" + if isinstance(result, dict) and result.get("error"): + return "未能读取目标信息" + return "已读取目标信息" + SYSTEM_PROMPT = """你是 opencli-admin 的全局操作助手。用户可能位于任意产品页面。\ 你的职责: 根据当前页面和对象上下文解释系统状态,并在已有工具覆盖范围内按用户意图查询或修改后端配置。 @@ -347,9 +384,21 @@ async def chat( identity: RequestIdentity | None = Depends(_optional_request_identity), db: AsyncSession = Depends(get_db), ) -> ApiResponse: + await _emit_activity( + "phase.changed", + "理解目标", + "正在结合当前页面、工作区和选中对象理解请求。", + state="completed", + ) provider = await _pick_provider(db, body.provider_id) client = await _build_client(provider) model = provider.default_model or "gpt-4o-mini" + await _emit_activity( + "phase.changed", + "制定执行路径", + "已选择可用模型,正在判断需要读取的信息和可能的操作。", + state="active", + ) system = SYSTEM_PROMPT if body.context: @@ -362,6 +411,12 @@ async def chat( messages += [{"role": m.role, "content": m.content} for m in body.messages] for _step in range(MAX_TOOL_STEPS): + await _emit_activity( + "phase.changed", + "分析当前状态", + "正在根据已获得的信息决定下一步。", + state="active", + ) try: response = await client.chat.completions.create( model=model, messages=messages, tools=TOOLS, tool_choice="auto" @@ -374,12 +429,26 @@ async def chat( tool_calls = msg.tool_calls or [] if not tool_calls: + await _emit_activity( + "run.completed", + "处理完成", + "已生成基于本次执行信息的结果摘要。", + state="completed", + ) return ApiResponse.ok(ChatReply(type="message", content=msg.content or "")) # 写工具命中 → 立即返回 proposal (不执行, 不继续推理) for tc in tool_calls: if tc.function.name in WRITE_TOOLS: args = _safe_json(tc.function.arguments) + label, target_type, target_id = _tool_public_description(tc.function.name, args) + await _emit_activity( + "tool.completed", + label, + "已定位目标并准备变更方案。", + state="completed", + target={"type": target_type, "id": target_id}, + ) proposal = await _build_proposal( db, tc.function.name, @@ -387,6 +456,13 @@ async def chat( identity=_require_write_identity(identity), workspace_id=_workspace_id(body.context), ) + await _emit_activity( + "approval.required", + "等待确认", + proposal.summary, + state="attention", + target={"type": target_type, "id": target_id}, + ) return ApiResponse.ok(ChatReply(type="proposal", proposal=proposal)) # 只读工具 → 执行, 喂回结果, 继续循环 @@ -405,7 +481,23 @@ async def chat( } ) for tc in tool_calls: - result = await _run_read_tool(db, tc.function.name, _safe_json(tc.function.arguments)) + args = _safe_json(tc.function.arguments) + label, target_type, target_id = _tool_public_description(tc.function.name, args) + await _emit_activity( + "tool.started", + label, + f"正在读取{target_type}的当前状态。", + state="active", + target={"type": target_type, "id": target_id}, + ) + result = await _run_read_tool(db, tc.function.name, args) + await _emit_activity( + "tool.completed", + label, + _result_public_summary(result), + state="completed", + target={"type": target_type, "id": target_id}, + ) messages.append( {"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)} ) @@ -413,6 +505,94 @@ async def chat( return ApiResponse.ok(ChatReply(type="message", content="(达到工具调用步数上限, 请换个说法再试)")) +@router.post("/stream") +async def chat_stream( + body: ChatRequest, + identity: RequestIdentity | None = Depends(_optional_request_identity), + db: AsyncSession = Depends(get_db), +) -> StreamingResponse: + """Stream public execution facts as NDJSON while the existing chat run executes. + + Events deliberately contain no model reasoning, raw tool arguments, credentials, or + unbounded tool results. The terminal ``reply`` event preserves the established ChatReply + contract so confirmation continues through the governed endpoint. + """ + + async def event_source(): + queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue() + sequence = 0 + + async def emit(event: dict[str, Any]) -> None: + nonlocal sequence + sequence += 1 + await queue.put({"sequence": sequence, **event}) + + async def produce() -> None: + token = _activity_sink.set(emit) + try: + await emit( + { + "type": "run.started", + "label": "开始处理", + "detail": "已接收请求,正在建立执行上下文。", + "state": "active", + } + ) + response = await chat(body, identity, db) + await emit( + { + "type": "reply", + "label": "结果已就绪", + "detail": "本次处理已返回结果。", + "state": "completed", + "reply": response.data.model_dump(mode="json"), + } + ) + except HTTPException as exc: + await emit( + { + "type": "run.failed", + "label": "处理未完成", + "detail": str(exc.detail), + "state": "failed", + "status": exc.status_code, + "recovery": "检查连接或目标状态后重试。", + } + ) + except Exception: + logger.exception("chat stream failed") + await emit( + { + "type": "run.failed", + "label": "处理未完成", + "detail": "Agent 暂时无法完成这项任务。", + "state": "failed", + "status": 500, + "recovery": "稍后重试,或调整请求后继续。", + } + ) + finally: + _activity_sink.reset(token) + await queue.put(None) + + task = asyncio.create_task(produce()) + try: + while True: + event = await queue.get() + if event is None: + break + yield json.dumps(event, ensure_ascii=False) + "\n" + finally: + if not task.done(): + task.cancel() + + return StreamingResponse( + event_source(), + media_type="application/x-ndjson", + headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}, + ) + + @router.post("/confirm", response_model=ApiResponse[dict]) async def confirm( body: ConfirmRequest, diff --git a/frontend/app/(app)/operations-agents/page.tsx b/frontend/app/(app)/operations-agents/page.tsx index 2476c97..07cb5fa 100644 --- a/frontend/app/(app)/operations-agents/page.tsx +++ b/frontend/app/(app)/operations-agents/page.tsx @@ -51,6 +51,15 @@ function parseJsonObject(value: string, label: string) { return parsed as Record } +function publicRunSummary(payload: Record | null) { + if (!payload) return null + const values = Object.entries(payload) + .filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)) + .slice(0, 4) + .map(([key, value]) => `${key}: ${String(value)}`) + return values.length ? values.join(' · ') : '已生成结构化执行结果,可在运行记录中审计。' +} + function ContractEditor({ workspaceId, agent }: { workspaceId: string; agent: OperationsAgent }) { const draft = useOperationsAgentDraft(workspaceId, agent.id) const versions = useOperationsAgentVersions(workspaceId, agent.id) @@ -346,7 +355,7 @@ export default function OperationsAgentsPage() {
SESSION OUTPUT
- {(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).length ?
{(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).map((run) =>
[{run.status}] {new Date(run.updated_at).toLocaleString()}
{run.trigger_type} → {run.target_resource_type}/{run.target_resource_id}
profile v{run.profile_version} · agent v{run.published_version}
{run.error_message ?
{run.error_message}
: null}{run.output_payload ?
{JSON.stringify(run.output_payload, null, 2)}
: null}
)}
:

还没有会话输出

智能体收到任务后,这里会显示真实的 CLI 活动和运行状态。

} + {(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).length ?
{(activity.data ?? []).filter((run) => run.operations_agent_id === selectedAgent.id).map((run) =>
{run.status === 'queued' ? '等待执行' : run.status === 'running' ? '正在执行' : run.status === 'completed' ? '已完成' : run.status === 'paused' ? '等待确认' : run.status === 'cancelled' ? '已取消' : '执行失败'}

目标:{run.target_resource_type} · {run.target_resource_id}

{run.error_message ?
{run.error_message}

检查目标状态后可以重新启动。

: null}{publicRunSummary(run.output_payload) ?
结果:{publicRunSummary(run.output_payload)}
: null}
)}
:

还没有执行活动

智能体收到任务后,这里会显示目标、当前状态和结果摘要。

}
diff --git a/frontend/components/shell/global-agent-dock.tsx b/frontend/components/shell/global-agent-dock.tsx index 148599d..f987190 100644 --- a/frontend/components/shell/global-agent-dock.tsx +++ b/frontend/components/shell/global-agent-dock.tsx @@ -1,7 +1,7 @@ 'use client' import { useQueryClient } from '@tanstack/react-query' -import { Bot, Check, Loader2, Send, ShieldCheck, X } from 'lucide-react' +import { Bot, Check, CircleAlert, CircleCheck, Clock3, Loader2, Monitor, RotateCcw, Send, ShieldCheck, Sparkles, X } from 'lucide-react' import { usePathname } from 'next/navigation' import { FormEvent, KeyboardEvent, useState } from 'react' @@ -16,7 +16,7 @@ import { } from '@/components/ui/sheet' import { Textarea } from '@/components/ui/textarea' import { apiClient } from '@/lib/api/client' -import type { ApiResponse } from '@/lib/api/types' +import { getApiAuthHeaders } from '@/lib/api/auth-headers' import { ROUTE_LABELS } from '@/lib/navigation' type AgentMessage = { @@ -40,6 +40,45 @@ type AgentReply = { proposal?: AgentProposal | null } +type ActivityState = 'active' | 'complete' | 'attention' + +type Activity = { + label: string + detail: string + state: ActivityState + target?: { type?: string; id?: string | null } +} + +type AgentRunEvent = { + sequence: number + type: string + label: string + detail: string + state?: 'active' | 'completed' | 'attention' | 'failed' + target?: { type?: string; id?: string | null } + recovery?: string + reply?: AgentReply +} + +function activityFromEvent(event: AgentRunEvent): Activity { + return { + label: event.label, + detail: event.recovery ? `${event.detail} ${event.recovery}` : event.detail, + state: event.state === 'completed' ? 'complete' : event.state === 'failed' || event.state === 'attention' ? 'attention' : 'active', + target: event.target, + } +} + +function activityForReply(reply: AgentReply): Activity[] { + if (reply.type === 'proposal' && reply.proposal) { + return [ + { label: '已定位操作对象', detail: reply.proposal.summary, state: 'complete' }, + { label: '等待你的确认', detail: '这是一次会改变软件状态的操作。确认后才会执行。', state: 'attention' }, + ] + } + return [{ label: '已完成处理', detail: '已基于当前可访问的数据生成结果。', state: 'complete' }] +} + export function GlobalAgentDock({ open, onOpenChange, @@ -55,6 +94,10 @@ export function GlobalAgentDock({ const [error, setError] = useState(null) const [sending, setSending] = useState(false) const [confirming, setConfirming] = useState(false) + const [goal, setGoal] = useState(null) + const [activities, setActivities] = useState([]) + const [lastFailedProposal, setLastFailedProposal] = useState(null) + const [showLiveSurface, setShowLiveSurface] = useState(false) async function sendMessage(event?: FormEvent) { event?.preventDefault() @@ -66,6 +109,11 @@ export function GlobalAgentDock({ setInput('') setError(null) setSending(true) + setGoal(content) + setActivities([ + { label: '理解你的目标', detail: '正在结合当前页面和选中对象梳理任务。', state: 'complete' }, + { label: '检查可用信息', detail: '正在判断是否需要读取数据或准备操作。', state: 'active' }, + ]) try { const searchParams = new URLSearchParams(window.location.search) const workspaceId = searchParams.get('workspace') @@ -76,7 +124,10 @@ export function GlobalAgentDock({ const sourceId = searchParams.get('source') ?? pathname.match(/^\/sources\/([^/]+)/)?.[1] ?? null - const response = await apiClient.post>('/chat', { + const response = await fetch('/api/v1/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getApiAuthHeaders() }, + body: JSON.stringify({ messages: nextMessages, context: { surface: ROUTE_LABELS[pathname] ?? pathname, @@ -87,8 +138,37 @@ export function GlobalAgentDock({ workflow_id: workflowId, source_id: sourceId, }, + }), }) - const reply = response.data.data + if (!response.ok || !response.body) throw new Error(`Agent 请求失败(${response.status})`) + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let reply: AgentReply | null = null + let streamError: string | null = null + while (true) { + const { value, done } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.trim()) continue + const runEvent = JSON.parse(line) as AgentRunEvent + if (runEvent.type === 'reply' && runEvent.reply) { + reply = runEvent.reply + } else { + setActivities((current) => { + const next = [...current.filter((item) => item.state !== 'active'), activityFromEvent(runEvent)] + return next.slice(-8) + }) + } + if (runEvent.type === 'run.failed') streamError = runEvent.detail + } + if (done) break + } + if (streamError) throw new Error(streamError) + if (!reply) throw new Error('Agent 执行结束但没有返回结果') if (reply.type === 'proposal' && reply.proposal) { setProposal(reply.proposal) } else { @@ -97,25 +177,40 @@ export function GlobalAgentDock({ { role: 'assistant', content: reply.content?.trim() || '没有返回内容。' }, ]) } + setActivities((current) => [...current.filter((item) => item.state !== 'active'), ...activityForReply(reply)].slice(-8)) } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Agent 暂时不可用') + const message = reason instanceof Error ? reason.message : 'Agent 暂时不可用' + setError(message) + setActivities([ + { label: '暂时无法完成理解', detail: message, state: 'attention' }, + { label: '恢复方式', detail: '请检查模型连接后重试,或换一种说法继续。', state: 'attention' }, + ]) } finally { setSending(false) } } - async function confirmProposal() { - if (!proposal || confirming) return + async function confirmProposal(proposalToConfirm = proposal) { + if (!proposalToConfirm || confirming) return setError(null) setConfirming(true) + setLastFailedProposal(null) + setActivities([ + { label: '已获得你的确认', detail: proposalToConfirm.summary, state: 'complete' }, + { label: '正在执行操作', detail: '系统正在应用这项变更。', state: 'active' }, + ]) try { - await apiClient.post('/chat/confirm', { proposal }) + await apiClient.post('/chat/confirm', { proposal: proposalToConfirm }) setMessages((current) => [ ...current, - { role: 'assistant', content: `已执行:${proposal.summary}` }, + { role: 'assistant', content: `已完成:${proposalToConfirm.summary}` }, ]) setProposal(null) await queryClient.invalidateQueries() + setActivities([ + { label: '操作已完成', detail: proposalToConfirm.summary, state: 'complete' }, + { label: '界面已同步', detail: '已刷新相关数据;你现在看到的是最新状态。', state: 'complete' }, + ]) } catch (reason) { const status = reason instanceof Error && 'status' in reason ? reason.status : undefined const message = reason instanceof Error ? reason.message : '操作执行失败' @@ -124,6 +219,11 @@ export function GlobalAgentDock({ ? `提案已失效或目标已变化:${message}。请拒绝后重新发起。` : message, ) + setLastFailedProposal(proposalToConfirm) + setActivities([ + { label: '操作未完成', detail: message, state: 'attention' }, + { label: '可恢复', detail: '检查目标状态后,可重新执行或回到对话调整请求。', state: 'attention' }, + ]) } finally { setConfirming(false) } @@ -178,14 +278,66 @@ export function GlobalAgentDock({ Agent 正在处理
) : null} + {goal ? ( +
+
+ + 正在处理 +
+

目标:{goal}

+
    + {activities.map((activity, index) => { + const Icon = activity.state === 'complete' ? CircleCheck : activity.state === 'attention' ? CircleAlert : Clock3 + return ( +
  1. + +
    +

    {activity.label}

    +

    {activity.detail}

    + {activity.target?.type ? ( +

    + 对象:{activity.target.type}{activity.target.id ? ` · ${activity.target.id}` : ''} +

    + ) : null} +
    +
  2. + ) + })} +
+
+ ) : null} + {goal ? ( +
+
+
+

+ + 软件现场 +

+

查看内置浏览器正在发生的实际变化。

+
+ +
+ {showLiveSurface ? ( +