diff --git a/.env.docker.example b/.env.docker.example
index 2162f948..9584bbcd 100644
--- a/.env.docker.example
+++ b/.env.docker.example
@@ -7,8 +7,10 @@ FRONTEND_PORT=3010
API_PORT=8031
PUBLIC_URL=http://localhost:8031
-# Required. The installers generate all four values automatically.
+# API transport token is required for non-localhost binds and Fleet/Agent/API
+# integrations. The installer generates it automatically.
API_AUTH_TOKEN=
+# Optional legacy emergency/OIDC bootstrap token; local login does not use it.
BOOTSTRAP_ADMIN_TOKEN=
SECRET_KEY=
CREDENTIAL_ENCRYPTION_KEY=
diff --git a/README.md b/README.md
index b93b5cfc..3c0589ed 100644
--- a/README.md
+++ b/README.md
@@ -55,12 +55,13 @@ Invoke-WebRequest https://raw.githubusercontent.com/2233admin/opencli-Razormind/
| API 文档 | http://localhost:8031/docs | REST API 与集成调试 |
| 内置浏览器 | http://localhost:6080 | 扫码或登录需要账号的平台 |
-安装完成后,终端会打印:
+安装完成后可以直接使用本地管理员账号登录:
-- `BOOTSTRAP_ADMIN_TOKEN`:首次进入管理界面使用;
-- `API_AUTH_TOKEN`:Fleet、Agent、API 和 MCP 访问使用。
+- 用户名:`admin`
+- 密码:`admin`
+- 登录后可在「账户设置」修改密码
-两者同时保存在安装目录的 `.env`。不要公开 noVNC、令牌或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。
+`API_AUTH_TOKEN` 仅由 Fleet、Agent、API 和 MCP 传输使用,自动保存在安装目录的 `.env`,不需要填入管理界面。不要公开 API 令牌、noVNC 或浏览器调试端口;远程部署建议使用 HTTPS、反向代理或 SSH 隧道。
## 正常的研究流程
diff --git a/backend/api/v1/identity.py b/backend/api/v1/identity.py
index 362ad6fe..613385bc 100644
--- a/backend/api/v1/identity.py
+++ b/backend/api/v1/identity.py
@@ -1,15 +1,70 @@
-"""Request identity endpoint."""
+"""Local administrator and request identity endpoints."""
from typing import Annotated
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, Field
+from backend.config import get_settings
from backend.schemas.common import ApiResponse
from backend.security.identity import RequestIdentity, get_request_identity
+from backend.security.local_auth import (
+ hash_password,
+ issue_local_token,
+ persist_password_hash,
+ verify_password,
+)
+
+
+class LocalLoginRequest(BaseModel):
+ username: str = Field(min_length=1, max_length=255)
+ password: str = Field(min_length=1, max_length=255)
+
+
+class ChangePasswordRequest(BaseModel):
+ current_password: str = Field(min_length=1, max_length=255)
+ new_password: str = Field(min_length=6, max_length=255)
+
router = APIRouter(prefix="/auth", tags=["auth"])
+@router.post("/login", response_model=ApiResponse[dict])
+async def local_login(body: LocalLoginRequest) -> ApiResponse:
+ settings = get_settings()
+ if body.username != settings.local_admin_username or not verify_password(
+ body.password, settings.local_admin_password_hash
+ ):
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED, "用户名或密码错误")
+
+ return ApiResponse.ok(
+ {
+ "access_token": issue_local_token(settings.local_admin_username, settings.secret_key),
+ "token_type": "bearer",
+ "using_default_password": verify_password(
+ "admin", settings.local_admin_password_hash
+ ),
+ }
+ )
+
+
+@router.post("/password", response_model=ApiResponse[dict])
+async def change_local_password(
+ body: ChangePasswordRequest,
+ identity: Annotated[RequestIdentity, Depends(get_request_identity)],
+) -> ApiResponse:
+ if identity.auth_method != "local":
+ raise HTTPException(status.HTTP_403_FORBIDDEN, "仅本地管理员可以修改本地密码")
+ settings = get_settings()
+ if not verify_password(body.current_password, settings.local_admin_password_hash):
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "当前密码错误")
+ if body.new_password == body.current_password:
+ raise HTTPException(status.HTTP_400_BAD_REQUEST, "新密码不能与当前密码相同")
+ persist_password_hash(hash_password(body.new_password))
+ get_settings.cache_clear()
+ return ApiResponse.ok({"message": "密码已更新"})
+
+
@router.get("/me", response_model=ApiResponse[dict])
async def read_identity(
identity: Annotated[RequestIdentity, Depends(get_request_identity)],
diff --git a/backend/api/v1/system.py b/backend/api/v1/system.py
index 06d0d0f4..2ef79885 100644
--- a/backend/api/v1/system.py
+++ b/backend/api/v1/system.py
@@ -1,16 +1,20 @@
-"""System configuration endpoint."""
+"""System-level configuration and deployment status endpoints."""
+
+from __future__ import annotations
import os
import re
+from typing import Literal
-from fastapi import APIRouter, HTTPException
-from pydantic import BaseModel
+from fastapi import APIRouter
+from pydantic import BaseModel, Field
from backend.config import get_settings
from backend.schemas.common import ApiResponse
router = APIRouter(prefix="/system", tags=["system"])
+
def _resolve_env_path() -> str:
if explicit := os.environ.get("ENV_FILE_PATH"):
return explicit
@@ -20,15 +24,14 @@ def _resolve_env_path() -> str:
]:
if os.path.exists(candidate):
return candidate
- # Fallback: project root .env (will be created if missing)
return os.path.join(os.path.dirname(__file__), "..", "..", "..", ".env")
def _update_env_file(key: str, value: str) -> None:
path = _resolve_env_path()
try:
- with open(path) as f:
- content = f.read()
+ with open(path, encoding="utf-8") as env_file:
+ content = env_file.read()
except FileNotFoundError:
content = ""
new_line = f"{key}={value}"
@@ -37,44 +40,72 @@ def _update_env_file(key: str, value: str) -> None:
content = re.sub(pattern, new_line, content, flags=re.MULTILINE)
else:
content = content.rstrip("\n") + f"\n{new_line}\n"
- with open(path, "w") as f:
- f.write(content)
+ with open(path, "w", encoding="utf-8") as env_file:
+ env_file.write(content)
class ConfigPatch(BaseModel):
- collection_mode: str | None = None
+ collection_mode: Literal["local", "agent"] | None = None
+ collection_orchestrator: Literal["admin", "iii"] | None = None
+ local_max_concurrent_pipelines: int | None = Field(default=None, ge=1, le=64)
+ opencli_timeout: int | None = Field(default=None, ge=1, le=3600)
+ default_timezone: str | None = Field(default=None, min_length=1, max_length=64)
+ public_url: str | None = Field(default=None, max_length=2048)
+ fleet_network_provider: Literal["lan", "netbird", "wireguard", "ssh", "custom"] | None = None
+ netbird_mode: Literal["off", "host", "docker"] | None = None
+ opencli_cdp_endpoint: str | None = Field(default=None, min_length=1, max_length=2048)
+ agent_pool_endpoints: str | None = Field(default=None, max_length=8192)
+ llm_request_timeout_seconds: int | None = Field(default=None, ge=1, le=3600)
+ llm_max_concurrency: int | None = Field(default=None, ge=1, le=64)
+ control_mode: Literal["advisory", "automatic"] | None = None
+ control_kill_switch: bool | None = None
+
+
+def _system_payload() -> dict:
+ settings = get_settings()
+ return {
+ "app_name": settings.app_name,
+ "app_env": settings.app_env,
+ "debug": settings.debug,
+ "collection_mode": settings.collection_mode,
+ "collection_orchestrator": settings.collection_orchestrator,
+ "task_executor": settings.task_executor,
+ "local_max_concurrent_pipelines": settings.local_max_concurrent_pipelines,
+ "opencli_timeout": settings.opencli_timeout,
+ "default_timezone": settings.default_timezone,
+ "public_url": settings.public_url,
+ "fleet_network_provider": settings.fleet_network_provider,
+ "netbird_mode": settings.netbird_mode,
+ "opencli_cdp_endpoint": settings.opencli_cdp_endpoint,
+ "agent_pool_endpoints": settings.cdp_endpoints,
+ "llm_request_timeout_seconds": settings.llm_request_timeout_seconds,
+ "llm_max_concurrency": settings.llm_max_concurrency,
+ "control_mode": settings.control_mode,
+ "control_kill_switch": settings.control_kill_switch,
+ "image_tag": settings.image_tag,
+ "database_kind": "sqlite" if settings.is_sqlite else "postgresql",
+ "api_auth_configured": bool(settings.api_auth_token),
+ "oidc_configured": bool(os.getenv("OIDC_ISSUER") and os.getenv("OIDC_AUDIENCE")),
+ "smtp_configured": bool(settings.smtp_host and settings.smtp_from),
+ "credential_encryption_configured": bool(settings.credential_encryption_key),
+ }
@router.get("/config", response_model=ApiResponse[dict])
async def get_config() -> ApiResponse:
- s = get_settings()
- return ApiResponse.ok(
- {
- "collection_mode": s.collection_mode,
- "task_executor": s.task_executor,
- "image_tag": s.image_tag,
- }
- )
+ return ApiResponse.ok(_system_payload())
@router.patch("/config", response_model=ApiResponse[dict])
async def update_config(body: ConfigPatch) -> ApiResponse:
- if body.collection_mode is not None:
- if body.collection_mode not in ("local", "agent"):
- raise HTTPException(
- status_code=400, detail="collection_mode must be 'local' or 'agent'"
- )
- _update_env_file("COLLECTION_MODE", body.collection_mode)
- # Also update the process env var so pydantic-settings picks up the new value
- # (env vars take priority over .env file in pydantic-settings v2)
- os.environ["COLLECTION_MODE"] = body.collection_mode
- get_settings.cache_clear()
-
- s = get_settings()
- return ApiResponse.ok(
- {
- "collection_mode": s.collection_mode,
- "task_executor": s.task_executor,
- "image_tag": s.image_tag,
- }
- )
+ updates = body.model_dump(exclude_none=True)
+ if not updates:
+ return ApiResponse.ok(_system_payload())
+
+ for key, value in updates.items():
+ env_key = key.upper()
+ env_value = str(value).lower() if isinstance(value, bool) else str(value)
+ _update_env_file(env_key, env_value)
+ os.environ[env_key] = env_value
+ get_settings.cache_clear()
+ return ApiResponse.ok(_system_payload())
diff --git a/backend/api/v1/workspaces.py b/backend/api/v1/workspaces.py
index 17969488..79a0bded 100644
--- a/backend/api/v1/workspaces.py
+++ b/backend/api/v1/workspaces.py
@@ -3,7 +3,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_db
-from backend.models.identity import User, Workspace, WorkspaceMembership, WorkspaceRole
+from backend.models.identity import Team, User, Workspace, WorkspaceMembership, WorkspaceRole
from backend.models.workflow import Project
from backend.schemas.common import ApiResponse
from backend.schemas.workflow_asset import ProjectRead
@@ -54,6 +54,51 @@ async def _get_or_create_user(
raise HTTPException(status.HTTP_409_CONFLICT, "Disabled user cannot join a Workspace")
return user
+async def _ensure_local_admin_workspace(
+ db: AsyncSession,
+ identity: RequestIdentity,
+) -> None:
+ if identity.auth_method != "local":
+ return
+
+ user = await db.scalar(select(User).where(User.subject == identity.subject))
+ if user is None:
+ user = User(
+ subject=identity.subject,
+ display_name=identity.name or "本地管理员",
+ )
+ db.add(user)
+ await db.flush()
+
+ workspace = await db.scalar(select(Workspace).where(Workspace.slug == "opencli-default"))
+ if workspace is None:
+ workspace = Workspace(name="OpenCLI 工作区", slug="opencli-default")
+ db.add(workspace)
+ await db.flush()
+
+ membership = await db.scalar(
+ select(WorkspaceMembership)
+ .where(WorkspaceMembership.workspace_id == workspace.id)
+ .where(WorkspaceMembership.user_id == user.id)
+ )
+ if membership is None:
+ db.add(
+ WorkspaceMembership(
+ workspace_id=workspace.id,
+ user_id=user.id,
+ role=WorkspaceRole.ADMIN,
+ )
+ )
+
+ team = await db.scalar(
+ select(Team)
+ .where(Team.workspace_id == workspace.id)
+ .where(Team.slug == "default")
+ )
+ if team is None:
+ db.add(Team(workspace_id=workspace.id, name="默认团队", slug="default"))
+ await db.flush()
+
@router.get(
"/governance/workspaces",
@@ -63,6 +108,7 @@ async def list_accessible_workspaces(
identity: RequestIdentity = Depends(get_request_identity),
db: AsyncSession = Depends(get_db),
) -> ApiResponse:
+ await _ensure_local_admin_workspace(db, identity)
rows = (
(
await db.execute(
diff --git a/backend/config.py b/backend/config.py
index 5b199923..46d835b3 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -3,6 +3,8 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
+from backend.security.local_auth import DEFAULT_LOCAL_ADMIN_PASSWORD_HASH
+
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -59,6 +61,11 @@ class Settings(BaseSettings):
# guard only allows on a localhost bind. Env: API_AUTH_TOKEN.
api_auth_token: str = ""
+ # Local-first account used by the NAS/server deployment. The password hash
+ # is persisted in .env after the user changes the default password.
+ local_admin_username: str = "admin"
+ local_admin_password_hash: str = DEFAULT_LOCAL_ADMIN_PASSWORD_HASH
+
# CLI channel binary allowlist (ADR-0005, audit P0-4). The cli channel is
# an arbitrary-binary-execution surface, so it only runs binaries the
# operator explicitly listed here. Comma-separated binary paths/names,
diff --git a/backend/security/fleet_auth.py b/backend/security/fleet_auth.py
index 94642385..df3e463e 100644
--- a/backend/security/fleet_auth.py
+++ b/backend/security/fleet_auth.py
@@ -67,6 +67,7 @@
from collections.abc import Sequence
from urllib.parse import parse_qs
+from jose import JWTError, jwt
from starlette.datastructures import Headers
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send
@@ -77,6 +78,10 @@
#: Path prefixes guarded by :class:`FleetAuthMiddleware`.
PROTECTED_PREFIXES = ("/api", "/mcp")
+# Local login is intentionally the only unauthenticated API route. Once the
+# user has a local bearer session, the identity dependency authenticates it.
+PUBLIC_PATHS = frozenset({"/api/v1/auth/login"})
+
_LOCALHOST_HOSTS = frozenset({"localhost", "::1"})
@@ -120,6 +125,17 @@ def enforce_bind_guard(host: str, token: str) -> None:
"local development."
)
+def _is_local_session(credential: str) -> bool:
+ try:
+ claims = jwt.decode(
+ credential,
+ get_settings().secret_key,
+ algorithms=["HS256"],
+ )
+ except JWTError:
+ return False
+ return claims.get("auth_method") == "local" and claims.get("sub") == "local-admin"
+
def _token_matches(candidate: str, token: str) -> bool:
"""Constant-time comparison of a caller-supplied credential against *token*."""
@@ -147,30 +163,34 @@ class FleetAuthMiddleware:
/health exemption rationale.
"""
+
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
- if scope["type"] not in ("http", "websocket") or not scope["path"].startswith(
- PROTECTED_PREFIXES
+ if (
+ scope["type"] not in ("http", "websocket")
+ or not scope["path"].startswith(PROTECTED_PREFIXES)
+ or scope["path"] in PUBLIC_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
- # patch, so don't freeze the token at middleware construction time.
+ # Read per request so a runtime configuration update is respected.
token = get_settings().api_auth_token
if not token:
- # Dev posture: no token configured -> API open. Only reachable on
- # a localhost bind thanks to enforce_bind_guard at startup.
+ # No fleet token configured: local deployments rely on the identity
+ # dependency and the bind guard limits this posture to localhost.
await self.app(scope, receive, send)
return
if scope["type"] == "websocket":
headers = Headers(scope=scope)
- credential = _bearer_credential(headers) or _query_token(scope.get("query_string", b""))
- if credential and _token_matches(credential, token):
+ bearer = _bearer_credential(headers)
+ credential = bearer or _query_token(scope.get("query_string", b""))
+ if _is_local_session(bearer) or (
+ credential and _token_matches(credential, token)
+ ):
await self.app(scope, receive, send)
return
await WebSocketClose(code=4401, reason="Invalid or missing API token")(
@@ -179,8 +199,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
return
headers = Headers(scope=scope)
- credential = headers.get("x-api-token", "") or _bearer_credential(headers)
- if credential and _token_matches(credential, token):
+ bearer = _bearer_credential(headers)
+ credential = headers.get("x-api-token", "") or bearer
+ if _is_local_session(bearer) or (credential and _token_matches(credential, token)):
await self.app(scope, receive, send)
return
diff --git a/backend/security/identity.py b/backend/security/identity.py
index 6b1a0f8f..e7c3fa7c 100644
--- a/backend/security/identity.py
+++ b/backend/security/identity.py
@@ -19,14 +19,18 @@ class IdentitySettings:
audience: str
jwks_url: str = ""
bootstrap_admin_token: str = ""
+ secret_key: str = "change-me-in-production"
@classmethod
def from_env(cls) -> IdentitySettings:
+ from backend.config import get_settings
+
return cls(
issuer=os.getenv("OIDC_ISSUER", "").rstrip("/"),
audience=os.getenv("OIDC_AUDIENCE", ""),
jwks_url=os.getenv("OIDC_JWKS_URL", ""),
bootstrap_admin_token=os.getenv("BOOTSTRAP_ADMIN_TOKEN", ""),
+ secret_key=get_settings().secret_key,
)
@@ -129,6 +133,18 @@ async def get_request_identity(request: Request) -> RequestIdentity:
"Bearer token required",
headers={"WWW-Authenticate": "Bearer"},
)
+ try:
+ local_claims = jwt.decode(token, resolved.secret_key, algorithms=["HS256"])
+ except JWTError:
+ local_claims = None
+ if local_claims and local_claims.get("auth_method") == "local":
+ return RequestIdentity(
+ subject="local-admin",
+ name=local_claims.get("name") or "本地管理员",
+ username=local_claims.get("username"),
+ is_platform_admin=True,
+ auth_method="local",
+ )
if resolved.bootstrap_admin_token and hmac.compare_digest(
token, resolved.bootstrap_admin_token
):
diff --git a/backend/security/local_auth.py b/backend/security/local_auth.py
new file mode 100644
index 00000000..428449b9
--- /dev/null
+++ b/backend/security/local_auth.py
@@ -0,0 +1,88 @@
+"""Local-first administrator password and session helpers."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import os
+import re
+import secrets
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+from jose import jwt
+
+
+def hash_password(password: str) -> str:
+ salt = secrets.token_bytes(16)
+ n, r, p = 16384, 8, 1
+ digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=n, r=r, p=p)
+ encode = base64.urlsafe_b64encode
+ return f"scrypt${n}${r}${p}${encode(salt).decode()}${encode(digest).decode()}"
+
+DEFAULT_LOCAL_ADMIN_PASSWORD_HASH = hash_password("admin")
+
+
+def verify_password(password: str, encoded: str) -> bool:
+ try:
+ scheme, n, r, p, salt_text, digest_text = encoded.split("$", 5)
+ if scheme != "scrypt":
+ return False
+ salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
+ expected = base64.urlsafe_b64decode(digest_text.encode("ascii"))
+ actual = hashlib.scrypt(
+ password.encode("utf-8"),
+ salt=salt,
+ n=int(n),
+ r=int(r),
+ p=int(p),
+ maxmem=64 * 1024 * 1024,
+ )
+ return hmac.compare_digest(actual, expected)
+ except (ValueError, TypeError, UnicodeError):
+ return False
+
+
+def issue_local_token(username: str, secret_key: str) -> str:
+ now = datetime.now(UTC)
+ return jwt.encode(
+ {
+ "sub": "local-admin",
+ "name": "本地管理员",
+ "username": username,
+ "is_platform_admin": True,
+ "auth_method": "local",
+ "iat": now,
+ "exp": now + timedelta(days=30),
+ },
+ secret_key,
+ algorithm="HS256",
+ )
+
+
+def persist_password_hash(password_hash: str) -> None:
+ """Persist the local password in the deployment .env and current process."""
+
+ os.environ["LOCAL_ADMIN_PASSWORD_HASH"] = password_hash
+ path = os.environ.get("ENV_FILE_PATH")
+ if not path:
+ for candidate in (Path("/app/.env"), Path(__file__).resolve().parents[2] / ".env"):
+ if candidate.exists():
+ path = str(candidate)
+ break
+ else:
+ path = str(Path(__file__).resolve().parents[2] / ".env")
+
+ env_path = Path(path)
+ try:
+ content = env_path.read_text(encoding="utf-8")
+ except FileNotFoundError:
+ content = ""
+ new_line = f"LOCAL_ADMIN_PASSWORD_HASH={password_hash}"
+ pattern = r"^LOCAL_ADMIN_PASSWORD_HASH=.*$"
+ if re.search(pattern, content, re.MULTILINE):
+ content = re.sub(pattern, new_line, content, flags=re.MULTILINE)
+ else:
+ content = content.rstrip("\n") + f"\n{new_line}\n"
+ env_path.write_text(content, encoding="utf-8")
diff --git a/docker-compose.yml b/docker-compose.yml
index 84ef3f63..61589023 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -155,7 +155,7 @@ services:
DEBUG: ${DEBUG:-false}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
API_AUTH_TOKEN: ${API_AUTH_TOKEN:?Set API_AUTH_TOKEN in .env or run scripts/install.sh}
- BOOTSTRAP_ADMIN_TOKEN: ${BOOTSTRAP_ADMIN_TOKEN:?Set BOOTSTRAP_ADMIN_TOKEN in .env or run scripts/install.sh}
+ BOOTSTRAP_ADMIN_TOKEN: ${BOOTSTRAP_ADMIN_TOKEN:-}
OPENCLI_MCP_ALLOWED_HOSTS: ${OPENCLI_MCP_ALLOWED_HOSTS:-}
OPENCLI_MCP_ALLOWED_ORIGINS: ${OPENCLI_MCP_ALLOWED_ORIGINS:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
diff --git a/docs/local-first-auth-PRD.md b/docs/local-first-auth-PRD.md
new file mode 100644
index 00000000..33c69944
--- /dev/null
+++ b/docs/local-first-auth-PRD.md
@@ -0,0 +1,96 @@
+# 本地优先登录简化 PRD
+
+## 1. 背景
+
+OpenCLI 是部署在 NAS 或自有服务器上的本地数据采集与编排工具,不是需要组织身份提供商才能使用的 SaaS 平台。
+
+现有登录页要求用户填写管理员身份令牌,并可选填写 Fleet API 令牌,同时展示 OIDC、首次部署和紧急恢复概念。这些信息不是本地用户完成首次使用所必需的,直接阻断了“部署后打开即用”的主链路。
+
+## 2. 产品目标
+
+让本地部署用户完成以下最短路径:
+
+```text
+部署 → 打开页面 → admin/admin 登录 → 配置采集 → 持续运行
+```
+
+首次登录后仅通过轻量提示提醒用户修改密码,不要求用户理解令牌、组织登录或 Fleet 网络。
+
+## 3. 范围
+
+### 本期包含
+
+- 本地管理员默认账号:`admin`
+- 本地管理员默认密码:`admin`
+- 登录页只展示用户名和密码
+- 登录成功后创建本地会话
+- 登录页提示默认密码可在设置中修改
+- 设置页提供修改本地管理员密码的入口
+- 修改密码后持久化到部署的 `.env`
+- Fleet API 令牌、OIDC 和组织登录不再出现在本地登录页
+- 保留后端已有 OIDC / Fleet 能力作为后续高级部署能力,但不阻塞本地登录
+
+### 本期不包含
+
+- 组织成员管理
+- 邀请、找回密码、邮件验证
+- 多因素认证
+- 多租户权限模型改造
+- Fleet 网络配置向导
+- 公网 SaaS 登录体验
+
+## 4. 用户流程
+
+### 首次登录
+
+1. 用户打开 `/login`。
+2. 页面预填用户名 `admin`,密码由用户填写;页面说明首次使用可使用 `admin / admin`。
+3. 登录成功后进入控制台。
+4. 控制台账户菜单或设置页显示“建议修改默认密码”提示。
+5. 用户可以稍后处理,不阻塞采集工具使用。
+
+### 修改密码
+
+1. 用户打开账户菜单中的“账户设置”。
+2. 输入当前密码、新密码和确认密码。
+3. 保存成功后当前会话继续有效,下次登录使用新密码。
+
+## 5. 交互要求
+
+- 登录卡片只保留用户名、密码和“登录”按钮。
+- 不出现“首次部署令牌”“管理员身份令牌”“Fleet API 令牌”“OIDC issuer”等部署术语。
+- “本地开发模式”仅在开发环境显示,不作为生产用户入口。
+- 密码修改失败时给出直接错误,不展示后端堆栈或配置细节。
+- 默认密码提示使用普通文案,不使用红色阻断式告警。
+
+## 6. API 契约
+
+### `POST /api/v1/auth/login`
+
+请求:
+
+```json
+{"username":"admin","password":"admin"}
+```
+
+响应:返回 bearer access token、本地管理员身份和 `using_default_password` 标记。
+
+### `POST /api/v1/auth/password`
+
+请求:
+
+```json
+{"current_password":"admin","new_password":"new-password"}
+```
+
+要求当前请求携带本地管理员 bearer token。成功后持久化密码哈希。
+
+## 7. 验收标准
+
+- `/login` 不再渲染任何令牌输入框。
+- 使用 `admin / admin` 可在全新本地部署中登录。
+- 错误密码返回明确失败,不创建会话。
+- 修改密码后,旧密码不能登录,新密码可以登录。
+- 登录成功后的 API 请求不要求用户手工填写 Fleet API 令牌。
+- 登录页 E2E 和后端认证测试覆盖以上行为。
+- OIDC / Fleet 后端代码仍可独立工作,但不进入本地登录主流程。
diff --git a/frontend/app/(app)/dashboard/page.tsx b/frontend/app/(app)/dashboard/page.tsx
index f8a71fa6..782176a5 100644
--- a/frontend/app/(app)/dashboard/page.tsx
+++ b/frontend/app/(app)/dashboard/page.tsx
@@ -639,7 +639,7 @@ export default function DashboardPage() {
diff --git a/frontend/app/(app)/operations-agents/page.tsx b/frontend/app/(app)/operations-agents/page.tsx
index 2476c970..a359cfbf 100644
--- a/frontend/app/(app)/operations-agents/page.tsx
+++ b/frontend/app/(app)/operations-agents/page.tsx
@@ -6,7 +6,7 @@ import { ArrowUp, Bell, Bot, CalendarClock, ChevronDown, CircleDot, Cloud, Code2
import { toast } from 'sonner'
import AgentAvatar from '@/components/smoothui/agent-avatar'
-import { useAutomations, useCreateAutomation, useMyWorkspaces, useOperationsAgentActivity, useOperationsAgentDraft, useOperationsAgents, useOperationsAgentVersions, usePatchAutomation, usePublishOperationsAgentVersion, useStartOperationsAgentRun, useUpdateOperationsAgentDraft } from '@/lib/api/hooks'
+import { useAutomations, useCreateAutomation, useGovernedWorkspaces, useOperationsAgentActivity, useOperationsAgentDraft, useOperationsAgents, useOperationsAgentVersions, usePatchAutomation, usePublishOperationsAgentVersion, useStartOperationsAgentRun, useUpdateOperationsAgentDraft } from '@/lib/api/hooks'
import type { Automation, OperationsAgent, OperationsAgentMode } from '@/lib/api/types'
import { cn } from '@/lib/utils'
import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components/shell/data-states'
@@ -175,7 +175,7 @@ function ContractEditor({ workspaceId, agent }: { workspaceId: string; agent: Op
}
export default function OperationsAgentsPage() {
- const workspaces = useMyWorkspaces()
+ const workspaces = useGovernedWorkspaces()
const [workspaceId, setWorkspaceId] = useState(null)
const [view, setView] = useState<'automations' | 'agents'>('automations')
const automations = useAutomations(workspaceId)
diff --git a/frontend/app/(app)/schedules/page.tsx b/frontend/app/(app)/schedules/page.tsx
index e039394c..ccc3dc7c 100644
--- a/frontend/app/(app)/schedules/page.tsx
+++ b/frontend/app/(app)/schedules/page.tsx
@@ -1,75 +1,5 @@
-'use client'
-
-import { useSchedules } from '@/lib/api/hooks'
-import { formatDateTime, formatRelative } from '@/lib/format'
-import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components/shell/data-states'
-import { PageContainer } from '@/components/shell/page-container'
-import { AUTOMATION_TABS, RouteTabs } from '@/components/shell/route-tabs'
-import { StatusBadge } from '@/components/shell/status-badge'
-import { Badge } from '@/components/ui/badge'
-import { Card } from '@/components/ui/card'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
+import { redirect } from 'next/navigation'
export default function SchedulesPage() {
- const { data, isLoading, isError, error } = useSchedules()
- const schedules = data?.data ?? []
-
- return (
- }
- >
- {isLoading ? (
-
- ) : isError ? (
-
- ) : schedules.length === 0 ? (
-
- ) : (
-
-
-
-
- 名称
- Cron 表达式
- 类型
- 状态
- 上次运行
- 下次运行
-
-
-
- {schedules.map((s) => (
-
- {s.name}
-
-
- {s.cron_expression}
-
-
-
- {s.is_one_time ? '一次性' : '周期'}
-
-
-
-
- {formatRelative(s.last_run_at)}
- {formatDateTime(s.next_run_at)}
-
- ))}
-
-
-
- )}
-
- )
+ redirect('/operations-agents')
}
diff --git a/frontend/app/(app)/settings/page.tsx b/frontend/app/(app)/settings/page.tsx
new file mode 100644
index 00000000..71dc5cc8
--- /dev/null
+++ b/frontend/app/(app)/settings/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from 'next/navigation'
+
+export default function SettingsPage() {
+ redirect('/system')
+}
diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx
new file mode 100644
index 00000000..e886b2a7
--- /dev/null
+++ b/frontend/app/(app)/system/page.tsx
@@ -0,0 +1,152 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { toast } from 'sonner'
+
+import { useAuth } from '@/components/auth/auth-provider'
+import { BACKEND_HINT, ErrorState, LoadingState } from '@/components/shell/data-states'
+import { PageContainer } from '@/components/shell/page-container'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
+import { useSystemConfig } from '@/lib/api/hooks'
+
+type StatusRowProps = {
+ label: string
+ value: string
+ status?: 'done' | 'pending' | 'readonly'
+}
+
+function StatusRow({ label, value, status = 'readonly' }: StatusRowProps) {
+ const statusLabel = status === 'done' ? '已配置' : status === 'pending' ? '待配置' : '只读'
+ return (
+
+ {label}
+
+ {value}
+ {statusLabel}
+
+
+ )
+}
+
+
+export default function SystemSettingsPage() {
+ const config = useSystemConfig()
+ const { changePassword } = useAuth()
+ const [currentPassword, setCurrentPassword] = useState('')
+ const [newPassword, setNewPassword] = useState('')
+ const [confirmPassword, setConfirmPassword] = useState('')
+ const [savingPassword, setSavingPassword] = useState(false)
+
+ async function savePassword(event: React.FormEvent) {
+ event.preventDefault()
+ if (newPassword !== confirmPassword) {
+ toast.error('两次输入的新密码不一致')
+ return
+ }
+ setSavingPassword(true)
+ try {
+ await changePassword(currentPassword, newPassword)
+ setCurrentPassword('')
+ setNewPassword('')
+ setConfirmPassword('')
+ toast.success('密码已更新')
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : '密码更新失败')
+ } finally {
+ setSavingPassword(false)
+ }
+ }
+
+ if (config.isLoading) return
+ if (config.isError || !config.data) {
+ return
+ }
+
+
+ return (
+
+
+
+
+ 系统概览
+ 用户先看结果,不需要先理解环境变量和部署参数。
+
+
+ 应用环境
{config.data.app_env}
+ 数据库
{config.data.database_kind}
+ 镜像版本
{config.data.image_tag}
+ 调试模式
{config.data.debug ? '开启' : '关闭'}
+
+
+
+
+
+ 任务与执行当前配置结果与待处理项。
+
+
+
+
+
+
+
+
+
+
+ 浏览器与 Agent查看执行资源是否准备好。
+
+
+
+
+
+
+ }>查看节点
+
+
+
+
+ AI 与人机协作展示模型与控制策略的当前状态。
+
+
+
+
+
+
+
+
+
+ 集成与密钥状态只显示是否准备好,不在浏览器中暴露实际密钥。
+
+
+
+
+
+
+
+
+
+
+ 管理员账户账户密码属于安全边界,仍保留明确的人工确认表单。
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
index 16447936..576cc3d0 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, SquareTerminal } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Suspense, useEffect, useState } from 'react'
@@ -21,7 +21,6 @@ import {
} from '@/components/ui/card'
import { Field, FieldDescription, FieldGroup, 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'
import { sanitizeReturnTo } from '@/lib/auth/oidc'
@@ -132,17 +131,10 @@ function LoginBackground({ theme, reduceMotion }: { theme: LoginBackdrop; reduce
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
- const {
- status,
- oidcEnabled,
- developmentLoginEnabled,
- signInWithOidc,
- signInWithBootstrap,
- enterDevelopmentMode,
- } = useAuth()
- const [identityToken, setIdentityToken] = useState('')
- const [fleetToken, setFleetToken] = useState('')
- const [submitting, setSubmitting] = useState<'oidc' | 'bootstrap' | 'development' | null>(null)
+ const { status, developmentLoginEnabled, signInWithPassword, enterDevelopmentMode } = useAuth()
+ const [username, setUsername] = useState('admin')
+ const [password, setPassword] = useState('')
+ const [submitting, setSubmitting] = useState<'password' | 'development' | null>(null)
const [reduceMotion, setReduceMotion] = useState(true)
const [backdrop, setBackdrop] = useState('liquid')
const [headlineWord, setHeadlineWord] = useState(0)
@@ -173,27 +165,19 @@ function LoginForm() {
return () => window.clearInterval(interval)
}, [reduceMotion])
- const optionalFleetToken = fleetToken.trim() || undefined
-
- async function startOidcLogin() {
- setSubmitting('oidc')
- try {
- await signInWithOidc(returnTo, optionalFleetToken)
- } catch (error) {
- toast.error(error instanceof Error ? error.message : '无法启动 OIDC 登录')
- setSubmitting(null)
- }
- }
-
- async function handleBootstrapLogin(event: React.FormEvent) {
+ async function handlePasswordLogin(event: React.FormEvent) {
event.preventDefault()
- setSubmitting('bootstrap')
+ setSubmitting('password')
try {
- await signInWithBootstrap(identityToken, optionalFleetToken)
- toast.success('管理员身份验证成功')
+ const usingDefaultPassword = await signInWithPassword(username, password)
+ toast.success(
+ usingDefaultPassword
+ ? '登录成功。你可以在账户设置中修改默认密码。'
+ : '登录成功',
+ )
router.replace(returnTo)
} catch (error) {
- toast.error(error instanceof Error ? error.message : '身份验证失败')
+ toast.error(error instanceof Error ? error.message : '用户名或密码错误')
setSubmitting(null)
}
}
@@ -201,7 +185,7 @@ function LoginForm() {
function handleDevelopmentLogin() {
setSubmitting('development')
try {
- enterDevelopmentMode(optionalFleetToken)
+ enterDevelopmentMode()
window.setTimeout(() => router.replace(returnTo), reduceMotion ? 0 : 285)
} catch (error) {
toast.error(error instanceof Error ? error.message : '无法进入本地开发模式')
@@ -213,9 +197,7 @@ function LoginForm() {
@@ -257,11 +239,7 @@ function LoginForm() {
{Array.from('OPENCLI').map((character, index) => (
-
+
{character}
))}
@@ -329,68 +307,33 @@ function LoginForm() {
登录控制台
- 使用组织账号登录;Bootstrap Admin 仅用于首次部署和紧急恢复。
+ 本地部署直接登录。首次使用可使用默认账号 admin / admin。
-
- {oidcEnabled ? (
-
- ) : (
-
- 当前未配置组织登录。请配置 OIDC issuer、client ID 和授权端点。
-
- )}
-
-
-
- 紧急管理员访问
-
-
-
-
-
-
+
+
)
}
diff --git a/frontend/components/shell/route-tabs.tsx b/frontend/components/shell/route-tabs.tsx
index 3d274242..2ca33ef9 100644
--- a/frontend/components/shell/route-tabs.tsx
+++ b/frontend/components/shell/route-tabs.tsx
@@ -62,7 +62,7 @@ export const ACTION_CENTER_TABS: RouteTab[] = [
]
export const AUTOMATION_TABS: RouteTab[] = [
- { href: '/schedules', label: '调度' },
+ { href: '/operations-agents', label: '自动化与智能体' },
{ href: '/agents', label: 'Agent' },
{ href: '/skills', label: '技能' },
]
diff --git a/frontend/e2e/login.spec.mjs b/frontend/e2e/login.spec.mjs
index f44344b7..90a5da0b 100644
--- a/frontend/e2e/login.spec.mjs
+++ b/frontend/e2e/login.spec.mjs
@@ -1,7 +1,10 @@
import { expect, test } from '@playwright/test'
-test('login page renders its administrator credentials form', async ({ page }) => {
+test('login page renders its local administrator credentials form', async ({ page }) => {
await page.goto('/login')
await expect(page.getByText('登录控制台')).toBeVisible()
- await expect(page.getByLabel('管理员身份令牌')).toBeVisible()
+ await expect(page.getByLabel('用户名')).toHaveValue('admin')
+ await expect(page.getByLabel('密码')).toBeVisible()
+ await expect(page.getByLabel('管理员身份令牌')).toHaveCount(0)
+ await expect(page.getByLabel('Fleet API 令牌(可选)')).toHaveCount(0)
})
diff --git a/frontend/lib/api/endpoints.ts b/frontend/lib/api/endpoints.ts
index cfe9742d..2e0b3073 100644
--- a/frontend/lib/api/endpoints.ts
+++ b/frontend/lib/api/endpoints.ts
@@ -91,6 +91,25 @@ export const resetWorkspaceSettings = () =>
export const getCurrentIdentity = () =>
apiClient.get>('/auth/me').then((r) => r.data.data)
+export const loginWithPassword = (username: string, password: string) =>
+ apiClient
+ .post<
+ ApiResponse<{
+ access_token: string
+ token_type: 'bearer'
+ using_default_password: boolean
+ }>
+ >('/auth/login', { username, password })
+ .then((r) => r.data.data)
+
+export const changeLocalPassword = (currentPassword: string, newPassword: string) =>
+ apiClient
+ .post>('/auth/password', {
+ current_password: currentPassword,
+ new_password: newPassword,
+ })
+ .then((r) => r.data.data)
+
export const listMyWorkspaces = () =>
apiClient.get>('/workspaces').then((r) => r.data.data)
@@ -716,8 +735,11 @@ export const getHealth = () =>
export const getSystemConfig = () =>
apiClient.get>('/system/config').then((r) => r.data.data)
+type SystemConfigPatch = Partial> & {
+ agent_pool_endpoints?: string
+}
-export const updateSystemConfig = (data: Partial) =>
+export const updateSystemConfig = (data: SystemConfigPatch) =>
apiClient.patch>('/system/config', data).then((r) => r.data.data)
export const getWsAgentStatus = () =>
diff --git a/frontend/lib/api/hooks.ts b/frontend/lib/api/hooks.ts
index e69bed3a..a26dbd1b 100644
--- a/frontend/lib/api/hooks.ts
+++ b/frontend/lib/api/hooks.ts
@@ -18,6 +18,19 @@ export function useMyWorkspaces() {
return useQuery({ queryKey: ['workspaces'], queryFn: api.listMyWorkspaces })
}
+export function useSystemConfig() {
+ return useQuery({ queryKey: ['system-config'], queryFn: api.getSystemConfig })
+}
+
+export function useUpdateSystemConfig() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: (data: Parameters[0]) =>
+ api.updateSystemConfig(data),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['system-config'] }),
+ })
+}
+
export function useWorkspaceProjects(workspaceId: string | null) {
return useQuery({
queryKey: ['workspace-projects', workspaceId],
diff --git a/frontend/lib/api/types.ts b/frontend/lib/api/types.ts
index d9963e3d..cafb2a90 100644
--- a/frontend/lib/api/types.ts
+++ b/frontend/lib/api/types.ts
@@ -422,9 +422,30 @@ export interface EdgeNodeEvent {
}
export interface SystemConfig {
+ app_name: string
+ app_env: string
+ debug: boolean
collection_mode: 'local' | 'agent'
+ collection_orchestrator: 'admin' | 'iii'
task_executor: 'local' | 'celery'
+ local_max_concurrent_pipelines: number
+ opencli_timeout: number
+ default_timezone: string
+ public_url: string
+ fleet_network_provider: 'lan' | 'netbird' | 'wireguard' | 'ssh' | 'custom'
+ netbird_mode: 'off' | 'host' | 'docker'
+ opencli_cdp_endpoint: string
+ agent_pool_endpoints: string[]
+ llm_request_timeout_seconds: number
+ llm_max_concurrency: number
+ control_mode: 'advisory' | 'automatic'
+ control_kill_switch: boolean
image_tag: string
+ database_kind: string
+ api_auth_configured: boolean
+ oidc_configured: boolean
+ smtp_configured: boolean
+ credential_encryption_configured: boolean
}
export interface NodeStats {
diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts
index 4db59d73..c54a9ef9 100644
--- a/frontend/lib/navigation.ts
+++ b/frontend/lib/navigation.ts
@@ -4,6 +4,7 @@ import {
Database,
LayoutDashboard,
PanelsTopLeft,
+ Settings2,
ShieldCheck,
Workflow,
type LucideIcon,
@@ -45,10 +46,10 @@ export const NAV_GROUPS: NavGroup[] = [
{ href: '/studio', label: '项目', icon: PanelsTopLeft, match: ['/studio', '/canvas'] },
{ href: '/plugins', label: '插件中心', icon: Blocks },
{
- href: '/schedules',
- label: '自动化与 Agent',
+ href: '/operations-agents',
+ label: '自动化与智能体',
icon: Workflow,
- match: ['/schedules', '/agents', '/skills'],
+ match: ['/operations-agents', '/schedules'],
},
],
},
@@ -73,6 +74,11 @@ export const NAV_GROUPS: NavGroup[] = [
icon: ShieldCheck,
match: ['/providers', '/control/actions'],
},
+ {
+ href: '/system',
+ label: '系统设置',
+ icon: Settings2,
+ },
],
},
]
@@ -86,14 +92,16 @@ export const ROUTE_LABELS: Record = {
'/canvas': '节点工作流(兼容入口)',
'/plugins': '插件中心',
'/sources': '数据(兼容入口)',
- '/schedules': '触发与调度',
+ '/schedules': '自动化与智能体',
'/tasks': '工作项',
'/records': '成果与数据',
'/notifications': '通知',
'/agents': '智能体',
+ '/operations-agents': '自动化与智能体',
+ '/system': '系统设置',
'/skills': '技能',
'/providers': '模型与连接',
- '/nodes': '执行资源',
'/workers': 'Worker',
'/control/actions': '控制与审计',
+ '/settings': '系统设置',
}
diff --git a/frontend/scripts/check-login-theme-regressions.mjs b/frontend/scripts/check-login-theme-regressions.mjs
index 40eddf7a..793eb2ce 100644
--- a/frontend/scripts/check-login-theme-regressions.mjs
+++ b/frontend/scripts/check-login-theme-regressions.mjs
@@ -14,13 +14,15 @@ test('login keeps the liquid, terminal, and pixel theme switcher', async () => {
assert.match(login, / {
+test('login uses local credentials and keeps the reduced-motion fallback', async () => {
const login = await read('app/login/page.tsx')
- assert.match(login, /signInWithOidc/)
- assert.match(login, /signInWithBootstrap/)
+ assert.match(login, /signInWithPassword/)
assert.match(login, /enterDevelopmentMode/)
assert.match(login, /prefers-reduced-motion: reduce/)
+ assert.doesNotMatch(login, /signInWithBootstrap/)
+ assert.doesNotMatch(login, /管理员身份令牌/)
+ assert.doesNotMatch(login, /Fleet API 令牌/)
})
test('auth defaults return to the project list instead of a contextless workflow', async () => {
diff --git a/scripts/install.sh b/scripts/install.sh
index a79c9a36..5a308589 100644
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -61,14 +61,12 @@ replace_env() {
}
api_token="$(random_hex 32)"
-bootstrap_token="$(random_hex 32)"
credential_encryption_key="$(random_fernet)"
if [ -z "$credential_encryption_key" ]; then
echo "Failed to generate CREDENTIAL_ENCRYPTION_KEY" >&2
exit 1
fi
replace_env API_AUTH_TOKEN "$api_token"
-replace_env BOOTSTRAP_ADMIN_TOKEN "$bootstrap_token"
replace_env SECRET_KEY "$(random_hex 32)"
replace_env CREDENTIAL_ENCRYPTION_KEY "$credential_encryption_key"
chmod 600 "$INSTALL_DIR/.env"
@@ -91,6 +89,5 @@ done
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 'Local login: admin / admin (change it later in Account Settings)\n'
+printf 'API_AUTH_TOKEN is generated for Fleet/Agent/API transport and stored in %s/.env\n' "$INSTALL_DIR"
diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py
index 226fc3e6..d6a62f73 100644
--- a/tests/integration/test_auth_api.py
+++ b/tests/integration/test_auth_api.py
@@ -6,6 +6,8 @@
change is visible immediately and undone automatically after each test.
"""
+import secrets
+
import pytest
from backend.config import get_settings
@@ -15,6 +17,7 @@
get_request_identity,
identity_dependency,
)
+from backend.security.local_auth import DEFAULT_LOCAL_ADMIN_PASSWORD_HASH
TOKEN = "fleet-test-token"
@@ -138,3 +141,67 @@ async def test_health_exempt_and_leaks_nothing(client, auth_enabled):
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
+
+
+@pytest.mark.asyncio
+async def test_local_admin_login_uses_simple_credentials(client, auth_enabled):
+ response = await client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"},
+ )
+ assert response.status_code == 200
+ payload = response.json()["data"]
+ assert payload["token_type"] == "bearer"
+ assert payload["using_default_password"] is True
+
+ identity = await client.get(
+ "/api/v1/auth/me",
+ headers={"Authorization": f"Bearer {payload['access_token']}"},
+ )
+ assert identity.status_code == 200
+ assert identity.json()["data"]["auth_method"] == "local"
+
+
+@pytest.mark.asyncio
+async def test_local_admin_login_rejects_wrong_password(client, auth_disabled):
+ wrong_password = f"wrong-{secrets.token_hex(8)}"
+ response = await client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": wrong_password},
+ )
+ assert response.status_code == 401
+
+
+
+@pytest.mark.asyncio
+async def test_local_admin_can_change_password(client, auth_disabled, monkeypatch, tmp_path):
+ new_password = f"local-{secrets.token_hex(8)}"
+ monkeypatch.setenv("ENV_FILE_PATH", str(tmp_path / ".env"))
+ monkeypatch.setenv("LOCAL_ADMIN_PASSWORD_HASH", DEFAULT_LOCAL_ADMIN_PASSWORD_HASH)
+ get_settings.cache_clear()
+ try:
+ login = await client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"},
+ )
+ token = login.json()["data"]["access_token"]
+ changed = await client.post(
+ "/api/v1/auth/password",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"current_password": "admin", "new_password": new_password},
+ )
+ assert changed.status_code == 200
+ assert (
+ await client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"},
+ )
+ ).status_code == 401
+ assert (
+ await client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": new_password},
+ )
+ ).status_code == 200
+ finally:
+ get_settings.cache_clear()
diff --git a/tests/integration/test_local_workspace_api.py b/tests/integration/test_local_workspace_api.py
new file mode 100644
index 00000000..cef51c81
--- /dev/null
+++ b/tests/integration/test_local_workspace_api.py
@@ -0,0 +1,23 @@
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_local_admin_gets_default_workspace_membership(client):
+ login = await client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "admin"},
+ )
+ token = login.json()["data"]["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ workspaces = await client.get("/api/v1/governance/workspaces", headers=headers)
+ assert workspaces.status_code == 200
+ workspace = workspaces.json()["data"][0]
+ assert workspace["slug"] == "opencli-default"
+
+ automations = await client.get(
+ f"/api/v1/workspaces/{workspace['id']}/automations",
+ headers=headers,
+ )
+ assert automations.status_code == 200
+ assert automations.json()["data"] == []
diff --git a/tests/integration/test_system_config_api.py b/tests/integration/test_system_config_api.py
new file mode 100644
index 00000000..6d43f9f7
--- /dev/null
+++ b/tests/integration/test_system_config_api.py
@@ -0,0 +1,38 @@
+import pytest
+
+from backend.config import get_settings
+
+
+@pytest.mark.asyncio
+async def test_system_config_exposes_runtime_sections(client):
+ response = await client.get("/api/v1/system/config")
+ assert response.status_code == 200
+ data = response.json()["data"]
+ assert data["collection_mode"] == "local"
+ assert "agent_pool_endpoints" in data
+ assert "credential_encryption_configured" in data
+ assert "control_kill_switch" in data
+
+
+@pytest.mark.asyncio
+async def test_system_config_updates_safe_runtime_fields(client, monkeypatch, tmp_path):
+ monkeypatch.setenv("ENV_FILE_PATH", str(tmp_path / ".env"))
+ get_settings.cache_clear()
+ try:
+ response = await client.patch(
+ "/api/v1/system/config",
+ json={
+ "collection_mode": "agent",
+ "local_max_concurrent_pipelines": 12,
+ "default_timezone": "Asia/Shanghai",
+ "control_kill_switch": True,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()["data"]
+ assert data["collection_mode"] == "agent"
+ assert data["local_max_concurrent_pipelines"] == 12
+ assert data["default_timezone"] == "Asia/Shanghai"
+ assert data["control_kill_switch"] is True
+ finally:
+ get_settings.cache_clear()
diff --git a/tests/unit/security/test_local_auth.py b/tests/unit/security/test_local_auth.py
new file mode 100644
index 00000000..f7ec4532
--- /dev/null
+++ b/tests/unit/security/test_local_auth.py
@@ -0,0 +1,17 @@
+from backend.security.local_auth import (
+ DEFAULT_LOCAL_ADMIN_PASSWORD_HASH,
+ hash_password,
+ verify_password,
+)
+
+
+def test_default_password_hash_accepts_admin_only():
+ assert verify_password("admin", DEFAULT_LOCAL_ADMIN_PASSWORD_HASH)
+ assert not verify_password("wrong", DEFAULT_LOCAL_ADMIN_PASSWORD_HASH)
+
+
+def test_password_hash_round_trip():
+ encoded = hash_password("new-local-password")
+ assert encoded.startswith("scrypt$")
+ assert verify_password("new-local-password", encoded)
+ assert not verify_password("admin", encoded)