Преглед на файлове

fix(M1): dogfood fixes — webui-dist serving regression + FR-001 one-click login

Phase C dogfood (启真实服务 + 浏览器走通三档 UI) 抓到两个单元测试查不出
的真 bug, 本 commit 修复并回归验证。152 backend test + webui build 全过。

## Bug 1 (critical): webui-dist 静态服务在 src-layout 迁移后失效

app.py 从 agentpaas/api/ 移到 agentpaas/src/agentpaas/api/ (Phase 1
commit a942525) 多了一层目录, `../../webui-dist` 从指向 repo-root/
webui-dist 变成指向 agentpaas/src/webui-dist (不存在) → 整个 desktop
app 打开是 JSON, 不是 UI。imports 冒烟测试只检查路由加载, 查不出静态
文件服务断裂 — 正是 dogfood 的价值。

Fix: _resolve_webui_dist() 探测候选路径列表:
  1. AGENTPAAS_WEBUI_DIST env (override)
  2. /app/webui-dist (Docker)
  3. <repo_root>/webui-dist (dev, 从 this file 上 4 层)
  4. <cwd>/webui-dist
dogfood 验证: curl / → <div id="root"> + /assets/*.js 200 ✓

## Bug 2 (FR-001 关键): desktop 向导 key-paste 死路

dogfood 发现: 教授双击 app → 服务 startup 自动 bootstrap tenant+key
写进 config.json → 但向导 step3 又让用户"粘贴现有 API Key" — 而 key
只在 config.json/终端里, 浏览器拿不到。FR-001 "不要求用户理解...
简化向导" 被这个死路破坏。

Fix:
- 后端 GET /setup/desktop-key (desktop + loopback only): 返回
  auto-bootstrap 的 key。desktop 模式机器本身就是安全边界(单本地用户),
  把 key 交给同机浏览器不比 key 躺在 home dir 更暴露。lab/paas 404。
- SetupWizard useEffect: mode=desktop && bootstrapped 时自动 fetch
  desktop-key → setApiKey → 跳 /dashboard。失败回退到手动向导。

dogfood 验证: 清 localStorage → 访问 /setup → 自动跳 /dashboard,
零 key-paste ✓

## 测试 (tests/test_deployment_mode.py +2 → 152)
- test_desktop_key_404_in_lab_mode: lab 模式端点不存在
- test_desktop_key_loopback_only: desktop 非 loopback 调用 → 404

## dogfood 完整验收 (desktop 模式, 真实服务 + 浏览器)

后端 7 curl 检查全过: /setup/mode (desktop + alias QindeMacBook.../
kennyliu67 + bootstrapped) / health / data-dir (LambdAgentDesktop) /
admin 404 / billing 404 / config.json (tn_local_{hex}) / 数据目录创建

前端 6 截图全过:
  - 向导 brand "ResearchAgent Desktop" + 副标题 "本地科研工作台"
  - 向导 step "完成设置" (desktop)
  - FR-002 数据目录面板 "你的资料保存在本机" + 路径 + 隐私说明
  - step2 Claude Code "推荐" (Q4)
  - 一键登录直达 dashboard (FR-001 fix)
  - sidebar: ResearchAgent Desktop + 🖥️Desktop chip(teal) +
    今日工作/智能体/资料库/模型与隐私 + 切换数据/重新配置
  - SR-002 隐私 modal: 会发送/不会发送两列 + Ollama 离线提示

## 已知小瑕疵 (非阻塞, 留待打磨)
- 向导 step3 panel 内文案 "初始化平台" 未 mode-aware (pill 已是
  "完成设置"); desktop 一键登录后用户看不到 step3, 影响微小
- Dashboard PageHeader "仪表盘/平台运行概览" 未 mode-aware (sidebar
  已是 "今日工作"); 后续 Dashboard 改造一起做

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kenny67nju преди 3 месеца
родител
ревизия
904c876
променени са 4 файла, в които са добавени 114 реда и са изтрити 3 реда
  1. 30 3
      agentpaas/src/agentpaas/api/app.py
  2. 36 0
      agentpaas/src/agentpaas/api/v1/setup.py
  3. 22 0
      tests/test_deployment_mode.py
  4. 26 0
      webui/src/pages/SetupWizard.tsx

+ 30 - 3
agentpaas/src/agentpaas/api/app.py

@@ -238,9 +238,36 @@ async def _on_startup():
 async def health():
     return {"status": "ok", "version": "0.1.0"}
 
-# Serve built Web UI static files when webui-dist/ exists
-_WEBUI_DIST = os.path.join(os.path.dirname(__file__), "..", "..", "webui-dist")
-_WEBUI_DIST = os.path.normpath(_WEBUI_DIST)
+# Serve built Web UI static files when webui-dist/ exists.
+#
+# Path resolution broke during the Phase 1 src-layout migration (this file
+# moved from agentpaas/api/app.py to agentpaas/src/agentpaas/api/app.py,
+# adding one directory level — the old `../../webui-dist` started pointing
+# at agentpaas/src/webui-dist, which doesn't exist; caught by dogfooding,
+# not unit tests, since the import test only checks routes load). Resolve
+# robustly by probing a list of candidate locations:
+#   1. AGENTPAAS_WEBUI_DIST env (explicit override)
+#   2. /app/webui-dist (Docker layout — Dockerfile COPYs it there)
+#   3. <repo_root>/webui-dist (dev: 4 levels up from this file's dir)
+#   4. <cwd>/webui-dist (running from repo root)
+def _resolve_webui_dist() -> str:
+    explicit = os.getenv("AGENTPAAS_WEBUI_DIST", "")
+    here = os.path.dirname(os.path.abspath(__file__))
+    candidates = [
+        explicit,
+        "/app/webui-dist",
+        # agentpaas/src/agentpaas/api -> up 4 -> repo root
+        os.path.normpath(os.path.join(here, "..", "..", "..", "..", "webui-dist")),
+        os.path.normpath(os.path.join(os.getcwd(), "webui-dist")),
+    ]
+    for c in candidates:
+        if c and os.path.isdir(c):
+            return c
+    # Return the dev default even if missing, so the `else` branch logs it.
+    return os.path.normpath(os.path.join(here, "..", "..", "..", "..", "webui-dist"))
+
+
+_WEBUI_DIST = _resolve_webui_dist()
 
 if os.path.isdir(_WEBUI_DIST):
     app.mount("/assets", StaticFiles(directory=os.path.join(_WEBUI_DIST, "assets")), name="assets")

+ 36 - 0
agentpaas/src/agentpaas/api/v1/setup.py

@@ -154,6 +154,42 @@ async def setup_health():
     }
 
 
+# ─────────────────────────────────────────────────────────────────────
+# FR-001 — desktop one-click login
+# ─────────────────────────────────────────────────────────────────────
+
+@router.get("/desktop-key")
+async def desktop_key(request: Request):
+    """Return the auto-bootstrapped API key for one-click desktop login.
+
+    CR FR-001: a professor double-clicks the app; the server auto-
+    bootstraps a tenant + key at startup and writes it to
+    ~/.agentpaas/config.json. Without this endpoint the first-run wizard
+    would then ask them to paste a key they have no easy way to retrieve
+    (it's only in config.json / the terminal). Dogfooding surfaced this
+    exact dead-end.
+
+    Security: desktop mode only + loopback only. In desktop mode the
+    machine IS the security boundary (single local user); handing the
+    key to a same-host browser is no more exposure than the key already
+    sitting in the user's home dir. In lab/paas this endpoint 404s — those
+    deployments use the SetupWizard's explicit key-paste flow.
+    """
+    from agentpaas.config import settings
+
+    if settings.deployment_mode != "desktop":
+        # Hide existence outside desktop (same posture as Q9 route gating).
+        raise HTTPException(status_code=404, detail="Not Found")
+    _require_loopback(request)
+
+    cfg = _load_config()
+    key = cfg.get("api_key", "")
+    if not key:
+        # Not bootstrapped yet — caller should fall back to the wizard.
+        raise HTTPException(status_code=404, detail="Not bootstrapped")
+    return {"api_key": key, "tenant_id": cfg.get("tenant_id", "")}
+
+
 # ─────────────────────────────────────────────────────────────────────
 # FR-002 — data directory (first-run wizard shows + lets desktop users
 # choose where their research data lives)

+ 22 - 0
tests/test_deployment_mode.py

@@ -348,6 +348,28 @@ def test_auto_bootstrap_is_idempotent(db, mode, tmp_path):
         setup_mod.CONFIG_FILE = original_cfg
 
 
+# ─────────────────────────────────────────────────────────────────────────────
+# FR-001 — desktop one-click login (/setup/desktop-key)
+# ─────────────────────────────────────────────────────────────────────────────
+
+def test_desktop_key_404_in_lab_mode(client, db, mode):
+    """The one-click-login key endpoint must not exist outside desktop —
+    lab/paas use the explicit SetupWizard paste flow. 404 hides it."""
+    settings.deployment_mode = "lab"
+    r = client.get("/api/v1/setup/desktop-key")
+    assert r.status_code == 404
+
+
+def test_desktop_key_loopback_only(client, db, mode):
+    """In desktop mode the endpoint is loopback-gated; TestClient's
+    synthetic host is non-loopback → 404 (the secure default we rely on
+    everywhere). The real desktop browser hits it from 127.0.0.1."""
+    settings.deployment_mode = "desktop"
+    r = client.get("/api/v1/setup/desktop-key")
+    # Non-loopback caller → 404 from _require_loopback.
+    assert r.status_code == 404
+
+
 # ─────────────────────────────────────────────────────────────────────────────
 # FR-002 — data-dir get/set (first-run wizard)
 # ─────────────────────────────────────────────────────────────────────────────

+ 26 - 0
webui/src/pages/SetupWizard.tsx

@@ -395,6 +395,32 @@ export default function SetupWizard() {
     navigate('/dashboard')
   }
 
+  // FR-001 one-click desktop login: if the server already auto-bootstrapped
+  // a tenant (mode=desktop + bootstrapped), grab the key from the
+  // loopback-only /setup/desktop-key endpoint and skip straight into the
+  // app. A professor double-clicking the app should NOT have to paste a
+  // key. Dogfooding found the wizard otherwise dead-ends here.
+  useEffect(() => {
+    if (mode !== 'desktop' || !modeData?.bootstrapped) return
+    let cancelled = false
+    ;(async () => {
+      try {
+        const r = await fetch('/api/v1/setup/desktop-key')
+        if (!r.ok) return // fall back to the manual wizard
+        const data = await r.json()
+        if (!cancelled && data.api_key) {
+          setApiKey(data.api_key)
+          navigate('/dashboard')
+        }
+      } catch {
+        /* fall back to manual wizard */
+      }
+    })()
+    return () => {
+      cancelled = true
+    }
+  }, [mode, modeData?.bootstrapped, setApiKey, navigate])
+
   const steps = [
     { n: 1, label: '环境检测' },
     { n: 2, label: '选择模型' },