From 5954e76c83e2d84aabba18f91e17af90a1d4439f 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/3] 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 1d85ab08..a6524854 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 00000000..6965147d --- /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 b93b5cfc..02370c96 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 e42907ec..5c687b6d 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 00000000..785cd05a --- /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 5b199923..f9aee35d 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 00000000..c144cea1 --- /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 0da337e8..d2019459 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 317a1f1a..3a8dfb5a 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 94642385..e9bf6897 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 6b1a0f8f..eadc4db3 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 00000000..dd9a4523 --- /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 00000000..40745201 --- /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 16447936..227813ab 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 cfe9742d..dcfa7dda 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 d0cd8b5e..1dce0473 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 da65ef3f..24f7a665 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 40eddf7a..ec73648c 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 00000000..d7b4e3dc --- /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 00000000..ac3a9550 --- /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 00000000..ecf273cb --- /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 00000000..1f7f3ba9 --- /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 00000000..8dd0aa79 --- /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 00000000..4ec2a3ef --- /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 f5e1f38c..ed28f246 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 a79c9a36..da401549 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 00000000..ab880877 --- /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 4dacf237..55e3e37e 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 30fc4cf8c2c8f19cf27898dcb3d85abcd325e741 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/3] 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 e9bf6897..923b68bd 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 ab880877..c1152c9e 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 ca9aec80182b7fbbe1e473ee1303b7003febb562 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <1012839419a-alt@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:49:09 +0800 Subject: [PATCH 3/3] test(auth): update migration head assertions + register auth.local endpoints in capability matrix --- docs/backend-capability-exposure-matrix.yaml | 37 ++++++++++++++++++- ...st_legacy_native_intelligence_migration.py | 2 +- .../test_legacy_plugin_migration.py | 6 +-- tests/unit/test_migration_heads.py | 2 +- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/backend-capability-exposure-matrix.yaml b/docs/backend-capability-exposure-matrix.yaml index 5a4a3961..ea9121ac 100644 --- a/docs/backend-capability-exposure-matrix.yaml +++ b/docs/backend-capability-exposure-matrix.yaml @@ -1,6 +1,6 @@ version: 1 source: backend.main.app.openapi -openapi_operation_count: 231 +openapi_operation_count: 234 allowed_dispositions: - operator_ui - studio_binding @@ -237,6 +237,14 @@ capability_groups: owner: opencli-admin wrapper_names: [] workflow_node_ids: [] +- capability_id: auth.local + label: 本地管理员认证 + projection: setup_card + distribution: builtin + lifecycle: active + owner: opencli-admin + wrapper_names: [] + workflow_node_ids: [] operations: - method: GET path: /api/v1/agents @@ -2317,6 +2325,33 @@ operations: decision: Start only published project workflows through the Studio runtime boundary. target_epic: Epic 8 capability_id: studio.workflow +- method: GET + path: /api/v1/auth/local/status + operation_id: local_status_api_v1_auth_local_status_get + disposition: setup_status + frontend_route: /login + wrapper: null + decision: First-run local administrator onboarding status check (T1 local admin onboarding, ex-PR #61). + target_epic: Epic 8 + capability_id: auth.local +- method: POST + path: /api/v1/auth/local/setup + operation_id: setup_local_admin_api_v1_auth_local_setup_post + disposition: setup_status + frontend_route: /login + wrapper: null + decision: Create the fixed local-admin record on first run (requires BOOTSTRAP_ADMIN_TOKEN). + target_epic: Epic 8 + capability_id: auth.local +- method: POST + path: /api/v1/auth/local/login + operation_id: local_login_api_v1_auth_local_login_post + disposition: machine_ingress + frontend_route: null + wrapper: null + decision: Local administrator password login issuing a server-signed 12-hour session. + target_epic: Epic 8 + capability_id: auth.local unreferenced_wrappers: - wrapper: getOperationsAgentVersion operation_id: get_agent_version_api_v1_workspaces__workspace_id__operations_agents__agent_id__versions__version_number__get diff --git a/tests/integration/test_legacy_native_intelligence_migration.py b/tests/integration/test_legacy_native_intelligence_migration.py index d4fb6b05..c0433e94 100644 --- a/tests/integration/test_legacy_native_intelligence_migration.py +++ b/tests/integration/test_legacy_native_intelligence_migration.py @@ -65,7 +65,7 @@ def test_legacy_plugin_head_rejoins_native_intelligence_head(tmp_path: Path) -> ) } - assert revision == ("k8l9m0n1o2p3",) + assert revision == ("z7a8b9c0d1e2",) assert marker == ("workspace-1", "native-intelligence-workspace") assert "intelligence_sessions" in tables assert "intelligence_artifacts" in tables diff --git a/tests/integration/test_legacy_plugin_migration.py b/tests/integration/test_legacy_plugin_migration.py index ef519433..e740b85a 100644 --- a/tests/integration/test_legacy_plugin_migration.py +++ b/tests/integration/test_legacy_plugin_migration.py @@ -87,7 +87,7 @@ def test_legacy_plugin_database_rejoins_current_migration_head(tmp_path: Path) - finally: connection.close() - assert revision == ("k8l9m0n1o2p3",) + assert revision == ("z7a8b9c0d1e2",) assert "version" in cursor_columns assert "identity_key" in record_columns assert "ix_collected_records_source_identity" in record_indexes @@ -135,7 +135,7 @@ def test_current_database_repairs_missing_plugin_installation_table(tmp_path: Pa finally: connection.close() - assert revision == ("k8l9m0n1o2p3",) + assert revision == ("z7a8b9c0d1e2",) assert table == ("plugin_installations",) assert "ix_plugin_installations_provider_key" in indexes @@ -184,7 +184,7 @@ def test_current_head_repairs_missing_record_identity_schema(tmp_path: Path) -> finally: connection.close() - assert revision == ("k8l9m0n1o2p3",) + assert revision == ("z7a8b9c0d1e2",) assert "identity_key" in columns assert "ix_collected_records_source_identity" in indexes assert record == ("source-1", None) diff --git a/tests/unit/test_migration_heads.py b/tests/unit/test_migration_heads.py index 46a01224..6e69aafd 100644 --- a/tests/unit/test_migration_heads.py +++ b/tests/unit/test_migration_heads.py @@ -13,7 +13,7 @@ def test_alembic_has_one_head(): config = Config() config.set_main_option("script_location", "backend/migrations") - assert ScriptDirectory.from_config(config).get_heads() == ["k8l9m0n1o2p3"] + assert ScriptDirectory.from_config(config).get_heads() == ["z7a8b9c0d1e2"] def test_upgrade_head_creates_identity_and_operations_tables(monkeypatch):