From f0346b099647f7db4b86464a6b045feba9a99b84 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:07:29 +0800 Subject: [PATCH 01/35] feat: simplify local administrator login --- .env.docker.example | 4 +- README.md | 9 +- backend/api/v1/identity.py | 59 +++++++- backend/config.py | 7 + backend/security/fleet_auth.py | 43 ++++-- backend/security/identity.py | 16 ++ backend/security/local_auth.py | 88 +++++++++++ docker-compose.yml | 2 +- docs/local-first-auth-PRD.md | 96 ++++++++++++ frontend/app/(app)/settings/page.tsx | 95 ++++++++++++ frontend/app/login/page.tsx | 138 +++++------------- frontend/components/auth/auth-provider.tsx | 62 ++++---- frontend/components/shell/app-header.tsx | 6 +- frontend/e2e/login.spec.mjs | 7 +- frontend/lib/api/endpoints.ts | 19 +++ frontend/lib/navigation.ts | 1 + .../scripts/check-login-theme-regressions.mjs | 8 +- scripts/install.sh | 7 +- tests/integration/test_auth_api.py | 67 +++++++++ tests/unit/security/test_local_auth.py | 17 +++ 20 files changed, 592 insertions(+), 159 deletions(-) create mode 100644 backend/security/local_auth.py create mode 100644 docs/local-first-auth-PRD.md create mode 100644 frontend/app/(app)/settings/page.tsx create mode 100644 tests/unit/security/test_local_auth.py 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/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)/settings/page.tsx b/frontend/app/(app)/settings/page.tsx new file mode 100644 index 00000000..991bd2c4 --- /dev/null +++ b/frontend/app/(app)/settings/page.tsx @@ -0,0 +1,95 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' + +import { useAuth } from '@/components/auth/auth-provider' +import { PageContainer } from '@/components/shell/page-container' +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' + +export default function SettingsPage() { + const { changePassword } = useAuth() + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [saving, setSaving] = useState(false) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (newPassword !== confirmPassword) { + toast.error('两次输入的新密码不一致') + return + } + setSaving(true) + try { + await changePassword(currentPassword, newPassword) + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + toast.success('密码已更新') + } catch (error) { + toast.error(error instanceof Error ? error.message : '密码更新失败') + } finally { + setSaving(false) + } + } + + return ( + + + + 修改密码 + 首次使用可以将默认密码 admin 修改为自己的密码。 + + +
+ + + 当前密码 + setCurrentPassword(event.target.value)} + required + /> + + + 新密码 + setNewPassword(event.target.value)} + required + /> + 至少 6 个字符。 + + + 确认新密码 + setConfirmPassword(event.target.value)} + required + /> + + + +
+
+
+
+ ) +} 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 和授权端点。 -
- )} - -
- - 紧急管理员访问 - -
- -
+ + - 管理员身份令牌 + 用户名 setIdentityToken(event.target.value)} - autoComplete="off" + id="local-username" + autoComplete="username" + value={username} + onChange={(event) => setUsername(event.target.value)} + required /> - 验证成功后仅保存在当前标签页会话中。 - Fleet API 令牌(可选) + 密码 setFleetToken(event.target.value)} - autoComplete="off" + autoComplete="current-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required /> - - 后端启用 Fleet Auth 时填写;留空沿用部署配置或浏览器中已有值。 - + 登录后可以在账户设置中修改密码。 @@ -398,22 +341,21 @@ function LoginForm() { {developmentLoginEnabled ? ( diff --git a/frontend/components/auth/auth-provider.tsx b/frontend/components/auth/auth-provider.tsx index d00ee1b9..74cd01a2 100644 --- a/frontend/components/auth/auth-provider.tsx +++ b/frontend/components/auth/auth-provider.tsx @@ -2,9 +2,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { getCurrentIdentity } from '@/lib/api/endpoints' +import { changeLocalPassword, getCurrentIdentity, loginWithPassword } from '@/lib/api/endpoints' import { AUTH_REQUIRED_EVENT } from '@/lib/api/auth-events' -import { setApiAuthToken } from '@/lib/api/auth-token' import { getOidcManager, isOidcConfigured, oidcReturnTo, sanitizeReturnTo } from '@/lib/auth/oidc' import { clearIdentityToken, @@ -22,10 +21,11 @@ type AuthContextValue = { identity: AuthIdentity | null oidcEnabled: boolean developmentLoginEnabled: boolean - signInWithOidc: (returnTo?: string, fleetToken?: string) => Promise + signInWithOidc: (returnTo?: string) => Promise + signInWithPassword: (username: string, password: string) => Promise + changePassword: (currentPassword: string, newPassword: string) => Promise completeOidcSignIn: () => Promise - signInWithBootstrap: (identityToken: string, fleetToken?: string) => Promise - enterDevelopmentMode: (fleetToken?: string) => void + enterDevelopmentMode: () => void signOut: () => Promise } @@ -135,13 +135,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return () => window.removeEventListener(AUTH_REQUIRED_EVENT, onAuthRequired) }, [becomeAnonymous, developmentLoginEnabled]) - const signInWithOidc = useCallback(async (returnTo = '/studio', fleetToken?: string) => { + const signInWithOidc = useCallback(async (returnTo = '/studio') => { const manager = getOidcManager() if (!manager) throw new Error('OIDC 登录尚未配置') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) await manager.signinRedirect({ state: { returnTo: sanitizeReturnTo(returnTo) } }) }, []) + const signInWithPassword = useCallback( + async (username: string, password: string) => { + const result = await loginWithPassword(username, password) + await acceptIdentityToken(result.access_token) + persistBootstrapIdentityToken(result.access_token) + return result.using_default_password + }, + [acceptIdentityToken], + ) + + const changePassword = useCallback(async (currentPassword: string, newPassword: string) => { + await changeLocalPassword(currentPassword, newPassword) + }, []) + const completeOidcSignIn = useCallback(async () => { const manager = getOidcManager() if (!manager) throw new Error('OIDC 登录尚未配置') @@ -151,28 +164,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return oidcReturnTo(user) }, [acceptIdentityToken]) - const signInWithBootstrap = useCallback( - async (identityToken: string, fleetToken?: string) => { - const trimmed = identityToken.trim() - if (!trimmed) throw new Error('请输入管理员身份令牌') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) - await acceptIdentityToken(trimmed) - persistBootstrapIdentityToken(trimmed) - }, - [acceptIdentityToken], - ) - - const enterDevelopmentMode = useCallback( - (fleetToken?: string) => { - if (!developmentLoginEnabled) throw new Error('本地开发模式不可用') - if (fleetToken !== undefined) setApiAuthToken(fleetToken) - clearIdentityToken() - setDevelopmentSession(true) - setIdentity(DEVELOPMENT_IDENTITY) - setStatus('authenticated') - }, - [developmentLoginEnabled], - ) + const enterDevelopmentMode = useCallback(() => { + if (!developmentLoginEnabled) throw new Error('本地开发模式不可用') + clearIdentityToken() + setDevelopmentSession(true) + setIdentity(DEVELOPMENT_IDENTITY) + setStatus('authenticated') + }, [developmentLoginEnabled]) const signOut = useCallback(async () => { const manager = getOidcManager() @@ -193,19 +191,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { oidcEnabled, developmentLoginEnabled, signInWithOidc, + signInWithPassword, + changePassword, completeOidcSignIn, - signInWithBootstrap, enterDevelopmentMode, signOut, }), [ + changePassword, completeOidcSignIn, developmentLoginEnabled, enterDevelopmentMode, identity, oidcEnabled, - signInWithBootstrap, signInWithOidc, + signInWithPassword, signOut, status, ], diff --git a/frontend/components/shell/app-header.tsx b/frontend/components/shell/app-header.tsx index 2f2b5455..cb5a02d5 100644 --- a/frontend/components/shell/app-header.tsx +++ b/frontend/components/shell/app-header.tsx @@ -1,7 +1,7 @@ 'use client' import Link from 'next/link' -import { Bot, LogOut, Search } from 'lucide-react' +import { Bot, LogOut, Search, Settings } from 'lucide-react' import { usePathname, useRouter } from 'next/navigation' import { Fragment } from 'react' @@ -140,6 +140,10 @@ export function AppHeader({ {accountLabel} + router.push('/settings')}> + + 账户设置 + 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..6355f248 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) diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 4db59d73..70cd34ec 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -96,4 +96,5 @@ export const ROUTE_LABELS: Record = { '/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/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) From 3b60ae5f2e4ced2a8c20b46f4de4f9b9fefd8052 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:51:06 +0800 Subject: [PATCH 02/35] feat: unify automation and agent workspace --- frontend/app/(app)/dashboard/page.tsx | 2 +- frontend/app/(app)/schedules/page.tsx | 74 +----------------------- frontend/components/shell/app-header.tsx | 12 ++-- frontend/components/shell/route-tabs.tsx | 2 +- frontend/lib/navigation.ts | 10 ++-- 5 files changed, 15 insertions(+), 85 deletions(-) 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)/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/components/shell/app-header.tsx b/frontend/components/shell/app-header.tsx index cb5a02d5..2265d537 100644 --- a/frontend/components/shell/app-header.tsx +++ b/frontend/components/shell/app-header.tsx @@ -39,10 +39,8 @@ function resolveLabels(pathname: string): string[] { return match ? [ROUTE_LABELS[match]] : [] } export function AppHeader({ - onOpenAgent, onOpenCommand, }: { - onOpenAgent?: () => void onOpenCommand?: () => void }) { const pathname = usePathname() @@ -91,17 +89,19 @@ export function AppHeader({ variant="outline" size="sm" className="hidden gap-2 sm:flex" - onClick={onOpenAgent} + nativeButton={false} + render={} > - Agent + 自动化与智能体 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/lib/navigation.ts b/frontend/lib/navigation.ts index 70cd34ec..5f4e0d18 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -45,10 +45,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'], }, ], }, @@ -86,14 +86,14 @@ export const ROUTE_LABELS: Record = { '/canvas': '节点工作流(兼容入口)', '/plugins': '插件中心', '/sources': '数据(兼容入口)', - '/schedules': '触发与调度', + '/schedules': '自动化与智能体', '/tasks': '工作项', '/records': '成果与数据', '/notifications': '通知', '/agents': '智能体', + '/operations-agents': '自动化与智能体', '/skills': '技能', '/providers': '模型与连接', - '/nodes': '执行资源', '/workers': 'Worker', '/control/actions': '控制与审计', '/settings': '账户设置', From b55c8b7d840f2353dca5bfe5b3f19fb3d1b2d408 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:57:53 +0800 Subject: [PATCH 03/35] fix: provision local admin workspace access --- backend/api/v1/workspaces.py | 48 ++++++++++++++++++- frontend/app/(app)/operations-agents/page.tsx | 4 +- tests/integration/test_local_workspace_api.py | 23 +++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 tests/integration/test_local_workspace_api.py 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/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/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"] == [] From e6c500d006d541df69ae188d41ee0c68d07b3772 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:13:55 +0800 Subject: [PATCH 04/35] feat: add system settings and floating agent window --- frontend/app/(app)/system/page.tsx | 87 +++++++++++++++++++ .../components/shell/global-agent-dock.tsx | 35 ++++---- frontend/lib/api/hooks.ts | 13 +++ frontend/lib/navigation.ts | 7 ++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 frontend/app/(app)/system/page.tsx diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx new file mode 100644 index 00000000..2fc0c80e --- /dev/null +++ b/frontend/app/(app)/system/page.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useSystemConfig, useUpdateSystemConfig } from '@/lib/api/hooks' +import { BACKEND_HINT, ErrorState, LoadingState } from '@/components/shell/data-states' +import { PageContainer } from '@/components/shell/page-container' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + +export default function SystemSettingsPage() { + const config = useSystemConfig() + const updateConfig = useUpdateSystemConfig() + const [collectionMode, setCollectionMode] = useState<'local' | 'agent'>('local') + + useEffect(() => { + if (config.data) setCollectionMode(config.data.collection_mode) + }, [config.data]) + + async function saveCollectionMode() { + try { + await updateConfig.mutateAsync({ collection_mode: collectionMode }) + toast.success('系统设置已保存') + } catch (error) { + toast.error(error instanceof Error ? error.message : '系统设置保存失败') + } + } + + if (config.isLoading) return + if (config.isError || !config.data) { + return + } + + return ( + +
+ + + 执行模式 + 决定采集任务由当前服务执行,还是交给远程 Agent。 + + + + + + + + + + 当前运行环境 + 当前部署读取到的基础运行信息。 + + +
+ 任务执行器 + {config.data.task_executor} +
+
+ 镜像版本 + {config.data.image_tag} +
+

+ 模型连接、浏览器节点、自动化与账户密码分别在对应设置页管理。 +

+
+
+
+
+ ) +} diff --git a/frontend/components/shell/global-agent-dock.tsx b/frontend/components/shell/global-agent-dock.tsx index 148599db..d4d777cc 100644 --- a/frontend/components/shell/global-agent-dock.tsx +++ b/frontend/components/shell/global-agent-dock.tsx @@ -8,12 +8,12 @@ import { FormEvent, KeyboardEvent, useState } from 'react' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from '@/components/ui/sheet' + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Textarea } from '@/components/ui/textarea' import { apiClient } from '@/lib/api/client' import type { ApiResponse } from '@/lib/api/types' @@ -136,18 +136,21 @@ export function GlobalAgentDock({ } return ( - - - - + + + + 全局 Agent - - + + 当前上下文:{ROUTE_LABELS[pathname] ?? pathname}。读取可直接执行,写入操作先生成确认提案。 未明确指定 Workspace 时,仅在后端能解析出唯一授权范围时允许确认写操作。 - - + +
@@ -238,7 +241,7 @@ export function GlobalAgentDock({
-
-
+ + ) } 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/navigation.ts b/frontend/lib/navigation.ts index 5f4e0d18..14d82625 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -4,6 +4,7 @@ import { Database, LayoutDashboard, PanelsTopLeft, + Settings2, ShieldCheck, Workflow, type LucideIcon, @@ -73,6 +74,11 @@ export const NAV_GROUPS: NavGroup[] = [ icon: ShieldCheck, match: ['/providers', '/control/actions'], }, + { + href: '/system', + label: '系统设置', + icon: Settings2, + }, ], }, ] @@ -92,6 +98,7 @@ export const ROUTE_LABELS: Record = { '/notifications': '通知', '/agents': '智能体', '/operations-agents': '自动化与智能体', + '/system': '系统设置', '/skills': '技能', '/providers': '模型与连接', '/workers': 'Worker', From a8cdb1680363af5e7cbdab72d908d1e3b3808f91 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:37:39 +0800 Subject: [PATCH 05/35] refactor: consolidate account settings into system settings --- frontend/app/(app)/settings/page.tsx | 94 +----------------------- frontend/app/(app)/system/page.tsx | 86 +++++++++++++++++++++- frontend/components/shell/app-header.tsx | 4 +- frontend/lib/navigation.ts | 2 +- 4 files changed, 88 insertions(+), 98 deletions(-) diff --git a/frontend/app/(app)/settings/page.tsx b/frontend/app/(app)/settings/page.tsx index 991bd2c4..71dc5cc8 100644 --- a/frontend/app/(app)/settings/page.tsx +++ b/frontend/app/(app)/settings/page.tsx @@ -1,95 +1,5 @@ -'use client' - -import { useState } from 'react' -import { toast } from 'sonner' - -import { useAuth } from '@/components/auth/auth-provider' -import { PageContainer } from '@/components/shell/page-container' -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 { redirect } from 'next/navigation' export default function SettingsPage() { - const { changePassword } = useAuth() - const [currentPassword, setCurrentPassword] = useState('') - const [newPassword, setNewPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [saving, setSaving] = useState(false) - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault() - if (newPassword !== confirmPassword) { - toast.error('两次输入的新密码不一致') - return - } - setSaving(true) - try { - await changePassword(currentPassword, newPassword) - setCurrentPassword('') - setNewPassword('') - setConfirmPassword('') - toast.success('密码已更新') - } catch (error) { - toast.error(error instanceof Error ? error.message : '密码更新失败') - } finally { - setSaving(false) - } - } - - return ( - - - - 修改密码 - 首次使用可以将默认密码 admin 修改为自己的密码。 - - -
- - - 当前密码 - setCurrentPassword(event.target.value)} - required - /> - - - 新密码 - setNewPassword(event.target.value)} - required - /> - 至少 6 个字符。 - - - 确认新密码 - setConfirmPassword(event.target.value)} - required - /> - - - -
-
-
-
- ) + redirect('/system') } diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index 2fc0c80e..e7420727 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -3,16 +3,24 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { useSystemConfig, useUpdateSystemConfig } from '@/lib/api/hooks' +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 { 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, useUpdateSystemConfig } from '@/lib/api/hooks' export default function SystemSettingsPage() { const config = useSystemConfig() const updateConfig = useUpdateSystemConfig() + const { changePassword } = useAuth() const [collectionMode, setCollectionMode] = useState<'local' | 'agent'>('local') + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [savingPassword, setSavingPassword] = useState(false) useEffect(() => { if (config.data) setCollectionMode(config.data.collection_mode) @@ -27,6 +35,26 @@ export default function SystemSettingsPage() { } } + 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 @@ -36,7 +64,7 @@ export default function SystemSettingsPage() {
@@ -77,10 +105,62 @@ export default function SystemSettingsPage() { {config.data.image_tag}

- 模型连接、浏览器节点、自动化与账户密码分别在对应设置页管理。 + 模型连接、浏览器节点和自动化分别在对应功能页管理。

+ + + + 管理员账户 + 在这里修改本地管理员密码,不再单独跳转到账户设置页面。 + + +
+ + + 当前密码 + setCurrentPassword(event.target.value)} + required + /> + + + 新密码 + setNewPassword(event.target.value)} + required + /> + 至少 6 个字符。 + + + 确认新密码 + setConfirmPassword(event.target.value)} + required + /> + + + +
+
+
) diff --git a/frontend/components/shell/app-header.tsx b/frontend/components/shell/app-header.tsx index 2265d537..04db630d 100644 --- a/frontend/components/shell/app-header.tsx +++ b/frontend/components/shell/app-header.tsx @@ -140,9 +140,9 @@ export function AppHeader({ {accountLabel} - router.push('/settings')}> + router.push('/system')}> - 账户设置 + 系统设置 diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 14d82625..c54a9ef9 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -103,5 +103,5 @@ export const ROUTE_LABELS: Record = { '/providers': '模型与连接', '/workers': 'Worker', '/control/actions': '控制与审计', - '/settings': '账户设置', + '/settings': '系统设置', } From 6156b9d601407a715da60a2230086c125d7d73af Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:50:33 +0800 Subject: [PATCH 06/35] feat: expand system settings center --- backend/api/v1/system.py | 103 ++++++---- frontend/app/(app)/system/page.tsx | 197 +++++++++++--------- frontend/lib/api/endpoints.ts | 5 +- frontend/lib/api/types.ts | 21 +++ tests/integration/test_system_config_api.py | 38 ++++ 5 files changed, 241 insertions(+), 123 deletions(-) create mode 100644 tests/integration/test_system_config_api.py 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/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index e7420727..5af9c536 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -6,29 +6,77 @@ 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, useUpdateSystemConfig } from '@/lib/api/hooks' +import type { SystemConfig } from '@/lib/api/types' + +function configuredLabel(value: boolean) { + return value ? '已配置' : '未配置' +} + +function StatusBadge({ configured }: { configured: boolean }) { + return {configuredLabel(configured)} +} export default function SystemSettingsPage() { const config = useSystemConfig() const updateConfig = useUpdateSystemConfig() const { changePassword } = useAuth() - const [collectionMode, setCollectionMode] = useState<'local' | 'agent'>('local') + const [form, setForm] = useState>({ + collection_mode: 'local', + collection_orchestrator: 'admin', + local_max_concurrent_pipelines: 8, + opencli_timeout: 120, + default_timezone: 'UTC', + public_url: '', + fleet_network_provider: 'lan', + netbird_mode: 'off', + opencli_cdp_endpoint: 'http://localhost:9222', + agent_pool_endpoints: [], + llm_request_timeout_seconds: 120, + llm_max_concurrency: 4, + control_mode: 'advisory', + control_kill_switch: false, + }) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [savingPassword, setSavingPassword] = useState(false) useEffect(() => { - if (config.data) setCollectionMode(config.data.collection_mode) + if (!config.data) return + setForm({ + collection_mode: config.data.collection_mode, + collection_orchestrator: config.data.collection_orchestrator, + local_max_concurrent_pipelines: config.data.local_max_concurrent_pipelines, + opencli_timeout: config.data.opencli_timeout, + default_timezone: config.data.default_timezone, + public_url: config.data.public_url, + fleet_network_provider: config.data.fleet_network_provider, + netbird_mode: config.data.netbird_mode, + opencli_cdp_endpoint: config.data.opencli_cdp_endpoint, + agent_pool_endpoints: config.data.agent_pool_endpoints, + llm_request_timeout_seconds: config.data.llm_request_timeout_seconds, + llm_max_concurrency: config.data.llm_max_concurrency, + control_mode: config.data.control_mode, + control_kill_switch: config.data.control_kill_switch, + }) }, [config.data]) - async function saveCollectionMode() { + function setField(key: K, value: (typeof form)[K]) { + setForm((current) => ({ ...current, [key]: value })) + } + + async function saveSystemConfig() { try { - await updateConfig.mutateAsync({ collection_mode: collectionMode }) + await updateConfig.mutateAsync({ + ...form, + agent_pool_endpoints: form.agent_pool_endpoints.join(','), + }) toast.success('系统设置已保存') } catch (error) { toast.error(error instanceof Error ? error.message : '系统设置保存失败') @@ -64,100 +112,77 @@ export default function SystemSettingsPage() { void saveSystemConfig()} disabled={updateConfig.isPending}>{updateConfig.isPending ? '保存中…' : '保存全部设置'}} > -
+
- 执行模式 - 决定采集任务由当前服务执行,还是交给远程 Agent。 + 系统概览 + 先确认当前部署状态,再调整下面的运行参数。 - - - + +

应用环境

{config.data.app_env}

+

数据库

{config.data.database_kind}

+

镜像版本

{config.data.image_tag}

+

调试模式

{config.data.debug ? '开启' : '关闭'}

- - - 当前运行环境 - 当前部署读取到的基础运行信息。 - - -
- 任务执行器 - {config.data.task_executor} -
-
- 镜像版本 - {config.data.image_tag} -
-

- 模型连接、浏览器节点和自动化分别在对应功能页管理。 -

-
-
+
+ + 任务与执行控制采集任务如何运行,以及单机资源上限。 + + + + + + + + - - - 管理员账户 - 在这里修改本地管理员密码,不再单独跳转到账户设置页面。 - + + 浏览器与 Agent管理本地 Chrome、远程节点与 Fleet 网络方式。 + + + + + + + + + + + AI 与人机协作限制模型调用资源,并设置 Agent 控制策略。 + + + + + + + + + + 集成与密钥状态只显示配置状态,不在浏览器中暴露实际密钥。 + +
API / Fleet 访问令牌
+
凭证加密密钥
+
OIDC 组织登录
+
SMTP 通知
+
+
+
+ + + 管理员账户系统级设置的一部分:在这里修改本地管理员密码。
- - 当前密码 - setCurrentPassword(event.target.value)} - required - /> - - - 新密码 - setNewPassword(event.target.value)} - required - /> - 至少 6 个字符。 - - - 确认新密码 - setConfirmPassword(event.target.value)} - required - /> - + 当前密码 setCurrentPassword(event.target.value)} required /> + 新密码 setNewPassword(event.target.value)} required />至少 6 个字符。 + 确认新密码 setConfirmPassword(event.target.value)} required /> - +
diff --git a/frontend/lib/api/endpoints.ts b/frontend/lib/api/endpoints.ts index 6355f248..2e0b3073 100644 --- a/frontend/lib/api/endpoints.ts +++ b/frontend/lib/api/endpoints.ts @@ -735,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/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/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() From bfb17f689442ce0d869e4fbb35d0ea04db3e4906 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:52:49 +0800 Subject: [PATCH 07/35] feat: add system module control links --- frontend/app/(app)/system/page.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index 5af9c536..6c730ebc 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import Link from 'next/link' import { toast } from 'sonner' import { useAuth } from '@/components/auth/auth-provider' @@ -173,6 +174,25 @@ export default function SystemSettingsPage() {
+ + 系统模块从这里进入各个系统级管理区域,不再把配置散落在不同入口。 + + {[ + ['/operations-agents', '自动化与智能体', '安排任务、选择 Agent 和查看活动'], + ['/nodes', '执行资源', '管理浏览器节点与远程 Agent'], + ['/providers', '模型与连接', '配置模型供应商和运行时连接'], + ['/notifications', '通知与交付', '配置消息渠道和通知规则'], + ['/control/actions', '控制与审计', '查看系统建议、执行记录和控制状态'], + ['/inbox', '任务与日志', '处理失败任务、待确认操作和通知'], + ].map(([href, title, description]) => ( + + {title} + {description} + + ))} + + + 管理员账户系统级设置的一部分:在这里修改本地管理员密码。 From fa1f9c8f1e5c2d024f1095c9b337e7a52bb90ad9 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:08:30 +0800 Subject: [PATCH 08/35] feat: make system settings agent-driven --- frontend/app/(app)/system/page.tsx | 168 ++++++------------ frontend/components/shell/app-shell.tsx | 17 +- .../components/shell/global-agent-dock.tsx | 9 +- 3 files changed, 81 insertions(+), 113 deletions(-) diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index 6c730ebc..f0c4b1fb 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useState } from 'react' import Link from 'next/link' import { toast } from 'sonner' @@ -12,78 +12,39 @@ 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, useUpdateSystemConfig } from '@/lib/api/hooks' -import type { SystemConfig } from '@/lib/api/types' +import { useSystemConfig } from '@/lib/api/hooks' -function configuredLabel(value: boolean) { - return value ? '已配置' : '未配置' +type StatusRowProps = { + label: string + value: string + status?: 'done' | 'pending' | 'readonly' } -function StatusBadge({ configured }: { configured: boolean }) { - return {configuredLabel(configured)} +function StatusRow({ label, value, status = 'readonly' }: StatusRowProps) { + const statusLabel = status === 'done' ? '已配置' : status === 'pending' ? '待配置' : '只读' + return ( +
+ {label} + + {value} + {statusLabel} + +
+ ) +} + +function requestAgent(prompt: string) { + window.dispatchEvent(new CustomEvent('open-global-agent', { detail: { prompt } })) } export default function SystemSettingsPage() { const config = useSystemConfig() - const updateConfig = useUpdateSystemConfig() const { changePassword } = useAuth() - const [form, setForm] = useState>({ - collection_mode: 'local', - collection_orchestrator: 'admin', - local_max_concurrent_pipelines: 8, - opencli_timeout: 120, - default_timezone: 'UTC', - public_url: '', - fleet_network_provider: 'lan', - netbird_mode: 'off', - opencli_cdp_endpoint: 'http://localhost:9222', - agent_pool_endpoints: [], - llm_request_timeout_seconds: 120, - llm_max_concurrency: 4, - control_mode: 'advisory', - control_kill_switch: false, - }) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [savingPassword, setSavingPassword] = useState(false) - useEffect(() => { - if (!config.data) return - setForm({ - collection_mode: config.data.collection_mode, - collection_orchestrator: config.data.collection_orchestrator, - local_max_concurrent_pipelines: config.data.local_max_concurrent_pipelines, - opencli_timeout: config.data.opencli_timeout, - default_timezone: config.data.default_timezone, - public_url: config.data.public_url, - fleet_network_provider: config.data.fleet_network_provider, - netbird_mode: config.data.netbird_mode, - opencli_cdp_endpoint: config.data.opencli_cdp_endpoint, - agent_pool_endpoints: config.data.agent_pool_endpoints, - llm_request_timeout_seconds: config.data.llm_request_timeout_seconds, - llm_max_concurrency: config.data.llm_max_concurrency, - control_mode: config.data.control_mode, - control_kill_switch: config.data.control_kill_switch, - }) - }, [config.data]) - - function setField(key: K, value: (typeof form)[K]) { - setForm((current) => ({ ...current, [key]: value })) - } - - async function saveSystemConfig() { - try { - await updateConfig.mutateAsync({ - ...form, - agent_pool_endpoints: form.agent_pool_endpoints.join(','), - }) - toast.success('系统设置已保存') - } catch (error) { - toast.error(error instanceof Error ? error.message : '系统设置保存失败') - } - } - async function savePassword(event: React.FormEvent) { event.preventDefault() if (newPassword !== confirmPassword) { @@ -109,18 +70,22 @@ export default function SystemSettingsPage() { return } + const runtimePrompt = '请检查当前 OpenCLI 的任务执行模式、调度器、并发数、超时和时区;列出未配置项,并在我确认后完成必要配置。' + const agentPrompt = '请检查浏览器节点、Agent Pool、CDP 地址和 Fleet 网络;告诉我哪些节点可用、哪些配置缺失,并在我确认后修复。' + const collaborationPrompt = '请检查模型连接、AI 并发限制、控制策略和全局暂停开关;用人能看懂的方式说明当前状态,并给出需要我确认的变更。' + return ( void saveSystemConfig()} disabled={updateConfig.isPending}>{updateConfig.isPending ? '保存中…' : '保存全部设置'}} + description="这里主要展示系统状态。需要变更时,让 Agent 读取上下文并协助配置。" + actions={} >
系统概览 - 先确认当前部署状态,再调整下面的运行参数。 + 用户先看结果,不需要先理解环境变量和部署参数。

应用环境

{config.data.app_env}

@@ -132,69 +97,54 @@ export default function SystemSettingsPage() {
- 任务与执行控制采集任务如何运行,以及单机资源上限。 - - - - - - + 任务与执行当前配置结果与待处理项。 + + + + + + + - 浏览器与 Agent管理本地 Chrome、远程节点与 Fleet 网络方式。 - - - - - - + 浏览器与 Agent查看执行资源是否准备好,具体修改交给 Agent。 + + + + + + +
- AI 与人机协作限制模型调用资源,并设置 Agent 控制策略。 - - - - - + AI 与人机协作模型与控制策略由 Agent 解释,人只确认有影响的变更。 + + + + + + - 集成与密钥状态只显示配置状态,不在浏览器中暴露实际密钥。 - -
API / Fleet 访问令牌
-
凭证加密密钥
-
OIDC 组织登录
-
SMTP 通知
+ 集成与密钥状态只显示是否准备好,不在浏览器中暴露实际密钥。 + + + + + +
- 系统模块从这里进入各个系统级管理区域,不再把配置散落在不同入口。 - - {[ - ['/operations-agents', '自动化与智能体', '安排任务、选择 Agent 和查看活动'], - ['/nodes', '执行资源', '管理浏览器节点与远程 Agent'], - ['/providers', '模型与连接', '配置模型供应商和运行时连接'], - ['/notifications', '通知与交付', '配置消息渠道和通知规则'], - ['/control/actions', '控制与审计', '查看系统建议、执行记录和控制状态'], - ['/inbox', '任务与日志', '处理失败任务、待确认操作和通知'], - ].map(([href, title, description]) => ( - - {title} - {description} - - ))} - - - - - 管理员账户系统级设置的一部分:在这里修改本地管理员密码。 + 管理员账户账户密码属于安全边界,仍保留明确的人工确认表单。
diff --git a/frontend/components/shell/app-shell.tsx b/frontend/components/shell/app-shell.tsx index 5d5c7730..d56885b0 100644 --- a/frontend/components/shell/app-shell.tsx +++ b/frontend/components/shell/app-shell.tsx @@ -1,24 +1,34 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { AppRouteTransition } from '@/components/motion/app-route-transition' import { AppHeader } from '@/components/shell/app-header' import { AppSidebar } from '@/components/shell/app-sidebar' import { CommandPalette } from '@/components/shell/command-palette' +import { GlobalAgentBubble } from '@/components/shell/global-agent-bubble' import { GlobalAgentDock } from '@/components/shell/global-agent-dock' import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar' export function AppShell({ children }: { children: React.ReactNode }) { const [commandOpen, setCommandOpen] = useState(false) const [agentOpen, setAgentOpen] = useState(false) + const [agentPrompt, setAgentPrompt] = useState('') + useEffect(() => { + function openAgent(event: Event) { + const prompt = (event as CustomEvent<{ prompt?: string }>).detail?.prompt?.trim() ?? '' + setAgentPrompt(prompt) + setAgentOpen(true) + } + window.addEventListener('open-global-agent', openAgent) + return () => window.removeEventListener('open-global-agent', openAgent) + }, []) return ( setAgentOpen(true)} onOpenCommand={() => setCommandOpen(true)} />
@@ -26,7 +36,8 @@ export function AppShell({ children }: { children: React.ReactNode }) {
- + { setAgentPrompt(''); setAgentOpen(true) }} /> +
) } diff --git a/frontend/components/shell/global-agent-dock.tsx b/frontend/components/shell/global-agent-dock.tsx index d4d777cc..1551d12c 100644 --- a/frontend/components/shell/global-agent-dock.tsx +++ b/frontend/components/shell/global-agent-dock.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import { Bot, Check, Loader2, Send, ShieldCheck, X } from 'lucide-react' import { usePathname } from 'next/navigation' -import { FormEvent, KeyboardEvent, useState } from 'react' +import { FormEvent, KeyboardEvent, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' @@ -43,9 +43,11 @@ type AgentReply = { export function GlobalAgentDock({ open, onOpenChange, + initialPrompt = '', }: { open: boolean onOpenChange: (open: boolean) => void + initialPrompt?: string }) { const pathname = usePathname() const queryClient = useQueryClient() @@ -55,6 +57,11 @@ export function GlobalAgentDock({ const [error, setError] = useState(null) const [sending, setSending] = useState(false) const [confirming, setConfirming] = useState(false) + useEffect(() => { + if (open && initialPrompt) { + setInput(initialPrompt) + } + }, [initialPrompt, open]) async function sendMessage(event?: FormEvent) { event?.preventDefault() From 78d133aeb1fa63658a55b480a6803c0680734deb Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:17:49 +0800 Subject: [PATCH 09/35] refactor: keep agent actions inside the floating chat --- frontend/app/(app)/system/page.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/frontend/app/(app)/system/page.tsx b/frontend/app/(app)/system/page.tsx index f0c4b1fb..e886b2a7 100644 --- a/frontend/app/(app)/system/page.tsx +++ b/frontend/app/(app)/system/page.tsx @@ -33,9 +33,6 @@ function StatusRow({ label, value, status = 'readonly' }: StatusRowProps) { ) } -function requestAgent(prompt: string) { - window.dispatchEvent(new CustomEvent('open-global-agent', { detail: { prompt } })) -} export default function SystemSettingsPage() { const config = useSystemConfig() @@ -70,16 +67,12 @@ export default function SystemSettingsPage() { return } - const runtimePrompt = '请检查当前 OpenCLI 的任务执行模式、调度器、并发数、超时和时区;列出未配置项,并在我确认后完成必要配置。' - const agentPrompt = '请检查浏览器节点、Agent Pool、CDP 地址和 Fleet 网络;告诉我哪些节点可用、哪些配置缺失,并在我确认后修复。' - const collaborationPrompt = '请检查模型连接、AI 并发限制、控制策略和全局暂停开关;用人能看懂的方式说明当前状态,并给出需要我确认的变更。' return ( requestAgent('请完整检查这套 OpenCLI 部署的系统设置,告诉我哪些已完成、哪些未完成。')}>让 Agent 检查全部} + description="查看整套 OpenCLI 部署的状态、完成项与待处理项。" >
@@ -104,30 +97,28 @@ export default function SystemSettingsPage() { - - 浏览器与 Agent查看执行资源是否准备好,具体修改交给 Agent。 + 浏览器与 Agent查看执行资源是否准备好。 -
+
- AI 与人机协作模型与控制策略由 Agent 解释,人只确认有影响的变更。 + AI 与人机协作展示模型与控制策略的当前状态。 - @@ -138,7 +129,6 @@ export default function SystemSettingsPage() { -
From ead91b402d3ff5ed87bae45561acd25aeb3db8c7 Mon Sep 17 00:00:00 2001 From: 1012839419a-alt <268505792+1012839419a-alt@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:48:36 +0800 Subject: [PATCH 10/35] feat: add governed local Codex runtime --- backend/agent_runtimes/base.py | 40 +- backend/agent_runtimes/codex_adapter.py | 430 ++++++++++++++++++ backend/agent_runtimes/pi_adapter.py | 2 +- backend/agent_runtimes/registry.py | 1 + backend/api/v1/automations.py | 46 +- ...aa1b2c3d4e5f_add_automation_starter_key.py | 24 + backend/models/automation.py | 8 +- backend/schemas/automation.py | 31 +- backend/schemas/operations_agent.py | 18 +- backend/schemas/provider.py | 3 + backend/schemas/provider_capacity.py | 96 ++++ .../services/automation_starter_service.py | 160 +++++++ .../operations_agent_runtime_service.py | 10 + frontend/app/(app)/operations-agents/page.tsx | 91 +++- .../providers/primary-model-card.tsx | 40 +- frontend/lib/api/endpoints.ts | 7 + frontend/lib/api/hooks.ts | 9 + .../local-codex-agent-runtime/proposal.md | 52 +++ .../specs/local-codex-agent-runtime/spec.md | 107 +++++ .../local-codex-agent-runtime/tasks.md | 41 ++ .../unit/agent_runtimes/test_codex_adapter.py | 188 ++++++++ tests/unit/api/test_automation_starters.py | 108 +++++ .../test_operations_agent_runtime_service.py | 3 +- tests/unit/test_operations_agent_schema.py | 50 ++ tests/unit/test_provider_capacity.py | 79 ++++ 25 files changed, 1616 insertions(+), 28 deletions(-) create mode 100644 backend/agent_runtimes/codex_adapter.py create mode 100644 backend/migrations/versions/aa1b2c3d4e5f_add_automation_starter_key.py create mode 100644 backend/schemas/provider_capacity.py create mode 100644 backend/services/automation_starter_service.py create mode 100644 openspec/changes/local-codex-agent-runtime/proposal.md create mode 100644 openspec/changes/local-codex-agent-runtime/specs/local-codex-agent-runtime/spec.md create mode 100644 openspec/changes/local-codex-agent-runtime/tasks.md create mode 100644 tests/unit/agent_runtimes/test_codex_adapter.py create mode 100644 tests/unit/api/test_automation_starters.py create mode 100644 tests/unit/test_operations_agent_schema.py create mode 100644 tests/unit/test_provider_capacity.py diff --git a/backend/agent_runtimes/base.py b/backend/agent_runtimes/base.py index 5bc4f1c6..95df5445 100644 --- a/backend/agent_runtimes/base.py +++ b/backend/agent_runtimes/base.py @@ -16,11 +16,30 @@ that is what prevents typos in ``type`` strings and missing ``task_id`` fields from ever reaching a caller. """ - from abc import ABC, abstractmethod from collections.abc import AsyncIterator from dataclasses import dataclass, field -from typing import Any +from typing import Any, Literal + + +@dataclass(frozen=True) +class RuntimeReadiness: + """Safe, typed evidence used before dispatching a runtime task. + + Readiness is intentionally separate from ``RuntimeCapabilities``: a + registered adapter may be known to the node while its executable or + working directory is unavailable. Implementations MUST keep secrets out + of this structure; it is suitable for Fleet diagnostics and wire output. + """ + + runtime: str + status: Literal["ready", "blocked"] + binary_present: bool + version: str | None = None + permitted_project_root: str | None = None + working_directory: str | None = None + reason_code: str | None = None + reason: str | None = None #: Closed tagged-union of runtime event types. Adapters MUST NOT emit any #: `type` outside this set — an unrecognized native event from the underlying @@ -151,6 +170,23 @@ async def health(self) -> bool: """Cheap liveness check for this runtime (binary present, sidecar reachable, ...). Does not run a task.""" + async def readiness(self, config: dict[str, Any] | None = None) -> RuntimeReadiness: + """Return safe pre-dispatch evidence for this runtime. + + Adapters with richer checks override this method. The default keeps + existing adapters source-compatible while giving callers a typed + readiness shape. + """ + ready = await self.health() + return RuntimeReadiness( + runtime=self.runtime_type, + status="ready" if ready else "blocked", + binary_present=ready, + reason_code=None if ready else "unavailable", + reason=None if ready else f"runtime {self.runtime_type!r} is unavailable", + ) + + @abstractmethod def validate_config(self, config: dict[str, Any]) -> list[str]: """Validate an AgentTask.config dict; return list of error strings diff --git a/backend/agent_runtimes/codex_adapter.py b/backend/agent_runtimes/codex_adapter.py new file mode 100644 index 00000000..a6f66e5d --- /dev/null +++ b/backend/agent_runtimes/codex_adapter.py @@ -0,0 +1,430 @@ +"""Registered-Agent adapter for the local Codex CLI. + +The adapter is deliberately an edge-side subprocess adapter. The control +plane sends an ``agent_task`` over the authenticated Agent transport; only the +registered Agent process imports this module and starts ``codex``. No shell is +used and no provider credential is copied into readiness or runtime events. + +Codex ``exec --json`` emits JSONL. The native protocol has changed names a few +times, so translation accepts the stable ``thread.*``, ``turn.*`` and +``item.*`` envelopes while keeping our event envelope closed. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import shutil +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +from backend.agent_runtimes.base import ( + AgentTask, + RuntimeAdapter, + RuntimeCapabilities, + RuntimeReadiness, + event_done, + event_error, + event_started, + event_state, + event_text, + event_tool_call, + event_tool_result, +) +from backend.agent_runtimes.registry import register_runtime + +logger = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT_SECONDS = 1800 +_MAX_TIMEOUT_SECONDS = 3600 +_VERSION_TIMEOUT_SECONDS = 5 +_KILL_GRACE_SECONDS = 10 +_STDERR_TAIL_BYTES = 2048 +_PERMISSION_MODES = frozenset({"approval_required", "full_auto", "read_only", "suggest_changes"}) +_SANDBOX_MODES = frozenset({"read-only", "workspace-write", "danger-full-access"}) +_VERSION_RE = re.compile(r"\bcodex(?:[- ]cli)?(?:\s+version)?\s+([0-9][0-9A-Za-z.+-]*)\b", re.I) +_BARE_VERSION_RE = re.compile(r"\b([0-9]+\.[0-9]+(?:\.[0-9]+)?(?:[-+][0-9A-Za-z.-]+)?)\b") + + +@register_runtime +class CodexRuntimeAdapter(RuntimeAdapter): + """Run ``codex exec --json`` on a registered local Agent node.""" + + runtime_type = "codex" + capabilities = RuntimeCapabilities( + transport="stdio", + streaming=True, + resume_by_id=False, + checkpoint="none", + concurrent_sessions=True, + ) + + def validate_config(self, config: dict[str, Any]) -> list[str]: + errors: list[str] = [] + binary = config.get("binary", "codex") + if not isinstance(binary, str) or not binary.strip(): + errors.append("'binary' must be a non-empty string") + elif "\x00" in binary: + errors.append("'binary' must not contain NUL bytes") + + for key in ("cwd", "project_root"): + if key in config and config[key] is not None: + value = config[key] + if not isinstance(value, str) or not value.strip(): + errors.append(f"'{key}' must be a non-empty string when provided") + elif "\x00" in value: + errors.append(f"'{key}' must not contain NUL bytes") + + if "args" in config and config["args"] is not None: + args = config["args"] + if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args): + errors.append("'args' must be a list of strings when provided") + + permission_mode = config.get("permission_mode") + if permission_mode is not None and permission_mode not in _PERMISSION_MODES: + errors.append( + "'permission_mode' must be one of " + ", ".join(sorted(_PERMISSION_MODES)) + ) + sandbox_mode = config.get("sandbox_mode") + if sandbox_mode is not None and sandbox_mode not in _SANDBOX_MODES: + errors.append("'sandbox_mode' must be one of " + ", ".join(sorted(_SANDBOX_MODES))) + if "model" in config and config["model"] is not None: + if not isinstance(config["model"], str) or not config["model"].strip(): + errors.append("'model' must be a non-empty string when provided") + + if "timeout_seconds" in config and config["timeout_seconds"] is not None: + timeout = config["timeout_seconds"] + if ( + not isinstance(timeout, (int, float)) + or isinstance(timeout, bool) + or not 0 < timeout <= _MAX_TIMEOUT_SECONDS + ): + errors.append( + f"'timeout_seconds' must be between 0 and {_MAX_TIMEOUT_SECONDS} when provided" + ) + return errors + + async def health(self) -> bool: + return self.is_available() + + @classmethod + def is_available(cls, binary: str = "codex") -> bool: + """Cheap check used by the Agent registration handshake.""" + if not isinstance(binary, str) or not binary or "\x00" in binary: + return False + return shutil.which(binary) is not None + + async def readiness(self, config: dict[str, Any] | None = None) -> RuntimeReadiness: + config = config or {} + errors = self.validate_config(config) + if errors: + return RuntimeReadiness( + runtime=self.runtime_type, + status="blocked", + binary_present=False, + reason_code="invalid_config", + reason="; ".join(errors), + ) + + binary = config.get("binary") or "codex" + resolved_binary = shutil.which(binary) + if resolved_binary is None: + return RuntimeReadiness( + runtime=self.runtime_type, + status="blocked", + binary_present=False, + reason_code="missing_binary", + reason=f"codex binary not found: {binary!r}", + ) + + try: + project_root, cwd = self._resolve_paths(config) + except ValueError as exc: + return RuntimeReadiness( + runtime=self.runtime_type, + status="blocked", + binary_present=True, + permitted_project_root=self._display_path(config.get("project_root")), + working_directory=self._display_path(config.get("cwd")), + reason_code="invalid_path", + reason=str(exc), + ) + + version = await self._detect_version( + resolved_binary, config.get("args") or [], timeout_seconds=_VERSION_TIMEOUT_SECONDS + ) + return RuntimeReadiness( + runtime=self.runtime_type, + status="ready", + binary_present=True, + version=version, + permitted_project_root=str(project_root), + working_directory=str(cwd), + ) + + def _compose_argv(self, config: dict[str, Any], prompt: str = "") -> list[str]: + binary = config.get("binary") or "codex" + argv = [binary, *(config.get("args") or []), "exec", "--json", "--color", "never"] + permission_mode = config.get("permission_mode") + if permission_mode == "full_auto": + # Codex exposes automatic review, not the old generic approval flag. + # Keep the sandbox bounded; the dangerous bypass flag is never + # selected by the Agent runtime. + argv.extend(("--approve-for-me", "--sandbox", "workspace-write")) + elif permission_mode == "read_only": + argv.extend(("--sandbox", "read-only")) + elif permission_mode in {"suggest_changes", "approval_required"}: + # Default Codex approval flow is the governed on-request mode. + pass + + sandbox_mode = config.get("sandbox_mode") + if sandbox_mode is not None and permission_mode not in {"read_only", "full_auto"}: + argv.extend(("--sandbox", sandbox_mode)) + model = config.get("model") + if model: + argv.extend(("--model", model)) + argv.append(prompt) + return argv + + def _compose_prompt(self, task: AgentTask) -> str: + payload = task.input if isinstance(task.input, dict) else {} + message = payload.get("message") or payload.get("prompt") or "" + if not isinstance(message, str): + message = str(message) + if task.instructions: + return f"{task.instructions}\n\n{message}".strip() + return message + + async def invoke(self, task: AgentTask) -> AsyncIterator[dict[str, Any]]: + config = task.config or {} + config_errors = self.validate_config(config) + if config_errors: + yield event_error(task.task_id, "; ".join(config_errors), error_type="ConfigError") + return + + binary = config.get("binary") or "codex" + resolved_binary = shutil.which(binary) + if resolved_binary is None: + yield event_error( + task.task_id, + f"codex binary not found: {binary!r}", + error_type="FileNotFoundError", + ) + return + try: + _project_root, cwd = self._resolve_paths(config) + except ValueError as exc: + yield event_error(task.task_id, str(exc), error_type="PathError") + return + + timeout_seconds = config.get("timeout_seconds") or _DEFAULT_TIMEOUT_SECONDS + version = await self._detect_version( + resolved_binary, + config.get("args") or [], + timeout_seconds=min(timeout_seconds, _VERSION_TIMEOUT_SECONDS), + ) + argv = self._compose_argv(config, self._compose_prompt(task)) + + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(cwd), + ) + except FileNotFoundError as exc: + yield event_error(task.task_id, f"codex binary not found: {binary!r}", type(exc).__name__) + return + except OSError as exc: + yield event_error(task.task_id, f"failed to spawn codex: {exc}", type(exc).__name__) + return + + yield event_started(task.task_id) + yield event_state( + task.task_id, + {"runtime": self.runtime_type, "codex_version": version, "working_directory": str(cwd)}, + ) + + accumulated_text: list[str] = [] + native_error: str | None = None + + async def _read_events() -> AsyncIterator[dict[str, Any]]: + assert proc.stdout is not None + while True: + line = await proc.stdout.readline() + if not line: + break + stripped = line.decode(errors="replace").strip("\r\n") + if not stripped: + continue + try: + native = json.loads(stripped) + except json.JSONDecodeError: + logger.debug("codex_adapter: skipping non-JSON stdout line: %r", stripped[:200]) + continue + if not isinstance(native, dict): + continue + translated = self._translate_event(task.task_id, native) + if translated is not None: + yield translated + + try: + async with asyncio.timeout(timeout_seconds): + async for event in _read_events(): + if event["type"] == "text": + accumulated_text.append(event.get("text", "")) + elif event["type"] == "error": + native_error = event.get("message") or "Codex reported an error" + break + yield event + except (TimeoutError, asyncio.CancelledError) as exc: + await self._stop_process(proc) + if isinstance(exc, asyncio.CancelledError): + raise + yield event_error( + task.task_id, + f"codex run timed out after {timeout_seconds}s", + error_type="TimeoutError", + ) + return + + returncode = await proc.wait() + if native_error is not None: + yield event_error(task.task_id, native_error, error_type="RuntimeInvocationError") + return + if returncode != 0: + stderr_tail = b"" + if proc.stderr is not None: + stderr_tail = await proc.stderr.read() + tail = stderr_tail[-_STDERR_TAIL_BYTES:].decode(errors="replace") + detail = f": {tail}" if tail else "" + yield event_error( + task.task_id, + f"codex exited with code {returncode}{detail}", + error_type="ProcessExitError", + ) + return + + yield event_done( + task.task_id, + result={ + "runtime": self.runtime_type, + "codex_version": version, + "exit_code": returncode, + "text": "".join(accumulated_text), + }, + ) + + async def _detect_version( + self, + binary: str, + args: list[str] | None = None, + timeout_seconds: float = _VERSION_TIMEOUT_SECONDS, + ) -> str | None: + proc: asyncio.subprocess.Process | None = None + try: + proc = await asyncio.create_subprocess_exec( + binary, + *(args or []), + "--version", + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _stderr = await asyncio.wait_for( + proc.communicate(), timeout=timeout_seconds + ) + except TimeoutError: + if proc is not None and proc.returncode is None: + proc.kill() + await proc.wait() + return None + except OSError: + return None + except asyncio.CancelledError: + if proc is not None and proc.returncode is None: + proc.kill() + await proc.wait() + raise + line = stdout.decode(errors="replace").splitlines()[0].strip() if stdout else "" + match = _VERSION_RE.search(line) or _BARE_VERSION_RE.search(line) + return match.group(0) if match else None + + async def _stop_process(self, proc: asyncio.subprocess.Process) -> None: + if proc.returncode is not None: + return + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=_KILL_GRACE_SECONDS) + except TimeoutError: + proc.kill() + await proc.wait() + + def _resolve_paths(self, config: dict[str, Any]) -> tuple[Path, Path]: + project_root_raw = config.get("project_root") or config.get("cwd") or str(Path.cwd()) + cwd_raw = config.get("cwd") or project_root_raw + project_root = Path(project_root_raw).expanduser().resolve() + cwd = Path(cwd_raw).expanduser().resolve() + if not project_root.is_dir(): + raise ValueError(f"permitted project root is not a directory: {project_root}") + if not cwd.is_dir(): + raise ValueError(f"working directory is not a directory: {cwd}") + try: + cwd.relative_to(project_root) + except ValueError as exc: + raise ValueError( + f"working directory {cwd} is outside permitted project root {project_root}" + ) from exc + return project_root, cwd + + @staticmethod + def _display_path(value: object) -> str | None: + if not isinstance(value, str) or not value.strip(): + return None + return str(Path(value).expanduser().resolve()) + + def _translate_event(self, task_id: str, native: dict[str, Any]) -> dict[str, Any] | None: + native_type = native.get("type") + if native_type in {"error", "turn.error"}: + return event_error(task_id, str(native.get("message") or native.get("error") or "Codex error")) + if native_type == "thread.started": + return event_state(task_id, {"thread_id": native.get("thread_id")}) + if native_type == "turn.started": + return event_state(task_id, {"turn": "started"}) + if native_type == "turn.completed": + state: dict[str, Any] = {"turn": "completed"} + if isinstance(native.get("usage"), dict): + state["usage"] = native["usage"] + return event_state(task_id, state) + + item = native.get("item") if isinstance(native.get("item"), dict) else native + item_type = item.get("type") + if item_type in {"agent_message", "assistant_message", "text"}: + text = item.get("text") or item.get("content") or native.get("text") + return event_text(task_id, text) if isinstance(text, str) and text else None + if item_type in {"command_execution", "tool_call", "function_call"}: + if native_type in {"item.completed", "tool_result", "function_result"}: + output = item.get("aggregated_output", item.get("output", item.get("result"))) + exit_code = item.get("exit_code") + return event_tool_result( + task_id, + name=str(item.get("command") or item.get("name") or "codex_tool"), + result=output, + call_id=item.get("id") or item.get("call_id"), + is_error=bool(item.get("is_error")) or exit_code not in (None, 0), + ) + return event_tool_call( + task_id, + name=str(item.get("command") or item.get("name") or "codex_tool"), + args=item.get("arguments") if isinstance(item.get("arguments"), dict) else {"command": item.get("command", "")}, + call_id=item.get("id") or item.get("call_id"), + ) + if native_type in {"text", "output_text.delta", "response.output_text.delta"}: + text = native.get("text") or native.get("delta") + return event_text(task_id, text) if isinstance(text, str) and text else None + logger.debug("codex_adapter: skipping unmapped native event type %r", native_type) + return None diff --git a/backend/agent_runtimes/pi_adapter.py b/backend/agent_runtimes/pi_adapter.py index 92972ddc..78434cbc 100644 --- a/backend/agent_runtimes/pi_adapter.py +++ b/backend/agent_runtimes/pi_adapter.py @@ -88,7 +88,7 @@ logger = logging.getLogger(__name__) -_DEFAULT_TIMEOUT_SECONDS = 300 +_DEFAULT_TIMEOUT_SECONDS = 1800 _KILL_GRACE_SECONDS = 10 _STDERR_TAIL_BYTES = 2048 _READ_ONLY_PROFILE_MODES = frozenset({"observe_only", "suggest_changes"}) diff --git a/backend/agent_runtimes/registry.py b/backend/agent_runtimes/registry.py index dc74a438..77198983 100644 --- a/backend/agent_runtimes/registry.py +++ b/backend/agent_runtimes/registry.py @@ -47,6 +47,7 @@ def _load_all_runtimes() -> None: """Import all agent-runtime adapter modules to trigger registration.""" from backend.agent_runtimes import ( # noqa: F401 bbx_adapter, + codex_adapter, miniflow_adapter, opentabs_adapter, pi_adapter, diff --git a/backend/api/v1/automations.py b/backend/api/v1/automations.py index 93da16eb..fff2ccd2 100644 --- a/backend/api/v1/automations.py +++ b/backend/api/v1/automations.py @@ -4,13 +4,55 @@ from backend.database import get_db from backend.models.automation import Automation -from backend.schemas.automation import AutomationCreate, AutomationRead, AutomationUpdate +from backend.schemas.automation import ( + AutomationCreate, + AutomationRead, + AutomationUpdate, + StarterInstallationPreview, + StarterInstallationResult, +) from backend.schemas.common import ApiResponse from backend.security.identity import RequestIdentity, get_request_identity from backend.security.workspace_rbac import WorkspacePermission, get_workspace_access, require_permission - +from backend.services.automation_starter_service import ( + install_starters, + preview_starter_installation, +) router = APIRouter(prefix="/workspaces/{workspace_id}/automations", tags=["automations"]) +@router.get( + "/starters/preview", + response_model=ApiResponse[StarterInstallationPreview], +) +async def preview_automation_starters( + workspace_id: str, + identity: RequestIdentity = Depends(get_request_identity), + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + access = await get_workspace_access(db, workspace_id, identity) + require_permission(access, WorkspacePermission.READ) + preview = await preview_starter_installation(db, workspace_id=workspace_id) + return ApiResponse.ok(preview) + + +@router.post( + "/starters/install", + response_model=ApiResponse[StarterInstallationResult], +) +async def install_automation_starters( + workspace_id: str, + identity: RequestIdentity = Depends(get_request_identity), + db: AsyncSession = Depends(get_db), +) -> ApiResponse: + access = await get_workspace_access(db, workspace_id, identity) + require_permission(access, WorkspacePermission.MANAGE_AGENT_IDENTITIES) + result = await install_starters( + db, + workspace_id=workspace_id, + created_by_user_id=access.user_id, + ) + return ApiResponse.ok(result) + @router.get("", response_model=ApiResponse[list[AutomationRead]]) async def list_automations( diff --git a/backend/migrations/versions/aa1b2c3d4e5f_add_automation_starter_key.py b/backend/migrations/versions/aa1b2c3d4e5f_add_automation_starter_key.py new file mode 100644 index 00000000..cb3c53d1 --- /dev/null +++ b/backend/migrations/versions/aa1b2c3d4e5f_add_automation_starter_key.py @@ -0,0 +1,24 @@ +"""add stable first-party starter identity to automations""" + +import sqlalchemy as sa +from alembic import op + +revision = "aa1b2c3d4e5f" +down_revision = "k8l9m0n1o2p3" +depends_on = None + +def upgrade() -> None: + op.add_column("automations", sa.Column("starter_key", sa.String(64), nullable=True)) + # A unique index is portable to SQLite (where ALTER TABLE cannot add a + # table-level unique constraint) and has the same uniqueness semantics. + op.create_index( + "uq_automations_workspace_starter_key", + "automations", + ["workspace_id", "starter_key"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("uq_automations_workspace_starter_key", table_name="automations") + op.drop_column("automations", "starter_key") diff --git a/backend/models/automation.py b/backend/models/automation.py index a481fdba..39e9d38d 100644 --- a/backend/models/automation.py +++ b/backend/models/automation.py @@ -1,4 +1,4 @@ -from sqlalchemy import JSON, Boolean, ForeignKey, String, Text +from sqlalchemy import JSON, Boolean, ForeignKey, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from backend.models.base import TimestampMixin @@ -8,10 +8,16 @@ class Automation(TimestampMixin): """Provider-neutral scheduled agent task, configurable by UI or API.""" __tablename__ = "automations" + __table_args__ = ( + UniqueConstraint("workspace_id", "starter_key", name="uq_automations_workspace_starter_key"), + ) workspace_id: Mapped[str] = mapped_column( ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True ) + # First-party starter identity. Null keeps existing user-created automations + # compatible while making starter installation concurrency-safe. + starter_key: Mapped[str | None] = mapped_column(String(64), nullable=True) name: Mapped[str] = mapped_column(String(255), nullable=False) prompt: Mapped[str] = mapped_column(Text, nullable=False) precheck: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/schemas/automation.py b/backend/schemas/automation.py index cf97b351..b0e4d05c 100644 --- a/backend/schemas/automation.py +++ b/backend/schemas/automation.py @@ -7,7 +7,12 @@ SessionMode = Literal["fresh", "reuse"] ApprovalMode = Literal["observe_only", "suggest_changes", "low_risk_automatic"] - +StarterKey = Literal["daily-run-brief", "weekly-system-review", "anomaly-follow-up"] +STARTER_KEYS: tuple[str, ...] = ( + "daily-run-brief", + "weekly-system-review", + "anomaly-follow-up", +) class AutomationCreate(BaseModel): name: str = Field(min_length=1, max_length=255) @@ -20,7 +25,7 @@ class AutomationCreate(BaseModel): approval_mode: ApprovalMode = "suggest_changes" project: dict = Field(default_factory=dict) enabled: bool = True - + starter_key: StarterKey | None = None class AutomationUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=255) @@ -38,6 +43,7 @@ class AutomationUpdate(BaseModel): class AutomationRead(UTCModel): id: str workspace_id: str + starter_key: StarterKey | None name: str prompt: str precheck: str | None @@ -53,3 +59,24 @@ class AutomationRead(UTCModel): updated_at: datetime model_config = {"from_attributes": True} + + +class StarterPreviewItem(BaseModel): + key: StarterKey + name: str + installed: bool + automation_id: str | None = None + + +class StarterInstallationPreview(BaseModel): + workspace_id: str + starters: list[StarterPreviewItem] + missing_count: int + installed_count: int + + +class StarterInstallationResult(StarterInstallationPreview): + created_count: int + skipped_count: int + + model_config = {"from_attributes": True} diff --git a/backend/schemas/operations_agent.py b/backend/schemas/operations_agent.py index f0dc1197..f537df9e 100644 --- a/backend/schemas/operations_agent.py +++ b/backend/schemas/operations_agent.py @@ -13,6 +13,8 @@ AGENT_CONTRACT_CONFIGURATION_KEY = "agent_contract" AGENT_RUNTIME_BINDING_CONFIGURATION_KEY = "runtime_binding" +DEFAULT_DEEP_RUN_TIMEOUT_SECONDS = 1800 +MAX_DEEP_RUN_TIMEOUT_SECONDS = 3600 MAX_AGENT_SCHEMA_BYTES = 65_536 MAX_AGENT_SCHEMA_DEPTH = 32 MAX_AGENT_MODEL_CONFIGURATION_BYTES = 262_144 @@ -60,11 +62,14 @@ class AgentRuntimeBindingV1(BaseModel): schema_version: Literal["agent.runtime-binding.v1"] agent_url: str = Field(min_length=1, max_length=512) - runtime: Literal["pi"] + runtime: Literal["miniflow", "pi", "codex"] workflow: str = Field(min_length=1, max_length=255) config: dict[str, JsonValue] = Field(default_factory=dict) - dispatch_timeout_seconds: int = Field(default=600, ge=1, le=3600) - + dispatch_timeout_seconds: int = Field( + default=DEFAULT_DEEP_RUN_TIMEOUT_SECONDS, + ge=1, + le=MAX_DEEP_RUN_TIMEOUT_SECONDS, + ) @field_validator("config") @classmethod def config_is_task_scoped(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: @@ -78,9 +83,12 @@ def config_is_task_scoped(cls, value: dict[str, JsonValue]) -> dict[str, JsonVal if timeout is not None and ( not isinstance(timeout, (int, float)) or isinstance(timeout, bool) - or not 1 <= timeout <= 3600 + or not 1 <= timeout <= MAX_DEEP_RUN_TIMEOUT_SECONDS ): - raise ValueError("config.timeout_seconds must be between 1 and 3600") + raise ValueError( + "config.timeout_seconds must be between 1 and " + f"{MAX_DEEP_RUN_TIMEOUT_SECONDS}" + ) return value @field_validator("agent_url") diff --git a/backend/schemas/provider.py b/backend/schemas/provider.py index f675817a..00aa0d62 100644 --- a/backend/schemas/provider.py +++ b/backend/schemas/provider.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field from backend.schemas.common import UTCModel +from backend.schemas.provider_capacity import ProviderCapacityRead, project_provider_capacity class ModelProviderCreate(BaseModel): @@ -60,6 +61,7 @@ class ModelProviderRead(UTCModel): default_model: Optional[str] notes: Optional[str] enabled: bool + capacity: ProviderCapacityRead created_at: datetime updated_at: datetime @@ -86,6 +88,7 @@ def from_model(cls, provider: Any) -> "ModelProviderRead": "default_model": provider.default_model, "notes": provider.notes, "enabled": provider.enabled, + "capacity": project_provider_capacity(provider), "created_at": provider.created_at, "updated_at": provider.updated_at, } diff --git a/backend/schemas/provider_capacity.py b/backend/schemas/provider_capacity.py new file mode 100644 index 00000000..4301c516 --- /dev/null +++ b/backend/schemas/provider_capacity.py @@ -0,0 +1,96 @@ +"""Provider capacity projections with an explicit, honest availability state. + +Capacity is intentionally not a quota calculator. A provider-specific usage +adapter may supply opaque usage data when it has a documented endpoint; when +there is no such adapter the projection remains ``unavailable``. No elapsed +runtime, request count, or other local observation is used to manufacture a +remaining percentage. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Mapping + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + + +class ProviderCapacityState(StrEnum): + """Availability of provider capacity evidence.""" + + MEASURED = "measured" + UNAVAILABLE = "unavailable" + NOT_APPLICABLE = "not_applicable" + + +class ProviderCapacityRead(BaseModel): + """Serialized provider capacity evidence. + + ``usage`` is deliberately opaque: providers do not share a quota schema, + so an adapter owns the shape of its documented response. In particular, + this model has no derived ``remaining_percent`` field. + """ + + model_config = ConfigDict(extra="forbid") + + state: ProviderCapacityState + usage: dict[str, JsonValue] | None = None + measured_at: datetime | None = None + source: str | None = Field(default=None, min_length=1, max_length=255) + reason: str | None = Field(default=None, min_length=1, max_length=1000) + + @model_validator(mode="after") + def validate_evidence(self) -> ProviderCapacityRead: + if self.state is ProviderCapacityState.MEASURED: + if self.usage is None: + raise ValueError("measured capacity requires adapter usage data") + if self.reason is not None: + raise ValueError("measured capacity cannot include an unavailable reason") + return self + if self.usage is not None or self.measured_at is not None: + raise ValueError("unavailable capacity must not include measured usage data") + return self + + @classmethod + def unavailable(cls, *, reason: str = "No supported provider usage endpoint") -> ProviderCapacityRead: + return cls(state=ProviderCapacityState.UNAVAILABLE, reason=reason) + + @classmethod + def not_applicable(cls, *, reason: str = "Runtime has no provider quota semantics") -> ProviderCapacityRead: + return cls(state=ProviderCapacityState.NOT_APPLICABLE, reason=reason) + + @classmethod + def measured( + cls, + usage: Mapping[str, JsonValue], + *, + source: str, + measured_at: datetime | None = None, + ) -> ProviderCapacityRead: + """Build a measured projection from an explicit provider adapter result.""" + return cls( + state=ProviderCapacityState.MEASURED, + usage=dict(usage), + measured_at=measured_at, + source=source, + ) + + +def project_provider_capacity(provider: Any) -> ProviderCapacityRead: + """Project only explicit adapter evidence from a provider-like object. + + Existing ``ModelProvider`` rows do not carry usage evidence, so they + serialize as ``unavailable``. A future documented adapter can attach a + ``capacity`` projection (or its serialized mapping) without changing this + API; no other provider fields are consulted. + """ + + value = getattr(provider, "capacity", None) + if value is None: + return ProviderCapacityRead.unavailable() + if isinstance(value, ProviderCapacityRead): + return value + if isinstance(value, Mapping): + return ProviderCapacityRead.model_validate(value) + raise TypeError("provider capacity adapter result must be a mapping or ProviderCapacityRead") diff --git a/backend/services/automation_starter_service.py b/backend/services/automation_starter_service.py new file mode 100644 index 00000000..14aaf96b --- /dev/null +++ b/backend/services/automation_starter_service.py @@ -0,0 +1,160 @@ +"""First-party Agent Starter installation for Workspace automations.""" + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.models.automation import Automation +from backend.schemas.automation import ( + StarterInstallationPreview, + StarterInstallationResult, + StarterPreviewItem, +) + + +@dataclass(frozen=True) +class StarterDefinition: + key: str + name: str + prompt: str + schedule: str + precheck: str | None = None + executor: str = "codex" + timezone: str = "UTC" + session_mode: str = "fresh" + approval_mode: str = "suggest_changes" + + def project(self) -> dict[str, str]: + return { + "starter_key": self.key, + "lineage": "first-party-agent-starter", + } + + +STARTER_DEFINITIONS: tuple[StarterDefinition, ...] = ( + StarterDefinition( + key="daily-run-brief", + name="运行简报 Agent", + prompt="Prepare a concise daily run brief from the latest workspace activity and open work.", + schedule="daily@09:00", + ), + StarterDefinition( + key="weekly-system-review", + name="系统回顾 Agent", + prompt="Review the workspace system state, summarize trends, and identify actionable improvements.", + schedule="weekly@monday@09:00", + ), + StarterDefinition( + key="anomaly-follow-up", + name="异常跟进 Agent", + prompt="Review unresolved anomalies, gather evidence, and propose the next safe follow-up actions.", + schedule="on_anomaly", + ), +) + +STARTER_KEYS: tuple[str, ...] = tuple( + definition.key for definition in STARTER_DEFINITIONS +) +AGENT_STARTERS = STARTER_DEFINITIONS + + + +def _preview( + workspace_id: str, + installed_by_key: dict[str, Automation], +) -> StarterInstallationPreview: + starters = [ + StarterPreviewItem( + key=definition.key, + name=definition.name, + installed=definition.key in installed_by_key, + automation_id=( + installed_by_key[definition.key].id + if definition.key in installed_by_key + else None + ), + ) + for definition in STARTER_DEFINITIONS + ] + installed_count = sum(item.installed for item in starters) + return StarterInstallationPreview( + workspace_id=workspace_id, + starters=starters, + missing_count=len(starters) - installed_count, + installed_count=installed_count, + ) + + +async def preview_starter_installation( + session: AsyncSession, + *, + workspace_id: str, +) -> StarterInstallationPreview: + rows = ( + await session.scalars( + select(Automation).where( + Automation.workspace_id == workspace_id, + Automation.starter_key.in_(STARTER_KEYS), + ) + ) + ).all() + return _preview(workspace_id, {row.starter_key: row for row in rows if row.starter_key}) + + +async def install_starters( + session: AsyncSession, + *, + workspace_id: str, + created_by_user_id: str, +) -> StarterInstallationResult: + """Install missing starters atomically and return the resulting inventory. + + A nested transaction keeps a failed pack installation from leaving a partial + set of rows behind. The unique workspace/starter key constraint is the final + guard against duplicate rows when requests race. + """ + + async with session.begin_nested(): + rows = ( + await session.scalars( + select(Automation) + .where( + Automation.workspace_id == workspace_id, + Automation.starter_key.in_(STARTER_KEYS), + ) + .with_for_update() + ) + ).all() + installed_by_key = {row.starter_key: row for row in rows if row.starter_key} + skipped_count = len(installed_by_key) + created_count = 0 + for definition in STARTER_DEFINITIONS: + if definition.key in installed_by_key: + continue + row = Automation( + workspace_id=workspace_id, + starter_key=definition.key, + name=definition.name, + prompt=definition.prompt, + precheck=definition.precheck, + executor=definition.executor, + schedule=definition.schedule, + timezone=definition.timezone, + session_mode=definition.session_mode, + approval_mode=definition.approval_mode, + project=definition.project(), + enabled=True, + created_by_user_id=created_by_user_id, + ) + session.add(row) + await session.flush() + installed_by_key[definition.key] = row + created_count += 1 + + preview = _preview(workspace_id, installed_by_key) + return StarterInstallationResult( + **preview.model_dump(), + created_count=created_count, + skipped_count=skipped_count, + ) diff --git a/backend/services/operations_agent_runtime_service.py b/backend/services/operations_agent_runtime_service.py index fe0b0e80..41af864c 100644 --- a/backend/services/operations_agent_runtime_service.py +++ b/backend/services/operations_agent_runtime_service.py @@ -100,6 +100,16 @@ async def dispatch_operations_agent_run(run_id: str) -> None: runtime_input = cast(dict[str, Any], run.input_payload) runtime_config = dict(binding.config) + configured_timeout = runtime_config.get("timeout_seconds") + if ( + not isinstance(configured_timeout, (int, float)) + or isinstance(configured_timeout, bool) + or configured_timeout < binding.dispatch_timeout_seconds + ): + # The edge runtime must not expire before the governed outer + # deep-run profile. Binding validation supplies the hard + # ceiling; this fills/raises the inner timeout to that profile. + runtime_config["timeout_seconds"] = binding.dispatch_timeout_seconds runtime_config["permission_mode"] = profile.mode state_contract_error: str | None = None diff --git a/frontend/app/(app)/operations-agents/page.tsx b/frontend/app/(app)/operations-agents/page.tsx index a359cfbf..f3efedbd 100644 --- a/frontend/app/(app)/operations-agents/page.tsx +++ b/frontend/app/(app)/operations-agents/page.tsx @@ -6,7 +6,8 @@ import { ArrowUp, Bell, Bot, CalendarClock, ChevronDown, CircleDot, Cloud, Code2 import { toast } from 'sonner' import AgentAvatar from '@/components/smoothui/agent-avatar' -import { useAutomations, useCreateAutomation, useGovernedWorkspaces, useOperationsAgentActivity, useOperationsAgentDraft, useOperationsAgents, useOperationsAgentVersions, usePatchAutomation, usePublishOperationsAgentVersion, useStartOperationsAgentRun, useUpdateOperationsAgentDraft } from '@/lib/api/hooks' +import SwitchboardCard from '@/components/smoothui/switchboard-card' +import { useAutomations, useCreateAutomation, useGovernedWorkspaces, useInstallAutomationStarters, 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' @@ -21,6 +22,37 @@ const SUGGESTIONS = [ { name: '异常跟进监控', prompt: '检查最近的异常活动,并将有证据的问题整理为待处理建议。', icon: FileSearch, color: 'text-emerald-400', schedule: 'weekdays@09:00' }, ] as const +const AGENT_STARTERS = [ + { + ...SUGGESTIONS[0], + name: '运行简报 Agent', + subtitle: '每天汇总运行、失败与待批准事项', + executor: 'codex', + pattern: [0, 1, 2, 18, 19, 20, 36, 37, 38, 54, 55, 56, 72, 73, 74], + }, + { + ...SUGGESTIONS[1], + name: '系统回顾 Agent', + subtitle: '每周整理变化、风险与待处理建议', + executor: 'claude', + pattern: [4, 5, 6, 22, 23, 24, 40, 41, 42, 58, 59, 60, 76, 77, 78], + }, + { + ...SUGGESTIONS[2], + name: '异常跟进 Agent', + subtitle: '工作日检查异常并生成证据化建议', + executor: 'chatcloud', + pattern: [8, 9, 10, 26, 27, 28, 44, 45, 46, 62, 63, 64, 80, 81, 82], + }, +] as const + +type AgentStarterInput = { + name: string + prompt: string + schedule: string + executor?: string +} + const EXECUTORS = [ { id: 'codex', name: 'Codex', icon: Code2, color: 'text-sky-400' }, { id: 'claude', name: 'Claude', icon: Sparkles, color: 'text-orange-400' }, @@ -62,7 +94,7 @@ function ContractEditor({ workspaceId, agent }: { workspaceId: string; agent: Op const [stateSchema, setStateSchema] = useState('') const [agentUrl, setAgentUrl] = useState('') const [workflow, setWorkflow] = useState('') - const [dispatchTimeout, setDispatchTimeout] = useState(600) + const [dispatchTimeout, setDispatchTimeout] = useState(1800) const [runtimeConfig, setRuntimeConfig] = useState('') const [reason, setReason] = useState('') @@ -76,8 +108,8 @@ function ContractEditor({ workspaceId, agent }: { workspaceId: string; agent: Op setStateSchema(JSON.stringify(contract?.state_schema ?? EMPTY_SCHEMA, null, 2)) setAgentUrl(binding?.agent_url ?? '') setWorkflow(binding?.workflow ?? '') - setDispatchTimeout(binding?.dispatch_timeout_seconds ?? 600) - setRuntimeConfig(JSON.stringify(binding?.config ?? {}, null, 2)) + setDispatchTimeout(binding?.dispatch_timeout_seconds ?? 1800) + setRuntimeConfig(JSON.stringify(binding?.config ?? { timeout_seconds: 1800 }, null, 2)) }, [draft.data]) async function saveDraft() { @@ -155,7 +187,7 @@ function ContractEditor({ workspaceId, agent }: { workspaceId: string; agent: Op - +