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 conversation, the managed `lark-shared` guidance points the user back to the
same settings entry with `?settings=integrations`. 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` 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 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 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 | | **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 | | **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 | | **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 | | **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 | | **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 | | **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 asyncio
import logging import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -17,12 +18,14 @@ from deerflow.integrations.lark_cli import (
LarkCliProbe, LarkCliProbe,
LarkConfigCompleteResult, LarkConfigCompleteResult,
LarkConfigStartResult, LarkConfigStartResult,
LarkFlowSupersededError,
LarkInstallResult, LarkInstallResult,
LarkIntegrationStatus, LarkIntegrationStatus,
complete_lark_auth, complete_lark_auth,
complete_lark_config, complete_lark_config,
get_lark_integration_status, get_lark_integration_status,
install_lark_integration, install_lark_integration,
set_lark_app_credentials,
start_lark_auth, start_lark_auth,
start_lark_config, start_lark_config,
) )
@ -94,6 +97,7 @@ class LarkAuthStartRequest(BaseModel):
recommend: bool = Field(default=False, description="Request the official recommended auto-approve scopes") 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") 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") 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): class LarkConfigStartRequest(BaseModel):
@ -103,6 +107,7 @@ class LarkConfigStartRequest(BaseModel):
class LarkConfigStartResponse(BaseModel): class LarkConfigStartResponse(BaseModel):
verification_url: str = Field(..., description="URL the user should open in a browser to configure the Lark app") 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") 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") expires_in: int | None = Field(None, description="Seconds before the configuration URL expires")
interval: int | None = Field(None, description="Suggested polling interval from Lark") interval: int | None = Field(None, description="Suggested polling interval from Lark")
user_code: str | None = Field(None, description="Optional user code shown by Lark") user_code: str | None = Field(None, description="Optional user code shown by Lark")
@ -111,6 +116,7 @@ class LarkConfigStartResponse(BaseModel):
class LarkConfigCompleteRequest(BaseModel): class LarkConfigCompleteRequest(BaseModel):
device_code: str = Field(..., description="Device code returned by config/start") 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") brand: str = Field(default="feishu", description="Brand returned by config/start")
interval: int | None = Field(default=None, description="Polling interval 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") expires_in: int | None = Field(default=None, description="Expiration returned by config/start")
@ -119,12 +125,20 @@ class LarkConfigCompleteRequest(BaseModel):
class LarkConfigCompleteResponse(BaseModel): class LarkConfigCompleteResponse(BaseModel):
success: bool success: bool
message: str message: str
generation: str
status: LarkIntegrationStatusResponse 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): class LarkAuthStartResponse(BaseModel):
verification_url: str = Field(..., description="URL the user should open in a browser to authorize") 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") 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") 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") 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") hint: str | None = Field(None, description="Optional guidance returned by lark-cli")
@ -132,6 +146,7 @@ class LarkAuthStartResponse(BaseModel):
class LarkAuthCompleteRequest(BaseModel): class LarkAuthCompleteRequest(BaseModel):
device_code: str = Field(..., description="Device code returned by auth/start") 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( wait_timeout_seconds: int = Field(
default=LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS, default=LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
ge=LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS, ge=LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS,
@ -205,6 +220,7 @@ def _config_start_to_response(result: LarkConfigStartResult) -> LarkConfigStartR
return LarkConfigStartResponse( return LarkConfigStartResponse(
verification_url=result.verification_url, verification_url=result.verification_url,
device_code=result.device_code, device_code=result.device_code,
generation=result.generation,
expires_in=result.expires_in, expires_in=result.expires_in,
interval=result.interval, interval=result.interval,
user_code=result.user_code, user_code=result.user_code,
@ -216,6 +232,7 @@ def _config_complete_to_response(result: LarkConfigCompleteResult, *, include_ho
return LarkConfigCompleteResponse( return LarkConfigCompleteResponse(
success=result.success, success=result.success,
message=result.message, message=result.message,
generation=result.generation,
status=_status_to_response(result.status, include_host_paths=include_host_paths), 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( return LarkAuthStartResponse(
verification_url=result.verification_url, verification_url=result.verification_url,
device_code=result.device_code, device_code=result.device_code,
generation=result.generation,
expires_in=result.expires_in, expires_in=result.expires_in,
user_code=result.user_code, user_code=result.user_code,
hint=result.hint, hint=result.hint,
@ -294,6 +312,7 @@ async def complete_lark_app_config(request: Request, body: LarkConfigCompleteReq
get_effective_user_id(), get_effective_user_id(),
config, config,
device_code=body.device_code, device_code=body.device_code,
generation=body.generation,
brand=body.brand, brand=body.brand,
interval=body.interval, interval=body.interval,
expires_in=body.expires_in, 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)) return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request))
except FileNotFoundError as e: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e: except TimeoutError as e:
raise HTTPException(status_code=504, detail=str(e)) raise HTTPException(status_code=504, detail=str(e))
except Exception as e: except Exception as e:
logger.error("Failed to complete Lark connection setup: %s", e, exc_info=True) logger.error("Failed to switch Lark app credentials: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to complete Lark connection setup.") 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") @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), domains=tuple(body.domains),
scope=body.scope, scope=body.scope,
recommend=body.recommend, recommend=body.recommend,
generation=body.generation,
) )
return _auth_start_to_response(result) return _auth_start_to_response(result)
except FileNotFoundError as e: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e: except TimeoutError as e:
@ -340,11 +387,14 @@ async def complete_lark_browser_auth(request: Request, body: LarkAuthCompleteReq
get_effective_user_id(), get_effective_user_id(),
config, config,
device_code=body.device_code, device_code=body.device_code,
generation=body.generation,
wait_timeout_seconds=body.wait_timeout_seconds, wait_timeout_seconds=body.wait_timeout_seconds,
) )
return _auth_complete_to_response(result, include_host_paths=await _is_admin_user(request)) return _auth_complete_to_response(result, include_host_paths=await _is_admin_user(request))
except FileNotFoundError as e: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e: except TimeoutError as e:

View File

@ -64,6 +64,8 @@ from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
from uuid import uuid4
from weakref import WeakValueDictionary
try: try:
import fcntl 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_SANDBOX_RUNTIME_DIR = "/mnt/integrations/lark-cli/runtime"
LARK_CLI_LINUX_ARCHES = ("amd64", "arm64") LARK_CLI_LINUX_ARCHES = ("amd64", "arm64")
LARK_CLI_RUNTIME_MANIFEST_FILE = ".deerflow-lark-cli-runtime.json" 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 # 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, # 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_SKILL_NAME_SET = frozenset(LARK_SKILL_NAMES)
_LARK_INSTALL_THREAD_LOCK = threading.Lock() _LARK_INSTALL_THREAD_LOCK = threading.Lock()
_LARK_RUNTIME_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) @dataclass(frozen=True)
@ -216,6 +221,7 @@ class LarkInstallResult:
class LarkConfigStartResult: class LarkConfigStartResult:
verification_url: str verification_url: str
device_code: str device_code: str
generation: str
expires_in: int | None = None expires_in: int | None = None
interval: int | None = None interval: int | None = None
user_code: str | None = None user_code: str | None = None
@ -227,12 +233,14 @@ class LarkConfigCompleteResult:
success: bool success: bool
status: LarkIntegrationStatus status: LarkIntegrationStatus
message: str message: str
generation: str
@dataclass(frozen=True) @dataclass(frozen=True)
class LarkAuthStartResult: class LarkAuthStartResult:
verification_url: str verification_url: str
device_code: str device_code: str
generation: str
expires_in: int | None = None expires_in: int | None = None
user_code: str | None = None user_code: str | None = None
hint: str | None = None hint: str | None = None
@ -245,6 +253,10 @@ class LarkAuthCompleteResult:
message: str 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: def lark_integration_root(_user_id: str | None = None) -> Path:
"""Return the shared root for globally installed managed Lark skills. """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" 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: 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. """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) 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: def _ensure_managed_sandbox_lark_cli(version: str) -> Path:
"""Install verified official Linux binaries for AIO sandbox execution.""" """Install verified official Linux binaries for AIO sandbox execution."""
tag = _normalize_lark_cli_version_tag(version) 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: 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.""" """Start the browser flow that creates/binds a Lark OAuth app for this user."""
parsed_brand = _normalize_lark_brand(brand) 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) begin_data = _request_lark_app_registration_begin(parsed_brand)
user_code = str(begin_data.get("user_code") or "").strip() user_code = str(begin_data.get("user_code") or "").strip()
device_code = str(begin_data.get("device_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( return LarkConfigStartResult(
verification_url=verification_url, verification_url=verification_url,
device_code=device_code, device_code=device_code,
generation=generation,
expires_in=_int_or_none(begin_data.get("expires_in")), expires_in=_int_or_none(begin_data.get("expires_in")),
interval=_int_or_none(begin_data.get("interval")), interval=_int_or_none(begin_data.get("interval")),
user_code=user_code, user_code=user_code,
@ -995,6 +1061,7 @@ def complete_lark_config(
config: AppConfig, config: AppConfig,
*, *,
device_code: str, device_code: str,
generation: str,
brand: str = "feishu", brand: str = "feishu",
interval: int | None = None, interval: int | None = None,
expires_in: int | None = None, expires_in: int | None = None,
@ -1004,6 +1071,8 @@ def complete_lark_config(
if not device_code: if not device_code:
raise ValueError("device_code is required.") raise ValueError("device_code is required.")
parsed_brand = _normalize_lark_brand(brand) 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( result = _poll_lark_app_registration(
device_code=device_code, device_code=device_code,
brand=parsed_brand, brand=parsed_brand,
@ -1028,12 +1097,57 @@ def complete_lark_config(
if not app_id or not app_secret: if not app_id or not app_secret:
raise ValueError("Lark app registration succeeded but did not return app credentials.") 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) with _lark_credential_lock(user_id):
status = get_lark_integration_status(user_id, config) 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( return LarkConfigCompleteResult(
success=True, success=True,
status=status, status=status,
message="Lark/Feishu connection setup completed.", 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, ...] = (), domains: tuple[str, ...] = (),
scope: str | None = None, scope: str | None = None,
recommend: bool = False, recommend: bool = False,
generation: str | None = None,
) -> LarkAuthStartResult: ) -> LarkAuthStartResult:
"""Start a non-blocking Lark device authorization flow. """Start a non-blocking Lark device authorization flow.
@ -1060,15 +1175,21 @@ def start_lark_auth(
if domain: if domain:
args.extend(["--domain", domain]) args.extend(["--domain", domain])
data = _run_lark_cli_json(args, user_id=user_id, timeout=20) with _lark_credential_lock(user_id):
verification_url = str(data.get("verification_url") or data.get("verification_uri_complete") or "").strip() if generation is None:
device_code = str(data.get("device_code") or "").strip() generation = _advance_lark_flow_generation_locked(user_id)
if not verification_url or not device_code: else:
raise ValueError("lark-cli did not return a verification_url and device_code.") 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( return LarkAuthStartResult(
verification_url=verification_url, verification_url=verification_url,
device_code=device_code, device_code=device_code,
generation=generation,
expires_in=_int_or_none(data.get("expires_in")), expires_in=_int_or_none(data.get("expires_in")),
user_code=str(data.get("user_code") or "") or None, user_code=str(data.get("user_code") or "") or None,
hint=str(data.get("hint") or "") or None, hint=str(data.get("hint") or "") or None,
@ -1080,6 +1201,7 @@ def complete_lark_auth(
config: AppConfig, config: AppConfig,
*, *,
device_code: str, device_code: str,
generation: str,
wait_timeout_seconds: int = LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS, wait_timeout_seconds: int = LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
) -> LarkAuthCompleteResult: ) -> LarkAuthCompleteResult:
"""Complete a Lark device authorization flow after the user approves it.""" """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: 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}.") 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() with _lark_credential_lock(user_id):
_run_lark_cli_json( _require_lark_flow_generation_locked(user_id, generation)
[path, "auth", "login", "--device-code", device_code, "--json"], path = _require_lark_cli_path()
user_id=user_id, _run_lark_cli_json(
timeout=wait_timeout_seconds, [path, "auth", "login", "--device-code", device_code, "--json"],
allow_empty_success=True, user_id=user_id,
) timeout=wait_timeout_seconds,
status = get_lark_integration_status(user_id, config, verify_auth=True) allow_empty_success=True,
)
status = get_lark_integration_status(user_id, config, verify_auth=True)
return LarkAuthCompleteResult( return LarkAuthCompleteResult(
success=status.auth.status == "authenticated", success=status.auth.status == "authenticated",
status=status, status=status,
@ -1292,23 +1416,34 @@ def _tenant_brand(result: dict[str, Any]) -> str | None:
return brand if brand in {"feishu", "lark"} else 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() path = _require_lark_cli_path()
try: try:
try: result = subprocess.run(
result = subprocess.run( [path, "config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", brand],
[path, "config", "init", "--app-id", app_id, "--app-secret-stdin", "--brand", _normalize_lark_brand(brand)], input=app_secret + "\n",
input=app_secret + "\n", check=False,
check=False, capture_output=True,
capture_output=True, text=True,
text=True, timeout=15,
timeout=15, env=env,
env=lark_cli_env(user_id), )
) except subprocess.TimeoutExpired as exc:
except subprocess.TimeoutExpired as exc: raise TimeoutError("Timed out while saving Lark connection setup.") from exc
raise TimeoutError("Timed out while saving Lark connection setup.") from exc
finally:
ensure_lark_cli_credential_tree(user_id)
if result.returncode != 0: if result.returncode != 0:
raw = (result.stderr or result.stdout or "").strip() raw = (result.stderr or result.stdout or "").strip()
parsed = _parse_json_object(raw) 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}") 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( def _run_lark_cli_json(
args: list[str], 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) 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: async def _config(tmp_path: Path) -> SimpleNamespace:
skills_root = tmp_path / "skills" skills_root = tmp_path / "skills"
await asyncio.to_thread((skills_root / "public").mkdir, parents=True, exist_ok=True) 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.setenv("PATH", f"{cli_path.parent}{os.pathsep}{os.environ.get('PATH', '')}")
monkeypatch.setattr(integrations, "get_effective_user_id", lambda: "loop-user") 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( response = await integrations.complete_lark_browser_auth(
request=None, request=None,
body=integrations.LarkAuthCompleteRequest(device_code="device-code"), body=integrations.LarkAuthCompleteRequest(device_code="device-code", generation=generation),
config=config, 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)) 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: def test_sandbox_lark_cli_env_prepends_managed_linux_runtime() -> None:
overlay = lark_cli.lark_cli_env_overlay("alice", sandbox_paths=True) 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.verification_url == "https://open.feishu.cn/auth/mock"
assert result.device_code == "device-code" 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"] == [ assert captured["args"] == [
"/usr/bin/lark-cli", "/usr/bin/lark-cli",
"auth", "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): def test_lark_cli_env_from_runtime_exposes_settings_auth_to_lark_commands(monkeypatch, tmp_path):
_patch_paths(monkeypatch, tmp_path / "home") _patch_paths(monkeypatch, tmp_path / "home")
runtime = SimpleNamespace(context={"user_id": "alice"}) 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 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: def test_lark_cli_json_rehardens_auth_files_written_by_cli(monkeypatch, tmp_path) -> None:
_patch_paths(monkeypatch, tmp_path / "home") _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 result.success is True
assert captured["args"] == [ 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( result = lark_cli.complete_lark_auth(
"alice", "alice",
config, config,
device_code="device-code", device_code="device-code",
generation=generation,
wait_timeout_seconds=8, wait_timeout_seconds=8,
) )
@ -1267,14 +1319,36 @@ def test_complete_lark_auth_accepts_short_automatic_poll_timeout(monkeypatch, tm
assert captured["timeout"] == 8 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: 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 "wait_timeout_seconds" in type(model).model_fields
assert model.wait_timeout_seconds == 8 assert model.wait_timeout_seconds == 8
with pytest.raises(ValueError): 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): 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): 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") result = lark_cli.start_lark_config("alice", brand="feishu")
assert result.device_code == "config-device-code" 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.user_code == "abc"
assert result.verification_url.startswith("https://open.feishu.cn/page/cli?") assert result.verification_url.startswith("https://open.feishu.cn/page/cli?")
assert "user_code=abc" in result.verification_url 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() (skills_root / "custom").mkdir()
config = _config(skills_root) config = _config(skills_root)
captured: dict[str, object] = {} 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( monkeypatch.setattr(
lark_cli, lark_cli,
@ -1320,6 +1405,11 @@ def test_complete_lark_config_saves_app_credentials_and_returns_status(monkeypat
"_save_lark_app_config_with_cli", "_save_lark_app_config_with_cli",
lambda user_id, **kwargs: captured.update({"user_id": user_id, **kwargs}), 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( monkeypatch.setattr(
lark_cli, lark_cli,
"get_lark_integration_status", "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.success is True
assert result.generation == generation
assert revoked == ["old-token"]
assert not token_file.exists()
assert captured == { assert captured == {
"user_id": "alice", "user_id": "alice",
"app_id": "cli_mock", "app_id": "cli_mock",
@ -1361,6 +1460,7 @@ def test_complete_lark_config_repolls_lark_tenant_for_client_secret(monkeypatch,
config = _config(skills_root) config = _config(skills_root)
poll_calls: list[dict[str, object]] = [] poll_calls: list[dict[str, object]] = []
captured: dict[str, object] = {} captured: dict[str, object] = {}
generation = _advance_lark_flow()
def _poll_lark_app_registration(**kwargs): def _poll_lark_app_registration(**kwargs):
poll_calls.append(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 result.success is True
assert [call["brand"] for call in poll_calls] == ["feishu", "lark"] 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: 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()) 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( lambda _user_id, **_kwargs: lark_cli.LarkConfigStartResult(
verification_url="https://open.feishu.cn/page/cli?user_code=config", verification_url="https://open.feishu.cn/page/cli?user_code=config",
device_code="config-device-code", device_code="config-device-code",
generation="config-generation",
expires_in=600, expires_in=600,
interval=5, interval=5,
user_code="config", 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.status_code == 200
assert response.json()["verification_url"] == "https://open.feishu.cn/page/cli?user_code=config" 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()["device_code"] == "config-device-code"
assert response.json()["generation"] == "config-generation"
def test_lark_config_complete_route_saves_app_credentials(monkeypatch, tmp_path): 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( lambda _user_id, _config, *, device_code, **_kwargs: lark_cli.LarkConfigCompleteResult(
success=True, success=True,
message=f"configured {device_code}", message=f"configured {device_code}",
generation="config-generation",
status=lark_cli.LarkIntegrationStatus( status=lark_cli.LarkIntegrationStatus(
installed=True, installed=True,
version="v1.0.65", 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: with TestClient(app) as client:
response = client.post( response = client.post(
"/api/integrations/lark/config/complete", "/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.status_code == 200
assert response.json()["success"] is True assert response.json()["success"] is True
assert response.json()["generation"] == "config-generation"
assert response.json()["status"]["app_configured"] is True 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): def test_lark_auth_start_route_returns_browser_url(monkeypatch, tmp_path):
config = _config(tmp_path / "skills") config = _config(tmp_path / "skills")
app = _make_app(system_role="user", config=config) 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( or lark_cli.LarkAuthStartResult(
verification_url="https://open.feishu.cn/auth/mock", verification_url="https://open.feishu.cn/auth/mock",
device_code="device-code", device_code="device-code",
generation="auth-generation",
expires_in=600, 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.status_code == 200
assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock" assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock"
assert response.json()["device_code"] == "device-code" 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): 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( or lark_cli.LarkAuthStartResult(
verification_url="https://open.feishu.cn/auth/mock", verification_url="https://open.feishu.cn/auth/mock",
device_code="device-code", device_code="device-code",
generation="auth-generation",
expires_in=600, 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.status_code == 200
assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock" assert response.json()["verification_url"] == "https://open.feishu.cn/auth/mock"
assert response.json()["device_code"] == "device-code" 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): 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) monkeypatch.setattr(integrations_router, "complete_lark_auth", _complete_auth)
with TestClient(app) as client: 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.status_code == 200
assert response.json()["success"] is True assert response.json()["success"] is True
assert response.json()["status"]["auth"]["status"] == "authenticated" assert response.json()["status"]["auth"]["status"] == "authenticated"
assert response.json()["status"]["auth"]["verified"] is True 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, mutation, disables switches until that mutation's success refetch completes,
displays the backend error `detail` through a toast, and invalidates displays the backend error `detail` through a toast, and invalidates
`["mcpConfig"]` only after success. `["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 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. 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"; "use client";
import { useQueryClient } from "@tanstack/react-query";
import { import {
CheckCircle2Icon, CheckCircle2Icon,
CopyIcon, CopyIcon,
@ -26,6 +27,7 @@ import { Input } from "@/components/ui/input";
import { useAuth } from "@/core/auth/AuthProvider"; import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
larkIntegrationQueryKey,
LarkIntegrationRequestError, LarkIntegrationRequestError,
type LarkAuthStartRequest, type LarkAuthStartRequest,
type LarkAuthStartResponse, type LarkAuthStartResponse,
@ -35,6 +37,7 @@ import {
useCompleteLarkConfiguration, useCompleteLarkConfiguration,
useInstallLarkIntegration, useInstallLarkIntegration,
useLarkIntegrationStatus, useLarkIntegrationStatus,
useSetLarkAppCredentials,
useStartLarkAuthorization, useStartLarkAuthorization,
useStartLarkConfiguration, useStartLarkConfiguration,
} from "@/core/integrations/lark"; } from "@/core/integrations/lark";
@ -124,6 +127,7 @@ export function IntegrationsSettingsPage() {
function LarkIntegrationCard() { function LarkIntegrationCard() {
const { t } = useI18n(); const { t } = useI18n();
const queryClient = useQueryClient();
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.system_role === "admin"; const isAdmin = user?.system_role === "admin";
const { data, isLoading, error, refetch, isFetching } = const { data, isLoading, error, refetch, isFetching } =
@ -133,8 +137,15 @@ function LarkIntegrationCard() {
const completeConfig = useCompleteLarkConfiguration(); const completeConfig = useCompleteLarkConfiguration();
const startAuth = useStartLarkAuthorization(); const startAuth = useStartLarkAuthorization();
const completeAuth = useCompleteLarkAuthorization(); const completeAuth = useCompleteLarkAuthorization();
const switchApp = useSetLarkAppCredentials();
const [pendingFlow, setPendingFlow] = useState<PendingLarkFlow | null>(null); const [pendingFlow, setPendingFlow] = useState<PendingLarkFlow | null>(null);
const [isCheckingConnection, setIsCheckingConnection] = useState(false); 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< const [selectedAuthDomains, setSelectedAuthDomains] = useState<
LarkAuthDomain[] LarkAuthDomain[]
>([]); >([]);
@ -147,10 +158,18 @@ function LarkIntegrationCard() {
); );
const authAttemptIdRef = useRef(0); const authAttemptIdRef = useRef(0);
const authDeadlineRef = useRef(0); const authDeadlineRef = useRef(0);
const isMountedRef = useRef(true); const flowGenerationRef = useRef(0);
const connectBusy = const connectBusy =
startConfig.isPending || completeConfig.isPending || startAuth.isPending; startConfig.isPending ||
const connectActionBusy = connectBusy || isCheckingConnection; completeConfig.isPending ||
startAuth.isPending ||
completeAuth.isPending ||
switchApp.isPending;
const integrationBusy =
connectBusy ||
isCheckingConnection ||
pendingFlow != null ||
install.isPending;
const credentialsConfigured = data?.auth.status === "authenticated"; const credentialsConfigured = data?.auth.status === "authenticated";
const isConnected = credentialsConfigured && data?.auth.verified === true; const isConnected = credentialsConfigured && data?.auth.verified === true;
// The sandbox-runtime readiness row only applies when the sandbox actually // 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( useEffect(
() => () => { () => () => {
isMountedRef.current = false; flowGenerationRef.current += 1;
if (authRetryTimeoutRef.current != null) { if (authRetryTimeoutRef.current != null) {
clearTimeout(authRetryTimeoutRef.current); clearTimeout(authRetryTimeoutRef.current);
} }
@ -226,39 +261,59 @@ function LarkIntegrationCard() {
const startUserAuth = ( const startUserAuth = (
browserWindow = browserWindowRef.current, browserWindow = browserWindowRef.current,
request = authRequestRef.current, request = authRequestRef.current,
generation = flowGenerationRef.current,
serverGeneration?: string,
) => { ) => {
startAuth.mutate(request, { startAuth.mutate(
onSuccess: (result) => { {
setPendingFlow({ kind: "auth", ...result }); ...request,
openAuthorizationUrl(result.verification_url, browserWindow); ...(serverGeneration ? { generation: serverGeneration } : {}),
authToastIdRef.current = toast.info(
t.settings.integrations.lark.authStarted,
);
startAutomaticAuthorizationCheck(result);
}, },
onError: (err) => { {
closePendingBrowserWindow(browserWindow); onSuccess: (result) => {
toast.error(err instanceof Error ? err.message : String(err)); 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 = () => { const handleContinueConnection = () => {
if (!pendingFlow || pendingFlow.kind !== "config") return; if (!pendingFlow || pendingFlow.kind !== "config") return;
const generation = flowGenerationRef.current;
completeConfig.mutate( completeConfig.mutate(
{ {
device_code: pendingFlow.device_code, device_code: pendingFlow.device_code,
generation: pendingFlow.generation,
brand: pendingFlow.brand, brand: pendingFlow.brand,
interval: pendingFlow.interval, interval: pendingFlow.interval,
expires_in: pendingFlow.expires_in, expires_in: pendingFlow.expires_in,
}, },
{ {
onSuccess: () => { onSuccess: (result) => {
if (!isActiveFlow(generation)) return;
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
toast.success(t.settings.integrations.lark.connectionReady); toast.success(t.settings.integrations.lark.connectionReady);
setPendingFlow(null); setPendingFlow(null);
startUserAuth(browserWindowRef.current); startUserAuth(
browserWindowRef.current,
authRequestRef.current,
generation,
result.generation,
);
}, },
onError: (err) => { onError: (err) => {
if (!isActiveFlow(generation)) return;
setPendingFlow(null); setPendingFlow(null);
toast.error(err instanceof Error ? err.message : String(err)); 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 = ( const startConnectionFlow = (
status: LarkIntegrationStatus, status: LarkIntegrationStatus,
browserWindow: Window | null, browserWindow: Window | null,
generation: number,
) => { ) => {
if (!status.app_configured) { if (!status.app_configured) {
startConfig.mutate( startBrowserAppRegistration("feishu", browserWindow, generation);
{ 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));
},
},
);
return; 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 => { const buildAuthRequest = (): LarkAuthStartRequest => {
@ -325,22 +437,32 @@ function LarkIntegrationCard() {
// would run outside the user gesture and be blocked by the browser. Opening // 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. // now and closing below when it turns out unneeded keeps the popup reliable.
const browserWindow = openPendingBrowserWindow(); const browserWindow = openPendingBrowserWindow();
const generation = beginFlow();
setIsCheckingConnection(true); setIsCheckingConnection(true);
try { try {
const refreshed = await refetch(); const refreshed = await refetch();
if (!isActiveFlow(generation)) return;
const latestStatus = refreshed.data ?? data; const latestStatus = refreshed.data ?? data;
startConnectionFlow(latestStatus, browserWindow); startConnectionFlow(latestStatus, browserWindow, generation);
} catch (err) { } catch (err) {
if (!isActiveFlow(generation)) return;
closePendingBrowserWindow(browserWindow); closePendingBrowserWindow(browserWindow);
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} finally { } finally {
setIsCheckingConnection(false); if (isActiveFlow(generation)) {
setIsCheckingConnection(false);
}
} }
}; };
const completeAuthorization = ( const completeAuthorization = (
deviceCode: string, deviceCode: string,
{ automatic, attemptId }: { automatic: boolean; attemptId?: number }, serverGeneration: string,
{
automatic,
attemptId,
generation,
}: { automatic: boolean; attemptId?: number; generation: number },
) => { ) => {
const toastOptions = const toastOptions =
authToastIdRef.current == null authToastIdRef.current == null
@ -349,6 +471,7 @@ function LarkIntegrationCard() {
completeAuth.mutate( completeAuth.mutate(
{ {
device_code: deviceCode, device_code: deviceCode,
generation: serverGeneration,
...(automatic ...(automatic
? { wait_timeout_seconds: AUTOMATIC_LARK_AUTH_WAIT_SECONDS } ? { 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 // react-query still fires this after the dialog unmounts; bail so we
// don't toast, setState, refetch, or reschedule a retry timer on a // don't toast, setState, refetch, or reschedule a retry timer on a
// component that is gone. // component that is gone.
if (!isMountedRef.current) { if (!isActiveFlow(generation)) return;
return;
}
if (automatic && attemptId !== authAttemptIdRef.current) { if (automatic && attemptId !== authAttemptIdRef.current) {
return; return;
} }
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
if (result.success) { if (result.success) {
clearAuthRetryTimer(); clearAuthRetryTimer();
toast.success(result.message, toastOptions); toast.success(result.message, toastOptions);
@ -378,13 +500,16 @@ function LarkIntegrationCard() {
toastOptions, toastOptions,
); );
if (automatic && attemptId != null) { if (automatic && attemptId != null) {
scheduleAuthorizationRetry(deviceCode, attemptId); scheduleAuthorizationRetry(
deviceCode,
serverGeneration,
attemptId,
generation,
);
} }
}, },
onError: (err) => { onError: (err) => {
if (!isMountedRef.current) { if (!isActiveFlow(generation)) return;
return;
}
if (automatic && attemptId !== authAttemptIdRef.current) { if (automatic && attemptId !== authAttemptIdRef.current) {
return; return;
} }
@ -398,7 +523,12 @@ function LarkIntegrationCard() {
toastOptions, toastOptions,
); );
if (attemptId != null) { if (attemptId != null) {
scheduleAuthorizationRetry(deviceCode, attemptId); scheduleAuthorizationRetry(
deviceCode,
serverGeneration,
attemptId,
generation,
);
} }
return; return;
} }
@ -414,28 +544,39 @@ function LarkIntegrationCard() {
const scheduleAuthorizationRetry = ( const scheduleAuthorizationRetry = (
deviceCode: string, deviceCode: string,
serverGeneration: string,
attemptId: number, attemptId: number,
generation: number,
) => { ) => {
clearAuthRetryTimer(); clearAuthRetryTimer();
if (!isMountedRef.current) { if (!isActiveFlow(generation)) return;
return;
}
if (Date.now() >= authDeadlineRef.current) { if (Date.now() >= authDeadlineRef.current) {
toast.info(t.settings.integrations.lark.authorizationStillPending); toast.info(t.settings.integrations.lark.authorizationStillPending);
return; return;
} }
authRetryTimeoutRef.current = setTimeout(() => { authRetryTimeoutRef.current = setTimeout(() => {
completeAuthorization(deviceCode, { automatic: true, attemptId }); completeAuthorization(deviceCode, serverGeneration, {
automatic: true,
attemptId,
generation,
});
}, 1500); }, 1500);
}; };
const startAutomaticAuthorizationCheck = (result: LarkAuthStartResponse) => { const startAutomaticAuthorizationCheck = (
result: LarkAuthStartResponse,
generation: number,
) => {
clearAuthRetryTimer(); clearAuthRetryTimer();
const attemptId = authAttemptIdRef.current + 1; const attemptId = authAttemptIdRef.current + 1;
authAttemptIdRef.current = attemptId; authAttemptIdRef.current = attemptId;
authDeadlineRef.current = authDeadlineRef.current =
Date.now() + Math.max(result.expires_in ?? 300, 30) * 1000; 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 = () => { const handleCompleteAuth = () => {
@ -445,7 +586,10 @@ function LarkIntegrationCard() {
} }
clearAuthRetryTimer(); clearAuthRetryTimer();
authAttemptIdRef.current += 1; authAttemptIdRef.current += 1;
completeAuthorization(pendingFlow.device_code, { automatic: false }); completeAuthorization(pendingFlow.device_code, pendingFlow.generation, {
automatic: false,
generation: flowGenerationRef.current,
});
}; };
const handleCopyAuthLink = async () => { const handleCopyAuthLink = async () => {
@ -461,12 +605,12 @@ function LarkIntegrationCard() {
const installDisabled = const installDisabled =
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
!isAdmin || !isAdmin ||
install.isPending; integrationBusy;
const authDisabled = const authDisabled =
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
!data?.installed || !data?.installed ||
!data?.cli.available || !data?.cli.available ||
connectActionBusy; integrationBusy;
const connectButtonLabel = isCheckingConnection const connectButtonLabel = isCheckingConnection
? t.settings.integrations.lark.checkingConnection ? t.settings.integrations.lark.checkingConnection
@ -637,7 +781,7 @@ function LarkIntegrationCard() {
size="sm" size="sm"
variant={selected ? "default" : "outline"} variant={selected ? "default" : "outline"}
onClick={() => toggleAuthDomain(domain.id)} onClick={() => toggleAuthDomain(domain.id)}
disabled={connectActionBusy} disabled={integrationBusy}
title={domain.description} title={domain.description}
> >
{domain.label} {domain.label}
@ -651,7 +795,7 @@ function LarkIntegrationCard() {
onChange={(event) => onChange={(event) =>
setCustomAuthScope(event.currentTarget.value) setCustomAuthScope(event.currentTarget.value)
} }
disabled={connectActionBusy} disabled={integrationBusy}
placeholder={ placeholder={
t.settings.integrations.lark.customScopePlaceholder t.settings.integrations.lark.customScopePlaceholder
} }
@ -679,17 +823,110 @@ function LarkIntegrationCard() {
onClick={() => void handleConnect()} onClick={() => void handleConnect()}
disabled={authDisabled} disabled={authDisabled}
> >
{connectActionBusy ? ( {connectBusy || isCheckingConnection ? (
<RefreshCwIcon className="size-4 animate-spin" /> <RefreshCwIcon className="size-4 animate-spin" />
) : null} ) : null}
{connectButtonLabel} {connectButtonLabel}
</Button> </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 && ( {!isAdmin && (
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
{t.settings.integrations.adminRequired} {t.settings.integrations.adminRequired}
</span> </span>
)} )}
</div> </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 && ( {install.isPending && (
<Alert> <Alert>
<RefreshCwIcon className="size-4 animate-spin" /> <RefreshCwIcon className="size-4 animate-spin" />

View File

@ -910,6 +910,20 @@ export const enUS: Translations = {
requestPermissions: "Request permissions", requestPermissions: "Request permissions",
alreadyConnected: alreadyConnected:
"Lark is already connected. If authorization expires, refresh the status and reconnect.", "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", connectionStarted: "Connection link opened",
connectionReady: "Connection is ready. Opening authorization...", connectionReady: "Connection is ready. Opening authorization...",
authStarted: authStarted:

View File

@ -769,6 +769,17 @@ export interface Translations {
connectedAction: string; connectedAction: string;
requestPermissions: string; requestPermissions: string;
alreadyConnected: 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; connectionStarted: string;
connectionReady: string; connectionReady: string;
authStarted: string; authStarted: string;

View File

@ -872,6 +872,19 @@ export const zhCN: Translations = {
requestPermissions: "申请新权限", requestPermissions: "申请新权限",
alreadyConnected: 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: "连接链接已打开", connectionStarted: "连接链接已打开",
connectionReady: "连接准备已完成,正在打开授权链接", connectionReady: "连接准备已完成,正在打开授权链接",
authStarted: "授权页已打开DeerFlow 会自动检测授权结果。", authStarted: "授权页已打开DeerFlow 会自动检测授权结果。",

View File

@ -8,6 +8,7 @@ import type {
LarkAuthStartResponse, LarkAuthStartResponse,
LarkConfigCompleteRequest, LarkConfigCompleteRequest,
LarkConfigCompleteResponse, LarkConfigCompleteResponse,
LarkConfigCredentialsRequest,
LarkConfigStartRequest, LarkConfigStartRequest,
LarkConfigStartResponse, LarkConfigStartResponse,
LarkInstallResponse, LarkInstallResponse,
@ -35,9 +36,12 @@ async function readErrorDetail(response: Response): Promise<string> {
return data.detail ?? `HTTP ${response.status}: ${response.statusText}`; 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( const response = await fetch(
`${getBackendBaseURL()}/api/integrations/lark/status`, `${getBackendBaseURL()}/api/integrations/lark/status`,
{ signal },
); );
if (!response.ok) { if (!response.ok) {
throw new LarkIntegrationRequestError( throw new LarkIntegrationRequestError(
@ -130,6 +134,28 @@ export async function completeLarkConfiguration(
return response.json(); 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( export async function completeLarkAuthorization(
request: LarkAuthCompleteRequest, request: LarkAuthCompleteRequest,
): Promise<LarkAuthCompleteResponse> { ): Promise<LarkAuthCompleteResponse> {

View File

@ -5,6 +5,7 @@ import {
completeLarkConfiguration, completeLarkConfiguration,
installLarkIntegration, installLarkIntegration,
loadLarkIntegrationStatus, loadLarkIntegrationStatus,
setLarkAppCredentials,
startLarkAuthorization, startLarkAuthorization,
startLarkConfiguration, startLarkConfiguration,
} from "./api"; } from "./api";
@ -14,7 +15,7 @@ export const larkIntegrationQueryKey = ["integrations", "lark"] as const;
export function useLarkIntegrationStatus() { export function useLarkIntegrationStatus() {
return useQuery({ return useQuery({
queryKey: larkIntegrationQueryKey, queryKey: larkIntegrationQueryKey,
queryFn: loadLarkIntegrationStatus, queryFn: ({ signal }) => loadLarkIntegrationStatus(signal),
}); });
} }
@ -45,24 +46,19 @@ export function useStartLarkConfiguration() {
} }
export function useCompleteLarkConfiguration() { export function useCompleteLarkConfiguration() {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: completeLarkConfiguration, 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() { export function useCompleteLarkAuthorization() {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: completeLarkAuthorization, mutationFn: completeLarkAuthorization,
onSuccess: (result) => {
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
},
}); });
} }

View File

@ -55,11 +55,13 @@ export interface LarkAuthStartRequest {
recommend?: boolean; recommend?: boolean;
domains?: string[]; domains?: string[];
scope?: string | null; scope?: string | null;
generation?: string;
} }
export interface LarkAuthStartResponse { export interface LarkAuthStartResponse {
verification_url: string; verification_url: string;
device_code: string; device_code: string;
generation: string;
expires_in: number | null; expires_in: number | null;
user_code: string | null; user_code: string | null;
hint: string | null; hint: string | null;
@ -72,6 +74,7 @@ export interface LarkConfigStartRequest {
export interface LarkConfigStartResponse { export interface LarkConfigStartResponse {
verification_url: string; verification_url: string;
device_code: string; device_code: string;
generation: string;
expires_in: number | null; expires_in: number | null;
interval: number | null; interval: number | null;
user_code: string | null; user_code: string | null;
@ -80,19 +83,28 @@ export interface LarkConfigStartResponse {
export interface LarkConfigCompleteRequest { export interface LarkConfigCompleteRequest {
device_code: string; device_code: string;
generation: string;
brand: "feishu" | "lark"; brand: "feishu" | "lark";
interval: number | null; interval: number | null;
expires_in: number | null; expires_in: number | null;
} }
export interface LarkConfigCredentialsRequest {
app_id: string;
app_secret: string;
brand: "feishu" | "lark";
}
export interface LarkConfigCompleteResponse { export interface LarkConfigCompleteResponse {
success: boolean; success: boolean;
message: string; message: string;
generation: string;
status: LarkIntegrationStatus; status: LarkIntegrationStatus;
} }
export interface LarkAuthCompleteRequest { export interface LarkAuthCompleteRequest {
device_code: string; device_code: string;
generation: string;
wait_timeout_seconds?: number; wait_timeout_seconds?: number;
} }

View File

@ -2,6 +2,39 @@ import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api"; 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.describe("Integrations settings", () => {
test("opens integrations settings from a query-string deep link", async ({ test("opens integrations settings from a query-string deep link", async ({
page, page,
@ -73,6 +106,7 @@ test.describe("Integrations settings", () => {
body: JSON.stringify({ body: JSON.stringify({
verification_url: "about:blank", verification_url: "about:blank",
device_code: "mock-config-device-code", device_code: "mock-config-device-code",
generation: "config-generation",
expires_in: 600, expires_in: 600,
interval: 5, interval: 5,
user_code: "config", user_code: "config",
@ -82,12 +116,14 @@ test.describe("Integrations settings", () => {
}); });
await page.route("**/api/integrations/lark/auth/start", async (route) => { await page.route("**/api/integrations/lark/auth/start", async (route) => {
authStartRequest = route.request().postDataJSON(); authStartRequest = route.request().postDataJSON();
const request = authStartRequest as { generation?: string };
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
verification_url: "https://open.feishu.cn/auth/mock-device", verification_url: "https://open.feishu.cn/auth/mock-device",
device_code: "mock-device-code", device_code: "mock-device-code",
generation: request.generation ?? "auth-generation",
expires_in: 600, expires_in: 600,
user_code: null, user_code: null,
hint: null, hint: null,
@ -141,12 +177,14 @@ test.describe("Integrations settings", () => {
recommend: false, recommend: false,
domains: ["calendar"], domains: ["calendar"],
scope: "calendar:calendar.event:read", scope: "calendar:calendar.event:read",
generation: "config-generation",
}); });
await expect await expect
.poll(() => authCompleteRequests) .poll(() => authCompleteRequests)
.toContainEqual({ .toContainEqual({
device_code: "mock-device-code", device_code: "mock-device-code",
generation: "config-generation",
wait_timeout_seconds: 8, wait_timeout_seconds: 8,
}); });
await expect( await expect(
@ -163,4 +201,164 @@ test.describe("Integrations settings", () => {
dialog.getByText("https://open.feishu.cn/auth/mock-device"), dialog.getByText("https://open.feishu.cn/auth/mock-device"),
).toBeVisible(); ).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({ body: JSON.stringify({
verification_url: "https://open.feishu.cn/page/cli?user_code=config", verification_url: "https://open.feishu.cn/page/cli?user_code=config",
device_code: "mock-config-device-code", device_code: "mock-config-device-code",
generation: "config-generation",
expires_in: 600, expires_in: 600,
interval: 5, interval: 5,
user_code: "config", user_code: "config",
@ -1278,6 +1279,36 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
body: JSON.stringify({ body: JSON.stringify({
success: true, success: true,
message: "Lark/Feishu connection setup completed.", 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, status: larkIntegrationStatus,
}), }),
}); });
@ -1287,12 +1318,16 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
void page.route("**/api/integrations/lark/auth/start", (route) => { void page.route("**/api/integrations/lark/auth/start", (route) => {
if (route.request().method() === "POST") { if (route.request().method() === "POST") {
const request = route.request().postDataJSON() as {
generation?: string;
};
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
verification_url: "https://open.feishu.cn/auth/mock-device", verification_url: "https://open.feishu.cn/auth/mock-device",
device_code: "mock-device-code", device_code: "mock-device-code",
generation: request.generation ?? "auth-generation",
expires_in: 600, expires_in: 600,
user_code: null, user_code: null,
hint: null, hint: null,

View File

@ -15,6 +15,7 @@ import {
installLarkIntegration, installLarkIntegration,
LarkIntegrationRequestError, LarkIntegrationRequestError,
loadLarkIntegrationStatus, loadLarkIntegrationStatus,
setLarkAppCredentials,
startLarkAuthorization, startLarkAuthorization,
startLarkConfiguration, startLarkConfiguration,
} from "@/core/integrations/lark/api"; } from "@/core/integrations/lark/api";
@ -35,6 +36,7 @@ beforeEach(() => {
describe("lark integration api", () => { describe("lark integration api", () => {
test("loads status", async () => { test("loads status", async () => {
const controller = new AbortController();
mockedFetch.mockResolvedValueOnce( mockedFetch.mockResolvedValueOnce(
jsonResponse(200, { jsonResponse(200, {
installed: false, installed: false,
@ -58,7 +60,9 @@ describe("lark integration api", () => {
}), }),
); );
await expect(loadLarkIntegrationStatus()).resolves.toMatchObject({ await expect(
loadLarkIntegrationStatus(controller.signal),
).resolves.toMatchObject({
installed: false, installed: false,
version: "v1.0.65", version: "v1.0.65",
sandbox_runtime_mode: "init-container", sandbox_runtime_mode: "init-container",
@ -66,6 +70,7 @@ describe("lark integration api", () => {
}); });
expect(mockedFetch).toHaveBeenCalledWith( expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/integrations/lark/status", "/backend/api/integrations/lark/status",
{ signal: controller.signal },
); );
}); });
@ -134,6 +139,7 @@ describe("lark integration api", () => {
jsonResponse(200, { jsonResponse(200, {
verification_url: "https://open.feishu.cn/auth/mock", verification_url: "https://open.feishu.cn/auth/mock",
device_code: "device-code", device_code: "device-code",
generation: "auth-generation",
expires_in: 600, expires_in: 600,
user_code: null, user_code: null,
hint: null, hint: null,
@ -149,6 +155,7 @@ describe("lark integration api", () => {
).resolves.toEqual({ ).resolves.toEqual({
verification_url: "https://open.feishu.cn/auth/mock", verification_url: "https://open.feishu.cn/auth/mock",
device_code: "device-code", device_code: "device-code",
generation: "auth-generation",
expires_in: 600, expires_in: 600,
user_code: null, user_code: null,
hint: null, hint: null,
@ -172,6 +179,7 @@ describe("lark integration api", () => {
jsonResponse(200, { jsonResponse(200, {
verification_url: "https://open.feishu.cn/page/cli?user_code=config", verification_url: "https://open.feishu.cn/page/cli?user_code=config",
device_code: "config-device-code", device_code: "config-device-code",
generation: "config-generation",
expires_in: 600, expires_in: 600,
interval: 5, interval: 5,
user_code: "config", user_code: "config",
@ -182,6 +190,7 @@ describe("lark integration api", () => {
await expect(startLarkConfiguration({ brand: "feishu" })).resolves.toEqual({ await expect(startLarkConfiguration({ brand: "feishu" })).resolves.toEqual({
verification_url: "https://open.feishu.cn/page/cli?user_code=config", verification_url: "https://open.feishu.cn/page/cli?user_code=config",
device_code: "config-device-code", device_code: "config-device-code",
generation: "config-generation",
expires_in: 600, expires_in: 600,
interval: 5, interval: 5,
user_code: "config", user_code: "config",
@ -202,6 +211,7 @@ describe("lark integration api", () => {
jsonResponse(200, { jsonResponse(200, {
success: true, success: true,
message: "Lark/Feishu connection setup completed.", message: "Lark/Feishu connection setup completed.",
generation: "config-generation",
status: { status: {
installed: true, installed: true,
version: "v1.0.65", version: "v1.0.65",
@ -234,6 +244,7 @@ describe("lark integration api", () => {
await expect( await expect(
completeLarkConfiguration({ completeLarkConfiguration({
device_code: "config-device-code", device_code: "config-device-code",
generation: "config-generation",
brand: "feishu", brand: "feishu",
interval: 5, interval: 5,
expires_in: 600, expires_in: 600,
@ -249,6 +260,7 @@ describe("lark integration api", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
device_code: "config-device-code", device_code: "config-device-code",
generation: "config-generation",
brand: "feishu", brand: "feishu",
interval: 5, interval: 5,
expires_in: 600, 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 () => { test("completes browser authorization", async () => {
mockedFetch.mockResolvedValueOnce( mockedFetch.mockResolvedValueOnce(
jsonResponse(200, { jsonResponse(200, {
@ -292,7 +364,10 @@ describe("lark integration api", () => {
); );
await expect( await expect(
completeLarkAuthorization({ device_code: "device-code" }), completeLarkAuthorization({
device_code: "device-code",
generation: "auth-generation",
}),
).resolves.toMatchObject({ ).resolves.toMatchObject({
success: true, success: true,
status: { auth: { status: "authenticated", user: "Alice" } }, status: { auth: { status: "authenticated", user: "Alice" } },
@ -302,7 +377,10 @@ describe("lark integration api", () => {
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code: "device-code" }), body: JSON.stringify({
device_code: "device-code",
generation: "auth-generation",
}),
}, },
); );
}); });