diff --git a/README.md b/README.md index 1c45903e3..e7afc2f71 100644 --- a/README.md +++ b/README.md @@ -756,6 +756,16 @@ or extend authorization. If an agent hits missing Lark authorization during a conversation, the managed `lark-shared` guidance points the user back to the same settings entry with `?settings=integrations`. +Once configured, **Change Lark app** lets a user point their DeerFlow account at +a different Lark/Feishu app without a reinstall — either by pasting an existing +app's App ID / App Secret or by re-registering an app in the browser. Switching +is per-user (it never touches another user's credentials), validates the new +credentials through the official CLI's live tenant-token probe before replacing +the active app, and revokes/removes the previous app's OAuth tokens. A rejected +credential change does not supersede an in-progress setup or authorization flow. +DeerFlow then immediately opens browser authorization for the newly bound app so +the switch ends in a usable connection. + Installing the Lark skill pack resolves the latest official `larksuite/cli` release from GitHub and downloads that version's skills at install time, so the Gateway needs outbound internet access for that step (it falls back to a diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 2f15f689b..a97d53fd4 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -556,7 +556,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores ` | **Console** (`/api/console`) | Read-only cross-thread observability for the current user (the data layer for an operations dashboard or external monitoring): `GET /stats` - headline counters (runs/threads/agents/tokens/cost); `GET /runs` - paginated run history joined with thread titles (per-run cost); `GET /usage` - zero-filled daily token series + per-model breakdown with spend. Queries `runs`/`threads_meta` directly as a reporting layer (no new `RunStore` methods); requires a SQL database backend — returns 503 on `database.backend: memory`. Real-cost estimation reads optional `models[*].pricing` (`currency`, `input_per_million`, `output_per_million`, `input_cache_hit_per_million`; `ModelConfig` is `extra="allow"`, so no schema change) and prices each run from its `token_usage_by_model` input/output split. Pricing is **cache-aware**: `RunJournal` accumulates prompt-cache hits from `usage_metadata.input_token_details.cache_read` into a sparse `cache_read_tokens` bucket key (also threaded through `SubagentTokenCollector` → `record_external_llm_usage_records`), and cache-hit input tokens are billed at `input_cache_hit_per_million` (omitted → billed at the miss price, a conservative upper bound). All priced models must use one currency; mixed currencies disable cost reporting and leave cost/currency fields null instead of producing invalid aggregates. Legacy rows fall back to run-level totals at `model_name`; unpriced models yield `cost: null` and cost fields are null when no pricing is configured | | **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - replace the full config with whole-payload stdio validation; `PATCH /config` - toggle one server while preserving the raw extensions config and validating only an enabled target; both writes reload config and reset the process-local MCP cache | | **Skills** (`/api/skills`) | `GET /` - list skills; `GET /{name}` - details; `PUT /{name}` - update enabled; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`); `POST /reload` - admin-only process-local prompt-cache invalidation after trusted external filesystem changes | -| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants | +| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_ready` (whether `lark-cli` will actually be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/config/credentials` - atomically switch the caller's per-user Lark app after validating the new `app_id`/`app_secret` through the official CLI's live tenant-token probe, revoke/remove the previous OAuth tokens, and restore the prior credential tree if the switch fails; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants. Config and auth flows carry a server-issued, per-user generation persisted under the credential lock; a rejected direct switch leaves the current generation unchanged, stale completions return 409, and browser re-registration uses the same token-clearing/revocation transaction as direct credential switches. | | **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data | | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail | diff --git a/backend/app/gateway/routers/integrations.py b/backend/app/gateway/routers/integrations.py index 697cd3925..32b8b4053 100644 --- a/backend/app/gateway/routers/integrations.py +++ b/backend/app/gateway/routers/integrations.py @@ -1,5 +1,6 @@ import asyncio import logging +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field @@ -17,12 +18,14 @@ from deerflow.integrations.lark_cli import ( LarkCliProbe, LarkConfigCompleteResult, LarkConfigStartResult, + LarkFlowSupersededError, LarkInstallResult, LarkIntegrationStatus, complete_lark_auth, complete_lark_config, get_lark_integration_status, install_lark_integration, + set_lark_app_credentials, start_lark_auth, start_lark_config, ) @@ -94,6 +97,7 @@ class LarkAuthStartRequest(BaseModel): recommend: bool = Field(default=False, description="Request the official recommended auto-approve scopes") domains: list[str] = Field(default_factory=list, description="Optional Lark auth domains, e.g. calendar or docs") scope: str | None = Field(default=None, description="Optional explicit OAuth scope string") + generation: str | None = Field(default=None, min_length=1, max_length=64, description="Optional current integration flow generation") class LarkConfigStartRequest(BaseModel): @@ -103,6 +107,7 @@ class LarkConfigStartRequest(BaseModel): class LarkConfigStartResponse(BaseModel): verification_url: str = Field(..., description="URL the user should open in a browser to configure the Lark app") device_code: str = Field(..., description="Device code used by config/complete after browser approval") + generation: str = Field(..., description="Server generation bound to this configuration flow") expires_in: int | None = Field(None, description="Seconds before the configuration URL expires") interval: int | None = Field(None, description="Suggested polling interval from Lark") user_code: str | None = Field(None, description="Optional user code shown by Lark") @@ -111,6 +116,7 @@ class LarkConfigStartResponse(BaseModel): class LarkConfigCompleteRequest(BaseModel): device_code: str = Field(..., description="Device code returned by config/start") + generation: str = Field(..., min_length=1, max_length=64, description="Generation returned by config/start") brand: str = Field(default="feishu", description="Brand returned by config/start") interval: int | None = Field(default=None, description="Polling interval returned by config/start") expires_in: int | None = Field(default=None, description="Expiration returned by config/start") @@ -119,12 +125,20 @@ class LarkConfigCompleteRequest(BaseModel): class LarkConfigCompleteResponse(BaseModel): success: bool message: str + generation: str status: LarkIntegrationStatusResponse +class LarkConfigCredentialsRequest(BaseModel): + app_id: str = Field(..., description="Lark/Feishu App ID to switch this user's integration to") + app_secret: str = Field(..., description="Lark/Feishu App Secret paired with app_id") + brand: Literal["feishu", "lark"] = Field(default="feishu", description="Lark brand: feishu or lark") + + class LarkAuthStartResponse(BaseModel): verification_url: str = Field(..., description="URL the user should open in a browser to authorize") device_code: str = Field(..., description="Device code used by the complete endpoint after browser approval") + generation: str = Field(..., description="Server generation bound to this authorization flow") expires_in: int | None = Field(None, description="Seconds before the authorization URL expires") user_code: str | None = Field(None, description="Optional user code shown by Lark") hint: str | None = Field(None, description="Optional guidance returned by lark-cli") @@ -132,6 +146,7 @@ class LarkAuthStartResponse(BaseModel): class LarkAuthCompleteRequest(BaseModel): device_code: str = Field(..., description="Device code returned by auth/start") + generation: str = Field(..., min_length=1, max_length=64, description="Generation returned by auth/start") wait_timeout_seconds: int = Field( default=LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS, ge=LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS, @@ -205,6 +220,7 @@ def _config_start_to_response(result: LarkConfigStartResult) -> LarkConfigStartR return LarkConfigStartResponse( verification_url=result.verification_url, device_code=result.device_code, + generation=result.generation, expires_in=result.expires_in, interval=result.interval, user_code=result.user_code, @@ -216,6 +232,7 @@ def _config_complete_to_response(result: LarkConfigCompleteResult, *, include_ho return LarkConfigCompleteResponse( success=result.success, message=result.message, + generation=result.generation, status=_status_to_response(result.status, include_host_paths=include_host_paths), ) @@ -224,6 +241,7 @@ def _auth_start_to_response(result: LarkAuthStartResult) -> LarkAuthStartRespons return LarkAuthStartResponse( verification_url=result.verification_url, device_code=result.device_code, + generation=result.generation, expires_in=result.expires_in, user_code=result.user_code, hint=result.hint, @@ -294,6 +312,7 @@ async def complete_lark_app_config(request: Request, body: LarkConfigCompleteReq get_effective_user_id(), config, device_code=body.device_code, + generation=body.generation, brand=body.brand, interval=body.interval, expires_in=body.expires_in, @@ -301,13 +320,38 @@ async def complete_lark_app_config(request: Request, body: LarkConfigCompleteReq return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) + except LarkFlowSupersededError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except TimeoutError as e: + raise HTTPException(status_code=504, detail=str(e)) + except Exception as e: + logger.error("Failed to complete Lark connection setup: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to complete Lark connection setup.") + + +@router.post("/lark/config/credentials", response_model=LarkConfigCompleteResponse, summary="Switch Lark/Feishu App Credentials") +async def switch_lark_app_credentials(request: Request, body: LarkConfigCredentialsRequest, config: AppConfig = Depends(get_config)) -> LarkConfigCompleteResponse: + try: + result = await asyncio.to_thread( + set_lark_app_credentials, + get_effective_user_id(), + config, + app_id=body.app_id, + app_secret=body.app_secret, + brand=body.brand, + ) + return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request)) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except TimeoutError as e: raise HTTPException(status_code=504, detail=str(e)) except Exception as e: - logger.error("Failed to complete Lark connection setup: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail="Failed to complete Lark connection setup.") + logger.error("Failed to switch Lark app credentials: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail="Failed to switch Lark app credentials.") @router.post("/lark/auth/start", response_model=LarkAuthStartResponse, summary="Start Lark/Feishu Browser Authorization") @@ -319,10 +363,13 @@ async def start_lark_browser_auth(body: LarkAuthStartRequest) -> LarkAuthStartRe domains=tuple(body.domains), scope=body.scope, recommend=body.recommend, + generation=body.generation, ) return _auth_start_to_response(result) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) + except LarkFlowSupersededError as e: + raise HTTPException(status_code=409, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except TimeoutError as e: @@ -340,11 +387,14 @@ async def complete_lark_browser_auth(request: Request, body: LarkAuthCompleteReq get_effective_user_id(), config, device_code=body.device_code, + generation=body.generation, wait_timeout_seconds=body.wait_timeout_seconds, ) return _auth_complete_to_response(result, include_host_paths=await _is_admin_user(request)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) + except LarkFlowSupersededError as e: + raise HTTPException(status_code=409, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except TimeoutError as e: diff --git a/backend/packages/harness/deerflow/integrations/lark_cli.py b/backend/packages/harness/deerflow/integrations/lark_cli.py index 88b40b4d5..e8b16e773 100644 --- a/backend/packages/harness/deerflow/integrations/lark_cli.py +++ b/backend/packages/harness/deerflow/integrations/lark_cli.py @@ -64,6 +64,8 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path, PurePosixPath from typing import Any +from uuid import uuid4 +from weakref import WeakValueDictionary try: import fcntl @@ -108,6 +110,7 @@ LARK_CLI_SANDBOX_DATA_DIR = "/mnt/integrations/lark-cli/data" LARK_CLI_SANDBOX_RUNTIME_DIR = "/mnt/integrations/lark-cli/runtime" LARK_CLI_LINUX_ARCHES = ("amd64", "arm64") LARK_CLI_RUNTIME_MANIFEST_FILE = ".deerflow-lark-cli-runtime.json" +LARK_CLI_FLOW_STATE_FILE = ".deerflow-lark-cli-flow.json" # Pattern B (issue #4338): loopback URL the sandbox shim uses to reach the broker # sidecar. LARK_BROKER_URL_ENV is imported from the broker module so the shim, @@ -164,6 +167,8 @@ LARK_SKILL_NAMES: tuple[str, ...] = ( LARK_SKILL_NAME_SET = frozenset(LARK_SKILL_NAMES) _LARK_INSTALL_THREAD_LOCK = threading.Lock() _LARK_RUNTIME_INSTALL_THREAD_LOCK = threading.Lock() +_LARK_CREDENTIAL_LOCKS_GUARD = threading.Lock() +_LARK_CREDENTIAL_LOCKS: WeakValueDictionary[str, threading.Lock] = WeakValueDictionary() @dataclass(frozen=True) @@ -216,6 +221,7 @@ class LarkInstallResult: class LarkConfigStartResult: verification_url: str device_code: str + generation: str expires_in: int | None = None interval: int | None = None user_code: str | None = None @@ -227,12 +233,14 @@ class LarkConfigCompleteResult: success: bool status: LarkIntegrationStatus message: str + generation: str @dataclass(frozen=True) class LarkAuthStartResult: verification_url: str device_code: str + generation: str expires_in: int | None = None user_code: str | None = None hint: str | None = None @@ -245,6 +253,10 @@ class LarkAuthCompleteResult: message: str +class LarkFlowSupersededError(ValueError): + """Raised when a delayed integration flow is no longer current.""" + + def lark_integration_root(_user_id: str | None = None) -> Path: """Return the shared root for globally installed managed Lark skills. @@ -280,6 +292,10 @@ def lark_cli_data_dir(user_id: str) -> Path: return get_paths().user_dir(user_id) / "integrations" / INTEGRATION_ID / "data" +def _lark_cli_credential_root(user_id: str) -> Path: + return get_paths().user_dir(user_id) / "integrations" / INTEGRATION_ID + + def ensure_lark_cli_credential_tree(user_id: str, *, paths: Paths | None = None) -> None: """Make the user's secret-bearing Lark CLI tree owner-only. @@ -456,6 +472,53 @@ def _exclusive_install_lock(lock_path: Path, thread_lock): msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) +def _lark_credential_thread_lock(user_id: str) -> threading.Lock: + with _LARK_CREDENTIAL_LOCKS_GUARD: + return _LARK_CREDENTIAL_LOCKS.setdefault(user_id, threading.Lock()) + + +@contextmanager +def _lark_credential_lock(user_id: str): + """Serialize credential replacement for one user across threads/processes.""" + root = _lark_cli_credential_root(user_id) + root.parent.mkdir(parents=True, exist_ok=True) + lock_path = root.parent / f".{INTEGRATION_ID}.credentials.lock" + with _exclusive_install_lock(lock_path, _lark_credential_thread_lock(user_id)): + yield + + +def _lark_flow_state_path(user_id: str) -> Path: + return _lark_cli_credential_root(user_id) / LARK_CLI_FLOW_STATE_FILE + + +def _write_lark_flow_generation_locked(user_id: str, generation: str) -> None: + ensure_lark_cli_credential_tree(user_id) + path = _lark_flow_state_path(user_id) + fd, temp_name = tempfile.mkstemp(prefix=f".{LARK_CLI_FLOW_STATE_FILE}.", dir=str(path.parent)) + os.close(fd) + temp_path = Path(temp_name) + try: + temp_path.write_text(json.dumps({"generation": generation}) + "\n", encoding="utf-8") + temp_path.chmod(0o600) + os.replace(temp_path, path) + finally: + temp_path.unlink(missing_ok=True) + + +def _advance_lark_flow_generation_locked(user_id: str) -> str: + generation = uuid4().hex + _write_lark_flow_generation_locked(user_id, generation) + return generation + + +def _require_lark_flow_generation_locked(user_id: str, generation: str) -> str: + expected = generation.strip() + state = _read_json_object_file(_lark_flow_state_path(user_id)) + if not expected or state is None or state.get("generation") != expected: + raise LarkFlowSupersededError("This Lark integration flow was superseded by a newer action.") + return expected + + def _ensure_managed_sandbox_lark_cli(version: str) -> Path: """Install verified official Linux binaries for AIO sandbox execution.""" tag = _normalize_lark_cli_version_tag(version) @@ -974,6 +1037,8 @@ def _probe_provisioner_capabilities(config: AppConfig, *, timeout: float = 5.0) def start_lark_config(user_id: str, *, brand: str = "feishu") -> LarkConfigStartResult: """Start the browser flow that creates/binds a Lark OAuth app for this user.""" parsed_brand = _normalize_lark_brand(brand) + with _lark_credential_lock(user_id): + generation = _advance_lark_flow_generation_locked(user_id) begin_data = _request_lark_app_registration_begin(parsed_brand) user_code = str(begin_data.get("user_code") or "").strip() device_code = str(begin_data.get("device_code") or "").strip() @@ -983,6 +1048,7 @@ def start_lark_config(user_id: str, *, brand: str = "feishu") -> LarkConfigStart return LarkConfigStartResult( verification_url=verification_url, device_code=device_code, + generation=generation, expires_in=_int_or_none(begin_data.get("expires_in")), interval=_int_or_none(begin_data.get("interval")), user_code=user_code, @@ -995,6 +1061,7 @@ def complete_lark_config( config: AppConfig, *, device_code: str, + generation: str, brand: str = "feishu", interval: int | None = None, expires_in: int | None = None, @@ -1004,6 +1071,8 @@ def complete_lark_config( if not device_code: raise ValueError("device_code is required.") parsed_brand = _normalize_lark_brand(brand) + with _lark_credential_lock(user_id): + generation = _require_lark_flow_generation_locked(user_id, generation) result = _poll_lark_app_registration( device_code=device_code, brand=parsed_brand, @@ -1028,12 +1097,57 @@ def complete_lark_config( if not app_id or not app_secret: raise ValueError("Lark app registration succeeded but did not return app credentials.") - _save_lark_app_config_with_cli(user_id, app_id=app_id, app_secret=app_secret, brand=final_brand) - status = get_lark_integration_status(user_id, config) + with _lark_credential_lock(user_id): + generation = _require_lark_flow_generation_locked(user_id, generation) + _replace_lark_app_credentials_locked( + user_id, + app_id=app_id, + app_secret=app_secret, + brand=final_brand, + ) + status = get_lark_integration_status(user_id, config) return LarkConfigCompleteResult( success=True, status=status, message="Lark/Feishu connection setup completed.", + generation=generation, + ) + + +def set_lark_app_credentials( + user_id: str, + config: AppConfig, + *, + app_id: str, + app_secret: str, + brand: str = "feishu", +) -> LarkConfigCompleteResult: + """Atomically switch this user's app and revoke the previous OAuth token.""" + app_id = app_id.strip() + app_secret = app_secret.strip() + if not app_id: + raise ValueError("app_id is required.") + if not app_secret: + raise ValueError("app_secret is required.") + parsed_brand = brand.strip().lower() + if parsed_brand not in {"feishu", "lark"}: + raise ValueError("brand must be feishu or lark.") + + with _lark_credential_lock(user_id): + _validate_lark_app_credentials_with_cli(app_id=app_id, app_secret=app_secret, brand=parsed_brand) + generation = _advance_lark_flow_generation_locked(user_id) + _replace_lark_app_credentials_locked( + user_id, + app_id=app_id, + app_secret=app_secret, + brand=parsed_brand, + ) + status = get_lark_integration_status(user_id, config) + return LarkConfigCompleteResult( + success=True, + status=status, + message="Lark/Feishu app switched. Reconnect to authorize the new app.", + generation=generation, ) @@ -1043,6 +1157,7 @@ def start_lark_auth( domains: tuple[str, ...] = (), scope: str | None = None, recommend: bool = False, + generation: str | None = None, ) -> LarkAuthStartResult: """Start a non-blocking Lark device authorization flow. @@ -1060,15 +1175,21 @@ def start_lark_auth( if domain: args.extend(["--domain", domain]) - data = _run_lark_cli_json(args, user_id=user_id, timeout=20) - verification_url = str(data.get("verification_url") or data.get("verification_uri_complete") or "").strip() - device_code = str(data.get("device_code") or "").strip() - if not verification_url or not device_code: - raise ValueError("lark-cli did not return a verification_url and device_code.") + with _lark_credential_lock(user_id): + if generation is None: + generation = _advance_lark_flow_generation_locked(user_id) + else: + generation = _require_lark_flow_generation_locked(user_id, generation) + data = _run_lark_cli_json(args, user_id=user_id, timeout=20) + verification_url = str(data.get("verification_url") or data.get("verification_uri_complete") or "").strip() + device_code = str(data.get("device_code") or "").strip() + if not verification_url or not device_code: + raise ValueError("lark-cli did not return a verification_url and device_code.") return LarkAuthStartResult( verification_url=verification_url, device_code=device_code, + generation=generation, expires_in=_int_or_none(data.get("expires_in")), user_code=str(data.get("user_code") or "") or None, hint=str(data.get("hint") or "") or None, @@ -1080,6 +1201,7 @@ def complete_lark_auth( config: AppConfig, *, device_code: str, + generation: str, wait_timeout_seconds: int = LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS, ) -> LarkAuthCompleteResult: """Complete a Lark device authorization flow after the user approves it.""" @@ -1089,14 +1211,16 @@ def complete_lark_auth( if not LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS <= wait_timeout_seconds <= LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS: raise ValueError(f"wait_timeout_seconds must be between {LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS} and {LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS}.") - path = _require_lark_cli_path() - _run_lark_cli_json( - [path, "auth", "login", "--device-code", device_code, "--json"], - user_id=user_id, - timeout=wait_timeout_seconds, - allow_empty_success=True, - ) - status = get_lark_integration_status(user_id, config, verify_auth=True) + with _lark_credential_lock(user_id): + _require_lark_flow_generation_locked(user_id, generation) + path = _require_lark_cli_path() + _run_lark_cli_json( + [path, "auth", "login", "--device-code", device_code, "--json"], + user_id=user_id, + timeout=wait_timeout_seconds, + allow_empty_success=True, + ) + status = get_lark_integration_status(user_id, config, verify_auth=True) return LarkAuthCompleteResult( success=status.auth.status == "authenticated", status=status, @@ -1292,23 +1416,34 @@ def _tenant_brand(result: dict[str, Any]) -> str | None: return brand if brand in {"feishu", "lark"} else None -def _save_lark_app_config_with_cli(user_id: str, *, app_id: str, app_secret: str, brand: str) -> None: +def _lark_cli_env_for_directories(*, config_dir: Path, data_dir: Path) -> dict[str, str]: + env = { + **os.environ, + "LARKSUITE_CLI_CONFIG_DIR": str(config_dir), + "LARKSUITE_CLI_DATA_DIR": str(data_dir), + "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", + "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1", + } + managed_bin = _lark_cli_managed_bin_dir() + if _lark_cli_managed_path() is not None: + env["PATH"] = f"{managed_bin}{os.pathsep}{os.environ.get('PATH', '')}" + return env + + +def _run_lark_config_init(*, app_id: str, app_secret: str, brand: str, env: dict[str, str]) -> None: path = _require_lark_cli_path() try: - try: - result = subprocess.run( - [path, "config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", _normalize_lark_brand(brand)], - input=app_secret + "\n", - check=False, - capture_output=True, - text=True, - timeout=15, - env=lark_cli_env(user_id), - ) - except subprocess.TimeoutExpired as exc: - raise TimeoutError("Timed out while saving Lark connection setup.") from exc - finally: - ensure_lark_cli_credential_tree(user_id) + result = subprocess.run( + [path, "config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", brand], + input=app_secret + "\n", + check=False, + capture_output=True, + text=True, + timeout=15, + env=env, + ) + except subprocess.TimeoutExpired as exc: + raise TimeoutError("Timed out while saving Lark connection setup.") from exc if result.returncode != 0: raw = (result.stderr or result.stdout or "").strip() parsed = _parse_json_object(raw) @@ -1316,6 +1451,98 @@ def _save_lark_app_config_with_cli(user_id: str, *, app_id: str, app_secret: str raise ValueError(message or f"lark-cli config init exited with code {result.returncode}") +def _save_lark_app_config_with_cli(user_id: str, *, app_id: str, app_secret: str, brand: str) -> None: + try: + _run_lark_config_init( + app_id=app_id, + app_secret=app_secret, + brand=brand, + env=lark_cli_env(user_id), + ) + finally: + ensure_lark_cli_credential_tree(user_id) + + +def _validate_lark_app_credentials_with_cli(*, app_id: str, app_secret: str, brand: str) -> None: + """Validate credentials through config init's live tenant-token probe.""" + with tempfile.TemporaryDirectory(prefix=".validating-lark-app-") as temp_dir: + root = Path(temp_dir) + config_dir = root / "config" + data_dir = root / "data" + config_dir.mkdir(mode=0o700) + data_dir.mkdir(mode=0o700) + _run_lark_config_init( + app_id=app_id, + app_secret=app_secret, + brand=brand, + env=_lark_cli_env_for_directories(config_dir=config_dir, data_dir=data_dir), + ) + + +def _replace_lark_app_credentials_locked(user_id: str, *, app_id: str, app_secret: str, brand: str) -> None: + ensure_lark_cli_credential_tree(user_id) + root = _lark_cli_credential_root(user_id) + with _lark_credential_transaction(user_id, root) as snapshot: + _save_lark_app_config_with_cli(user_id, app_id=app_id, app_secret=app_secret, brand=brand) + _clear_directory_contents(lark_cli_data_dir(user_id)) + _revoke_lark_auth_from_snapshot(snapshot) + + +def _clear_directory_contents(directory: Path) -> None: + if directory.is_symlink(): + raise ValueError(f"Lark CLI credential path must not be a symlink: {directory}") + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + for child in directory.iterdir(): + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + else: + child.unlink() + + +@contextmanager +def _lark_credential_transaction(user_id: str, root: Path): + """Restore the active credential tree if a switch step fails.""" + with tempfile.TemporaryDirectory(prefix=".switching-lark-app-", dir=str(root.parent)) as temp_dir: + snapshot = Path(temp_dir) / "credentials" + shutil.copytree(root, snapshot, symlinks=False) + try: + yield snapshot + except Exception: + _restore_lark_credential_tree(root, snapshot) + ensure_lark_cli_credential_tree(user_id) + raise + + +def _restore_lark_credential_tree(root: Path, snapshot: Path) -> None: + for name in ("config", "data"): + target = root / name + _clear_directory_contents(target) + shutil.copytree(snapshot / name, target, dirs_exist_ok=True, symlinks=False) + + +def _revoke_lark_auth_from_snapshot(snapshot: Path) -> None: + data_dir = snapshot / "data" + if not any(path.is_file() for path in data_dir.rglob("*")): + return + path = _require_lark_cli_path() + try: + result = subprocess.run( + [path, "auth", "logout", "--json"], + check=False, + capture_output=True, + text=True, + timeout=15, + env=_lark_cli_env_for_directories(config_dir=snapshot / "config", data_dir=data_dir), + ) + except subprocess.TimeoutExpired as exc: + raise TimeoutError("Timed out while revoking the previous Lark authorization.") from exc + if result.returncode != 0: + raw = (result.stderr or result.stdout or "").strip() + parsed = _parse_json_object(raw) + message = _auth_error_message(parsed) if parsed else raw + raise ValueError(message or f"lark-cli auth logout exited with code {result.returncode}") + + def _run_lark_cli_json( args: list[str], *, diff --git a/backend/tests/blocking_io/test_integrations_router.py b/backend/tests/blocking_io/test_integrations_router.py index 7f8fa3482..6cec60321 100644 --- a/backend/tests/blocking_io/test_integrations_router.py +++ b/backend/tests/blocking_io/test_integrations_router.py @@ -68,6 +68,11 @@ def _reset_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(paths_module, "_paths", None) +def _advance_lark_flow(user_id: str) -> str: + with lark_cli._lark_credential_lock(user_id): + return lark_cli._advance_lark_flow_generation_locked(user_id) + + async def _config(tmp_path: Path) -> SimpleNamespace: skills_root = tmp_path / "skills" await asyncio.to_thread((skills_root / "public").mkdir, parents=True, exist_ok=True) @@ -119,10 +124,11 @@ async def test_lark_auth_complete_route_does_not_block_event_loop(tmp_path: Path monkeypatch.setenv("PATH", f"{cli_path.parent}{os.pathsep}{os.environ.get('PATH', '')}") monkeypatch.setattr(integrations, "get_effective_user_id", lambda: "loop-user") + generation = await asyncio.to_thread(_advance_lark_flow, "loop-user") response = await integrations.complete_lark_browser_auth( request=None, - body=integrations.LarkAuthCompleteRequest(device_code="device-code"), + body=integrations.LarkAuthCompleteRequest(device_code="device-code", generation=generation), config=config, ) diff --git a/backend/tests/test_lark_cli_integration.py b/backend/tests/test_lark_cli_integration.py index 8f0bd4419..e1bef5f74 100644 --- a/backend/tests/test_lark_cli_integration.py +++ b/backend/tests/test_lark_cli_integration.py @@ -79,6 +79,11 @@ def _patch_paths(monkeypatch, base_dir: Path) -> None: monkeypatch.setattr(paths_module, "_paths", Paths(base_dir=base_dir)) +def _advance_lark_flow(user_id: str = "alice") -> str: + with lark_cli._lark_credential_lock(user_id): + return lark_cli._advance_lark_flow_generation_locked(user_id) + + def test_sandbox_lark_cli_env_prepends_managed_linux_runtime() -> None: overlay = lark_cli.lark_cli_env_overlay("alice", sandbox_paths=True) @@ -968,6 +973,8 @@ def test_start_lark_auth_returns_browser_url(monkeypatch, tmp_path): assert result.verification_url == "https://open.feishu.cn/auth/mock" assert result.device_code == "device-code" + assert result.generation + assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": result.generation} assert captured["args"] == [ "/usr/bin/lark-cli", "auth", @@ -1018,6 +1025,25 @@ def test_start_lark_auth_uses_minimal_login_by_default(monkeypatch, tmp_path): ] +def test_start_lark_auth_reuses_parent_flow_generation(monkeypatch, tmp_path): + _patch_paths(monkeypatch, tmp_path / "home") + generation = _advance_lark_flow() + monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "/usr/bin/lark-cli") + monkeypatch.setattr( + lark_cli, + "_run_lark_cli_json", + lambda *_args, **_kwargs: { + "verification_url": "https://open.feishu.cn/auth/mock", + "device_code": "device-code", + }, + ) + + result = lark_cli.start_lark_auth("alice", generation=generation) + + assert result.generation == generation + assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": generation} + + def test_lark_cli_env_from_runtime_exposes_settings_auth_to_lark_commands(monkeypatch, tmp_path): _patch_paths(monkeypatch, tmp_path / "home") runtime = SimpleNamespace(context={"user_id": "alice"}) @@ -1085,6 +1111,29 @@ def test_save_lark_app_config_rehardens_files_written_by_cli(monkeypatch, tmp_pa assert stat.S_IMODE(config_file.stat().st_mode) == 0o600 +def test_validate_lark_app_credentials_surfaces_cli_probe_rejection(monkeypatch, tmp_path) -> None: + _patch_paths(monkeypatch, tmp_path / "home") + monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "/usr/bin/lark-cli") + + def _run(args, **kwargs): + assert kwargs["env"]["LARKSUITE_CLI_CONFIG_DIR"] != str(lark_cli.lark_cli_config_dir("alice")) + return subprocess.CompletedProcess( + args=args, + returncode=3, + stdout='{"ok":false,"error":{"type":"config","subtype":"invalid_client","message":"The specified app does not exist."}}', + stderr="", + ) + + monkeypatch.setattr(lark_cli.subprocess, "run", _run) + + with pytest.raises(ValueError, match="specified app does not exist"): + lark_cli._validate_lark_app_credentials_with_cli( + app_id="cli_invalid", + app_secret="invalid-secret", + brand="feishu", + ) + + def test_lark_cli_json_rehardens_auth_files_written_by_cli(monkeypatch, tmp_path) -> None: _patch_paths(monkeypatch, tmp_path / "home") @@ -1204,7 +1253,8 @@ def test_complete_lark_auth_polls_device_code_and_returns_status(monkeypatch, tm ), ) - result = lark_cli.complete_lark_auth("alice", config, device_code="device-code") + generation = _advance_lark_flow() + result = lark_cli.complete_lark_auth("alice", config, device_code="device-code", generation=generation) assert result.success is True assert captured["args"] == [ @@ -1256,10 +1306,12 @@ def test_complete_lark_auth_accepts_short_automatic_poll_timeout(monkeypatch, tm ), ) + generation = _advance_lark_flow() result = lark_cli.complete_lark_auth( "alice", config, device_code="device-code", + generation=generation, wait_timeout_seconds=8, ) @@ -1267,14 +1319,36 @@ def test_complete_lark_auth_accepts_short_automatic_poll_timeout(monkeypatch, tm assert captured["timeout"] == 8 +def test_complete_lark_auth_rejects_superseded_generation_before_token_write(monkeypatch, tmp_path) -> None: + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + stale_generation = _advance_lark_flow() + current_generation = _advance_lark_flow() + monkeypatch.setattr( + lark_cli, + "_run_lark_cli_json", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("stale auth must not write tokens")), + ) + + with pytest.raises(lark_cli.LarkFlowSupersededError, match="superseded"): + lark_cli.complete_lark_auth( + "alice", + config, + device_code="stale-device-code", + generation=stale_generation, + ) + + assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": current_generation} + + def test_auth_complete_request_bounds_poll_timeout() -> None: - model = integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=8) + model = integrations_router.LarkAuthCompleteRequest(device_code="device-code", generation="flow-generation", wait_timeout_seconds=8) assert "wait_timeout_seconds" in type(model).model_fields assert model.wait_timeout_seconds == 8 with pytest.raises(ValueError): - integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=4) + integrations_router.LarkAuthCompleteRequest(device_code="device-code", generation="flow-generation", wait_timeout_seconds=4) with pytest.raises(ValueError): - integrations_router.LarkAuthCompleteRequest(device_code="device-code", wait_timeout_seconds=46) + integrations_router.LarkAuthCompleteRequest(device_code="device-code", generation="flow-generation", wait_timeout_seconds=46) def test_start_lark_config_returns_app_registration_url(monkeypatch, tmp_path): @@ -1293,6 +1367,8 @@ def test_start_lark_config_returns_app_registration_url(monkeypatch, tmp_path): result = lark_cli.start_lark_config("alice", brand="feishu") assert result.device_code == "config-device-code" + assert result.generation + assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": result.generation} assert result.user_code == "abc" assert result.verification_url.startswith("https://open.feishu.cn/page/cli?") assert "user_code=abc" in result.verification_url @@ -1305,6 +1381,15 @@ def test_complete_lark_config_saves_app_credentials_and_returns_status(monkeypat (skills_root / "custom").mkdir() config = _config(skills_root) captured: dict[str, object] = {} + revoked: list[str] = [] + config_dir = lark_cli.lark_cli_config_dir("alice") + data_dir = lark_cli.lark_cli_data_dir("alice") + config_dir.mkdir(parents=True) + data_dir.mkdir(parents=True) + (config_dir / "config.json").write_text("old-config", encoding="utf-8") + token_file = data_dir / "token.json" + token_file.write_text("old-token", encoding="utf-8") + generation = _advance_lark_flow() monkeypatch.setattr( lark_cli, @@ -1320,6 +1405,11 @@ def test_complete_lark_config_saves_app_credentials_and_returns_status(monkeypat "_save_lark_app_config_with_cli", lambda user_id, **kwargs: captured.update({"user_id": user_id, **kwargs}), ) + monkeypatch.setattr( + lark_cli, + "_revoke_lark_auth_from_snapshot", + lambda snapshot: revoked.append((snapshot / "data" / "token.json").read_text(encoding="utf-8")), + ) monkeypatch.setattr( lark_cli, "get_lark_integration_status", @@ -1342,9 +1432,18 @@ def test_complete_lark_config_saves_app_credentials_and_returns_status(monkeypat ), ) - result = lark_cli.complete_lark_config("alice", config, device_code="config-device-code", brand="feishu") + result = lark_cli.complete_lark_config( + "alice", + config, + device_code="config-device-code", + generation=generation, + brand="feishu", + ) assert result.success is True + assert result.generation == generation + assert revoked == ["old-token"] + assert not token_file.exists() assert captured == { "user_id": "alice", "app_id": "cli_mock", @@ -1361,6 +1460,7 @@ def test_complete_lark_config_repolls_lark_tenant_for_client_secret(monkeypatch, config = _config(skills_root) poll_calls: list[dict[str, object]] = [] captured: dict[str, object] = {} + generation = _advance_lark_flow() def _poll_lark_app_registration(**kwargs): poll_calls.append(kwargs) @@ -1403,7 +1503,13 @@ def test_complete_lark_config_repolls_lark_tenant_for_client_secret(monkeypatch, ), ) - result = lark_cli.complete_lark_config("alice", config, device_code="config-device-code", brand="feishu") + result = lark_cli.complete_lark_config( + "alice", + config, + device_code="config-device-code", + generation=generation, + brand="feishu", + ) assert result.success is True assert [call["brand"] for call in poll_calls] == ["feishu", "lark"] @@ -1415,6 +1521,66 @@ def test_complete_lark_config_repolls_lark_tenant_for_client_secret(monkeypatch, } +def test_complete_lark_config_rejects_registration_superseded_by_direct_switch(monkeypatch, tmp_path): + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + stale_generation = _advance_lark_flow() + poll_started = threading.Event() + release_poll = threading.Event() + saved_apps: list[str] = [] + monkeypatch.setattr(lark_cli, "_validate_lark_app_credentials_with_cli", lambda **_kwargs: None) + monkeypatch.setattr( + lark_cli, + "_save_lark_app_config_with_cli", + lambda _user_id, *, app_id, **_kwargs: saved_apps.append(app_id), + ) + monkeypatch.setattr(lark_cli, "_revoke_lark_auth_from_snapshot", lambda _snapshot: None) + monkeypatch.setattr( + lark_cli, + "get_lark_integration_status", + lambda _user_id, _config, **_kwargs: _status_stub( + app_configured=True, + app_id="cli_direct", + auth_status="not_authorized", + ), + ) + + def _poll(**_kwargs): + poll_started.set() + assert release_poll.wait(timeout=3) + return { + "client_id": "cli_stale", + "client_secret": "stale-secret", + "user_info": {"tenant_brand": "feishu"}, + } + + monkeypatch.setattr(lark_cli, "_poll_lark_app_registration", _poll) + with ThreadPoolExecutor(max_workers=1) as executor: + completion = executor.submit( + lark_cli.complete_lark_config, + "alice", + config, + device_code="stale-device-code", + generation=stale_generation, + ) + assert poll_started.wait(timeout=3) + try: + switched = lark_cli.set_lark_app_credentials( + "alice", + config, + app_id="cli_direct", + app_secret="direct-secret", + ) + finally: + release_poll.set() + with pytest.raises(lark_cli.LarkFlowSupersededError, match="superseded"): + completion.result(timeout=3) + + assert switched.generation != stale_generation + assert saved_apps == ["cli_direct"] + assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": switched.generation} + + def _make_user(system_role: str) -> User: return User(email=f"{system_role}-integration@example.com", password_hash="x", system_role=system_role, id=uuid4()) @@ -1531,6 +1697,7 @@ def test_lark_config_start_route_returns_browser_url(monkeypatch, tmp_path): lambda _user_id, **_kwargs: lark_cli.LarkConfigStartResult( verification_url="https://open.feishu.cn/page/cli?user_code=config", device_code="config-device-code", + generation="config-generation", expires_in=600, interval=5, user_code="config", @@ -1544,6 +1711,7 @@ def test_lark_config_start_route_returns_browser_url(monkeypatch, tmp_path): assert response.status_code == 200 assert response.json()["verification_url"] == "https://open.feishu.cn/page/cli?user_code=config" assert response.json()["device_code"] == "config-device-code" + assert response.json()["generation"] == "config-generation" def test_lark_config_complete_route_saves_app_credentials(monkeypatch, tmp_path): @@ -1556,6 +1724,7 @@ def test_lark_config_complete_route_saves_app_credentials(monkeypatch, tmp_path) lambda _user_id, _config, *, device_code, **_kwargs: lark_cli.LarkConfigCompleteResult( success=True, message=f"configured {device_code}", + generation="config-generation", status=lark_cli.LarkIntegrationStatus( installed=True, version="v1.0.65", @@ -1579,14 +1748,44 @@ def test_lark_config_complete_route_saves_app_credentials(monkeypatch, tmp_path) with TestClient(app) as client: response = client.post( "/api/integrations/lark/config/complete", - json={"device_code": "config-device-code", "brand": "feishu", "interval": 5, "expires_in": 600}, + json={ + "device_code": "config-device-code", + "generation": "config-generation", + "brand": "feishu", + "interval": 5, + "expires_in": 600, + }, ) assert response.status_code == 200 assert response.json()["success"] is True + assert response.json()["generation"] == "config-generation" assert response.json()["status"]["app_configured"] is True +def test_lark_config_complete_route_rejects_superseded_flow(monkeypatch, tmp_path): + config = _config(tmp_path / "skills") + app = _make_app(system_role="user", config=config) + monkeypatch.setattr( + integrations_router, + "complete_lark_config", + lambda *_args, **_kwargs: (_ for _ in ()).throw(lark_cli.LarkFlowSupersededError("This Lark integration flow was superseded by a newer action.")), + ) + + with TestClient(app) as client: + response = client.post( + "/api/integrations/lark/config/complete", + json={ + "device_code": "stale-device-code", + "generation": "stale-generation", + "brand": "feishu", + }, + ) + + assert response.status_code == 409 + assert "superseded" in response.json()["detail"] + + def test_lark_auth_start_route_returns_browser_url(monkeypatch, tmp_path): config = _config(tmp_path / "skills") app = _make_app(system_role="user", config=config) @@ -1600,6 +1799,7 @@ def test_lark_auth_start_route_returns_browser_url(monkeypatch, tmp_path): or lark_cli.LarkAuthStartResult( verification_url="https://open.feishu.cn/auth/mock", device_code="device-code", + generation="auth-generation", expires_in=600, ) ), @@ -1611,7 +1811,8 @@ def test_lark_auth_start_route_returns_browser_url(monkeypatch, tmp_path): assert response.status_code == 200 assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock" assert response.json()["device_code"] == "device-code" - assert captured_kwargs == {"domains": (), "scope": None, "recommend": False} + assert response.json()["generation"] == "auth-generation" + assert captured_kwargs == {"domains": (), "scope": None, "recommend": False, "generation": None} def test_lark_auth_start_route_passes_explicit_recommend(monkeypatch, tmp_path): @@ -1627,6 +1828,7 @@ def test_lark_auth_start_route_passes_explicit_recommend(monkeypatch, tmp_path): or lark_cli.LarkAuthStartResult( verification_url="https://open.feishu.cn/auth/mock", device_code="device-code", + generation="auth-generation", expires_in=600, ) ), @@ -1638,7 +1840,7 @@ def test_lark_auth_start_route_passes_explicit_recommend(monkeypatch, tmp_path): assert response.status_code == 200 assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock" assert response.json()["device_code"] == "device-code" - assert captured_kwargs == {"domains": (), "scope": None, "recommend": True} + assert captured_kwargs == {"domains": (), "scope": None, "recommend": True, "generation": None} def test_lark_auth_complete_route_polls_device_code(monkeypatch, tmp_path): @@ -1673,10 +1875,282 @@ def test_lark_auth_complete_route_polls_device_code(monkeypatch, tmp_path): monkeypatch.setattr(integrations_router, "complete_lark_auth", _complete_auth) with TestClient(app) as client: - response = client.post("/api/integrations/lark/auth/complete", json={"device_code": "device-code"}) + response = client.post( + "/api/integrations/lark/auth/complete", + json={"device_code": "device-code", "generation": "auth-generation"}, + ) assert response.status_code == 200 assert response.json()["success"] is True assert response.json()["status"]["auth"]["status"] == "authenticated" assert response.json()["status"]["auth"]["verified"] is True - assert captured_kwargs == {"device_code": "device-code", "wait_timeout_seconds": 45} + assert captured_kwargs == { + "device_code": "device-code", + "generation": "auth-generation", + "wait_timeout_seconds": 45, + } + + +def _status_stub(*, app_configured: bool, app_id: str | None, auth_status: str) -> lark_cli.LarkIntegrationStatus: + return lark_cli.LarkIntegrationStatus( + installed=True, + version="v1.0.65", + manifest_version="v1.0.65", + latest_available_version=None, + runtime_version_mismatch=False, + app_configured=app_configured, + app_id=app_id, + app_brand="feishu", + skills_expected=27, + skills_installed=27, + installed_skills=("lark-doc",), + enabled_skills=("lark-doc",), + install_path="/tmp/lark", + cli=lark_cli.LarkCliProbe(available=True), + auth=lark_cli.LarkAuthProbe(status=auth_status, user=None), + ) + + +def test_set_lark_app_credentials_validates_switches_and_revokes_prior_auth(monkeypatch, tmp_path): + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + calls: list[tuple[str, object]] = [] + pending_generation = _advance_lark_flow() + + config_dir = lark_cli.lark_cli_config_dir("alice") + data_dir = lark_cli.lark_cli_data_dir("alice") + config_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "config.json").write_text('{"apps":[{"appId":"cli_old","appSecret":"old-secret"}]}', encoding="utf-8") + token_file = data_dir / "token.json" + token_file.write_text('{"access_token": "old-app-token"}', encoding="utf-8") + + monkeypatch.setattr( + lark_cli, + "_validate_lark_app_credentials_with_cli", + lambda **kwargs: calls.append( + ( + "validate", + { + **kwargs, + "generation": json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8"))["generation"], + }, + ) + ), + ) + + def _save(user_id, **kwargs): + calls.append(("save", {"user_id": user_id, **kwargs})) + (config_dir / "config.json").write_text('{"apps":[{"appId":"cli_new","appSecret":"new-secret"}]}', encoding="utf-8") + + monkeypatch.setattr( + lark_cli, + "_save_lark_app_config_with_cli", + _save, + ) + monkeypatch.setattr( + lark_cli, + "_revoke_lark_auth_from_snapshot", + lambda snapshot: calls.append(("revoke", (snapshot / "data" / "token.json").read_text(encoding="utf-8"))), + ) + monkeypatch.setattr( + lark_cli, + "get_lark_integration_status", + lambda _user_id, _config, **_kwargs: _status_stub(app_configured=True, app_id="cli_new", auth_status="not_authorized"), + ) + + result = lark_cli.set_lark_app_credentials("alice", config, app_id=" cli_new ", app_secret=" new-secret ", brand="lark") + + assert result.success is True + assert calls == [ + ("validate", {"app_id": "cli_new", "app_secret": "new-secret", "brand": "lark", "generation": pending_generation}), + ("save", {"user_id": "alice", "app_id": "cli_new", "app_secret": "new-secret", "brand": "lark"}), + ("revoke", '{"access_token": "old-app-token"}'), + ] + assert result.generation != pending_generation + assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": result.generation} + assert not token_file.exists() + + +def test_set_lark_app_credentials_validation_failure_preserves_active_tree(monkeypatch, tmp_path): + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + config_dir = lark_cli.lark_cli_config_dir("alice") + data_dir = lark_cli.lark_cli_data_dir("alice") + config_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "config.json" + token_file = data_dir / "token.json" + config_file.write_text("old-config", encoding="utf-8") + token_file.write_text("old-token", encoding="utf-8") + pending_generation = _advance_lark_flow() + + monkeypatch.setattr( + lark_cli, + "_validate_lark_app_credentials_with_cli", + lambda **_kwargs: (_ for _ in ()).throw(ValueError("invalid credentials")), + ) + monkeypatch.setattr( + lark_cli, + "_save_lark_app_config_with_cli", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("active config must not be touched")), + ) + + with pytest.raises(ValueError, match="invalid credentials"): + lark_cli.set_lark_app_credentials("alice", config, app_id="cli_new", app_secret="bad-secret") + + assert config_file.read_text(encoding="utf-8") == "old-config" + assert token_file.read_text(encoding="utf-8") == "old-token" + assert json.loads(lark_cli._lark_flow_state_path("alice").read_text(encoding="utf-8")) == {"generation": pending_generation} + + +@pytest.mark.parametrize("failure_step", ["save", "revoke"]) +def test_set_lark_app_credentials_failure_restores_active_tree(monkeypatch, tmp_path, failure_step): + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + config_dir = lark_cli.lark_cli_config_dir("alice") + data_dir = lark_cli.lark_cli_data_dir("alice") + config_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "config.json" + token_file = data_dir / "token.json" + config_file.write_text("old-config", encoding="utf-8") + token_file.write_text("old-token", encoding="utf-8") + + monkeypatch.setattr(lark_cli, "_validate_lark_app_credentials_with_cli", lambda **_kwargs: None) + + def _save(*_args, **_kwargs): + config_file.write_text("new-config", encoding="utf-8") + if failure_step == "save": + raise ValueError("save failed") + + monkeypatch.setattr(lark_cli, "_save_lark_app_config_with_cli", _save) + monkeypatch.setattr( + lark_cli, + "_revoke_lark_auth_from_snapshot", + lambda _snapshot: (_ for _ in ()).throw(ValueError("revoke failed")) if failure_step == "revoke" else None, + ) + + with pytest.raises(ValueError, match=f"{failure_step} failed"): + lark_cli.set_lark_app_credentials("alice", config, app_id="cli_new", app_secret="new-secret") + + assert config_file.read_text(encoding="utf-8") == "old-config" + assert token_file.read_text(encoding="utf-8") == "old-token" + + +@pytest.mark.parametrize( + ("app_id", "app_secret"), + [("", "secret"), ("cli_new", "")], +) +def test_set_lark_app_credentials_rejects_missing_fields(monkeypatch, tmp_path, app_id, app_secret): + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + + def _must_not_run(*_args, **_kwargs): + raise AssertionError("credentials must be validated before touching the CLI") + + monkeypatch.setattr(lark_cli, "_save_lark_app_config_with_cli", _must_not_run) + + with pytest.raises(ValueError): + lark_cli.set_lark_app_credentials("alice", config, app_id=app_id, app_secret=app_secret) + + +def test_set_lark_app_credentials_rejects_invalid_brand(monkeypatch, tmp_path): + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + monkeypatch.setattr( + lark_cli, + "_validate_lark_app_credentials_with_cli", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("invalid brand must fail first")), + ) + + with pytest.raises(ValueError, match="brand must be feishu or lark"): + lark_cli.set_lark_app_credentials("alice", config, app_id="cli_new", app_secret="new-secret", brand="larks") + + +def test_set_lark_app_credentials_serializes_same_user(monkeypatch, tmp_path): + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + active = 0 + max_active = 0 + state_lock = threading.Lock() + + def _validate(**_kwargs): + nonlocal active, max_active + with state_lock: + active += 1 + max_active = max(max_active, active) + time.sleep(0.05) + with state_lock: + active -= 1 + + monkeypatch.setattr(lark_cli, "_validate_lark_app_credentials_with_cli", _validate) + monkeypatch.setattr(lark_cli, "_save_lark_app_config_with_cli", lambda *_args, **_kwargs: None) + monkeypatch.setattr(lark_cli, "_revoke_lark_auth_from_snapshot", lambda _snapshot: None) + monkeypatch.setattr( + lark_cli, + "get_lark_integration_status", + lambda _user_id, _config, **_kwargs: _status_stub(app_configured=True, app_id="cli_new", auth_status="not_authorized"), + ) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list( + executor.map( + lambda suffix: lark_cli.set_lark_app_credentials( + "alice", + config, + app_id=f"cli_{suffix}", + app_secret="new-secret", + ), + ("one", "two"), + ) + ) + + assert all(result.success for result in results) + assert max_active == 1 + + +def test_lark_config_credentials_route_switches_app(monkeypatch, tmp_path): + config = _config(tmp_path / "skills") + app = _make_app(system_role="user", config=config) + captured: dict[str, object] = {} + + monkeypatch.setattr( + integrations_router, + "set_lark_app_credentials", + lambda _user_id, _config, *, app_id, app_secret, brand: ( + captured.update({"app_id": app_id, "app_secret": app_secret, "brand": brand}) + or lark_cli.LarkConfigCompleteResult( + success=True, + message="Lark/Feishu app switched. Reconnect to authorize the new app.", + generation="switch-generation", + status=_status_stub(app_configured=True, app_id="cli_new", auth_status="not_authorized"), + ) + ), + ) + + with TestClient(app) as client: + response = client.post( + "/api/integrations/lark/config/credentials", + json={"app_id": "cli_new", "app_secret": "new-secret", "brand": "feishu"}, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + assert response.json()["generation"] == "switch-generation" + assert response.json()["status"]["app_configured"] is True + assert response.json()["status"]["auth"]["status"] == "not_authorized" + assert captured == {"app_id": "cli_new", "app_secret": "new-secret", "brand": "feishu"} + + +def test_lark_config_credentials_route_rejects_invalid_brand(tmp_path): + config = _config(tmp_path / "skills") + app = _make_app(system_role="user", config=config) + + with TestClient(app) as client: + response = client.post( + "/api/integrations/lark/config/credentials", + json={"app_id": "cli_new", "app_secret": "new-secret", "brand": "larks"}, + ) + + assert response.status_code == 422 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 31caf907e..7e4044027 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -83,6 +83,10 @@ The frontend is a stateful chat application. Users create **threads** (conversat mutation, disables switches until that mutation's success refetch completes, displays the backend error `detail` through a toast, and invalidates `["mcpConfig"]` only after success. + Settings > Integrations uses a local generation only to suppress stale React + callbacks; server-issued Lark flow generations must be passed through every + config/auth completion and across switch-or-register to authorization chains + so backend cross-tab ordering remains authoritative. 6. Components subscribe to thread state and render updates The chat header's context-window control is intentionally persistent: while `context_usage` is unavailable, `ContextUsageBadge` renders a gauge placeholder rather than unmounting; once data arrives, the same position shows the percentage. `useThreadTokenUsage` retains placeholder data only when the response `thread_id` still matches the active route, so same-thread refetches do not flicker and cross-thread navigation never displays the previous chat's usage. diff --git a/frontend/src/app/mock/api/integrations/lark/config/credentials/route.ts b/frontend/src/app/mock/api/integrations/lark/config/credentials/route.ts new file mode 100644 index 000000000..c07a6c57c --- /dev/null +++ b/frontend/src/app/mock/api/integrations/lark/config/credentials/route.ts @@ -0,0 +1,36 @@ +export function POST() { + return Response.json({ + success: true, + message: "Lark/Feishu app switched. Reconnect to authorize the new app.", + status: { + installed: true, + version: "v1.0.65", + manifest_version: "v1.0.65", + latest_available_version: "v1.0.65", + runtime_version_mismatch: false, + app_configured: true, + app_id: "cli_switched_mock", + app_brand: "feishu", + skills_expected: 27, + skills_installed: 4, + installed_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"], + enabled_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"], + install_path: "/mock/integrations/skills/lark-cli", + cli: { + available: true, + path: "/usr/bin/lark-cli", + version: "lark-cli version v1.0.65", + error: null, + }, + auth: { + status: "not_authorized", + message: "Lark user authorization is not configured", + user: null, + verified: false, + }, + sandbox_runtime_mode: "none", + sandbox_runtime_ready: false, + sandbox_runtime_detail: null, + }, + }); +} diff --git a/frontend/src/components/workspace/settings/integrations-settings-page.tsx b/frontend/src/components/workspace/settings/integrations-settings-page.tsx index 84c4b8e2e..8508b96d5 100644 --- a/frontend/src/components/workspace/settings/integrations-settings-page.tsx +++ b/frontend/src/components/workspace/settings/integrations-settings-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { CheckCircle2Icon, CopyIcon, @@ -26,6 +27,7 @@ import { Input } from "@/components/ui/input"; import { useAuth } from "@/core/auth/AuthProvider"; import { useI18n } from "@/core/i18n/hooks"; import { + larkIntegrationQueryKey, LarkIntegrationRequestError, type LarkAuthStartRequest, type LarkAuthStartResponse, @@ -35,6 +37,7 @@ import { useCompleteLarkConfiguration, useInstallLarkIntegration, useLarkIntegrationStatus, + useSetLarkAppCredentials, useStartLarkAuthorization, useStartLarkConfiguration, } from "@/core/integrations/lark"; @@ -124,6 +127,7 @@ export function IntegrationsSettingsPage() { function LarkIntegrationCard() { const { t } = useI18n(); + const queryClient = useQueryClient(); const { user } = useAuth(); const isAdmin = user?.system_role === "admin"; const { data, isLoading, error, refetch, isFetching } = @@ -133,8 +137,15 @@ function LarkIntegrationCard() { const completeConfig = useCompleteLarkConfiguration(); const startAuth = useStartLarkAuthorization(); const completeAuth = useCompleteLarkAuthorization(); + const switchApp = useSetLarkAppCredentials(); const [pendingFlow, setPendingFlow] = useState(null); const [isCheckingConnection, setIsCheckingConnection] = useState(false); + const [showChangeApp, setShowChangeApp] = useState(false); + const [changeAppId, setChangeAppId] = useState(""); + const [changeAppSecret, setChangeAppSecret] = useState(""); + const [changeAppBrand, setChangeAppBrand] = useState<"feishu" | "lark">( + "feishu", + ); const [selectedAuthDomains, setSelectedAuthDomains] = useState< LarkAuthDomain[] >([]); @@ -147,10 +158,18 @@ function LarkIntegrationCard() { ); const authAttemptIdRef = useRef(0); const authDeadlineRef = useRef(0); - const isMountedRef = useRef(true); + const flowGenerationRef = useRef(0); const connectBusy = - startConfig.isPending || completeConfig.isPending || startAuth.isPending; - const connectActionBusy = connectBusy || isCheckingConnection; + startConfig.isPending || + completeConfig.isPending || + startAuth.isPending || + completeAuth.isPending || + switchApp.isPending; + const integrationBusy = + connectBusy || + isCheckingConnection || + pendingFlow != null || + install.isPending; const credentialsConfigured = data?.auth.status === "authenticated"; const isConnected = credentialsConfigured && data?.auth.verified === true; // The sandbox-runtime readiness row only applies when the sandbox actually @@ -180,9 +199,25 @@ function LarkIntegrationCard() { } }; + const beginFlow = () => { + clearAuthRetryTimer(); + authAttemptIdRef.current += 1; + flowGenerationRef.current += 1; + void queryClient.cancelQueries({ queryKey: larkIntegrationQueryKey }); + if (authToastIdRef.current != null) { + toast.dismiss(authToastIdRef.current); + authToastIdRef.current = null; + } + setPendingFlow(null); + return flowGenerationRef.current; + }; + + const isActiveFlow = (generation: number) => + generation === flowGenerationRef.current; + useEffect( () => () => { - isMountedRef.current = false; + flowGenerationRef.current += 1; if (authRetryTimeoutRef.current != null) { clearTimeout(authRetryTimeoutRef.current); } @@ -226,39 +261,59 @@ function LarkIntegrationCard() { const startUserAuth = ( browserWindow = browserWindowRef.current, request = authRequestRef.current, + generation = flowGenerationRef.current, + serverGeneration?: string, ) => { - startAuth.mutate(request, { - onSuccess: (result) => { - setPendingFlow({ kind: "auth", ...result }); - openAuthorizationUrl(result.verification_url, browserWindow); - authToastIdRef.current = toast.info( - t.settings.integrations.lark.authStarted, - ); - startAutomaticAuthorizationCheck(result); + startAuth.mutate( + { + ...request, + ...(serverGeneration ? { generation: serverGeneration } : {}), }, - onError: (err) => { - closePendingBrowserWindow(browserWindow); - toast.error(err instanceof Error ? err.message : String(err)); + { + onSuccess: (result) => { + if (!isActiveFlow(generation)) return; + setPendingFlow({ kind: "auth", ...result }); + openAuthorizationUrl(result.verification_url, browserWindow); + authToastIdRef.current = toast.info( + t.settings.integrations.lark.authStarted, + ); + startAutomaticAuthorizationCheck(result, generation); + }, + onError: (err) => { + if (!isActiveFlow(generation)) return; + closePendingBrowserWindow(browserWindow); + toast.error(err instanceof Error ? err.message : String(err)); + }, }, - }); + ); }; const handleContinueConnection = () => { if (!pendingFlow || pendingFlow.kind !== "config") return; + const generation = flowGenerationRef.current; completeConfig.mutate( { device_code: pendingFlow.device_code, + generation: pendingFlow.generation, brand: pendingFlow.brand, interval: pendingFlow.interval, expires_in: pendingFlow.expires_in, }, { - onSuccess: () => { + onSuccess: (result) => { + if (!isActiveFlow(generation)) return; + queryClient.setQueryData(larkIntegrationQueryKey, result.status); toast.success(t.settings.integrations.lark.connectionReady); setPendingFlow(null); - startUserAuth(browserWindowRef.current); + startUserAuth( + browserWindowRef.current, + authRequestRef.current, + generation, + result.generation, + ); }, onError: (err) => { + if (!isActiveFlow(generation)) return; setPendingFlow(null); toast.error(err instanceof Error ? err.message : String(err)); }, @@ -266,28 +321,85 @@ function LarkIntegrationCard() { ); }; + const startBrowserAppRegistration = ( + brand: "feishu" | "lark", + browserWindow: Window | null, + generation: number, + ) => { + startConfig.mutate( + { brand }, + { + onSuccess: (result) => { + if (!isActiveFlow(generation)) return; + setPendingFlow({ kind: "config", ...result }); + openAuthorizationUrl(result.verification_url, browserWindow); + toast.success(t.settings.integrations.lark.connectionStarted); + }, + onError: (err) => { + if (!isActiveFlow(generation)) return; + closePendingBrowserWindow(browserWindow); + toast.error(err instanceof Error ? err.message : String(err)); + }, + }, + ); + }; + const startConnectionFlow = ( status: LarkIntegrationStatus, browserWindow: Window | null, + generation: number, ) => { if (!status.app_configured) { - startConfig.mutate( - { brand: "feishu" }, - { - onSuccess: (result) => { - setPendingFlow({ kind: "config", ...result }); - openAuthorizationUrl(result.verification_url, browserWindow); - toast.success(t.settings.integrations.lark.connectionStarted); - }, - onError: (err) => { - closePendingBrowserWindow(browserWindow); - toast.error(err instanceof Error ? err.message : String(err)); - }, - }, - ); + startBrowserAppRegistration("feishu", browserWindow, generation); return; } - startUserAuth(browserWindow); + startUserAuth(browserWindow, authRequestRef.current, generation); + }; + + const handleReRegisterInBrowser = () => { + authRequestRef.current = buildAuthRequest(); + const browserWindow = openPendingBrowserWindow(); + const generation = beginFlow(); + startBrowserAppRegistration(changeAppBrand, browserWindow, generation); + }; + + const handleChangeAppSubmit = () => { + // Pre-open the browser tab synchronously inside the click gesture: the + // subsequent user authorization runs only after the switch POST resolves, + // and opening the window then would be outside the gesture and blocked by + // the browser (same constraint as handleConnect). + authRequestRef.current = buildAuthRequest(); + const browserWindow = openPendingBrowserWindow(); + const generation = beginFlow(); + switchApp.mutate( + { + app_id: changeAppId.trim(), + app_secret: changeAppSecret.trim(), + brand: changeAppBrand, + }, + { + onSuccess: (result) => { + if (!isActiveFlow(generation)) return; + queryClient.setQueryData(larkIntegrationQueryKey, result.status); + toast.success(t.settings.integrations.lark.changeAppSwitched); + setChangeAppSecret(""); + setShowChangeApp(false); + // The new app has no user authorization yet; drive the browser auth + // flow immediately so the switch ends in a usable connection. + startUserAuth( + browserWindow, + authRequestRef.current, + generation, + result.generation, + ); + }, + onError: (err) => { + if (!isActiveFlow(generation)) return; + closePendingBrowserWindow(browserWindow); + toast.error(err instanceof Error ? err.message : String(err)); + }, + }, + ); }; const buildAuthRequest = (): LarkAuthStartRequest => { @@ -325,22 +437,32 @@ function LarkIntegrationCard() { // would run outside the user gesture and be blocked by the browser. Opening // now and closing below when it turns out unneeded keeps the popup reliable. const browserWindow = openPendingBrowserWindow(); + const generation = beginFlow(); setIsCheckingConnection(true); try { const refreshed = await refetch(); + if (!isActiveFlow(generation)) return; const latestStatus = refreshed.data ?? data; - startConnectionFlow(latestStatus, browserWindow); + startConnectionFlow(latestStatus, browserWindow, generation); } catch (err) { + if (!isActiveFlow(generation)) return; closePendingBrowserWindow(browserWindow); toast.error(err instanceof Error ? err.message : String(err)); } finally { - setIsCheckingConnection(false); + if (isActiveFlow(generation)) { + setIsCheckingConnection(false); + } } }; const completeAuthorization = ( deviceCode: string, - { automatic, attemptId }: { automatic: boolean; attemptId?: number }, + serverGeneration: string, + { + automatic, + attemptId, + generation, + }: { automatic: boolean; attemptId?: number; generation: number }, ) => { const toastOptions = authToastIdRef.current == null @@ -349,6 +471,7 @@ function LarkIntegrationCard() { completeAuth.mutate( { device_code: deviceCode, + generation: serverGeneration, ...(automatic ? { wait_timeout_seconds: AUTOMATIC_LARK_AUTH_WAIT_SECONDS } : {}), @@ -358,12 +481,11 @@ function LarkIntegrationCard() { // react-query still fires this after the dialog unmounts; bail so we // don't toast, setState, refetch, or reschedule a retry timer on a // component that is gone. - if (!isMountedRef.current) { - return; - } + if (!isActiveFlow(generation)) return; if (automatic && attemptId !== authAttemptIdRef.current) { return; } + queryClient.setQueryData(larkIntegrationQueryKey, result.status); if (result.success) { clearAuthRetryTimer(); toast.success(result.message, toastOptions); @@ -378,13 +500,16 @@ function LarkIntegrationCard() { toastOptions, ); if (automatic && attemptId != null) { - scheduleAuthorizationRetry(deviceCode, attemptId); + scheduleAuthorizationRetry( + deviceCode, + serverGeneration, + attemptId, + generation, + ); } }, onError: (err) => { - if (!isMountedRef.current) { - return; - } + if (!isActiveFlow(generation)) return; if (automatic && attemptId !== authAttemptIdRef.current) { return; } @@ -398,7 +523,12 @@ function LarkIntegrationCard() { toastOptions, ); if (attemptId != null) { - scheduleAuthorizationRetry(deviceCode, attemptId); + scheduleAuthorizationRetry( + deviceCode, + serverGeneration, + attemptId, + generation, + ); } return; } @@ -414,28 +544,39 @@ function LarkIntegrationCard() { const scheduleAuthorizationRetry = ( deviceCode: string, + serverGeneration: string, attemptId: number, + generation: number, ) => { clearAuthRetryTimer(); - if (!isMountedRef.current) { - return; - } + if (!isActiveFlow(generation)) return; if (Date.now() >= authDeadlineRef.current) { toast.info(t.settings.integrations.lark.authorizationStillPending); return; } authRetryTimeoutRef.current = setTimeout(() => { - completeAuthorization(deviceCode, { automatic: true, attemptId }); + completeAuthorization(deviceCode, serverGeneration, { + automatic: true, + attemptId, + generation, + }); }, 1500); }; - const startAutomaticAuthorizationCheck = (result: LarkAuthStartResponse) => { + const startAutomaticAuthorizationCheck = ( + result: LarkAuthStartResponse, + generation: number, + ) => { clearAuthRetryTimer(); const attemptId = authAttemptIdRef.current + 1; authAttemptIdRef.current = attemptId; authDeadlineRef.current = Date.now() + Math.max(result.expires_in ?? 300, 30) * 1000; - completeAuthorization(result.device_code, { automatic: true, attemptId }); + completeAuthorization(result.device_code, result.generation, { + automatic: true, + attemptId, + generation, + }); }; const handleCompleteAuth = () => { @@ -445,7 +586,10 @@ function LarkIntegrationCard() { } clearAuthRetryTimer(); authAttemptIdRef.current += 1; - completeAuthorization(pendingFlow.device_code, { automatic: false }); + completeAuthorization(pendingFlow.device_code, pendingFlow.generation, { + automatic: false, + generation: flowGenerationRef.current, + }); }; const handleCopyAuthLink = async () => { @@ -461,12 +605,12 @@ function LarkIntegrationCard() { const installDisabled = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || !isAdmin || - install.isPending; + integrationBusy; const authDisabled = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || !data?.installed || !data?.cli.available || - connectActionBusy; + integrationBusy; const connectButtonLabel = isCheckingConnection ? t.settings.integrations.lark.checkingConnection @@ -637,7 +781,7 @@ function LarkIntegrationCard() { size="sm" variant={selected ? "default" : "outline"} onClick={() => toggleAuthDomain(domain.id)} - disabled={connectActionBusy} + disabled={integrationBusy} title={domain.description} > {domain.label} @@ -651,7 +795,7 @@ function LarkIntegrationCard() { onChange={(event) => setCustomAuthScope(event.currentTarget.value) } - disabled={connectActionBusy} + disabled={integrationBusy} placeholder={ t.settings.integrations.lark.customScopePlaceholder } @@ -679,17 +823,110 @@ function LarkIntegrationCard() { onClick={() => void handleConnect()} disabled={authDisabled} > - {connectActionBusy ? ( + {connectBusy || isCheckingConnection ? ( ) : null} {connectButtonLabel} + {data.installed && data.cli.available && data.app_configured && ( + + )} {!isAdmin && ( {t.settings.integrations.adminRequired} )} + {showChangeApp && data.installed && data.cli.available && ( +
+
+
+ {t.settings.integrations.lark.changeAppTitle} +
+

+ {t.settings.integrations.lark.changeAppDescription} +

+
+
+ {(["feishu", "lark"] as const).map((brand) => ( + + ))} +
+
+ + setChangeAppId(event.currentTarget.value) + } + disabled={integrationBusy} + placeholder={t.settings.integrations.lark.changeAppIdLabel} + aria-label={t.settings.integrations.lark.changeAppIdLabel} + /> + + setChangeAppSecret(event.currentTarget.value) + } + disabled={integrationBusy} + placeholder={ + t.settings.integrations.lark.changeAppSecretLabel + } + aria-label={ + t.settings.integrations.lark.changeAppSecretLabel + } + /> +

+ {t.settings.integrations.lark.changeAppAuthResetNote} +

+
+
+ + +
+
+ )} {install.isPending && ( diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index e694850fc..d8b9f49b6 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -910,6 +910,20 @@ export const enUS: Translations = { requestPermissions: "Request permissions", alreadyConnected: "Lark is already connected. If authorization expires, refresh the status and reconnect.", + changeAppButton: "Change Lark app", + changeAppTitle: "Switch to a different Lark app", + changeAppDescription: + "Point your DeerFlow account at a different Lark/Feishu app. This only affects your account; other users are not changed.", + changeAppIdLabel: "App ID", + changeAppSecretLabel: "App Secret", + changeAppAuthResetNote: + "Switching revokes the previous app's authorization. You will authorize the new app next.", + changeAppSubmit: "Switch app", + changeAppReRegister: "Re-register in browser", + changeAppSwitched: + "Lark app switched. Reconnect to authorize the new app.", + brandFeishu: "Feishu", + brandLark: "Lark", connectionStarted: "Connection link opened", connectionReady: "Connection is ready. Opening authorization...", authStarted: diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 65d00a0c3..fdcf728fe 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -769,6 +769,17 @@ export interface Translations { connectedAction: string; requestPermissions: string; alreadyConnected: string; + changeAppButton: string; + changeAppTitle: string; + changeAppDescription: string; + changeAppIdLabel: string; + changeAppSecretLabel: string; + changeAppAuthResetNote: string; + changeAppSubmit: string; + changeAppReRegister: string; + changeAppSwitched: string; + brandFeishu: string; + brandLark: string; connectionStarted: string; connectionReady: string; authStarted: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 2b0564b78..05b722c89 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -872,6 +872,19 @@ export const zhCN: Translations = { requestPermissions: "申请新权限", alreadyConnected: "飞书已连接,无需重复授权。如果授权已过期,刷新状态后可重新连接。", + changeAppButton: "切换飞书 Bot", + changeAppTitle: "切换到其他飞书 App", + changeAppDescription: + "把你的 DeerFlow 账号指向另一个 Lark/飞书 App。只影响你自己的账号,不影响其他用户。", + changeAppIdLabel: "App ID", + changeAppSecretLabel: "App Secret", + changeAppAuthResetNote: + "切换时会撤销旧 App 的授权,随后需要授权新 App。", + changeAppSubmit: "切换 App", + changeAppReRegister: "在浏览器重新注册", + changeAppSwitched: "已切换飞书 App。请重新连接以授权新 App。", + brandFeishu: "飞书", + brandLark: "Lark", connectionStarted: "连接链接已打开", connectionReady: "连接准备已完成,正在打开授权链接", authStarted: "授权页已打开,DeerFlow 会自动检测授权结果。", diff --git a/frontend/src/core/integrations/lark/api.ts b/frontend/src/core/integrations/lark/api.ts index dac796523..1c6b28d33 100644 --- a/frontend/src/core/integrations/lark/api.ts +++ b/frontend/src/core/integrations/lark/api.ts @@ -8,6 +8,7 @@ import type { LarkAuthStartResponse, LarkConfigCompleteRequest, LarkConfigCompleteResponse, + LarkConfigCredentialsRequest, LarkConfigStartRequest, LarkConfigStartResponse, LarkInstallResponse, @@ -35,9 +36,12 @@ async function readErrorDetail(response: Response): Promise { return data.detail ?? `HTTP ${response.status}: ${response.statusText}`; } -export async function loadLarkIntegrationStatus(): Promise { +export async function loadLarkIntegrationStatus( + signal?: AbortSignal, +): Promise { const response = await fetch( `${getBackendBaseURL()}/api/integrations/lark/status`, + { signal }, ); if (!response.ok) { throw new LarkIntegrationRequestError( @@ -130,6 +134,28 @@ export async function completeLarkConfiguration( return response.json(); } +export async function setLarkAppCredentials( + request: LarkConfigCredentialsRequest, +): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/integrations/lark/config/credentials`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }, + ); + if (!response.ok) { + throw new LarkIntegrationRequestError( + response.status, + await readErrorDetail(response), + ); + } + return response.json(); +} + export async function completeLarkAuthorization( request: LarkAuthCompleteRequest, ): Promise { diff --git a/frontend/src/core/integrations/lark/hooks.ts b/frontend/src/core/integrations/lark/hooks.ts index 22d57b7f0..836b5c061 100644 --- a/frontend/src/core/integrations/lark/hooks.ts +++ b/frontend/src/core/integrations/lark/hooks.ts @@ -5,6 +5,7 @@ import { completeLarkConfiguration, installLarkIntegration, loadLarkIntegrationStatus, + setLarkAppCredentials, startLarkAuthorization, startLarkConfiguration, } from "./api"; @@ -14,7 +15,7 @@ export const larkIntegrationQueryKey = ["integrations", "lark"] as const; export function useLarkIntegrationStatus() { return useQuery({ queryKey: larkIntegrationQueryKey, - queryFn: loadLarkIntegrationStatus, + queryFn: ({ signal }) => loadLarkIntegrationStatus(signal), }); } @@ -45,24 +46,19 @@ export function useStartLarkConfiguration() { } export function useCompleteLarkConfiguration() { - const queryClient = useQueryClient(); return useMutation({ mutationFn: completeLarkConfiguration, - onSuccess: async (result) => { - queryClient.setQueryData(larkIntegrationQueryKey, result.status); - await queryClient.invalidateQueries({ - queryKey: larkIntegrationQueryKey, - }); - }, + }); +} + +export function useSetLarkAppCredentials() { + return useMutation({ + mutationFn: setLarkAppCredentials, }); } export function useCompleteLarkAuthorization() { - const queryClient = useQueryClient(); return useMutation({ mutationFn: completeLarkAuthorization, - onSuccess: (result) => { - queryClient.setQueryData(larkIntegrationQueryKey, result.status); - }, }); } diff --git a/frontend/src/core/integrations/lark/types.ts b/frontend/src/core/integrations/lark/types.ts index 023bda8ac..e5c769974 100644 --- a/frontend/src/core/integrations/lark/types.ts +++ b/frontend/src/core/integrations/lark/types.ts @@ -55,11 +55,13 @@ export interface LarkAuthStartRequest { recommend?: boolean; domains?: string[]; scope?: string | null; + generation?: string; } export interface LarkAuthStartResponse { verification_url: string; device_code: string; + generation: string; expires_in: number | null; user_code: string | null; hint: string | null; @@ -72,6 +74,7 @@ export interface LarkConfigStartRequest { export interface LarkConfigStartResponse { verification_url: string; device_code: string; + generation: string; expires_in: number | null; interval: number | null; user_code: string | null; @@ -80,19 +83,28 @@ export interface LarkConfigStartResponse { export interface LarkConfigCompleteRequest { device_code: string; + generation: string; brand: "feishu" | "lark"; interval: number | null; expires_in: number | null; } +export interface LarkConfigCredentialsRequest { + app_id: string; + app_secret: string; + brand: "feishu" | "lark"; +} + export interface LarkConfigCompleteResponse { success: boolean; message: string; + generation: string; status: LarkIntegrationStatus; } export interface LarkAuthCompleteRequest { device_code: string; + generation: string; wait_timeout_seconds?: number; } diff --git a/frontend/tests/e2e/integrations.spec.ts b/frontend/tests/e2e/integrations.spec.ts index 7be132df8..ef5d9ac4a 100644 --- a/frontend/tests/e2e/integrations.spec.ts +++ b/frontend/tests/e2e/integrations.spec.ts @@ -2,6 +2,39 @@ import { expect, test } from "@playwright/test"; import { mockLangGraphAPI } from "./utils/mock-api"; +function configuredLarkStatus() { + return { + installed: true, + version: "v1.0.65", + manifest_version: "v1.0.65", + latest_available_version: "v1.0.65", + runtime_version_mismatch: false, + app_configured: true, + app_id: "cli_existing_mock", + app_brand: "feishu", + skills_expected: 27, + skills_installed: 4, + installed_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"], + enabled_skills: ["lark-doc", "lark-im", "lark-shared", "lark-sheets"], + install_path: "/mock/integrations/skills/lark-cli", + cli: { + available: true, + path: "/usr/bin/lark-cli", + version: "lark-cli version v1.0.65", + error: null, + }, + auth: { + status: "authenticated", + message: "Lark authorization is live-verified.", + user: "existing-user", + verified: true, + }, + sandbox_runtime_mode: "none", + sandbox_runtime_ready: false, + sandbox_runtime_detail: null, + }; +} + test.describe("Integrations settings", () => { test("opens integrations settings from a query-string deep link", async ({ page, @@ -73,6 +106,7 @@ test.describe("Integrations settings", () => { body: JSON.stringify({ verification_url: "about:blank", device_code: "mock-config-device-code", + generation: "config-generation", expires_in: 600, interval: 5, user_code: "config", @@ -82,12 +116,14 @@ test.describe("Integrations settings", () => { }); await page.route("**/api/integrations/lark/auth/start", async (route) => { authStartRequest = route.request().postDataJSON(); + const request = authStartRequest as { generation?: string }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ verification_url: "https://open.feishu.cn/auth/mock-device", device_code: "mock-device-code", + generation: request.generation ?? "auth-generation", expires_in: 600, user_code: null, hint: null, @@ -141,12 +177,14 @@ test.describe("Integrations settings", () => { recommend: false, domains: ["calendar"], scope: "calendar:calendar.event:read", + generation: "config-generation", }); await expect .poll(() => authCompleteRequests) .toContainEqual({ device_code: "mock-device-code", + generation: "config-generation", wait_timeout_seconds: 8, }); await expect( @@ -163,4 +201,164 @@ test.describe("Integrations settings", () => { dialog.getByText("https://open.feishu.cn/auth/mock-device"), ).toBeVisible(); }); + + test("can switch the Lark app by entering new credentials", async ({ + page, + }) => { + mockLangGraphAPI(page); + + // A configured + CLI-available account is the precondition for surfacing + // the "Change Lark app" control. + const configuredStatus = configuredLarkStatus(); + await page.route("**/api/integrations/lark/status", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(configuredStatus), + }); + }); + + let credentialsRequest: unknown; + let releaseCredentials!: () => void; + const credentialsGate = new Promise((resolve) => { + releaseCredentials = resolve; + }); + await page.route( + "**/api/integrations/lark/config/credentials", + async (route) => { + credentialsRequest = route.request().postDataJSON(); + await credentialsGate; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: true, + message: + "Lark/Feishu app switched. Reconnect to authorize the new app.", + generation: "switch-generation", + status: { + ...configuredStatus, + app_id: "cli_new_mock", + auth: { + status: "not_authorized", + message: "not authorized", + user: null, + verified: false, + }, + }, + }), + }); + }, + ); + let authStartRequest: unknown; + await page.route("**/api/integrations/lark/auth/start", async (route) => { + authStartRequest = route.request().postDataJSON(); + const request = authStartRequest as { generation?: string }; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + verification_url: "https://open.feishu.cn/auth/switched-app", + device_code: "switched-device-code", + generation: request.generation ?? "auth-generation", + expires_in: 600, + user_code: null, + hint: null, + }), + }); + }); + + await page.goto("/workspace/chats/new?settings=integrations"); + + const dialog = page.getByRole("dialog", { name: "Settings" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible(); + + // Reveal the switch form and submit new app credentials. + await dialog.getByRole("button", { name: "Change Lark app" }).click(); + await expect( + dialog.getByText("Switch to a different Lark app"), + ).toBeVisible(); + await dialog.getByLabel("App ID").fill("cli_new_mock"); + await dialog.getByLabel("App Secret").fill("super-secret"); + const popupPromise = page.waitForEvent("popup"); + await dialog.getByRole("button", { name: "Switch app" }).click(); + const popup = await popupPromise; + await expect( + dialog.getByRole("button", { name: "Opening connection link..." }), + ).toBeDisabled(); + await expect( + dialog.getByRole("button", { name: "Re-register in browser" }), + ).toBeDisabled(); + releaseCredentials(); + + await expect + .poll(() => credentialsRequest) + .toMatchObject({ + app_id: "cli_new_mock", + app_secret: "super-secret", + brand: "feishu", + }); + + // Switching a bot immediately drives the new app's browser authorization. + await expect + .poll(() => authStartRequest) + .toEqual({ + recommend: false, + domains: [], + scope: null, + generation: "switch-generation", + }); + await expect + .poll(() => popup.url()) + .toBe("https://open.feishu.cn/auth/switched-app"); + await popup.close(); + }); + + test("keeps selected permissions when re-registering the Lark app", async ({ + page, + }) => { + mockLangGraphAPI(page); + const configuredStatus = configuredLarkStatus(); + await page.route("**/api/integrations/lark/status", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(configuredStatus), + }); + }); + let authStartRequest: unknown; + await page.route("**/api/integrations/lark/auth/start", async (route) => { + authStartRequest = route.request().postDataJSON(); + await route.fallback(); + }); + + await page.goto("/workspace/chats/new?settings=integrations"); + const dialog = page.getByRole("dialog", { name: "Settings" }); + await dialog.getByRole("button", { name: "Calendar" }).click(); + await dialog + .getByLabel("Exact OAuth scope") + .fill("calendar:calendar.event:read"); + await dialog.getByRole("button", { name: "Change Lark app" }).click(); + const popupPromise = page.waitForEvent("popup"); + await dialog + .getByRole("button", { name: "Re-register in browser" }) + .click(); + const popup = await popupPromise; + await dialog + .getByRole("button", { + name: "I completed browser confirmation, continue", + }) + .click(); + + await expect + .poll(() => authStartRequest) + .toEqual({ + recommend: false, + domains: ["calendar"], + scope: "calendar:calendar.event:read", + generation: "config-generation", + }); + await popup.close(); + }); }); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index ad636487b..13daeac82 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -1248,6 +1248,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { body: JSON.stringify({ verification_url: "https://open.feishu.cn/page/cli?user_code=config", device_code: "mock-config-device-code", + generation: "config-generation", expires_in: 600, interval: 5, user_code: "config", @@ -1278,6 +1279,36 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { body: JSON.stringify({ success: true, message: "Lark/Feishu connection setup completed.", + generation: "config-generation", + status: larkIntegrationStatus, + }), + }); + } + return route.fallback(); + }); + + void page.route("**/api/integrations/lark/config/credentials", (route) => { + if (route.request().method() === "POST") { + larkIntegrationStatus = { + ...larkIntegrationStatus, + app_configured: true, + app_id: "cli_switched_mock", + app_brand: "feishu", + auth: { + status: "not_authorized", + message: "Lark user authorization is not configured", + user: null, + verified: false, + }, + }; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: true, + message: + "Lark/Feishu app switched. Reconnect to authorize the new app.", + generation: "switch-generation", status: larkIntegrationStatus, }), }); @@ -1287,12 +1318,16 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { void page.route("**/api/integrations/lark/auth/start", (route) => { if (route.request().method() === "POST") { + const request = route.request().postDataJSON() as { + generation?: string; + }; return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ verification_url: "https://open.feishu.cn/auth/mock-device", device_code: "mock-device-code", + generation: request.generation ?? "auth-generation", expires_in: 600, user_code: null, hint: null, diff --git a/frontend/tests/unit/core/integrations/lark/api.test.ts b/frontend/tests/unit/core/integrations/lark/api.test.ts index 816e05981..b35a5b7c0 100644 --- a/frontend/tests/unit/core/integrations/lark/api.test.ts +++ b/frontend/tests/unit/core/integrations/lark/api.test.ts @@ -15,6 +15,7 @@ import { installLarkIntegration, LarkIntegrationRequestError, loadLarkIntegrationStatus, + setLarkAppCredentials, startLarkAuthorization, startLarkConfiguration, } from "@/core/integrations/lark/api"; @@ -35,6 +36,7 @@ beforeEach(() => { describe("lark integration api", () => { test("loads status", async () => { + const controller = new AbortController(); mockedFetch.mockResolvedValueOnce( jsonResponse(200, { installed: false, @@ -58,7 +60,9 @@ describe("lark integration api", () => { }), ); - await expect(loadLarkIntegrationStatus()).resolves.toMatchObject({ + await expect( + loadLarkIntegrationStatus(controller.signal), + ).resolves.toMatchObject({ installed: false, version: "v1.0.65", sandbox_runtime_mode: "init-container", @@ -66,6 +70,7 @@ describe("lark integration api", () => { }); expect(mockedFetch).toHaveBeenCalledWith( "/backend/api/integrations/lark/status", + { signal: controller.signal }, ); }); @@ -134,6 +139,7 @@ describe("lark integration api", () => { jsonResponse(200, { verification_url: "https://open.feishu.cn/auth/mock", device_code: "device-code", + generation: "auth-generation", expires_in: 600, user_code: null, hint: null, @@ -149,6 +155,7 @@ describe("lark integration api", () => { ).resolves.toEqual({ verification_url: "https://open.feishu.cn/auth/mock", device_code: "device-code", + generation: "auth-generation", expires_in: 600, user_code: null, hint: null, @@ -172,6 +179,7 @@ describe("lark integration api", () => { jsonResponse(200, { verification_url: "https://open.feishu.cn/page/cli?user_code=config", device_code: "config-device-code", + generation: "config-generation", expires_in: 600, interval: 5, user_code: "config", @@ -182,6 +190,7 @@ describe("lark integration api", () => { await expect(startLarkConfiguration({ brand: "feishu" })).resolves.toEqual({ verification_url: "https://open.feishu.cn/page/cli?user_code=config", device_code: "config-device-code", + generation: "config-generation", expires_in: 600, interval: 5, user_code: "config", @@ -202,6 +211,7 @@ describe("lark integration api", () => { jsonResponse(200, { success: true, message: "Lark/Feishu connection setup completed.", + generation: "config-generation", status: { installed: true, version: "v1.0.65", @@ -234,6 +244,7 @@ describe("lark integration api", () => { await expect( completeLarkConfiguration({ device_code: "config-device-code", + generation: "config-generation", brand: "feishu", interval: 5, expires_in: 600, @@ -249,6 +260,7 @@ describe("lark integration api", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_code: "config-device-code", + generation: "config-generation", brand: "feishu", interval: 5, expires_in: 600, @@ -257,6 +269,66 @@ describe("lark integration api", () => { ); }); + test("switches app credentials", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(200, { + success: true, + message: + "Lark/Feishu app switched. Reconnect to authorize the new app.", + generation: "switch-generation", + status: { + installed: true, + version: "v1.0.65", + manifest_version: "v1.0.65", + latest_available_version: "v1.0.65", + runtime_version_mismatch: false, + app_configured: true, + app_id: "cli_new", + app_brand: "feishu", + skills_expected: 27, + skills_installed: 1, + installed_skills: ["lark-doc"], + enabled_skills: ["lark-doc"], + install_path: "/tmp/lark-cli", + cli: { + available: true, + path: "/usr/bin/lark-cli", + version: "v1.0.65", + error: null, + }, + auth: { + status: "not_authorized", + message: "not authorized", + user: null, + }, + }, + }), + ); + + await expect( + setLarkAppCredentials({ + app_id: "cli_new", + app_secret: "new-secret", + brand: "feishu", + }), + ).resolves.toMatchObject({ + success: true, + status: { app_configured: true, app_id: "cli_new" }, + }); + expect(mockedFetch).toHaveBeenCalledWith( + "/backend/api/integrations/lark/config/credentials", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + app_id: "cli_new", + app_secret: "new-secret", + brand: "feishu", + }), + }, + ); + }); + test("completes browser authorization", async () => { mockedFetch.mockResolvedValueOnce( jsonResponse(200, { @@ -292,7 +364,10 @@ describe("lark integration api", () => { ); await expect( - completeLarkAuthorization({ device_code: "device-code" }), + completeLarkAuthorization({ + device_code: "device-code", + generation: "auth-generation", + }), ).resolves.toMatchObject({ success: true, status: { auth: { status: "authenticated", user: "Alice" } }, @@ -302,7 +377,10 @@ describe("lark integration api", () => { { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ device_code: "device-code" }), + body: JSON.stringify({ + device_code: "device-code", + generation: "auth-generation", + }), }, ); });