diff --git a/README.md b/README.md index e89cb5802a..1652f76b94 100644 --- a/README.md +++ b/README.md @@ -1170,7 +1170,11 @@ no hostPath/PVC runtime mount. Publish the image under [`docker/lark-cli-init`](docker/lark-cli-init/README.md) and set `LARK_CLI_INIT_IMAGE` on the provisioner; it stays off (legacy behavior) when unset. The Lark integration status (`GET /api/integrations/lark/status`) reports -`sandbox_runtime_mode` and `sandbox_runtime_ready` so the Settings UI shows +`sandbox_runtime_mode`, `sandbox_runtime_probed`, and `sandbox_runtime_ready`. +`sandbox_runtime_probed` marks whether runtime readiness was actually +evaluated; responses from older backends may omit the flag, in which case the +Settings mutation cache keeps the last probed runtime fields instead of +overwriting them with an unevaluated fallback — so the Settings UI shows whether `lark-cli` will actually be present in the sandbox at chat time, rather than a green status hiding a later `command not found`. diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index 2a574c5eae..69cab1d358 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -112,7 +112,7 @@ owner-scoped assistant version selection remains enabled. | **MCP Tasks** (`/api/threads/{id}/mcp-tasks`) | `GET /` - current user's durable tasks for one owned thread; `GET /{task_id}` - bounded result/input/status-error/cancellation-error detail, including cancellation attempt count, without remote task IDs or driver configuration | | **Skills** (`/api/skills`) | `GET /` - list; `GET /{name}` - inspect; `PUT /{name}` - toggle; `POST /install` - install a thread-local .skill archive; `POST /install/upload` - admin-only multipart, authorized before parsing and capped at a 100 MiB file plus 1 MiB framing; `POST /reload` - invalidate process-local cache after trusted filesystem changes | | **Subagents** (`/api/subagents`) | Admin managed-worker CRUD and listing. | -| **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. | +| **Integrations** (`/api/integrations`) | `GET /lark/status` - inspect managed Lark/Feishu CLI integration state, including `sandbox_runtime_mode` / `sandbox_runtime_probed` / `sandbox_runtime_ready` (whether `lark-cli` will be present in the sandbox at chat time); `POST /lark/install` - admin-only install of the official `lark-*` managed skill pack; `POST /lark/config/start` and `/lark/config/complete` - internal first-time Lark connection setup; `POST /lark/config/credentials` - atomically switch the caller's per-user Lark app after validating the new `app_id`/`app_secret` through the official CLI's live tenant-token probe, revoke/remove the previous OAuth tokens, and restore the prior credential tree if the switch fails; `POST /lark/auth/start` and `/lark/auth/complete` - browser device-flow user authorization without terminal access, with optional `domains` / exact `scope` for incremental permission grants. Config and auth flows carry a server-issued, per-user generation persisted under the credential lock; a rejected direct switch leaves the current generation unchanged, stale completions return 409, and browser re-registration uses the same token-clearing/revocation transaction as direct credential switches. | | **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data | | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); non-mounted sync releases its lease after the last holder exits; `GET /list` - list; `DELETE /{filename}` - delete a regular file; symlinks 404 and a converted `.md` is kept (#5672) | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - branch a completed assistant turn with a replay checkpoint; inherited titles take next-free displayed sibling suffixes, including explicit/renamed ones, while explicit titles stay unchanged. Durable `branch` admission rejects races. 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` - summarize older active context, deriving memory policy and bucket from the state-producing checkpoint rather than request `agent_name`, and block while a run is in flight; unexpected failures return a generic 500 detail | diff --git a/backend/app/gateway/routers/integrations.py b/backend/app/gateway/routers/integrations.py index 32b8b40537..12f6891804 100644 --- a/backend/app/gateway/routers/integrations.py +++ b/backend/app/gateway/routers/integrations.py @@ -82,6 +82,7 @@ class LarkIntegrationStatusResponse(BaseModel): cli: LarkCliProbeResponse auth: LarkAuthProbeResponse sandbox_runtime_mode: str = Field("none", description="How lark-cli is provisioned into the sandbox: none, gateway-download, init-container, or broker") + sandbox_runtime_probed: bool = Field(False, description="Whether sandbox runtime readiness was evaluated rather than conservatively defaulted") sandbox_runtime_ready: bool = Field(False, description="Whether the sandbox lark-cli runtime is provisioned and usable at chat time") sandbox_runtime_detail: str | None = Field(None, description="Human-readable reason when the sandbox runtime is not ready") @@ -202,6 +203,7 @@ def _status_to_response(status: LarkIntegrationStatus, *, include_host_paths: bo cli=cli, auth=_auth_probe_to_response(status.auth), sandbox_runtime_mode=status.sandbox_runtime_mode, + sandbox_runtime_probed=status.sandbox_runtime_probed, sandbox_runtime_ready=status.sandbox_runtime_ready, sandbox_runtime_detail=status.sandbox_runtime_detail, ) diff --git a/backend/packages/harness/deerflow/integrations/lark_cli.py b/backend/packages/harness/deerflow/integrations/lark_cli.py index e09292233e..721c65fa50 100644 --- a/backend/packages/harness/deerflow/integrations/lark_cli.py +++ b/backend/packages/harness/deerflow/integrations/lark_cli.py @@ -215,6 +215,7 @@ class LarkIntegrationStatus: cli: LarkCliProbe auth: LarkAuthProbe sandbox_runtime_mode: str = "none" + sandbox_runtime_probed: bool = False sandbox_runtime_ready: bool = False sandbox_runtime_detail: str | None = None @@ -1616,7 +1617,7 @@ def _resolve_sandbox_runtime_readiness( config: AppConfig, *, probe: bool, -) -> tuple[str, bool, str | None]: +) -> tuple[str, bool, str | None, bool]: """Resolve the sandbox lark-cli runtime mode and readiness. Modes: @@ -1632,31 +1633,37 @@ def _resolve_sandbox_runtime_readiness( supersedes ``init-container`` when both are available. ``probe`` gates the (best-effort, short-timeout) provisioner capability call. + + The fourth element reports whether readiness was actually evaluated. It is + False only for the unevaluated remote fallback (``probe=False`` with a + remote provisioner) and lives next to that branch so the two can never + drift apart; every other mode resolves readiness deterministically or via + a real probe, so it reports True. """ if not _uses_aio_sandbox(config): - return "none", False, "Sandbox does not run lark-cli in this configuration." + return "none", False, "Sandbox does not run lark-cli in this configuration.", True if _uses_remote_provisioner(config): if not probe: - return "init-container", False, None + return "init-container", False, None, False caps = _probe_provisioner_capabilities(config) if caps is None: - return "init-container", False, "Could not reach the provisioner to confirm the lark-cli runtime image." + return "init-container", False, "Could not reach the provisioner to confirm the lark-cli runtime image.", True # Pattern B (broker) supersedes Pattern A (init-container binary) when # the provisioner has a broker image configured. if caps["lark_cli_broker_image"]: - return "broker", True, None + return "broker", True, None, True if caps["lark_cli_init_image"]: - return "init-container", True, None - return "init-container", False, "The provisioner has no lark-cli runtime image configured (LARK_CLI_INIT_IMAGE / LARK_CLI_BROKER_IMAGE)." + return "init-container", True, None, True + return "init-container", False, "The provisioner has no lark-cli runtime image configured (LARK_CLI_INIT_IMAGE / LARK_CLI_BROKER_IMAGE).", True # Local AIO: Gateway-download runtime dir. runtime_dir = lark_cli_managed_sandbox_dir() try: _validate_lark_cli_sandbox_runtime(runtime_dir) except (ValueError, OSError): - return "gateway-download", False, "The managed sandbox lark-cli runtime is not installed." - return "gateway-download", True, None + return "gateway-download", False, "The managed sandbox lark-cli runtime is not installed.", True + return "gateway-download", True, None, True LARK_BROKER_MODE_TTL_SECONDS = 60 @@ -1714,6 +1721,29 @@ def sandbox_lark_broker_active(config: AppConfig | None = None) -> bool: return active +@dataclass(frozen=True) +class _LarkCredentialSnapshot: + """Credential-derived status fields captured under ``_lark_credential_lock``. + + Mutation endpoints must snapshot these fields in the same critical section + that commits the credential change; the slower, credential-independent + runtime probe can then run after the lock is released without letting a + concurrent app switch or auth flow mix newer credential state (or cleared + tokens) into the response for the just-completed mutation. + """ + + app_config: dict[str, str | bool | None] + auth: LarkAuthProbe + + +def _read_lark_credential_snapshot(user_id: str, *, verify_auth: bool) -> _LarkCredentialSnapshot: + """Read the credential-derived status fields; call under the credential lock.""" + return _LarkCredentialSnapshot( + app_config=read_lark_app_config(user_id), + auth=probe_lark_auth(user_id, verify=verify_auth), + ) + + def get_lark_integration_status( user_id: str, config: AppConfig, @@ -1721,16 +1751,19 @@ def get_lark_integration_status( verify_auth: bool = False, check_latest: bool = False, check_runtime: bool = False, + credential_snapshot: _LarkCredentialSnapshot | None = None, ) -> LarkIntegrationStatus: root = lark_integration_root(user_id) manifest = _read_manifest(root) - app_config = read_lark_app_config(user_id) + if credential_snapshot is None: + credential_snapshot = _read_lark_credential_snapshot(user_id, verify_auth=verify_auth) + app_config = credential_snapshot.app_config installed_skills = tuple(sorted(_installed_lark_skill_names(root))) enabled_skills = tuple(sorted(_enabled_lark_skill_names(user_id, config))) manifest_version = str(manifest.get("version")) if manifest else None cli = probe_lark_cli() latest_available = _cached_latest_lark_cli_version() if check_latest else None - runtime_mode, runtime_ready, runtime_detail = _resolve_sandbox_runtime_readiness(config, probe=check_runtime) + runtime_mode, runtime_ready, runtime_detail, runtime_probed = _resolve_sandbox_runtime_readiness(config, probe=check_runtime) return LarkIntegrationStatus( installed=bool(manifest) and "lark-shared" in installed_skills, version=manifest_version or FALLBACK_LARK_CLI_VERSION, @@ -1746,13 +1779,39 @@ def get_lark_integration_status( enabled_skills=enabled_skills, install_path=str(root), cli=cli, - auth=probe_lark_auth(user_id, verify=verify_auth), + auth=credential_snapshot.auth, sandbox_runtime_mode=runtime_mode, + sandbox_runtime_probed=runtime_probed, sandbox_runtime_ready=runtime_ready, sandbox_runtime_detail=runtime_detail, ) +def _get_lark_mutation_status( + user_id: str, + config: AppConfig, + *, + verify_auth: bool = False, + credential_snapshot: _LarkCredentialSnapshot | None = None, +) -> LarkIntegrationStatus: + """Build a mutation response with the same runtime probe used by the GET route. + + Pass ``credential_snapshot`` (captured under ``_lark_credential_lock`` right + after the mutation) so the credential-derived fields stay tied to the + committed change while the independent runtime probe runs unlocked; without + it, a concurrent app switch or auth flow landing during that probe could + clear the new tokens or mix a newer ``app_id``/auth state into this + response. + """ + return get_lark_integration_status( + user_id, + config, + verify_auth=verify_auth, + check_runtime=True, + credential_snapshot=credential_snapshot, + ) + + def _normalize_version(value: str | None) -> str | None: """Extract a comparable ``major.minor.patch`` from a version-ish string.""" if not value: @@ -1855,7 +1914,17 @@ def install_lark_integration( sandbox_version = str(installed_manifest.get("version") or resolved_version or FALLBACK_LARK_CLI_VERSION) _ensure_managed_sandbox_lark_cli(sandbox_version) - status = get_lark_integration_status(user_id, config) + # Install does not change credentials, but it shares the mutation-status + # discipline: snapshot the credential-derived fields under the credential + # lock so a concurrent app switch or auth flow landing during the unlocked + # runtime probe cannot mix newer credential state into this response. The + # snapshot's auth probe runs `lark-cli auth status` as a subprocess + # (worst case its 8s timeout), so a concurrent same-user mutation may now + # wait on the lock for that duration — an accepted trade-off since install + # itself never mutates credentials. + with _lark_credential_lock(user_id): + credential_snapshot = _read_lark_credential_snapshot(user_id, verify_auth=False) + status = _get_lark_mutation_status(user_id, config, credential_snapshot=credential_snapshot) content_changed = previous_content_sha is not None and previous_content_sha != content_sha message = f"Installed {len(installed_skills)} Lark/Feishu skills." if content_changed: @@ -1990,7 +2059,8 @@ def complete_lark_config( app_secret=app_secret, brand=final_brand, ) - status = get_lark_integration_status(user_id, config) + credential_snapshot = _read_lark_credential_snapshot(user_id, verify_auth=False) + status = _get_lark_mutation_status(user_id, config, credential_snapshot=credential_snapshot) return LarkConfigCompleteResult( success=True, status=status, @@ -2027,7 +2097,8 @@ def set_lark_app_credentials( app_secret=app_secret, brand=parsed_brand, ) - status = get_lark_integration_status(user_id, config) + credential_snapshot = _read_lark_credential_snapshot(user_id, verify_auth=False) + status = _get_lark_mutation_status(user_id, config, credential_snapshot=credential_snapshot) return LarkConfigCompleteResult( success=True, status=status, @@ -2105,7 +2176,8 @@ def complete_lark_auth( timeout=wait_timeout_seconds, allow_empty_success=True, ) - status = get_lark_integration_status(user_id, config, verify_auth=True) + credential_snapshot = _read_lark_credential_snapshot(user_id, verify_auth=True) + status = _get_lark_mutation_status(user_id, config, verify_auth=True, credential_snapshot=credential_snapshot) return LarkAuthCompleteResult( success=status.auth.status == "authenticated", status=status, diff --git a/backend/packages/harness/deerflow/skills/AGENTS.md b/backend/packages/harness/deerflow/skills/AGENTS.md index d7858f467e..7fe6e2165e 100644 --- a/backend/packages/harness/deerflow/skills/AGENTS.md +++ b/backend/packages/harness/deerflow/skills/AGENTS.md @@ -13,7 +13,7 @@ - `skills/describe.py` — `build_describe_skill_tool(catalog)` builds the `describe_skill` tool as a closure; `build_skill_search_setup(skills, enabled, ...)` produces a `SkillSearchSetup(describe_skill_tool, skill_names)` that is wired into both the LangGraph agent factory (`agent.py`) and the embedded client (`client.py`). - **Slash activation**: `/skill-name task` loads that enabled skill's `SKILL.md` for the current model call only. The resolver rejects leading whitespace, missing separators, reserved command syntax (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`, `/agent`, and the exact composer-only alias `/context compact`), disabled skills, and skills outside a custom agent's whitelist. A custom skill named `context` remains activatable for other task text. - **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory -- **Managed integrations**: Lark/Feishu CLI support installs one global official `lark-*` pack as read-only `SkillCategory.INTEGRATION` entries under `/mnt/skills/integrations/lark-cli/...`; enabled flags, app configuration, and OAuth data remain per-user. Install resolves the newest `larksuite/cli` release from GitHub (`releases/latest`) at install time (falling back to a bottom-line pinned version if the lookup fails) rather than hard-coding the pack version; integrity relies on the official host + structural archive guards + a recorded hash of the effective installed tree after shared guidance injection (not a pinned archive-byte SHA, which GitHub does not keep stable). The Gateway image still installs a pinned `@larksuite/cli` binary, so `get_lark_integration_status` surfaces `latest_available_version` and `runtime_version_mismatch` for the UI. AIO installs additionally verify and publish official Linux amd64/arm64 binaries under `{DEER_FLOW_HOME}/integrations/lark-cli/sandbox-cli`, mounted read-only at `/mnt/integrations/lark-cli/runtime`; `/mnt/integrations/lark-cli/config` (app credentials, incl. the long-lived `appSecret`) is mounted **read-only** into the sandbox, its empty `config/locks` subdirectory is over-mounted writable for `lark-cli` coordination files, and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, all mapping to owner-only per-user directories. **Sandbox trust boundary:** the credential-bearing config and data dirs are still *readable* by arbitrary sandbox processes (the agent's `bash` tool, or code reached via prompt-injection in a tool result), so the app secret and tokens are exposed to sandbox-side code even though they never reach the browser — the read-only config mount only prevents in-sandbox tampering, not read/exfiltration. The sidecar credential-broker (Pattern B, issue #4338) is the fix that removes these plaintext mounts from sandbox execution: set `LARK_CLI_BROKER_IMAGE` on the provisioner (see `docker/lark-cli-broker/`) and the Gateway sends `provision_lark_cli_broker` on sandbox create. The provisioner then runs a `lark-cli-broker` sidecar that owns the per-user `config`/`config/locks`/`data` mounts (mounted into the **sidecar only**, at `/var/lark/{config,config/locks,data}` with only the nested locks mount writable) and serves the `lark-cli` command surface on Pod loopback (`http://127.0.0.1:8788`); a shim init container (`install-shim`) writes a forwarding `lark-cli` into the shared runtime `emptyDir`, so the sandbox gets `DEERFLOW_LARK_BROKER_URL` + a shim on PATH but **no** credential files. The on-PATH `bin/lark-cli` is a `/bin/sh` launcher that resolves a Python 3 interpreter and execs the Python shim body (`bin/lark-cli-shim.py`) beside it, so broker mode does not silently ENOEXEC on a sandbox image without a `#!/usr/bin/env python3`-resolvable interpreter — it fails loudly (exit 127, actionable message) and can be pinned with `DEERFLOW_LARK_BROKER_PYTHON`. The broker runs `lark-cli` in the sidecar's cwd and cannot see the sandbox filesystem, so cwd is intentionally **not** forwarded and file-I/O subcommands relative to the sandbox cwd are unsupported (command surface only). An optional `DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS` denylist (comma-separated command prefixes, forwarded from the provisioner) lets the broker refuse secret-dumping subcommands before spawning the binary. `lark_cli_env_overlay(broker=True)` therefore omits `LARKSUITE_CLI_CONFIG_DIR`/`DATA_DIR`; `sandbox_lark_broker_active()` (TTL-cached provisioner `/api/capabilities` probe, tight timeout + longer negative caching on the bash hot path) selects broker vs. binary mode for both the bash env overlay and status. `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` supplies a validated, symlink-free pre-staged runtime for air-gapped deployments. For the remote provisioner (K8s), the runtime binary is otherwise provisioned by an optional init container + shared `emptyDir` (Pattern A): set `LARK_CLI_INIT_IMAGE` on the provisioner (see `docker/lark-cli-init/`) and the Gateway sends `provision_lark_cli_runtime` on sandbox create once the pack is installed, so remote installs skip the Gateway-side GitHub download entirely. Broker (Pattern B) supersedes the init-container binary (Pattern A) when both images are configured. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container` / `broker`) and `sandbox_runtime_ready` (remote modes read the provisioner `GET /api/capabilities`: `lark_cli_init_image` / `lark_cli_broker_image`) so a green UI can't hide a chat-time `lark-cli: command not found`. Cheap status probes are explicitly not live-verified; users authorize or reconnect through the browser device-flow endpoints instead of running terminal commands. +- **Managed integrations**: Lark/Feishu CLI support installs one global official `lark-*` pack as read-only `SkillCategory.INTEGRATION` entries under `/mnt/skills/integrations/lark-cli/...`; enabled flags, app configuration, and OAuth data remain per-user. Install resolves the newest `larksuite/cli` release from GitHub (`releases/latest`) at install time (falling back to a bottom-line pinned version if the lookup fails) rather than hard-coding the pack version; integrity relies on the official host + structural archive guards + a recorded hash of the effective installed tree after shared guidance injection (not a pinned archive-byte SHA, which GitHub does not keep stable). The Gateway image still installs a pinned `@larksuite/cli` binary, so `get_lark_integration_status` surfaces `latest_available_version` and `runtime_version_mismatch` for the UI. AIO installs additionally verify and publish official Linux amd64/arm64 binaries under `{DEER_FLOW_HOME}/integrations/lark-cli/sandbox-cli`, mounted read-only at `/mnt/integrations/lark-cli/runtime`; `/mnt/integrations/lark-cli/config` (app credentials, incl. the long-lived `appSecret`) is mounted **read-only** into the sandbox, its empty `config/locks` subdirectory is over-mounted writable for `lark-cli` coordination files, and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, all mapping to owner-only per-user directories. **Sandbox trust boundary:** the credential-bearing config and data dirs are still *readable* by arbitrary sandbox processes (the agent's `bash` tool, or code reached via prompt-injection in a tool result), so the app secret and tokens are exposed to sandbox-side code even though they never reach the browser — the read-only config mount only prevents in-sandbox tampering, not read/exfiltration. The sidecar credential-broker (Pattern B, issue #4338) is the fix that removes these plaintext mounts from sandbox execution: set `LARK_CLI_BROKER_IMAGE` on the provisioner (see `docker/lark-cli-broker/`) and the Gateway sends `provision_lark_cli_broker` on sandbox create. The provisioner then runs a `lark-cli-broker` sidecar that owns the per-user `config`/`config/locks`/`data` mounts (mounted into the **sidecar only**, at `/var/lark/{config,config/locks,data}` with only the nested locks mount writable) and serves the `lark-cli` command surface on Pod loopback (`http://127.0.0.1:8788`); a shim init container (`install-shim`) writes a forwarding `lark-cli` into the shared runtime `emptyDir`, so the sandbox gets `DEERFLOW_LARK_BROKER_URL` + a shim on PATH but **no** credential files. The on-PATH `bin/lark-cli` is a `/bin/sh` launcher that resolves a Python 3 interpreter and execs the Python shim body (`bin/lark-cli-shim.py`) beside it, so broker mode does not silently ENOEXEC on a sandbox image without a `#!/usr/bin/env python3`-resolvable interpreter — it fails loudly (exit 127, actionable message) and can be pinned with `DEERFLOW_LARK_BROKER_PYTHON`. The broker runs `lark-cli` in the sidecar's cwd and cannot see the sandbox filesystem, so cwd is intentionally **not** forwarded and file-I/O subcommands relative to the sandbox cwd are unsupported (command surface only). An optional `DEERFLOW_LARK_BROKER_DENY_SUBCOMMANDS` denylist (comma-separated command prefixes, forwarded from the provisioner) lets the broker refuse secret-dumping subcommands before spawning the binary. `lark_cli_env_overlay(broker=True)` therefore omits `LARKSUITE_CLI_CONFIG_DIR`/`DATA_DIR`; `sandbox_lark_broker_active()` (TTL-cached provisioner `/api/capabilities` probe, tight timeout + longer negative caching on the bash hot path) selects broker vs. binary mode for both the bash env overlay and status. `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` supplies a validated, symlink-free pre-staged runtime for air-gapped deployments. For the remote provisioner (K8s), the runtime binary is otherwise provisioned by an optional init container + shared `emptyDir` (Pattern A): set `LARK_CLI_INIT_IMAGE` on the provisioner (see `docker/lark-cli-init/`) and the Gateway sends `provision_lark_cli_runtime` on sandbox create once the pack is installed, so remote installs skip the Gateway-side GitHub download entirely. Broker (Pattern B) supersedes the init-container binary (Pattern A) when both images are configured. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container` / `broker`) and `sandbox_runtime_ready` (remote modes read the provisioner `GET /api/capabilities`: `lark_cli_init_image` / `lark_cli_broker_image`), plus `sandbox_runtime_probed` separating a confirmed probe result from the conservative unprobed fallback (mutation responses carry a probed status built from a credential snapshot taken under the credential lock), so a green UI can't hide a chat-time `lark-cli: command not found`. Cheap status probes are explicitly not live-verified; users authorize or reconnect through the browser device-flow endpoints instead of running terminal commands. - **SkillScan**: `packages/harness/deerflow/skills/skillscan/` is the native deterministic scanner for `.skill` archives and agent-managed skill writes. It runs offline before the LLM scanner, emits structured findings (`rule_id`, `severity`, `file`, `line`, `message`, `remediation`, redacted `evidence` — category/analyzer are encoded in the `rule_id` prefix), blocks `CRITICAL`, and passes warning findings into `scan_skill_content()`. The moderation adapter must normalize both plain-text responses and LangChain Responses API text blocks before parsing the required JSON decision. `scan_archive_preflight()` / `scan_skill_dir()` are pure sync functions (dispatch off the event loop); `enforce_static_scan()` applies the blocking policy and the `skill_scan.enabled` kill switch. `skills/package_files.py` is the single definition of code files (`scripts/` members, code suffixes, extensionless `#!` files) and executable magic bytes; the installer, export guard, and SkillScan all import it, so do not re-derive either rule locally. Files that fail NUL-free UTF-8 decoding are treated as binaries unless they are code files: interpreters still run those, so they raise `package-undecodable-script` (HIGH) and are analyzed over a lossy decode, keeping one stray byte from hiding a file or downgrading a `CRITICAL` match; one with executable magic skips the text rules, which only misread its string tables. The Python instance-client signal deliberately follows only a one-level, same-scope evidence chain (PR #4265 review): a proven imported constructor bound to a simple name, optional name-to-name alias propagation, rebinding invalidation, and a constructor-supported outbound method or context-manager use; bare canonical-looking names never fall back to module identity. Nested scopes never inherit client handles and inherit only constructor aliases proven stable by a binding-only enclosing-scope prepass. Comprehensions, walrus-bearing statements, annotations, executable expressions inside complex binding targets, unsupported operations, and ambiguous flows produce no finding from this signal; skipped constructs invalidate all names they may bind, while representative false negatives are pinned by `test_python_declared_false_negatives_stay_unreported`. Compound bodies are walked from isolated copies so wrapping code in `if True:` is not a bypass, while copied scope entries, binding-only prepasses, and AST visits consume a deterministic work budget and the walk stops after its first sink. Budget or recursion exhaustion skips only this best-effort signal and retains deterministic findings already collected for the file. Do not add Semgrep/OpenGrep or YAML rule-engine dependencies to the core path; Phase 1 rule specs live in Python constants next to their analyzers in `skillscan/orchestrator.py`. - **Skill Review Core**: `packages/harness/deerflow/skills/review/` provides read-only package snapshots, deterministic facts, resource/eval analysis, report rendering, and the CLI (`python -m deerflow.skills.review.cli`). It reuses the shared frontmatter helper and SkillScan; it must not import `app.*`, execute target scripts, install dependencies, or call networks. JSON contracts live in `contracts/skill_review/`. The analyzer hands SkillScan a byte-faithful copy of every non-symlink snapshot file (binary entries carry `content_base64`), so package rules such as `package-executable-binary` cover binaries; only eval-fixture `SKILL.md` samples are withheld, while scripts and other files under `evals/fixtures/` are scanned. Materialization uses exclusive create, so a duplicate archive member or a case-folded name fails the scan closed (`not_assessed: skillscan`) instead of overwriting an earlier file, as does an entry without bytes in an untruncated snapshot. The `review_skill_package` built-in tool labels results with `review_subject_entry` and never `skill_context_entry`, so reviewing a target does not activate it, bind its `required-secrets`, or apply its `allowed-tools`. Its model-visible `ToolMessage.content` is a compact JSON payload with untrusted control tags neutralized; the full raw review payload, including Markdown renders, stays in `ToolMessage.artifact`. CI should run the CLI with `--fail-on error --fail-on-incomplete` so blocker/error findings and truncated/not-assessed packages fail the gate. The public `skills/public/skill-reviewer` skill owns semantic readiness review and suggestions only; mutation and runtime experiments remain owned by `skill-creator`. diff --git a/backend/tests/test_lark_cli_integration.py b/backend/tests/test_lark_cli_integration.py index 4cf1aa2ffe..ec9312b0f2 100644 --- a/backend/tests/test_lark_cli_integration.py +++ b/backend/tests/test_lark_cli_integration.py @@ -553,10 +553,11 @@ def test_remote_provisioner_install_skips_gateway_sandbox_runtime(monkeypatch, t def test_status_runtime_mode_none_for_non_aio(monkeypatch, tmp_path) -> None: config = _config(tmp_path / "skills") config.sandbox = SimpleNamespace(use="deerflow.sandbox.local:LocalSandboxProvider") - mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) + mode, ready, detail, probed = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) assert mode == "none" assert ready is False assert detail + assert probed is True def test_status_runtime_mode_gateway_download_ready(monkeypatch, tmp_path) -> None: @@ -575,20 +576,22 @@ def test_status_runtime_mode_gateway_download_ready(monkeypatch, tmp_path) -> No target.write_bytes(b"\x7fELF") target.chmod(0o755) - mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) + mode, ready, detail, probed = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) assert mode == "gateway-download" assert ready is True assert detail is None + assert probed is True def test_status_runtime_mode_gateway_download_not_ready(monkeypatch, tmp_path) -> None: _patch_paths(monkeypatch, tmp_path / "home") config = _config(tmp_path / "skills") config.sandbox = SimpleNamespace(use="deerflow.community.aio_sandbox:AioSandboxProvider") - mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) + mode, ready, detail, probed = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) assert mode == "gateway-download" assert ready is False assert detail + assert probed is True def test_status_runtime_mode_init_container_ready(monkeypatch, tmp_path) -> None: @@ -598,10 +601,11 @@ def test_status_runtime_mode_init_container_ready(monkeypatch, tmp_path) -> None provisioner_url="http://provisioner:8002", ) monkeypatch.setattr(lark_cli, "_probe_provisioner_capabilities", lambda _config: {"lark_cli_init_image": True, "lark_cli_broker_image": False}) - mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) + mode, ready, detail, probed = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) assert mode == "init-container" assert ready is True assert detail is None + assert probed is True def test_status_runtime_mode_broker_supersedes_init_container(monkeypatch, tmp_path) -> None: @@ -612,10 +616,11 @@ def test_status_runtime_mode_broker_supersedes_init_container(monkeypatch, tmp_p ) # Broker (Pattern B) wins even when the init image is also configured. monkeypatch.setattr(lark_cli, "_probe_provisioner_capabilities", lambda _config: {"lark_cli_init_image": True, "lark_cli_broker_image": True}) - mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) + mode, ready, detail, probed = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) assert mode == "broker" assert ready is True assert detail is None + assert probed is True def test_status_runtime_mode_init_container_not_configured(monkeypatch, tmp_path) -> None: @@ -625,10 +630,11 @@ def test_status_runtime_mode_init_container_not_configured(monkeypatch, tmp_path provisioner_url="http://provisioner:8002", ) monkeypatch.setattr(lark_cli, "_probe_provisioner_capabilities", lambda _config: {"lark_cli_init_image": False, "lark_cli_broker_image": False}) - mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) + mode, ready, detail, probed = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) assert mode == "init-container" assert ready is False assert detail + assert probed is True def test_status_runtime_mode_init_container_unreachable(monkeypatch, tmp_path) -> None: @@ -638,10 +644,11 @@ def test_status_runtime_mode_init_container_unreachable(monkeypatch, tmp_path) - provisioner_url="http://provisioner:8002", ) monkeypatch.setattr(lark_cli, "_probe_provisioner_capabilities", lambda _config: None) - mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) + mode, ready, detail, probed = lark_cli._resolve_sandbox_runtime_readiness(config, probe=True) assert mode == "init-container" assert ready is False assert detail + assert probed is True def test_status_runtime_probe_skipped_when_not_requested(monkeypatch, tmp_path) -> None: @@ -655,10 +662,32 @@ def test_status_runtime_probe_skipped_when_not_requested(monkeypatch, tmp_path) raise AssertionError("provisioner should not be probed when probe=False") monkeypatch.setattr(lark_cli, "_probe_provisioner_capabilities", _fail) - mode, ready, detail = lark_cli._resolve_sandbox_runtime_readiness(config, probe=False) + mode, ready, detail, probed = lark_cli._resolve_sandbox_runtime_readiness(config, probe=False) assert mode == "init-container" assert ready is False assert detail is None + assert probed is False + + +def test_status_explicitly_reports_remote_runtime_probe_state(monkeypatch, tmp_path) -> None: + _patch_paths(monkeypatch, tmp_path / "home") + config = _config(tmp_path / "skills") + config.sandbox = SimpleNamespace( + use="deerflow.community.aio_sandbox:AioSandboxProvider", + provisioner_url="http://provisioner:8002", + ) + monkeypatch.setattr( + lark_cli, + "_probe_provisioner_capabilities", + lambda _config: {"lark_cli_init_image": True, "lark_cli_broker_image": False}, + ) + + unprobed = lark_cli.get_lark_integration_status("alice", config, check_runtime=False) + probed = lark_cli.get_lark_integration_status("alice", config, check_runtime=True) + + assert unprobed.sandbox_runtime_probed is False + assert probed.sandbox_runtime_probed is True + assert probed.sandbox_runtime_ready is True def _reset_broker_mode_cache() -> None: @@ -938,6 +967,60 @@ def test_install_lark_integration_reports_content_change_on_reinstall(monkeypatc reset_skill_storage() +def test_install_lark_integration_snapshots_credentials_under_lock(monkeypatch, tmp_path) -> None: + reset_skill_storage() + _patch_paths(monkeypatch, tmp_path / "home") + skills_root = tmp_path / "skills" + (skills_root / "public").mkdir(parents=True) + (skills_root / "custom").mkdir() + config = _config(skills_root) + archive = _make_lark_cli_source_zip(tmp_path) + + lock_state = {"active": False} + snapshot_lock_states: list[bool] = [] + status_lock_states: list[bool] = [] + + class _CredentialLock: + def __enter__(self): + assert lock_state["active"] is False + lock_state["active"] = True + return self + + def __exit__(self, *_args): + lock_state["active"] = False + + monkeypatch.setattr(lark_cli, "_lark_credential_lock", lambda _user_id: _CredentialLock()) + monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65")) + + def _credential_snapshot(_user_id, *, verify_auth): + snapshot_lock_states.append(lock_state["active"]) + return lark_cli._LarkCredentialSnapshot( + app_config={"configured": True, "app_id": "cli_test", "brand": "feishu"}, + auth=lark_cli.LarkAuthProbe(status="authenticated", user="Alice", verified=False), + ) + + monkeypatch.setattr(lark_cli, "_read_lark_credential_snapshot", _credential_snapshot) + + real_status = lark_cli.get_lark_integration_status + + def _status(_user_id, _config, **kwargs): + status_lock_states.append(lock_state["active"]) + assert kwargs.get("check_runtime") is True + assert kwargs.get("credential_snapshot") is not None + return real_status(_user_id, _config, **kwargs) + + monkeypatch.setattr(lark_cli, "get_lark_integration_status", _status) + + result = lark_cli.install_lark_integration("alice", config, source_archive=archive) + + assert result.success is True + assert result.status.app_id == "cli_test" + assert result.status.auth.status == "authenticated" + assert snapshot_lock_states == [True] + assert status_lock_states == [False] + reset_skill_storage() + + def test_install_lark_integration_rejects_zip_slip_member(monkeypatch, tmp_path): _patch_paths(monkeypatch, tmp_path / "home") config = _config(tmp_path / "skills") @@ -2212,6 +2295,299 @@ def test_complete_lark_auth_polls_device_code_and_returns_status(monkeypatch, tm } +def test_complete_lark_auth_captures_status_under_lock_and_probes_runtime_after(monkeypatch, tmp_path) -> None: + config = _config(tmp_path / "skills") + lock_state = {"active": False} + credential_state = { + "app_id": "previous-app", + "auth": lark_cli.LarkAuthProbe(status="not_authorized", user=None), + } + snapshot_lock_states: list[bool] = [] + status_lock_states: list[bool] = [] + + class _CredentialLock: + def __enter__(self): + assert lock_state["active"] is False + lock_state["active"] = True + + def __exit__(self, *_args): + lock_state["active"] = False + # Model another tab switching credentials as soon as this operation unlocks. + credential_state["app_id"] = "next-app" + credential_state["auth"] = lark_cli.LarkAuthProbe(status="not_authorized", user=None) + + monkeypatch.setattr(lark_cli, "_lark_credential_lock", lambda _user_id: _CredentialLock()) + monkeypatch.setattr(lark_cli, "_require_lark_flow_generation_locked", lambda _user_id, generation: generation) + monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "/usr/bin/lark-cli") + + def _complete_login(*_args, **_kwargs): + assert lock_state["active"] is True + credential_state["app_id"] = "completed-app" + credential_state["auth"] = lark_cli.LarkAuthProbe(status="authenticated", user="Alice", verified=True) + + monkeypatch.setattr(lark_cli, "_run_lark_cli_json", _complete_login) + + def _credential_snapshot(_user_id, *, verify_auth): + snapshot_lock_states.append(lock_state["active"]) + assert verify_auth is True + return lark_cli._LarkCredentialSnapshot( + app_config={"configured": True, "app_id": credential_state["app_id"], "brand": "feishu"}, + auth=credential_state["auth"], + ) + + monkeypatch.setattr(lark_cli, "_read_lark_credential_snapshot", _credential_snapshot) + + def _status(_user_id, _config, **kwargs): + status_lock_states.append(lock_state["active"]) + assert kwargs.get("check_runtime") is True + credential_snapshot = kwargs["credential_snapshot"] + 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=True, + app_id=credential_snapshot.app_config["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=credential_snapshot.auth, + sandbox_runtime_mode="init-container", + sandbox_runtime_probed=True, + sandbox_runtime_ready=True, + ) + + monkeypatch.setattr(lark_cli, "get_lark_integration_status", _status) + + result = lark_cli.complete_lark_auth( + "alice", + config, + device_code="device-code", + generation="auth-generation", + ) + + assert result.success is True + assert result.status.app_id == "completed-app" + assert result.status.auth.status == "authenticated" + assert result.status.sandbox_runtime_ready is True + assert snapshot_lock_states == [True] + assert status_lock_states == [False] + + +def test_complete_lark_auth_status_ignores_credential_changes_during_runtime_probe(monkeypatch, tmp_path) -> None: + """End-to-end with the real status builder: a credential change landing while + the unlocked runtime probe runs must not turn the completed login into + success=false or mix newer credential state into the response.""" + _patch_paths(monkeypatch, tmp_path / "home") + skills_root = tmp_path / "skills" + (skills_root / "public").mkdir(parents=True) + (skills_root / "custom").mkdir() + config = _config(skills_root) + + credential_state = { + "app": {"configured": True, "app_id": "completed-app", "brand": "feishu"}, + "auth": lark_cli.LarkAuthProbe(status="authenticated", user="Alice", verified=True), + } + monkeypatch.setattr(lark_cli, "read_lark_app_config", lambda _user_id: dict(credential_state["app"])) + monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, *, verify=False: credential_state["auth"]) + monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, version="1.0.65")) + monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "/usr/bin/lark-cli") + monkeypatch.setattr(lark_cli, "_run_lark_cli_json", lambda *_args, **_kwargs: {}) + + runtime_probes: list[bool] = [] + + def _runtime_probe(_config, *, probe): + # A concurrent app switch clears these tokens while the runtime probe runs. + credential_state["app"] = {"configured": True, "app_id": "racer-app", "brand": "feishu"} + credential_state["auth"] = lark_cli.LarkAuthProbe(status="not_authorized", message="tokens cleared") + runtime_probes.append(probe) + return "init-container", True, None, True + + monkeypatch.setattr(lark_cli, "_resolve_sandbox_runtime_readiness", _runtime_probe) + + generation = _advance_lark_flow() + result = lark_cli.complete_lark_auth("alice", config, device_code="device-code", generation=generation) + + assert runtime_probes == [True] + assert result.success is True + assert result.status.app_id == "completed-app" + assert result.status.auth.status == "authenticated" + assert result.status.sandbox_runtime_probed is True + assert result.status.sandbox_runtime_ready is True + + +def test_complete_lark_config_captures_status_under_lock_and_probes_runtime_after(monkeypatch, tmp_path) -> None: + config = _config(tmp_path / "skills") + lock_state = {"active": False} + credential_state = { + "app_id": "previous-app", + "auth": lark_cli.LarkAuthProbe(status="not_authorized", user=None), + } + snapshot_lock_states: list[bool] = [] + status_lock_states: list[bool] = [] + + class _CredentialLock: + def __enter__(self): + assert lock_state["active"] is False + lock_state["active"] = True + + def __exit__(self, *_args): + lock_state["active"] = False + # Model another tab switching credentials as soon as this operation unlocks. + credential_state["app_id"] = "next-app" + credential_state["auth"] = lark_cli.LarkAuthProbe(status="not_authorized", user=None) + + monkeypatch.setattr(lark_cli, "_lark_credential_lock", lambda _user_id: _CredentialLock()) + monkeypatch.setattr(lark_cli, "_require_lark_flow_generation_locked", lambda _user_id, generation: generation) + monkeypatch.setattr( + lark_cli, + "_poll_lark_app_registration", + lambda **_kwargs: {"client_id": "cli_registered", "client_secret": "registered-secret"}, + ) + + def _replace(_user_id, *, app_id, app_secret, brand): + assert lock_state["active"] is True + credential_state["app_id"] = app_id + credential_state["auth"] = lark_cli.LarkAuthProbe(status="not_authorized", message="authorize the new app") + + monkeypatch.setattr(lark_cli, "_replace_lark_app_credentials_locked", _replace) + + def _credential_snapshot(_user_id, *, verify_auth): + snapshot_lock_states.append(lock_state["active"]) + assert verify_auth is False + return lark_cli._LarkCredentialSnapshot( + app_config={"configured": True, "app_id": credential_state["app_id"], "brand": "feishu"}, + auth=credential_state["auth"], + ) + + monkeypatch.setattr(lark_cli, "_read_lark_credential_snapshot", _credential_snapshot) + + def _status(_user_id, _config, **kwargs): + status_lock_states.append(lock_state["active"]) + assert kwargs.get("check_runtime") is True + credential_snapshot = kwargs["credential_snapshot"] + 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=True, + app_id=credential_snapshot.app_config["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=credential_snapshot.auth, + sandbox_runtime_mode="init-container", + sandbox_runtime_probed=True, + sandbox_runtime_ready=True, + ) + + monkeypatch.setattr(lark_cli, "get_lark_integration_status", _status) + + result = lark_cli.complete_lark_config( + "alice", + config, + device_code="device-code", + generation="config-generation", + ) + + assert result.success is True + assert result.status.app_id == "cli_registered" + assert result.status.sandbox_runtime_ready is True + assert snapshot_lock_states == [True] + assert status_lock_states == [False] + + +def test_set_lark_app_credentials_captures_status_under_lock_and_probes_runtime_after(monkeypatch, tmp_path) -> None: + config = _config(tmp_path / "skills") + lock_state = {"active": False} + credential_state = { + "app_id": "previous-app", + "auth": lark_cli.LarkAuthProbe(status="authenticated", user="Alice"), + } + snapshot_lock_states: list[bool] = [] + status_lock_states: list[bool] = [] + + class _CredentialLock: + def __enter__(self): + assert lock_state["active"] is False + lock_state["active"] = True + + def __exit__(self, *_args): + lock_state["active"] = False + # Model another tab switching credentials as soon as this operation unlocks. + credential_state["app_id"] = "racer-app" + credential_state["auth"] = lark_cli.LarkAuthProbe(status="authenticated", user="Racer") + + monkeypatch.setattr(lark_cli, "_lark_credential_lock", lambda _user_id: _CredentialLock()) + monkeypatch.setattr(lark_cli, "_validate_lark_app_credentials_with_cli", lambda **kwargs: None) + monkeypatch.setattr(lark_cli, "_advance_lark_flow_generation_locked", lambda _user_id: "switch-generation") + + def _replace(_user_id, *, app_id, app_secret, brand): + assert lock_state["active"] is True + credential_state["app_id"] = app_id + credential_state["auth"] = lark_cli.LarkAuthProbe(status="not_authorized", message="prior token revoked") + + monkeypatch.setattr(lark_cli, "_replace_lark_app_credentials_locked", _replace) + + def _credential_snapshot(_user_id, *, verify_auth): + snapshot_lock_states.append(lock_state["active"]) + assert verify_auth is False + return lark_cli._LarkCredentialSnapshot( + app_config={"configured": True, "app_id": credential_state["app_id"], "brand": "feishu"}, + auth=credential_state["auth"], + ) + + monkeypatch.setattr(lark_cli, "_read_lark_credential_snapshot", _credential_snapshot) + + def _status(_user_id, _config, **kwargs): + status_lock_states.append(lock_state["active"]) + assert kwargs.get("check_runtime") is True + credential_snapshot = kwargs["credential_snapshot"] + 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=True, + app_id=credential_snapshot.app_config["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=credential_snapshot.auth, + sandbox_runtime_mode="init-container", + sandbox_runtime_probed=True, + sandbox_runtime_ready=True, + ) + + monkeypatch.setattr(lark_cli, "get_lark_integration_status", _status) + + result = lark_cli.set_lark_app_credentials("alice", config, app_id="cli_new", app_secret="new-secret") + + assert result.success is True + assert result.generation == "switch-generation" + assert result.status.app_id == "cli_new" + assert result.status.auth.status == "not_authorized" + assert result.status.sandbox_runtime_ready is True + assert snapshot_lock_states == [True] + assert status_lock_states == [False] + + def test_complete_lark_auth_accepts_short_automatic_poll_timeout(monkeypatch, tmp_path) -> None: assert "wait_timeout_seconds" in inspect.signature(lark_cli.complete_lark_auth).parameters _patch_paths(monkeypatch, tmp_path / "home") @@ -2576,6 +2952,7 @@ def test_lark_status_is_available_to_authenticated_users(monkeypatch, tmp_path): install_path="/tmp/lark-cli", cli=lark_cli.LarkCliProbe(available=False, error="missing"), auth=lark_cli.LarkAuthProbe(status="unavailable", message="missing"), + sandbox_runtime_probed=True, ), ) @@ -2584,6 +2961,7 @@ def test_lark_status_is_available_to_authenticated_users(monkeypatch, tmp_path): assert response.status_code == 200 assert response.json()["installed"] is False + assert response.json()["sandbox_runtime_probed"] is True def _status_with_host_paths() -> lark_cli.LarkIntegrationStatus: diff --git a/frontend/src/app/mock/api/integrations/lark/auth/complete/route.ts b/frontend/src/app/mock/api/integrations/lark/auth/complete/route.ts index b046c4ddd8..e87b468346 100644 --- a/frontend/src/app/mock/api/integrations/lark/auth/complete/route.ts +++ b/frontend/src/app/mock/api/integrations/lark/auth/complete/route.ts @@ -29,6 +29,7 @@ export function POST() { verified: true, }, sandbox_runtime_mode: "init-container", + sandbox_runtime_probed: true, sandbox_runtime_ready: true, sandbox_runtime_detail: null, }, diff --git a/frontend/src/app/mock/api/integrations/lark/config/complete/route.ts b/frontend/src/app/mock/api/integrations/lark/config/complete/route.ts index 45dd0b98b1..818de0f52f 100644 --- a/frontend/src/app/mock/api/integrations/lark/config/complete/route.ts +++ b/frontend/src/app/mock/api/integrations/lark/config/complete/route.ts @@ -29,6 +29,7 @@ export function POST() { verified: false, }, sandbox_runtime_mode: "none", + sandbox_runtime_probed: true, sandbox_runtime_ready: false, sandbox_runtime_detail: null, }, diff --git a/frontend/src/app/mock/api/integrations/lark/config/credentials/route.ts b/frontend/src/app/mock/api/integrations/lark/config/credentials/route.ts index c07a6c57c0..b09a713ffd 100644 --- a/frontend/src/app/mock/api/integrations/lark/config/credentials/route.ts +++ b/frontend/src/app/mock/api/integrations/lark/config/credentials/route.ts @@ -29,6 +29,7 @@ export function POST() { verified: false, }, sandbox_runtime_mode: "none", + sandbox_runtime_probed: true, sandbox_runtime_ready: false, sandbox_runtime_detail: null, }, diff --git a/frontend/src/app/mock/api/integrations/lark/install/route.ts b/frontend/src/app/mock/api/integrations/lark/install/route.ts index 91e7e8dbc3..70c07b9349 100644 --- a/frontend/src/app/mock/api/integrations/lark/install/route.ts +++ b/frontend/src/app/mock/api/integrations/lark/install/route.ts @@ -32,6 +32,7 @@ export function POST() { verified: false, }, sandbox_runtime_mode: "none", + sandbox_runtime_probed: true, sandbox_runtime_ready: false, sandbox_runtime_detail: null, }, diff --git a/frontend/src/app/mock/api/integrations/lark/status/route.ts b/frontend/src/app/mock/api/integrations/lark/status/route.ts index c694379ec9..a754b15818 100644 --- a/frontend/src/app/mock/api/integrations/lark/status/route.ts +++ b/frontend/src/app/mock/api/integrations/lark/status/route.ts @@ -25,6 +25,7 @@ const status = { verified: false, }, sandbox_runtime_mode: "none", + sandbox_runtime_probed: true, sandbox_runtime_ready: false, sandbox_runtime_detail: null, }; diff --git a/frontend/src/components/workspace/capabilities/lark-plugin-settings.tsx b/frontend/src/components/workspace/capabilities/lark-plugin-settings.tsx index 2241d90c5a..ebf297b7fe 100644 --- a/frontend/src/components/workspace/capabilities/lark-plugin-settings.tsx +++ b/frontend/src/components/workspace/capabilities/lark-plugin-settings.tsx @@ -28,6 +28,7 @@ import { useAuth } from "@/core/auth/AuthProvider"; import { writeTextToClipboard } from "@/core/clipboard"; import { useI18n } from "@/core/i18n/hooks"; import { + cacheLarkMutationStatus, larkIntegrationQueryKey, LarkIntegrationRequestError, type LarkAuthStartRequest, @@ -303,7 +304,7 @@ function LarkIntegrationCard() { { onSuccess: (result) => { if (!isActiveFlow(generation)) return; - queryClient.setQueryData(larkIntegrationQueryKey, result.status); + void cacheLarkMutationStatus(queryClient, result.status); toast.success(t.settings.integrations.lark.connectionReady); setPendingFlow(null); startUserAuth( @@ -381,7 +382,7 @@ function LarkIntegrationCard() { { onSuccess: (result) => { if (!isActiveFlow(generation)) return; - queryClient.setQueryData(larkIntegrationQueryKey, result.status); + void cacheLarkMutationStatus(queryClient, result.status); toast.success(t.settings.integrations.lark.changeAppSwitched); setChangeAppSecret(""); setShowChangeApp(false); @@ -486,7 +487,7 @@ function LarkIntegrationCard() { if (automatic && attemptId !== authAttemptIdRef.current) { return; } - queryClient.setQueryData(larkIntegrationQueryKey, result.status); + void cacheLarkMutationStatus(queryClient, result.status); if (result.success) { clearAuthRetryTimer(); toast.success(result.message, toastOptions); diff --git a/frontend/src/core/integrations/lark/api.ts b/frontend/src/core/integrations/lark/api.ts index 1c6b28d334..8c564db73f 100644 --- a/frontend/src/core/integrations/lark/api.ts +++ b/frontend/src/core/integrations/lark/api.ts @@ -36,6 +36,18 @@ async function readErrorDetail(response: Response): Promise { return data.detail ?? `HTTP ${response.status}: ${response.statusText}`; } +function normalizeLarkIntegrationStatus( + status: LarkIntegrationStatus, +): LarkIntegrationStatus { + // Backends predating the flag omit it entirely; an absent flag means the + // runtime readiness was not reported as evaluated, so default to false and + // let the mutation cache preserve the last authoritative runtime fields. + return { + ...status, + sandbox_runtime_probed: status.sandbox_runtime_probed ?? false, + }; +} + export async function loadLarkIntegrationStatus( signal?: AbortSignal, ): Promise { @@ -49,7 +61,7 @@ export async function loadLarkIntegrationStatus( await readErrorDetail(response), ); } - return response.json(); + return normalizeLarkIntegrationStatus(await response.json()); } export async function installLarkIntegration(): Promise { @@ -65,7 +77,8 @@ export async function installLarkIntegration(): Promise { await readErrorDetail(response), ); } - return response.json(); + const data = (await response.json()) as LarkInstallResponse; + return { ...data, status: normalizeLarkIntegrationStatus(data.status) }; } export async function startLarkAuthorization( @@ -131,7 +144,8 @@ export async function completeLarkConfiguration( await readErrorDetail(response), ); } - return response.json(); + const data = (await response.json()) as LarkConfigCompleteResponse; + return { ...data, status: normalizeLarkIntegrationStatus(data.status) }; } export async function setLarkAppCredentials( @@ -153,7 +167,8 @@ export async function setLarkAppCredentials( await readErrorDetail(response), ); } - return response.json(); + const data = (await response.json()) as LarkConfigCompleteResponse; + return { ...data, status: normalizeLarkIntegrationStatus(data.status) }; } export async function completeLarkAuthorization( @@ -175,5 +190,6 @@ export async function completeLarkAuthorization( await readErrorDetail(response), ); } - return response.json(); + const data = (await response.json()) as LarkAuthCompleteResponse; + return { ...data, status: normalizeLarkIntegrationStatus(data.status) }; } diff --git a/frontend/src/core/integrations/lark/cache.ts b/frontend/src/core/integrations/lark/cache.ts new file mode 100644 index 0000000000..58ac9d45cf --- /dev/null +++ b/frontend/src/core/integrations/lark/cache.ts @@ -0,0 +1,45 @@ +import { type QueryClient } from "@tanstack/react-query"; + +import { type LarkIntegrationStatus } from "./types"; + +export const larkIntegrationQueryKey = ["integrations", "lark"] as const; + +type LarkIntegrationQueryClient = Pick< + QueryClient, + "cancelQueries" | "setQueryData" | "invalidateQueries" +>; + +function hasUnprobedRuntime(status: LarkIntegrationStatus): boolean { + return !status.sandbox_runtime_probed; +} + +export async function cacheLarkMutationStatus( + queryClient: LarkIntegrationQueryClient, + status: LarkIntegrationStatus, +): Promise { + // Await the cancel so an in-flight status read fully settles before the + // mutation snapshot lands: a fire-and-forget cancel loses to a GET whose + // fetch already resolved but whose cache write is still queued, and that + // stale write would clobber the fresher mutation status. + await queryClient.cancelQueries({ queryKey: larkIntegrationQueryKey }); + queryClient.setQueryData( + larkIntegrationQueryKey, + (current) => + current && hasUnprobedRuntime(status) + ? { + ...status, + sandbox_runtime_mode: current.sandbox_runtime_mode, + sandbox_runtime_probed: current.sandbox_runtime_probed, + sandbox_runtime_ready: current.sandbox_runtime_ready, + sandbox_runtime_detail: current.sandbox_runtime_detail, + } + : status, + ); + if (hasUnprobedRuntime(status)) { + // Unprobed means the response came from a backend that predates the + // field. The cancelled in-flight GET carried the authoritative runtime + // fields, so refetch instead of risking the conservative fallback + // lingering until the next focus/remount. + void queryClient.invalidateQueries({ queryKey: larkIntegrationQueryKey }); + } +} diff --git a/frontend/src/core/integrations/lark/hooks.ts b/frontend/src/core/integrations/lark/hooks.ts index 8c20f806f5..6a374cab55 100644 --- a/frontend/src/core/integrations/lark/hooks.ts +++ b/frontend/src/core/integrations/lark/hooks.ts @@ -9,8 +9,7 @@ import { startLarkAuthorization, startLarkConfiguration, } from "./api"; - -export const larkIntegrationQueryKey = ["integrations", "lark"] as const; +import { cacheLarkMutationStatus, larkIntegrationQueryKey } from "./cache"; export function useLarkIntegrationStatus() { return useQuery({ @@ -24,7 +23,7 @@ export function useInstallLarkIntegration() { return useMutation({ mutationFn: installLarkIntegration, onSuccess: async (result) => { - queryClient.setQueryData(larkIntegrationQueryKey, result.status); + await cacheLarkMutationStatus(queryClient, result.status); await queryClient.invalidateQueries({ queryKey: larkIntegrationQueryKey, }); diff --git a/frontend/src/core/integrations/lark/index.ts b/frontend/src/core/integrations/lark/index.ts index 0733bf1a44..c12bb1e083 100644 --- a/frontend/src/core/integrations/lark/index.ts +++ b/frontend/src/core/integrations/lark/index.ts @@ -1,3 +1,4 @@ export * from "./api"; +export * from "./cache"; export * from "./hooks"; export * from "./types"; diff --git a/frontend/src/core/integrations/lark/types.ts b/frontend/src/core/integrations/lark/types.ts index e5c769974d..493d0df86d 100644 --- a/frontend/src/core/integrations/lark/types.ts +++ b/frontend/src/core/integrations/lark/types.ts @@ -40,6 +40,7 @@ export interface LarkIntegrationStatus { cli: LarkCliProbe; auth: LarkAuthProbe; sandbox_runtime_mode: LarkSandboxRuntimeMode; + sandbox_runtime_probed: boolean; sandbox_runtime_ready: boolean; sandbox_runtime_detail: string | null; } diff --git a/frontend/tests/e2e/integrations.spec.ts b/frontend/tests/e2e/integrations.spec.ts index cfe4e7026d..a08d3a3fad 100644 --- a/frontend/tests/e2e/integrations.spec.ts +++ b/frontend/tests/e2e/integrations.spec.ts @@ -30,6 +30,7 @@ function configuredLarkStatus() { verified: true, }, sandbox_runtime_mode: "none", + sandbox_runtime_probed: true, sandbox_runtime_ready: false, sandbox_runtime_detail: null, }; diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 3b530b4a43..5a0ac3bd05 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -381,7 +381,9 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { sandbox_runtime_mode: "init-container" as | "none" | "gateway-download" - | "init-container", + | "init-container" + | "broker", + sandbox_runtime_probed: true, sandbox_runtime_ready: false, sandbox_runtime_detail: "The provisioner has no lark-cli init image configured (LARK_CLI_INIT_IMAGE)." as @@ -1969,6 +1971,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { verified: false, }, sandbox_runtime_mode: "init-container", + sandbox_runtime_probed: true, sandbox_runtime_ready: true, sandbox_runtime_detail: null, }; diff --git a/frontend/tests/unit/core/integrations/lark/api.test.ts b/frontend/tests/unit/core/integrations/lark/api.test.ts index b35a5b7c0e..4db39f7690 100644 --- a/frontend/tests/unit/core/integrations/lark/api.test.ts +++ b/frontend/tests/unit/core/integrations/lark/api.test.ts @@ -55,6 +55,7 @@ describe("lark integration api", () => { cli: { available: false, path: null, version: null, error: "missing" }, auth: { status: "unavailable", message: "missing", user: null }, sandbox_runtime_mode: "init-container", + sandbox_runtime_probed: true, sandbox_runtime_ready: false, sandbox_runtime_detail: "init image not configured", }), @@ -66,6 +67,7 @@ describe("lark integration api", () => { installed: false, version: "v1.0.65", sandbox_runtime_mode: "init-container", + sandbox_runtime_probed: true, sandbox_runtime_ready: false, }); expect(mockedFetch).toHaveBeenCalledWith( @@ -384,4 +386,29 @@ describe("lark integration api", () => { }, ); }); + + test("status load defaults sandbox_runtime_probed to false when the backend omits it", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(200, { installed: false, version: "v1.0.65" }), + ); + + const result = await loadLarkIntegrationStatus(); + + expect(result.sandbox_runtime_probed).toBe(false); + }); + + test("mutation responses default sandbox_runtime_probed to false when the backend omits it", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(200, { + success: true, + installed_skills: ["lark-doc"], + message: "Installed 1 Lark/Feishu skills.", + status: { installed: true, version: "v1.0.65" }, + }), + ); + + const result = await installLarkIntegration(); + + expect(result.status.sandbox_runtime_probed).toBe(false); + }); }); diff --git a/frontend/tests/unit/core/integrations/lark/cache.test.ts b/frontend/tests/unit/core/integrations/lark/cache.test.ts new file mode 100644 index 0000000000..4e9cfc7439 --- /dev/null +++ b/frontend/tests/unit/core/integrations/lark/cache.test.ts @@ -0,0 +1,156 @@ +import { expect, rs, test } from "@rstest/core"; +import { QueryClient } from "@tanstack/react-query"; + +import { cacheLarkMutationStatus } from "@/core/integrations/lark/cache"; +import { type LarkIntegrationStatus } from "@/core/integrations/lark/types"; + +const status: LarkIntegrationStatus = { + installed: true, + version: "v1.0.65", + manifest_version: "v1.0.65", + latest_available_version: null, + runtime_version_mismatch: false, + app_configured: true, + app_id: "cli_mock", + app_brand: "feishu", + skills_expected: 27, + skills_installed: 27, + installed_skills: ["lark-doc"], + enabled_skills: ["lark-doc"], + install_path: "/tmp/lark", + cli: { + available: true, + path: "/usr/bin/lark-cli", + version: "v1.0.65", + error: null, + }, + auth: { + status: "authenticated", + message: null, + user: "Alice", + verified: true, + }, + sandbox_runtime_mode: "init-container", + sandbox_runtime_probed: true, + sandbox_runtime_ready: true, + sandbox_runtime_detail: null, +}; + +test("mutation status cancels stale reads before populating an empty cache", async () => { + const queryClient = new QueryClient(); + const cancelQueries = rs.spyOn(queryClient, "cancelQueries"); + + await cacheLarkMutationStatus(queryClient, status); + + expect(cancelQueries).toHaveBeenCalledWith({ + queryKey: ["integrations", "lark"], + }); + expect(queryClient.getQueryData(["integrations", "lark"])).toEqual(status); +}); + +test("unprobed mutation status preserves runtime fields from the authoritative GET", async () => { + const queryClient = new QueryClient(); + const invalidateQueries = rs.spyOn(queryClient, "invalidateQueries"); + queryClient.setQueryData(["integrations", "lark"], { + ...status, + auth: { ...status.auth, verified: false }, + }); + const mutationStatus: LarkIntegrationStatus = { + ...status, + // Different mode from the cached status, so the assertions below prove + // the runtime fields came from the cache rather than the mutation. + sandbox_runtime_mode: "gateway-download", + sandbox_runtime_probed: false, + sandbox_runtime_ready: false, + sandbox_runtime_detail: null, + }; + + await cacheLarkMutationStatus(queryClient, mutationStatus); + + expect( + queryClient.getQueryData(["integrations", "lark"]), + ).toMatchObject({ + auth: { verified: true }, + sandbox_runtime_mode: "init-container", + sandbox_runtime_probed: true, + sandbox_runtime_ready: true, + sandbox_runtime_detail: null, + }); + // Unprobed mutation responses self-heal: the cancelled in-flight GET is + // refetched so the conservative fallback cannot linger. + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["integrations", "lark"], + }); +}); + +test("missing sandbox_runtime_probed (older backend) is treated as unprobed", async () => { + const queryClient = new QueryClient(); + const invalidateQueries = rs.spyOn(queryClient, "invalidateQueries"); + queryClient.setQueryData(["integrations", "lark"], status); + // Simulate a pre-field backend response: the key is absent at runtime even + // though the type marks it required. + const mutationStatus = { ...status } as Partial; + delete mutationStatus.sandbox_runtime_probed; + + await cacheLarkMutationStatus( + queryClient, + mutationStatus as LarkIntegrationStatus, + ); + + expect( + queryClient.getQueryData(["integrations", "lark"]), + ).toMatchObject({ + sandbox_runtime_mode: "init-container", + sandbox_runtime_probed: true, + sandbox_runtime_ready: true, + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["integrations", "lark"], + }); +}); + +test("explicitly probed unready status does not depend on detail text", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(["integrations", "lark"], status); + const mutationStatus: LarkIntegrationStatus = { + ...status, + sandbox_runtime_probed: true, + sandbox_runtime_ready: false, + sandbox_runtime_detail: null, + }; + + await cacheLarkMutationStatus(queryClient, mutationStatus); + + expect( + queryClient.getQueryData(["integrations", "lark"]), + ).toMatchObject({ + sandbox_runtime_probed: true, + sandbox_runtime_ready: false, + sandbox_runtime_detail: null, + }); +}); + +test("probed mutation status replaces older runtime fields", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(["integrations", "lark"], { + ...status, + sandbox_runtime_ready: false, + sandbox_runtime_detail: "provisioner was unavailable", + }); + const mutationStatus: LarkIntegrationStatus = { + ...status, + sandbox_runtime_mode: "broker", + sandbox_runtime_probed: true, + }; + + await cacheLarkMutationStatus(queryClient, mutationStatus); + + expect( + queryClient.getQueryData(["integrations", "lark"]), + ).toMatchObject({ + sandbox_runtime_mode: "broker", + sandbox_runtime_probed: true, + sandbox_runtime_ready: true, + sandbox_runtime_detail: null, + }); +}); diff --git a/frontend/tests/unit/core/integrations/lark/hooks.dom.test.tsx b/frontend/tests/unit/core/integrations/lark/hooks.dom.test.tsx new file mode 100644 index 0000000000..384001dd33 --- /dev/null +++ b/frontend/tests/unit/core/integrations/lark/hooks.dom.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; + +rs.mock("@/core/integrations/lark/api", () => ({ + completeLarkAuthorization: rs.fn(), + completeLarkConfiguration: rs.fn(), + installLarkIntegration: rs.fn(), + loadLarkIntegrationStatus: rs.fn(), + setLarkAppCredentials: rs.fn(), + startLarkAuthorization: rs.fn(), + startLarkConfiguration: rs.fn(), +})); + +import { installLarkIntegration } from "@/core/integrations/lark/api"; +import { useInstallLarkIntegration } from "@/core/integrations/lark/hooks"; +import type { LarkIntegrationStatus } from "@/core/integrations/lark/types"; + +const mockedInstall = rs.mocked(installLarkIntegration); + +const status: LarkIntegrationStatus = { + installed: true, + version: "v1.0.65", + manifest_version: "v1.0.65", + latest_available_version: null, + runtime_version_mismatch: false, + app_configured: true, + app_id: "cli_mock", + app_brand: "feishu", + skills_expected: 27, + skills_installed: 27, + installed_skills: ["lark-doc"], + enabled_skills: ["lark-doc"], + install_path: "/tmp/lark", + cli: { + available: true, + path: "/usr/bin/lark-cli", + version: "v1.0.65", + error: null, + }, + auth: { + status: "authenticated", + message: null, + user: "Alice", + verified: true, + }, + sandbox_runtime_mode: "init-container", + sandbox_runtime_probed: true, + sandbox_runtime_ready: true, + sandbox_runtime_detail: null, +}; + +afterEach(() => { + cleanup(); + mockedInstall.mockReset(); +}); + +describe("useInstallLarkIntegration", () => { + it("writes the mutation status through cacheLarkMutationStatus", async () => { + // Regression guard: the install mutation must cancel stale in-flight + // reads (i.e. go through cacheLarkMutationStatus) instead of a bare + // setQueryData, so version-skewed unprobed responses cannot clobber the + // authoritative runtime fields. + mockedInstall.mockResolvedValueOnce({ + success: true, + installed_skills: ["lark-doc"], + message: "Installed 1 Lark/Feishu skills.", + status, + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const cancelQueries = rs.spyOn(queryClient, "cancelQueries"); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const { result } = renderHook(() => useInstallLarkIntegration(), { + wrapper, + }); + + await act(async () => { + await result.current.mutateAsync(); + }); + + expect(cancelQueries).toHaveBeenCalledWith({ + queryKey: ["integrations", "lark"], + }); + expect(queryClient.getQueryData(["integrations", "lark"])).toEqual(status); + }); +});