Bladeren bron

feat(M1): Phase B finish — mode-aware defaults + /setup/mode + test isolation

接 88bdddb 的 deployment_mode spike, 收尾 Phase B (Desktop runtime) 的
剩余项, 并修两个测试隔离 bug。148/148 test 全过。

## 生产代码

### config.py — mode-aware 默认值 (CR FR-002 + Q12)

- data_dir: desktop → ~/LambdAgentDesktop (用户可见, branded, Finder
  里找得到), lab/paas → ~/.agentpaas/data (隐藏, 遵循 OS 约定)。
  _default_data_dir() 加 mode 参数。
- host bind: desktop/lab → 127.0.0.1 (默认锁死, 与 audit critical #1
  loopback 守门同款), paas → 0.0.0.0 (生产经反代)。AGENTPAAS_HOST 任何
  模式都可覆盖。

### setup.py — GET /api/v1/setup/mode (Q8 chip 端点)

无 auth 端点, 供 webui 顶栏 mode chip 渲染 (login 之前就要显示)。
返回 {mode, tenant_alias, bootstrapped}:
- mode: desktop|lab|paas
- tenant_alias: {hostname}/{user} (desktop) 或 tenant.name (lab/paas);
  不泄露 tenant_id (PK, 会流进 LLM provider logs)
- bootstrapped: 首次启动向导用此信号决定是否渲染自己

### app.py — 两个 test-isolation 守门 (AGENTPAAS_TESTING)

发现并修复两个 bug, 都因为 startup hook / middleware 在 test 环境下
对真实系统状态有副作用:

1. startup auto_bootstrap_desktop() 在每次 TestClient(app) 都往真实
   ~/.agentpaas/config.json 写 API key + 污染系统 DB。AGENTPAAS_TESTING=1
   时跳过。
2. audit #25 的 rate-limit middleware (unauth 每 IP 10/min) 在跑到第
   130+ 个 test 时把 synthetic `testclient` host 的桶用尽, 最后两个
   test 撞 429。AGENTPAAS_TESTING=1 时豁免 (不削弱生产姿态 — audit #25
   防的是公网 surface, 不是 test harness)。

## 测试

### conftest.py
os.environ.setdefault("AGENTPAAS_TESTING", "1") — 全局开启 test 守门。

### test_deployment_mode.py +5 test (11 → 16)
- test_config_desktop_picks_visible_data_dir   — FR-002 data dir 分流
- test_config_default_host_is_loopback_for_non_paas — Q12 bind 默认值
- test_setup_mode_endpoint_no_auth_required    — Q8 端点无 auth 可达
- test_setup_mode_reflects_current_deployment_mode — 不缓存, 实时反映
- test_setup_mode_reports_bootstrapped_false_on_fresh_install — wizard 信号

## 验证

  $ pytest tests/ -q
  148 passed in 5.37s   (132 老 + 11 spike + 5 本批)

## Phase B 完成度

  ✓ deployment_mode flag (88bdddb)
  ✓ auto-bootstrap helper (88bdddb)
  ✓ /admin /billing 隐藏 (88bdddb)
  ✓ data_dir mode 分流 (本 commit, FR-002)
  ✓ host bind mode 分流 (本 commit, Q12)
  ✓ /setup/mode 端点 (本 commit, Q8 webui 接口准备)

Phase B 收尾。下一步 Phase C: webui 顶栏 mode chip + 文案重命名 +
SR-002 隐私警告 + first-run wizard。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kenny67nju 3 maanden geleden
bovenliggende
commit
56eebc7

+ 16 - 1
agentpaas/src/agentpaas/api/app.py

@@ -56,6 +56,15 @@ async def rate_limit(request: Request, call_next):
     if request.url.path in ("/health", "/", "/docs", "/redoc", "/openapi.json"):
         return await call_next(request)
 
+    # Tests fire 100+ requests per pytest run against the synthetic
+    # `testclient` host; that exhausts the unauth-per-IP bucket (default
+    # 10/min) and 429s legitimate test traffic. AGENTPAAS_TESTING=1
+    # exempts test runs without weakening the production posture (which
+    # is the whole point of audit #25 — to defend the public surface,
+    # not the integration harness).
+    if os.getenv("AGENTPAAS_TESTING"):
+        return await call_next(request)
+
     # Safety-critical: a run-cancel POST must NEVER be rate-limited. We hit
     # this exact wall during run_e48f99 — the chat UI's 5s polling burned
     # through the 60/min bucket, and when the user finally clicked Stop the
@@ -211,7 +220,13 @@ async def _on_startup():
     # create one + an admin API key + write to ~/.agentpaas/config.json.
     # User then opens http://127.0.0.1:8000 and is logged in.
     # No-op if any active tenant already exists.
-    if settings.deployment_mode == "desktop":
+    #
+    # Safety: AGENTPAAS_TESTING=1 disables the auto-bootstrap so test
+    # fixtures don't clobber the user's real ~/.agentpaas/config.json on
+    # every TestClient(app) invocation. Tests that want to exercise the
+    # bootstrap path call `auto_bootstrap_desktop()` explicitly (with
+    # CONFIG_FILE patched to a tmp path).
+    if settings.deployment_mode == "desktop" and not os.getenv("AGENTPAAS_TESTING"):
         try:
             from agentpaas.api.v1.setup import auto_bootstrap_desktop
             auto_bootstrap_desktop()

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

@@ -153,6 +153,54 @@ async def setup_health():
     }
 
 
+@router.get("/mode")
+async def get_deployment_mode():
+    """Unauthenticated read of the current deployment_mode + display info.
+
+    CR-20260607-001 rev.2 Q8 decision: the webui top bar surfaces a chip
+    showing which mode the user is on (Desktop / Lab / PaaS). The chip
+    is rendered BEFORE the user logs in (so the SetupWizard / first-run
+    flow can adapt to the mode), so this endpoint must be reachable
+    without a Bearer.
+
+    Response shape lets the frontend stay dumb:
+      - mode:         "desktop" | "lab" | "paas"
+      - tenant_alias: human-readable string for the chip ({hostname}/{user}
+                      for desktop, tenant.name for lab/paas, "" if unset)
+      - bootstrapped: true once the desktop auto-bootstrap has run; the
+                      first-run wizard short-circuits to login once true
+
+    Defense-in-depth: even though /mode itself is no-auth, the
+    tenant_alias is the hostname/user pair — same info available from
+    OS-level network packet sniffing on the same LAN. We do not
+    leak tenant_id (PK that flows into LLM provider logs).
+    """
+    from agentpaas.config import settings
+
+    mode = settings.deployment_mode
+    tenant_alias = ""
+    bootstrapped = False
+    try:
+        cfg = _load_config()
+        if cfg.get("api_key"):
+            bootstrapped = True
+        db = get_db()
+        row = db.fetchone(
+            "SELECT name FROM tenants WHERE status='active' "
+            "ORDER BY created_at ASC LIMIT 1"
+        )
+        if row:
+            tenant_alias = row.get("name", "") or ""
+    except Exception:  # pragma: no cover (no DB / fresh install)
+        pass
+
+    return {
+        "mode": mode,
+        "tenant_alias": tenant_alias,
+        "bootstrapped": bootstrapped,
+    }
+
+
 # ─────────────────────────────────────────────────────────────────────
 # Desktop-mode auto-bootstrap (CR-20260607-001 rev.2, Q11)
 # ─────────────────────────────────────────────────────────────────────

+ 24 - 4
agentpaas/src/agentpaas/config.py

@@ -29,15 +29,25 @@ from pathlib import Path
 from typing import Optional
 
 
-def _default_data_dir() -> str:
+def _default_data_dir(mode: str = "desktop") -> str:
     """
     Resolve the data directory in priority order:
     1. AGENTPAAS_DATA_DIR env var (explicit override, used by Docker: /data)
-    2. ~/.agentpaas/data  (standard user install on Mac/Linux/Windows)
+    2. mode-aware default:
+       - desktop: ~/LambdAgentDesktop  (user-visible, branded — CR FR-002)
+       - lab / paas: ~/.agentpaas/data (hidden, standard config-dir style)
+
+    Rationale (CR-20260607-001 rev.2 §FR-002): desktop users are non-
+    technical scientists — they need to find the data directory in
+    Finder/Explorer without showing hidden folders. A capitalized,
+    project-branded dir name in $HOME accomplishes that. lab / paas
+    deployments keep the hidden dir to follow OS conventions.
     """
     explicit = os.getenv("AGENTPAAS_DATA_DIR", "")
     if explicit:
         return explicit
+    if mode == "desktop":
+        return str(Path.home() / "LambdAgentDesktop")
     return str(Path.home() / ".agentpaas" / "data")
 
 
@@ -60,12 +70,22 @@ class AgentPaaSConfig:
             )
         self.deployment_mode: str = _mode
 
-        self.host: str = os.getenv("AGENTPAAS_HOST", "0.0.0.0")
+        # ── Network bind (CR rev.2 §Q12, audit critical #1 same posture) ──
+        # `desktop`: 127.0.0.1 only — locked down by default, matches the
+        #   loopback-only /setup/bootstrap defense. Users with truly local
+        #   reverse-proxy needs can override via AGENTPAAS_HOST=0.0.0.0.
+        # `lab`: 127.0.0.1 by default too — operator opens LAN explicitly
+        #   via nginx (recommended) or AGENTPAAS_HOST=0.0.0.0 (advanced).
+        # `paas`: 0.0.0.0 (always behind a reverse proxy in production).
+        _default_host = "0.0.0.0" if _mode == "paas" else "127.0.0.1"
+        self.host: str = os.getenv("AGENTPAAS_HOST", _default_host)
         self.port: int = int(os.getenv("AGENTPAAS_PORT", "8000"))
         self.debug: bool = os.getenv("AGENTPAAS_DEBUG", "false").lower() == "true"
 
         # ── Data directory (single source of truth for all runtime files) ──
-        self.data_dir: str = _default_data_dir()
+        # Mode-aware: desktop uses ~/LambdAgentDesktop (visible to users),
+        # lab/paas use ~/.agentpaas/data (hidden, follows OS convention).
+        self.data_dir: str = _default_data_dir(_mode)
 
         # Derived sub-directories — callers should use these, not hardcode paths
         self.instances_dir: str = os.getenv(

+ 9 - 0
tests/conftest.py

@@ -23,3 +23,12 @@ _LAMBDAGENT_SRC = os.path.join(_MONOREPO_ROOT, "lambdagent", "src")
 for _p in (_LAMBDAGENT_SRC, _MONOREPO_ROOT):
     if _p not in sys.path:
         sys.path.insert(0, _p)
+
+# Critical: disable startup-time auto-bootstrap during tests.
+# Without this, every `TestClient(app)` would fire the desktop bootstrap
+# hook, write a fresh API key to the developer's real
+# ~/.agentpaas/config.json, and pollute the system DB with phantom tenants.
+# Tests that want to exercise the bootstrap path do so explicitly via
+# `auto_bootstrap_desktop()` with CONFIG_FILE patched to a tmp path
+# (see test_deployment_mode.py).
+os.environ.setdefault("AGENTPAAS_TESTING", "1")

+ 89 - 0
tests/test_deployment_mode.py

@@ -130,6 +130,47 @@ def test_config_invalid_mode_raises():
         os.environ.pop("AGENTPAAS_DEPLOYMENT_MODE", None)
 
 
+def test_config_desktop_picks_visible_data_dir(tmp_path, monkeypatch):
+    """CR FR-002: desktop users need a discoverable, branded data dir
+    (`~/LambdAgentDesktop`), not a hidden `~/.agentpaas/data`. lab/paas
+    keep the hidden dir to follow OS convention."""
+    monkeypatch.delenv("AGENTPAAS_DATA_DIR", raising=False)
+    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
+    monkeypatch.setenv("HOME", str(tmp_path))
+    from agentpaas.config import AgentPaaSConfig
+    cfg = AgentPaaSConfig()
+    assert cfg.deployment_mode == "desktop"
+    assert cfg.data_dir.endswith("LambdAgentDesktop"), cfg.data_dir
+
+    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "lab")
+    cfg2 = AgentPaaSConfig()
+    assert cfg2.deployment_mode == "lab"
+    assert cfg2.data_dir.endswith(".agentpaas/data"), cfg2.data_dir
+
+
+def test_config_default_host_is_loopback_for_non_paas(monkeypatch, tmp_path):
+    """Q12 + audit critical #1 same posture: desktop / lab default to
+    127.0.0.1 (locked down). paas defaults to 0.0.0.0 (behind reverse
+    proxy in production). AGENTPAAS_HOST overrides in all cases."""
+    monkeypatch.delenv("AGENTPAAS_HOST", raising=False)
+    monkeypatch.setenv("HOME", str(tmp_path))
+    from agentpaas.config import AgentPaaSConfig
+
+    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
+    assert AgentPaaSConfig().host == "127.0.0.1"
+
+    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "lab")
+    assert AgentPaaSConfig().host == "127.0.0.1"
+
+    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "paas")
+    assert AgentPaaSConfig().host == "0.0.0.0"
+
+    # Explicit override wins in any mode (advanced user case).
+    monkeypatch.setenv("AGENTPAAS_HOST", "0.0.0.0")
+    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
+    assert AgentPaaSConfig().host == "0.0.0.0"
+
+
 # ─────────────────────────────────────────────────────────────────────────────
 # Middleware: desktop blocks /admin /billing → 404 (Q9 oracle defense)
 # ─────────────────────────────────────────────────────────────────────────────
@@ -207,6 +248,54 @@ def test_paas_mode_does_not_block_billing(client, db):
     assert r.status_code != 404
 
 
+# ─────────────────────────────────────────────────────────────────────────────
+# /api/v1/setup/mode — Q8 chip endpoint (unauthenticated)
+# ─────────────────────────────────────────────────────────────────────────────
+
+def test_setup_mode_endpoint_no_auth_required(client, db):
+    """Q8: the mode chip renders BEFORE login (so the first-run wizard
+    can adapt). /setup/mode must be reachable without a Bearer."""
+    r = client.get("/api/v1/setup/mode")
+    assert r.status_code == 200, r.text[:200]
+    body = r.json()
+    assert "mode" in body and body["mode"] in ("desktop", "lab", "paas")
+    assert "tenant_alias" in body
+    assert "bootstrapped" in body
+
+
+def test_setup_mode_reflects_current_deployment_mode(client, db, mode):
+    """Same client, different settings.deployment_mode → endpoint reports
+    the current value (no caching)."""
+    settings.deployment_mode = "paas"
+    assert client.get("/api/v1/setup/mode").json()["mode"] == "paas"
+
+    settings.deployment_mode = "lab"
+    assert client.get("/api/v1/setup/mode").json()["mode"] == "lab"
+
+    settings.deployment_mode = "desktop"
+    assert client.get("/api/v1/setup/mode").json()["mode"] == "desktop"
+
+
+def test_setup_mode_reports_bootstrapped_false_on_fresh_install(client, db, tmp_path):
+    """Fresh DB + no config file → bootstrapped=false. The first-run
+    wizard depends on this signal to know whether to render itself.
+
+    We have to patch CONFIG_FILE because the developer's real
+    ~/.agentpaas/config.json may already contain an api_key from a real
+    install; without the patch this test would assert against that
+    file's contents."""
+    from agentpaas.api.v1 import setup as setup_mod
+    original = setup_mod.CONFIG_FILE
+    setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
+    try:
+        r = client.get("/api/v1/setup/mode")
+        body = r.json()
+        assert body["bootstrapped"] is False
+        assert body["tenant_alias"] == ""
+    finally:
+        setup_mod.CONFIG_FILE = original
+
+
 # ─────────────────────────────────────────────────────────────────────────────
 # auto_bootstrap_desktop — Q11 tenant_id naming + idempotency
 # ─────────────────────────────────────────────────────────────────────────────