feat(integrations): support switching Lark app credentials (#4703)

* feat(integrations): support switching Lark app credentials

* fix(integrations): harden Lark app switching

* refactor(integrations): simplify Lark switch flow

* fix(integrations): reject superseded Lark flows

* test(integrations): pass Lark flow generation

* fix(integrations): preserve pending Lark flows

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Ryker_Feng 2026-08-10 07:19:19 +08:00 committed by GitHub
parent e16ef2969b
commit e401ae2d7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1544 additions and 117 deletions

View File

@ -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

View File

@ -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 |

View File

@ -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:

View File

@ -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],
*,

View File

@ -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,
)

View File

@ -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

View File

@ -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.

View File

@ -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,
},
});
}

View File

@ -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<PendingLarkFlow | null>(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 ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : null}
{connectButtonLabel}
</Button>
{data.installed && data.cli.available && data.app_configured && (
<Button
variant="ghost"
onClick={() => setShowChangeApp((current) => !current)}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
integrationBusy
}
>
{t.settings.integrations.lark.changeAppButton}
</Button>
)}
{!isAdmin && (
<span className="text-muted-foreground text-sm">
{t.settings.integrations.adminRequired}
</span>
)}
</div>
{showChangeApp && data.installed && data.cli.available && (
<div className="space-y-3 rounded-lg border p-3">
<div className="space-y-1">
<div className="text-sm font-medium">
{t.settings.integrations.lark.changeAppTitle}
</div>
<p className="text-muted-foreground text-sm">
{t.settings.integrations.lark.changeAppDescription}
</p>
</div>
<div className="flex flex-wrap gap-2">
{(["feishu", "lark"] as const).map((brand) => (
<Button
key={brand}
type="button"
size="sm"
variant={changeAppBrand === brand ? "default" : "outline"}
onClick={() => setChangeAppBrand(brand)}
disabled={integrationBusy}
>
{brand === "feishu"
? t.settings.integrations.lark.brandFeishu
: t.settings.integrations.lark.brandLark}
</Button>
))}
</div>
<div className="space-y-2">
<Input
value={changeAppId}
onChange={(event) =>
setChangeAppId(event.currentTarget.value)
}
disabled={integrationBusy}
placeholder={t.settings.integrations.lark.changeAppIdLabel}
aria-label={t.settings.integrations.lark.changeAppIdLabel}
/>
<Input
type="password"
value={changeAppSecret}
onChange={(event) =>
setChangeAppSecret(event.currentTarget.value)
}
disabled={integrationBusy}
placeholder={
t.settings.integrations.lark.changeAppSecretLabel
}
aria-label={
t.settings.integrations.lark.changeAppSecretLabel
}
/>
<p className="text-muted-foreground text-xs">
{t.settings.integrations.lark.changeAppAuthResetNote}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={handleChangeAppSubmit}
disabled={
integrationBusy ||
!changeAppId.trim() ||
!changeAppSecret.trim()
}
>
{switchApp.isPending ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : null}
{t.settings.integrations.lark.changeAppSubmit}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleReRegisterInBrowser}
disabled={integrationBusy}
>
<ExternalLinkIcon className="size-4" />
{t.settings.integrations.lark.changeAppReRegister}
</Button>
</div>
</div>
)}
{install.isPending && (
<Alert>
<RefreshCwIcon className="size-4 animate-spin" />

View File

@ -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:

View File

@ -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;

View File

@ -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 会自动检测授权结果。",

View File

@ -8,6 +8,7 @@ import type {
LarkAuthStartResponse,
LarkConfigCompleteRequest,
LarkConfigCompleteResponse,
LarkConfigCredentialsRequest,
LarkConfigStartRequest,
LarkConfigStartResponse,
LarkInstallResponse,
@ -35,9 +36,12 @@ async function readErrorDetail(response: Response): Promise<string> {
return data.detail ?? `HTTP ${response.status}: ${response.statusText}`;
}
export async function loadLarkIntegrationStatus(): Promise<LarkIntegrationStatus> {
export async function loadLarkIntegrationStatus(
signal?: AbortSignal,
): Promise<LarkIntegrationStatus> {
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<LarkConfigCompleteResponse> {
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<LarkAuthCompleteResponse> {

View File

@ -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);
},
});
}

View File

@ -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;
}

View File

@ -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<void>((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();
});
});

View File

@ -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,

View File

@ -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",
}),
},
);
});