feat: add Lark CLI integration (#3971)

* feat: add lark cli integration

* fix: polish lark integration actions

* feat: support lark incremental permissions

* fix: detect lark authorization completion

* fix: harden lark integration install

* feat: expand lark auth scopes and reuse host auth in sandbox

Default lark auth to least-privilege (recommend=false, base sign-in only)
and expose the full set of lark-cli --domain business domains as native
--domain grants instead of a 4-domain read-only mapping. Resolve the
skill pack from the latest larksuite/cli GitHub release at install time
with content-hash integrity, and surface version/runtime drift in status.

Share the per-user lark-cli config/data profile between the Gateway
Settings auth flow and agent conversations by mounting the integration
dirs into the AIO sandbox and injecting the matching env for lark-cli
commands, with an allowlisted extra_mounts path in the provisioner/K8s
backend and traversal guards on integration paths.

* style: fix lint issues from ruff and prettier

Sort imports in the provisioner PVC test and re-wrap two long i18n
description strings to satisfy backend ruff and frontend prettier CI.

* fix(lark): address managed integration review feedback

* fix(frontend): stabilize integrations settings e2e

* test(sandbox): isolate remote backend legacy visibility check

* test: fix backend unit failures after merge

* Harden Lark integration review fixes

* Format Lark integration E2E test

* fix(lark): harden sandbox credential exposure and status disclosure

Address willem_bd's security review on PR #3971:

- Mount the per-user lark-cli config dir (long-lived appSecret) read-only
  into the AIO sandbox; only the refreshable-token data dir stays writable.
- Redact host filesystem paths (install_path, cli.path) from
  GET /lark/status and the config/auth complete responses for non-admin
  callers, fail-closed on any auth error.
- Document the npm postinstall trade-off (--ignore-scripts is not viable
  because @larksuite/cli fetches its platform binary in postinstall).
- Document the sandbox credential trust boundary in AGENTS.md and README,
  pointing at the sidecar-broker follow-up (#4338).

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Ryker_Feng 2026-07-26 08:09:17 +08:00 committed by GitHub
parent 68797c5759
commit 7aa314b4c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 8132 additions and 56 deletions

View File

@ -50,6 +50,7 @@ deer-flow/
├── frontend/ # Next.js frontend (pnpm) — see frontend/AGENTS.md
├── docker/ # docker-compose files, nginx config, provisioner
├── skills/ # Agent skills: public/ (committed), custom/ (gitignored)
│ # Managed integration skill packs are installed per user under .deer-flow/users/{user_id}/skills/integrations/
├── contracts/ # Cross-component JSON contracts (e.g. subagent status, skill review)
├── scripts/ # Root orchestration scripts invoked by the Makefile (check, configure, doctor, support_bundle, serve, nginx, docker, deploy, setup_wizard)
├── tests/ # Root-level tests (currently tests/skills/ — public skill tests)

View File

@ -664,6 +664,70 @@ An enabled skill's `allowed-tools` policy applies only after that skill is expli
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
Managed integrations install shared read-only skill packs without mixing them
into custom skills. The Lark/Feishu CLI integration is available under
`Settings → Integrations → Lark / Feishu CLI`; an administrator installs or
upgrades the official `lark-*` pack once under
`{DEER_FLOW_HOME}/integrations/skills/lark-cli`, and every user discovers that
same pack with an independent enabled state. Each user's app configuration and
OAuth data remain isolated under
`{DEER_FLOW_HOME}/users/{user_id}/integrations/lark-cli/{config,data}`. These
secret directories are restricted to `0700`, regular credential files to
`0600`, and symlinks are rejected.
After installation, users can click **Connect Lark** to open a browser
authorization link; no terminal authorization is required. The same UI can
request additional permission domains such as Calendar, Docs, or Drive, or a
specific OAuth scope reported by `lark-cli`. A cheap status refresh only
inspects the local credential tree, so the UI reports **Credentials configured
(not live-verified)** until an explicit browser completion performs live token
verification. The action then remains **Reconnect Lark** so users can replace
or extend authorization. If an agent hits missing Lark authorization during a
conversation, the managed `lark-shared` guidance points the user back to the
same settings entry with `?settings=integrations`.
Installing the Lark skill pack resolves the latest official `larksuite/cli`
release from GitHub and downloads that version's skills at install time, so the
Gateway needs outbound internet access for that step (it falls back to a
bottom-line pinned version if the release lookup fails). The settings page shows
the installed version and, when available, the newest published version so an
admin can reinstall to upgrade. Air-gapped deployments can pre-stage the archive
and point `DEER_FLOW_LARK_CLI_SKILLS_ARCHIVE` at the local file. Integrity does
not depend on a pinned archive byte hash (GitHub does not guarantee stable
source-archive bytes); instead the download is restricted to the official GitHub
host, every archive member passes structural safety guards, and a content hash
of the effective installed skill tree (including DeerFlow's injected shared
guidance) is recorded so content changes are auditable across reinstalls.
When `sandbox.use` selects the AIO provider, the same install also downloads the
official Linux amd64 and arm64 CLI release archives, verifies their published
SHA-256 checksums, safely extracts one executable per architecture, and mounts
the resulting runtime read-only at `/mnt/integrations/lark-cli/runtime`. An
architecture-selecting launcher in that mount makes `lark-cli` available in the
sandbox `PATH`. Air-gapped AIO deployments can pre-stage a symlink-free runtime
tree containing `bin/lark-cli` plus both `linux-{amd64,arm64}/lark-cli` files and
set `DEER_FLOW_LARK_CLI_SANDBOX_RUNTIME_DIR` to that directory.
> **Sandbox trust boundary:** the browser never receives the Lark app secret, but
> agent conversations run `lark-cli` inside the sandbox, so the per-user
> credential directories are mounted into it: `config` (holding the long-lived
> `appSecret`) is mounted **read-only** and `data` (refreshable OAuth tokens)
> writable. Both remain *readable* by any process the agent runs there, so code
> reached via prompt injection in a tool result could read them. Treat the
> sandbox as inside the Lark credential trust boundary until the sidecar
> credential-broker follow-up removes these mounts from sandbox execution.
For remote/Kubernetes deployments (the provisioner backend), the sandbox
`lark-cli` runtime can instead be supplied by an optional init container that
copies the binaries into a shared `emptyDir` — no install-time GitHub download and
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
whether `lark-cli` will actually be present in the sandbox at chat time, rather
than a green status hiding a later `command not found`.
If a trusted operator manages the configured skills directory through an external mount such as MinIO, NFS, or CSI, an administrator can call `POST /api/skills/reload` after changing files. This invalidates skill prompt caches for the current Gateway process and waits up to the bounded refresh timeout so subsequent runs rescan the latest files; running tasks are unchanged. A loader-level filesystem failure returns a generic server error and preserves the last successfully loaded process cache rather than publishing an empty catalog. Uvicorn workers and Kubernetes Pods must each be targeted separately. Direct mount writes bypass the validation, SkillScan, and history applied by DeerFlow's install/edit APIs, so only operator-controlled systems should have write access.
Skill installs and agent-managed skill edits run through **SkillScan**, a native deterministic safety scanner before the LLM-based skill scanner. Phase 1 runs offline with no Semgrep/OpenGrep dependency, blocks high-confidence `CRITICAL` findings such as private keys or shell execution, and passes warning findings to the LLM scanner for contextual review. Python instance-client exfiltration checks follow a minimal same-scope evidence chain: a simple name bound to a known client constructor, optional name-to-name aliases, and an actual outbound method or context-manager use supported by that constructor. Constructor roots must be proven imports; bare canonical-looking names are not inferred as modules. Nested scopes do not inherit client handles and inherit only constructor import aliases that are never rebound in the enclosing scope. Comprehensions, walrus-bearing statements, annotations, complex binding targets, unsupported operations, and ambiguous branch flows produce no finding from this signal; skipped constructs conservatively invalidate every name they may bind so stale client state cannot create a finding. A deterministic work budget or recursion limit reached by this best-effort analysis does not discard findings already collected for the file. Set `skill_scan.enabled: false` in `config.yaml` to disable only the deterministic analyzers; safe archive extraction and the LLM scanner still run.
@ -713,6 +777,9 @@ Web UI chat links percent-encode custom thread identifiers before placing them i
/mnt/skills/custom
└── your-custom-skill/SKILL.md ← yours
/mnt/skills/integrations
└── lark-cli/lark-doc/SKILL.md ← managed, read-only
```
#### Claude Code Integration

View File

@ -48,6 +48,7 @@ deer-flow/
│ │ │ └── registry.py # Agent registry
│ │ ├── tools/builtins/ # Built-in tools (present_files, ask_clarification, view_image, review_skill_package)
│ │ ├── mcp/ # MCP integration (tools, cache, client)
│ │ ├── integrations/ # Managed first-party integration installers (e.g. Lark CLI skill pack)
│ │ ├── models/ # Model factory with thinking/vision support
│ │ ├── skills/ # Skills discovery, loading, parsing
│ │ ├── config/ # Configuration system (app, model, sandbox, tool, etc.)
@ -219,6 +220,9 @@ Blocking-IO runtime gate (`tests/blocking_io/`):
SQLite path resolution plus `ensure_sqlite_parent_dir`, fix for #1912);
`test_jsonl_run_event_store.py` (locks `JsonlRunEventStore`'s async
API offloading its file IO via `asyncio.to_thread`);
`test_integrations_router.py` (locks Lark integration install and auth
completion route handlers offloading archive filesystem work and `lark-cli`
subprocesses);
`test_uploads_middleware.py` (locks `UploadsMiddleware.abefore_agent`
offloading the uploads-directory scan off the event loop);
`test_uploads_router.py` (locks Gateway upload/list/delete endpoints
@ -425,6 +429,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). 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` - update config (saves to extensions_config.json) |
| **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 |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. 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 |
@ -614,9 +619,9 @@ E2B output sync records remote file versions and actual host file metadata in a
### Skills System (`packages/harness/deerflow/skills/`)
- **Location**: `deer-flow/skills/{public,custom}/`
- **Location**: global public skills live under `deer-flow/skills/public/`; user-authored custom skills live under `{DEER_FLOW_HOME}/users/{user_id}/skills/custom/`; globally managed integration skills live under `{DEER_FLOW_HOME}/integrations/skills/{provider}/`; per-user integration credentials remain under `{DEER_FLOW_HOME}/users/{user_id}/integrations/{provider}/{config,data}`
- **Format**: Directory with `SKILL.md` (YAML frontmatter: name, description, license, allowed-tools, required-secrets)
- **Loading**: `load_skills()` recursively scans namespace directories under `skills/{public,custom}`, but stops descending once it finds a `SKILL.md`; that directory is a package boundary, so no nested `SKILL.md` is registered as a runtime skill. SkillScan has a deliberately narrower packaging rule: known eval fixtures are permitted as support data, while other nested `SKILL.md` files are reported as package defects. It parses runtime metadata and reads enabled state from extensions_config.json.
- **Loading**: `load_skills()` recursively scans public, per-user custom, global integration, and legacy custom locations for `SKILL.md`, parses metadata, and reads enabled state from extensions_config.json plus per-user skill state for non-public categories; that directory is a package boundary, so no nested `SKILL.md` is registered as a runtime skill. SkillScan has a deliberately narrower packaging rule: known eval fixtures are permitted as support data, while other nested `SKILL.md` files are reported as package defects. It parses runtime metadata and reads enabled state from extensions_config.json.
- **External reload**: `POST /api/skills/reload` is an admin-only, process-local invalidation hook for trusted MinIO/NFS/CSI writes. `SkillStorage` instances do not cache a catalog — `load_skills()` scans on every call — so the route clears all `(app_config, user_id)` entries and the rendered prompt-section LRU, then waits up to the shared refresh timeout for the existing off-loop single-flight refresh. Each invalidation receives a generation-bound result handle; a successful scan atomically replaces the global enabled-skills cache, while a loader-level failure propagates to the HTTP waiter and preserves the last-known-good global cache. Per-user/config scans capture the refresh version and cannot repopulate shared caches if invalidation occurs while they are loading. A timed-out HTTP wait fails generically while the daemon refresh worker continues. Subsequent runs rescan after a successful reload; active runs keep their existing snapshot. Each Uvicorn worker/Kubernetes Pod must be targeted separately. Direct mount writes bypass install/edit validation, SkillScan, and history, so mounted roots are an operator-controlled trust boundary.
- **Tool policy**: Lead-agent `allowed-tools` declarations apply dynamically only to slash-activated skills and skills captured in `ThreadState.skill_context` through configured `read_file` loads; passive enabled skills and custom-agent skill allowlists remain discoverable without clamping the global toolset. Slash policy is dominant for its run, preventing subsequently read skills from widening explicit authority; autonomous captured skills use the existing union only when no slash source exists. `tool_search` and `describe_skill` stay available as framework discovery infrastructure, while every discovered or promoted business tool still requires active-policy permission for schema visibility and execution; `task` likewise requires an explicit declaration. Each active model call intentionally reloads the full live registry so enable/disable changes, frontmatter edits, and custom/public name-shadow winners take effect without a stale TTL or unsafe direct-path cache; all tool calls produced by that model step reuse the resulting source-and-path-signed decision. Registry failures and all-invalid active sets fail closed, while stale individual paths are skipped when another valid skill remains. This is best-effort behavioral scoping, not a hard security boundary: alternate loading paths are not captured and bounded autonomous context may evict entries. Subagents still filter statically because their configured skills are all loaded into the session at startup.
- **Injection (legacy / default)**: Enabled skills are listed in the agent system prompt with full metadata and container paths (`<available_skills>` block). Controlled by `skills.deferred_discovery: false` (default).
@ -625,6 +630,7 @@ E2B output sync records remote file versions and actual host file metadata in a
- `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 channel commands (`/new`, `/help`, `/bootstrap`, `/status`, `/models`, `/memory`, `/goal`), disabled skills, and skills outside a custom agent's whitelist.
- **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 and `/mnt/integrations/lark-cli/data` (refreshable OAuth tokens) stays writable, both mapping to owner-only per-user credential directories. **Sandbox trust boundary:** those two 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 follow-up (issue #4338) is the planned fix that removes these plaintext mounts from sandbox execution. `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 is instead 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. `get_lark_integration_status(check_runtime=True)` surfaces `sandbox_runtime_mode` (`none` / `gateway-download` / `init-container`) and `sandbox_runtime_ready` (init-container mode reads the provisioner `GET /api/capabilities`) 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()`. `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. 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 `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`.

View File

@ -13,6 +13,8 @@ FROM python:3.12-slim-bookworm AS builder
ARG NODE_MAJOR=22
ARG APT_MIRROR
ARG UV_INDEX_URL
ARG NPM_REGISTRY
ARG LARK_CLI_NPM_VERSION=1.0.65
# Optional extras to install (e.g. "postgres" for PostgreSQL support)
# Usage: docker build --build-arg UV_EXTRAS=postgres,discord ...
ARG UV_EXTRAS
@ -36,6 +38,11 @@ RUN apt-get update && apt-get install -y \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install the official Lark/Feishu CLI used by the managed Lark integration.
RUN if [ -n "${NPM_REGISTRY}" ]; then npm config set registry "${NPM_REGISTRY}"; fi \
&& npm install -g @larksuite/cli@${LARK_CLI_NPM_VERSION} \
&& lark-cli --version
# Install uv (source image overridable via UV_IMAGE build arg)
COPY --from=uv-source /uv /uvx /usr/local/bin/
@ -95,7 +102,10 @@ ENV PYTHONIOENCODING=utf-8
COPY --from=builder /usr/bin/node /usr/bin/node
COPY --from=builder /usr/lib/node_modules /usr/lib/node_modules
RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm \
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx
&& ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx \
&& LARK_CLI_BIN="$(node -p 'const bin=require("/usr/lib/node_modules/@larksuite/cli/package.json").bin; typeof bin === "string" ? bin : (bin["lark-cli"] || Object.values(bin)[0])')" \
&& ln -s "../lib/node_modules/@larksuite/cli/${LARK_CLI_BIN#./}" /usr/bin/lark-cli \
&& lark-cli --version
# Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket)
COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker

View File

@ -25,6 +25,7 @@ from app.gateway.routers import (
feedback,
github_webhooks,
input_polish,
integrations,
mcp,
memory,
models,
@ -516,6 +517,9 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Skills API is mounted at /api/skills
app.include_router(skills.router)
# First-party integrations API is mounted at /api/integrations
app.include_router(integrations.router)
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
app.include_router(artifacts.router)

View File

@ -0,0 +1,354 @@
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_config, require_admin_user
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig
from deerflow.integrations.lark_cli import (
LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS,
LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS,
LarkAuthCompleteResult,
LarkAuthProbe,
LarkAuthStartResult,
LarkCliProbe,
LarkConfigCompleteResult,
LarkConfigStartResult,
LarkInstallResult,
LarkIntegrationStatus,
complete_lark_auth,
complete_lark_config,
get_lark_integration_status,
install_lark_integration,
start_lark_auth,
start_lark_config,
)
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/integrations", tags=["integrations"])
_ADMIN_REQUIRED_DETAIL = "Admin privileges required to install integrations."
async def _is_admin_user(request: Request) -> bool:
"""Non-raising admin check used to gate host-path disclosure in responses.
Fails closed: any error (missing middleware state, auth failure) is treated
as non-admin so host paths are redacted rather than accidentally exposed.
"""
try:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
except Exception:
return False
return True
class LarkCliProbeResponse(BaseModel):
available: bool = Field(..., description="Whether lark-cli is available to the Gateway, either managed by DeerFlow or on PATH")
path: str | None = Field(None, description="Resolved lark-cli executable path")
version: str | None = Field(None, description="lark-cli --version output")
error: str | None = Field(None, description="Probe failure message")
class LarkAuthProbeResponse(BaseModel):
status: str = Field(..., description="Auth status: authenticated, not_configured, unavailable, or error")
message: str | None = Field(None, description="Human-readable status detail")
user: str | None = Field(None, description="Authenticated Lark/Feishu user display value when available")
verified: bool = Field(False, description="Whether this status came from a live token verification")
class LarkIntegrationStatusResponse(BaseModel):
installed: bool = Field(..., description="Whether the managed Lark skill pack is installed")
version: str = Field(..., description="Installed Lark CLI skill-pack version (from manifest, resolved at install time)")
manifest_version: str | None = Field(None, description="Installed manifest version")
latest_available_version: str | None = Field(None, description="Newest larksuite/cli release version available on GitHub, when known")
runtime_version_mismatch: bool = Field(False, description="Whether the installed skill-pack version differs from the Gateway runtime lark-cli binary")
app_configured: bool = Field(..., description="Whether lark-cli has app_id/app_secret configured for this user")
app_id: str | None = Field(None, description="Configured Lark app ID")
app_brand: str | None = Field(None, description="Configured Lark brand: feishu or lark")
skills_expected: int = Field(..., description="Number of skills expected in the official pack")
skills_installed: int = Field(..., description="Number of installed managed Lark skills")
installed_skills: list[str] = Field(default_factory=list, description="Installed managed Lark skill names")
enabled_skills: list[str] = Field(default_factory=list, description="Installed Lark skills currently enabled for this user")
install_path: str = Field(..., description="Host path of the managed Lark skill pack")
cli: LarkCliProbeResponse
auth: LarkAuthProbeResponse
sandbox_runtime_mode: str = Field("none", description="How lark-cli is provisioned into the sandbox: none, gateway-download, or init-container")
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")
class LarkInstallResponse(BaseModel):
success: bool
installed_skills: list[str]
message: str
status: LarkIntegrationStatusResponse
class LarkAuthStartRequest(BaseModel):
recommend: bool = Field(default=False, description="Request the official recommended auto-approve scopes")
domains: list[str] = Field(default_factory=list, description="Optional Lark auth domains, e.g. calendar or docs")
scope: str | None = Field(default=None, description="Optional explicit OAuth scope string")
class LarkConfigStartRequest(BaseModel):
brand: str = Field(default="feishu", description="Lark brand to start app registration for: feishu or lark")
class LarkConfigStartResponse(BaseModel):
verification_url: str = Field(..., description="URL the user should open in a browser to configure the Lark app")
device_code: str = Field(..., description="Device code used by config/complete after browser approval")
expires_in: int | None = Field(None, description="Seconds before the configuration URL expires")
interval: int | None = Field(None, description="Suggested polling interval from Lark")
user_code: str | None = Field(None, description="Optional user code shown by Lark")
brand: str = Field(..., description="Brand used for this app registration flow")
class LarkConfigCompleteRequest(BaseModel):
device_code: str = Field(..., description="Device code returned by config/start")
brand: str = Field(default="feishu", description="Brand returned by config/start")
interval: int | None = Field(default=None, description="Polling interval returned by config/start")
expires_in: int | None = Field(default=None, description="Expiration returned by config/start")
class LarkConfigCompleteResponse(BaseModel):
success: bool
message: str
status: LarkIntegrationStatusResponse
class LarkAuthStartResponse(BaseModel):
verification_url: str = Field(..., description="URL the user should open in a browser to authorize")
device_code: str = Field(..., description="Device code used by the complete endpoint after browser approval")
expires_in: int | None = Field(None, description="Seconds before the authorization URL expires")
user_code: str | None = Field(None, description="Optional user code shown by Lark")
hint: str | None = Field(None, description="Optional guidance returned by lark-cli")
class LarkAuthCompleteRequest(BaseModel):
device_code: str = Field(..., description="Device code returned by auth/start")
wait_timeout_seconds: int = Field(
default=LARK_AUTH_COMPLETE_DEFAULT_WAIT_SECONDS,
ge=LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS,
le=LARK_AUTH_COMPLETE_MAX_WAIT_SECONDS,
description="Maximum seconds for this device-code poll; automatic UI polling uses a shorter wait",
)
class LarkAuthCompleteResponse(BaseModel):
success: bool
message: str
status: LarkIntegrationStatusResponse
def _cli_probe_to_response(probe: LarkCliProbe) -> LarkCliProbeResponse:
return LarkCliProbeResponse(
available=probe.available,
path=probe.path,
version=probe.version,
error=probe.error,
)
def _auth_probe_to_response(probe: LarkAuthProbe) -> LarkAuthProbeResponse:
return LarkAuthProbeResponse(
status=probe.status,
message=probe.message,
user=probe.user,
verified=probe.verified,
)
def _status_to_response(status: LarkIntegrationStatus, *, include_host_paths: bool = True) -> LarkIntegrationStatusResponse:
cli = _cli_probe_to_response(status.cli)
if not include_host_paths:
# Host filesystem paths (Gateway layout) are admin-only info; redact them
# for non-admin callers of the otherwise non-gated status/complete routes.
cli = cli.model_copy(update={"path": None})
return LarkIntegrationStatusResponse(
installed=status.installed,
version=status.version,
manifest_version=status.manifest_version,
latest_available_version=status.latest_available_version,
runtime_version_mismatch=status.runtime_version_mismatch,
app_configured=status.app_configured,
app_id=status.app_id,
app_brand=status.app_brand,
skills_expected=status.skills_expected,
skills_installed=status.skills_installed,
installed_skills=list(status.installed_skills),
enabled_skills=list(status.enabled_skills),
install_path=status.install_path if include_host_paths else "",
cli=cli,
auth=_auth_probe_to_response(status.auth),
sandbox_runtime_mode=status.sandbox_runtime_mode,
sandbox_runtime_ready=status.sandbox_runtime_ready,
sandbox_runtime_detail=status.sandbox_runtime_detail,
)
def _install_to_response(result: LarkInstallResult) -> LarkInstallResponse:
return LarkInstallResponse(
success=result.success,
installed_skills=list(result.installed_skills),
message=result.message,
status=_status_to_response(result.status),
)
def _config_start_to_response(result: LarkConfigStartResult) -> LarkConfigStartResponse:
return LarkConfigStartResponse(
verification_url=result.verification_url,
device_code=result.device_code,
expires_in=result.expires_in,
interval=result.interval,
user_code=result.user_code,
brand=result.brand,
)
def _config_complete_to_response(result: LarkConfigCompleteResult, *, include_host_paths: bool = True) -> LarkConfigCompleteResponse:
return LarkConfigCompleteResponse(
success=result.success,
message=result.message,
status=_status_to_response(result.status, include_host_paths=include_host_paths),
)
def _auth_start_to_response(result: LarkAuthStartResult) -> LarkAuthStartResponse:
return LarkAuthStartResponse(
verification_url=result.verification_url,
device_code=result.device_code,
expires_in=result.expires_in,
user_code=result.user_code,
hint=result.hint,
)
def _auth_complete_to_response(result: LarkAuthCompleteResult, *, include_host_paths: bool = True) -> LarkAuthCompleteResponse:
return LarkAuthCompleteResponse(
success=result.success,
message=result.message,
status=_status_to_response(result.status, include_host_paths=include_host_paths),
)
@router.get("/lark/status", response_model=LarkIntegrationStatusResponse, summary="Get Lark/Feishu Integration Status")
async def get_lark_status(request: Request, config: AppConfig = Depends(get_config)) -> LarkIntegrationStatusResponse:
try:
status = await asyncio.to_thread(get_lark_integration_status, get_effective_user_id(), config, check_latest=True, check_runtime=True)
return _status_to_response(status, include_host_paths=await _is_admin_user(request))
except Exception as e:
logger.error("Failed to get Lark integration status: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to get Lark integration status.")
@router.post("/lark/install", response_model=LarkInstallResponse, summary="Install Lark/Feishu Skill Pack")
async def install_lark(request: Request, config: AppConfig = Depends(get_config)) -> LarkInstallResponse:
await require_admin_user(request, detail=_ADMIN_REQUIRED_DETAIL)
try:
result = await asyncio.to_thread(install_lark_integration, get_effective_user_id(), config)
await refresh_skills_system_prompt_cache_async()
return _install_to_response(result)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Failed to install Lark integration: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to install Lark integration.")
@router.post("/lark/config/start", response_model=LarkConfigStartResponse, summary="Start Lark/Feishu App Configuration")
async def start_lark_app_config(body: LarkConfigStartRequest) -> LarkConfigStartResponse:
try:
result = await asyncio.to_thread(
start_lark_config,
get_effective_user_id(),
brand=body.brand,
)
return _config_start_to_response(result)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e:
raise HTTPException(status_code=504, detail=str(e))
except Exception as e:
logger.error("Failed to start Lark connection setup: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to start Lark connection setup.")
@router.post("/lark/config/complete", response_model=LarkConfigCompleteResponse, summary="Complete Lark/Feishu App Configuration")
async def complete_lark_app_config(request: Request, body: LarkConfigCompleteRequest, config: AppConfig = Depends(get_config)) -> LarkConfigCompleteResponse:
try:
result = await asyncio.to_thread(
complete_lark_config,
get_effective_user_id(),
config,
device_code=body.device_code,
brand=body.brand,
interval=body.interval,
expires_in=body.expires_in,
)
return _config_complete_to_response(result, include_host_paths=await _is_admin_user(request))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e:
raise HTTPException(status_code=504, detail=str(e))
except Exception as e:
logger.error("Failed to complete Lark connection setup: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to complete Lark connection setup.")
@router.post("/lark/auth/start", response_model=LarkAuthStartResponse, summary="Start Lark/Feishu Browser Authorization")
async def start_lark_browser_auth(body: LarkAuthStartRequest) -> LarkAuthStartResponse:
try:
result = await asyncio.to_thread(
start_lark_auth,
get_effective_user_id(),
domains=tuple(body.domains),
scope=body.scope,
recommend=body.recommend,
)
return _auth_start_to_response(result)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e:
raise HTTPException(status_code=504, detail=str(e))
except Exception as e:
logger.error("Failed to start Lark authorization: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to start Lark authorization.")
@router.post("/lark/auth/complete", response_model=LarkAuthCompleteResponse, summary="Complete Lark/Feishu Browser Authorization")
async def complete_lark_browser_auth(request: Request, body: LarkAuthCompleteRequest, config: AppConfig = Depends(get_config)) -> LarkAuthCompleteResponse:
try:
result = await asyncio.to_thread(
complete_lark_auth,
get_effective_user_id(),
config,
device_code=body.device_code,
wait_timeout_seconds=body.wait_timeout_seconds,
)
return _auth_complete_to_response(result, include_host_paths=await _is_admin_user(request))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except 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 authorization: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to complete Lark authorization.")

View File

@ -39,6 +39,8 @@ from deerflow.community.warm_pool_lifecycle import (
)
from deerflow.config import get_app_config
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths, join_host_path
from deerflow.integrations.lark_cli import INTEGRATION_ID as LARK_CLI_INTEGRATION_ID
from deerflow.integrations.lark_cli import LARK_CLI_SANDBOX_CONFIG_DIR, LARK_CLI_SANDBOX_DATA_DIR, LARK_CLI_SANDBOX_RUNTIME_DIR, ensure_lark_cli_credential_tree, lark_skills_installed
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
@ -744,7 +746,40 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
mounts.extend(skills_mounts)
logger.info(f"Adding skills mounts: {skills_mounts}")
return mounts
user_skill_mounts = self._get_user_skill_mounts(user_id=user_id)
if user_skill_mounts:
mounts.extend(user_skill_mounts)
logger.info(f"Adding user skill mounts: {user_skill_mounts}")
lark_cli_mounts = self._get_lark_cli_runtime_mounts(user_id=user_id)
if lark_cli_mounts:
mounts.extend(lark_cli_mounts)
logger.info(f"Adding Lark CLI runtime mounts: {lark_cli_mounts}")
return self._dedupe_mounts_by_container_path(mounts)
@staticmethod
def _dedupe_mounts_by_container_path(mounts: list[tuple[str, str, bool]]) -> list[tuple[str, str, bool]]:
"""Keep the first mount for each container path.
Duplicate container paths are rejected by the provisioner and can also
fail local Docker creation. The earlier mount wins because mount helpers
are appended in priority order: thread data, skill roots, integration
skill roots, then integration runtimes/credentials.
"""
seen: set[str] = set()
deduped: list[tuple[str, str, bool]] = []
for host_path, container_path, read_only in mounts:
if container_path in seen:
logger.warning(
"Skipping duplicate sandbox mount for container path %s from host %s",
container_path,
host_path,
)
continue
seen.add(container_path)
deduped.append((host_path, container_path, read_only))
return deduped
@staticmethod
def _get_thread_mounts(thread_id: str, *, user_id: str | None = None) -> list[tuple[str, str, bool]]:
@ -839,6 +874,84 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
return mounts
@staticmethod
def _get_user_skill_mounts(*, user_id: str | None = None) -> list[tuple[str, str, bool]]:
"""Mount managed integration skills into AIO sandboxes.
Per-user custom skills are already mounted by ``_get_skills_mounts``.
This helper adds the shared integration skill root so sandbox paths match
the skill registry without duplicating ``/mnt/skills/custom``.
"""
try:
config = get_app_config()
paths = get_paths()
skills_container_path = config.skills.container_path
paths.integration_skills_dir().mkdir(parents=True, exist_ok=True)
return [
(paths.host_integration_skills_dir(), f"{skills_container_path}/integrations", True),
]
except Exception as e:
logger.warning(f"Could not setup user skill mounts: {e}")
return []
@staticmethod
def _lark_integration_active(user_id: str | None = None) -> bool:
"""Whether the managed Lark skill pack is installed for this user.
Drives whether a sandbox requests the lark-cli runtime (init container /
Gateway-download mount). Independent of whether a local ``sandbox-cli``
dir exists, so remote/K8s can opt in without a Gateway-side download.
"""
try:
effective_user_id = AioSandboxProvider._effective_acquire_user_id(user_id)
return lark_skills_installed(effective_user_id)
except Exception as e: # pragma: no cover - defensive
logger.warning(f"Could not determine Lark integration state: {e}")
return False
@staticmethod
def _get_lark_cli_runtime_mounts(*, user_id: str | None = None) -> list[tuple[str, str, bool]]:
"""Mount the per-user lark-cli config/data dirs used by Settings auth.
Settings endpoints run ``lark-cli`` on the Gateway with
``LARKSUITE_CLI_CONFIG_DIR`` / ``DATA_DIR`` pointing at
``users/{user}/integrations/lark-cli``. Agent conversations run
``lark-cli`` inside the sandbox, so those same directories must be
mounted into the container or the CLI sees a separate unauthenticated
profile.
The ``config`` dir holds the long-lived Lark ``appSecret`` (written by
``lark-cli config init`` on the Gateway, never in-sandbox), so it is
mounted **read-only**: sandbox processes only need to read it, and a
read-only bind stops a compromised agent from tampering with or
replacing the app credentials. The ``data`` dir holds refreshable OAuth
tokens that ``lark-cli auth`` updates in-sandbox, so it stays writable.
This is defense-in-depth only both dirs remain readable to arbitrary
sandbox processes until the auth-proxy follow-up (issue #4338) lands.
See the sandbox trust-boundary note in ``backend/AGENTS.md``.
"""
try:
paths = get_paths()
effective_user_id = AioSandboxProvider._effective_acquire_user_id(user_id)
ensure_lark_cli_credential_tree(effective_user_id, paths=paths)
mounts = [
(paths.host_user_integration_config_dir(effective_user_id, LARK_CLI_INTEGRATION_ID), LARK_CLI_SANDBOX_CONFIG_DIR, True),
(paths.host_user_integration_data_dir(effective_user_id, LARK_CLI_INTEGRATION_ID), LARK_CLI_SANDBOX_DATA_DIR, False),
]
runtime_dir = paths.base_dir / "integrations" / LARK_CLI_INTEGRATION_ID / "sandbox-cli"
if runtime_dir.is_dir():
mounts.append(
(
join_host_path(str(paths.host_base_dir), "integrations", LARK_CLI_INTEGRATION_ID, "sandbox-cli"),
LARK_CLI_SANDBOX_RUNTIME_DIR,
True,
)
)
return mounts
except Exception as e:
logger.warning(f"Could not setup Lark CLI runtime mounts: {e}")
return []
# ── Idle timeout management ──────────────────────────────────────────
def _cleanup_idle_resources(self, idle_timeout: float) -> None:
@ -1694,6 +1807,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"""
effective_user_id = self._effective_acquire_user_id(user_id)
extra_mounts = self._get_extra_mounts(thread_id, user_id=effective_user_id)
provision_lark_cli_runtime = self._lark_integration_active(effective_user_id)
# Enforce replicas: only warm-pool containers count toward eviction budget.
# Active sandboxes are in use by live threads and must not be forcibly stopped.
@ -1702,7 +1816,13 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
evicted = self._evict_oldest_warm()
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
info = self._backend.create(thread_id, sandbox_id, extra_mounts=extra_mounts or None, user_id=effective_user_id)
info = self._backend.create(
thread_id,
sandbox_id,
extra_mounts=extra_mounts or None,
user_id=effective_user_id,
provision_lark_cli_runtime=provision_lark_cli_runtime,
)
# Wait for sandbox to be ready
if not wait_for_sandbox_ready(info.sandbox_url, timeout=60):
@ -1715,6 +1835,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"""Async counterpart to ``_create_sandbox``."""
effective_user_id = self._effective_acquire_user_id(user_id)
extra_mounts = await asyncio.to_thread(self._get_extra_mounts, thread_id, user_id=effective_user_id)
provision_lark_cli_runtime = await asyncio.to_thread(self._lark_integration_active, effective_user_id)
# Enforce replicas: only warm-pool containers count toward eviction budget.
# Active sandboxes are in use by live threads and must not be forcibly stopped.
@ -1723,7 +1844,14 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
evicted = await asyncio.to_thread(self._evict_oldest_warm)
self._log_replicas_soft_cap(replicas, sandbox_id, evicted)
info = await asyncio.to_thread(self._backend.create, thread_id, sandbox_id, extra_mounts=extra_mounts or None, user_id=effective_user_id)
info = await asyncio.to_thread(
self._backend.create,
thread_id,
sandbox_id,
extra_mounts=extra_mounts or None,
user_id=effective_user_id,
provision_lark_cli_runtime=provision_lark_cli_runtime,
)
# Wait for sandbox to be ready without blocking the event loop.
if not await wait_for_sandbox_ready_async(info.sandbox_url, timeout=60):

View File

@ -81,6 +81,7 @@ class SandboxBackend(ABC):
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
provision_lark_cli_runtime: bool = False,
) -> SandboxInfo:
"""Create/provision a new sandbox.
@ -90,6 +91,9 @@ class SandboxBackend(ABC):
extra_mounts: Additional volume mounts as (host_path, container_path, read_only) tuples.
Ignored by backends that don't manage containers (e.g., remote).
user_id: User bucket that the sandbox should mount or provision for.
provision_lark_cli_runtime: Ask the backend to provision the sandbox
lark-cli runtime via its native mechanism (e.g. the provisioner's
init container + emptyDir). Backends that can't do this ignore it.
Returns:
SandboxInfo with connection details.

View File

@ -271,6 +271,7 @@ class LocalContainerBackend(SandboxBackend):
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
provision_lark_cli_runtime: bool = False,
) -> SandboxInfo:
"""Start a new container and return its connection info.
@ -280,6 +281,8 @@ class LocalContainerBackend(SandboxBackend):
extra_mounts: Additional volume mounts as (host_path, container_path, read_only) tuples.
user_id: User bucket already reflected in extra_mounts. Accepted for
interface compatibility with remote backends.
provision_lark_cli_runtime: Ignored the local backend provisions the
lark-cli runtime via the Gateway-download bind mount in extra_mounts.
Returns:
SandboxInfo with container details.
@ -287,7 +290,7 @@ class LocalContainerBackend(SandboxBackend):
Raises:
RuntimeError: If the container fails to start.
"""
del user_id
del user_id, provision_lark_cli_runtime
container_name = f"{self._container_prefix}-{sandbox_id}"
# Retry loop: if Docker rejects the port (e.g. a stale container still

View File

@ -29,6 +29,48 @@ from .sandbox_info import SandboxInfo
logger = logging.getLogger(__name__)
_PROVISIONER_EXTRA_MOUNT_PATHS = {
"/mnt/acp-workspace",
"/mnt/skills/custom",
"/mnt/skills/integrations",
"/mnt/integrations/lark-cli/config",
"/mnt/integrations/lark-cli/data",
"/mnt/integrations/lark-cli/runtime",
}
_LARK_CLI_RUNTIME_CONTAINER_PATH = "/mnt/integrations/lark-cli/runtime"
def _provisioner_extra_mounts_payload(
extra_mounts: list[tuple[str, str, bool]] | None,
*,
provision_lark_cli_runtime: bool = False,
) -> list[dict[str, object]]:
"""Return only extra mounts the provisioner knows how to recreate safely.
When ``provision_lark_cli_runtime`` is set, the provisioner supplies the
lark-cli runtime via an init container + emptyDir, so the runtime extra mount
is dropped here to avoid a colliding hostPath/PVC mount at the same path. The
per-user config/data credential mounts are always forwarded.
"""
if not extra_mounts:
return []
payload: list[dict[str, object]] = []
for host_path, container_path, read_only in extra_mounts:
if container_path not in _PROVISIONER_EXTRA_MOUNT_PATHS:
continue
if provision_lark_cli_runtime and container_path == _LARK_CLI_RUNTIME_CONTAINER_PATH:
continue
payload.append(
{
"host_path": host_path,
"container_path": container_path,
"read_only": read_only,
}
)
return payload
class RemoteSandboxBackend(SandboxBackend):
"""Backend that delegates sandbox lifecycle to the provisioner service.
@ -72,13 +114,20 @@ class RemoteSandboxBackend(SandboxBackend):
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
provision_lark_cli_runtime: bool = False,
) -> SandboxInfo:
"""Create a sandbox Pod + Service via the provisioner.
Calls ``POST /api/sandboxes`` which creates a dedicated Pod +
NodePort Service in k3s.
"""
return self._provisioner_create(thread_id, sandbox_id, extra_mounts, user_id=user_id)
return self._provisioner_create(
thread_id,
sandbox_id,
extra_mounts,
user_id=user_id,
provision_lark_cli_runtime=provision_lark_cli_runtime,
)
def destroy(self, info: SandboxInfo) -> None:
"""Destroy a sandbox Pod + Service via the provisioner."""
@ -149,20 +198,28 @@ class RemoteSandboxBackend(SandboxBackend):
extra_mounts: list[tuple[str, str, bool]] | None = None,
*,
user_id: str | None = None,
provision_lark_cli_runtime: bool = False,
) -> SandboxInfo:
"""POST /api/sandboxes → create Pod + Service."""
del extra_mounts
effective_user_id = user_id or get_effective_user_id()
include_legacy_skills = user_should_see_legacy_skills(effective_user_id)
payload = {
"sandbox_id": sandbox_id,
"thread_id": thread_id,
"user_id": effective_user_id,
"include_legacy_skills": include_legacy_skills,
"provision_lark_cli_runtime": provision_lark_cli_runtime,
}
provisioner_extra_mounts = _provisioner_extra_mounts_payload(
extra_mounts,
provision_lark_cli_runtime=provision_lark_cli_runtime,
)
if provisioner_extra_mounts:
payload["extra_mounts"] = provisioner_extra_mounts
try:
resp = requests.post(
f"{self._provisioner_url}/api/sandboxes",
json={
"sandbox_id": sandbox_id,
"thread_id": thread_id,
"user_id": effective_user_id,
"include_legacy_skills": include_legacy_skills,
},
json=payload,
headers=self._auth_headers(),
timeout=30,
)

View File

@ -315,7 +315,7 @@ class ExtensionsConfig(BaseModel):
skill_config = self.skills.get(skill_name)
if skill_config is None:
# Default to enabled for all skill categories
return skill_category in ("public", "custom", "legacy")
return skill_category in ("public", "custom", "legacy", "integrations")
return skill_config.enabled

View File

@ -12,6 +12,7 @@ VIRTUAL_PATH_PREFIX = "/mnt/user-data"
_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
_SAFE_USER_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
_SAFE_INTEGRATION_ID_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
_UNSAFE_USER_ID_CHAR_RE = re.compile(r"[^A-Za-z0-9_\-]")
_SAFE_USER_ID_DIGEST_HEX_LEN = 16
@ -37,6 +38,18 @@ def _validate_user_id(user_id: str) -> str:
return user_id
def _validate_integration_id(integration_id: str) -> str:
"""Validate an integration ID before using it in filesystem paths."""
if not _SAFE_INTEGRATION_ID_RE.match(integration_id):
raise ValueError(f"Invalid integration_id {integration_id!r}: only alphanumeric characters, dots, hyphens, and underscores are allowed.")
# The charset allows dots for names like ``some.integration``; reject the
# bare ``.``/``..`` path components so a future caller cannot escape the
# per-integration namespace via ``_join_host_path(..., integration_id, ...)``.
if integration_id in {".", ".."}:
raise ValueError(f"Invalid integration_id {integration_id!r}: '.' and '..' are not allowed.")
return integration_id
def make_safe_user_id(raw: str) -> str:
"""Normalize an external identity into the user-id charset (``[A-Za-z0-9_-]``).
@ -236,6 +249,15 @@ class Paths:
"""
return self.user_skills_dir(user_id) / "custom"
def integration_skills_dir(self) -> Path:
"""Globally installed managed integration skills.
Layout: ``{base_dir}/integrations/skills/{provider}/{skill}/``. The
package contents are shared and read-only; credentials and enabled
state remain user-scoped elsewhere under ``users/{user_id}``.
"""
return self.base_dir / "integrations" / "skills"
def thread_dir(self, thread_id: str, *, user_id: str | None = None) -> Path:
"""
Host path for a thread's data.
@ -325,6 +347,22 @@ class Paths:
"""Host path for the ACP workspace mount source."""
return _join_host_path(self.host_thread_dir(thread_id, user_id=user_id), "acp-workspace")
def host_user_custom_skills_dir(self, user_id: str) -> str:
"""Host path for a user's custom skills directory, preserving Windows path syntax."""
return _join_host_path(self._host_base_dir_str(), "users", _validate_user_id(user_id), "skills", "custom")
def host_integration_skills_dir(self) -> str:
"""Host path for globally installed managed integration skills."""
return _join_host_path(self._host_base_dir_str(), "integrations", "skills")
def host_user_integration_config_dir(self, user_id: str, integration_id: str) -> str:
"""Host path for a user's managed integration runtime config directory."""
return _join_host_path(self._host_base_dir_str(), "users", _validate_user_id(user_id), "integrations", _validate_integration_id(integration_id), "config")
def host_user_integration_data_dir(self, user_id: str, integration_id: str) -> str:
"""Host path for a user's managed integration runtime data directory."""
return _join_host_path(self._host_base_dir_str(), "users", _validate_user_id(user_id), "integrations", _validate_integration_id(integration_id), "data")
def ensure_thread_dirs(self, thread_id: str, *, user_id: str | None = None) -> None:
"""Create all standard sandbox directories for a thread.

View File

@ -0,0 +1 @@
"""First-party integration installers and status helpers."""

File diff suppressed because it is too large Load Diff

View File

@ -136,6 +136,7 @@ class LocalSandboxProvider(SandboxProvider):
_RESERVED_CONTAINER_PREFIXES = [
f"{container_path}/public",
f"{container_path}/custom",
f"{container_path}/integrations",
f"{container_path}/legacy",
_ACP_WORKSPACE_VIRTUAL_PREFIX,
_USER_DATA_VIRTUAL_PREFIX,
@ -287,7 +288,9 @@ class LocalSandboxProvider(SandboxProvider):
config = get_app_config()
skills_container_path = config.skills.container_path
user_custom_path = paths.user_custom_skills_dir(effective_user_id)
integrations_path = paths.integration_skills_dir()
user_custom_path.mkdir(parents=True, exist_ok=True)
integrations_path.mkdir(parents=True, exist_ok=True)
mappings.append(
PathMapping(
@ -296,6 +299,13 @@ class LocalSandboxProvider(SandboxProvider):
read_only=True,
)
)
mappings.append(
PathMapping(
container_path=f"{skills_container_path}/integrations",
local_path=str(integrations_path),
read_only=True,
)
)
except Exception as exc:
logger.warning("Could not setup per-thread custom skills mount: %s", exc, exc_info=True)

View File

@ -163,6 +163,7 @@ def _extract_skill_name_from_skills_path(path: str) -> str | None:
/mnt/skills/public/bootstrap/SKILL.md "bootstrap"
/mnt/skills/custom/my-skill/SKILL.md "my-skill"
/mnt/skills/legacy/my-skill/references/... "my-skill"
/mnt/skills/integrations/lark-cli/lark-doc/SKILL.md "lark-doc"
/mnt/skills/public/bootstrap/ "bootstrap"
Returns None if the path doesn't contain a recognizable skill name pattern.
"""
@ -173,16 +174,22 @@ def _extract_skill_name_from_skills_path(path: str) -> str | None:
relative = path[len(skills_prefix) :].lstrip("/")
if not relative:
return None
# Expected patterns: "public/<name>/...", "custom/<name>/...", "legacy/<name>/..."
# Expected patterns: "public/<name>/...", "custom/<name>/...",
# "legacy/<name>/...", "integrations/<provider>/<name>/..."
# or "<name>/..." (direct skill access). Empty segments are dropped so a
# directory entry ("public/", as `ls` emits for dirs) is still recognized as
# a category root rather than yielding an empty skill name.
parts = [part for part in relative.split("/") if part]
if len(parts) >= 2 and parts[0] in ("public", "custom", "legacy"):
return parts[1]
if len(parts) == 1 and parts[0] in ("public", "custom", "legacy"):
if len(parts) >= 3 and parts[0] == "integrations":
return parts[2]
if len(parts) == 1 and parts[0] in ("public", "custom", "legacy", "integrations"):
# Category root like /mnt/skills/custom — not a skill path.
return None
if len(parts) == 2 and parts[0] == "integrations":
# Provider root like /mnt/skills/integrations/lark-cli.
return None
if len(parts) >= 1:
# Direct path like /mnt/skills/my-skill/SKILL.md
return parts[0]
@ -215,6 +222,8 @@ def _is_disabled_skill_path(path: str, *, user_id: str | None = None) -> bool:
category = "custom"
elif relative.startswith("legacy/"):
category = "legacy"
elif relative.startswith("integrations/"):
category = "integrations"
else:
# Try to infer from storage
effective_uid = user_id or get_effective_user_id()
@ -307,7 +316,7 @@ def _resolve_skills_path(path: str) -> str:
relative = path[len(skills_container) :].lstrip("/")
# Per-user custom skills: resolve to user-specific directory.
# Per-user custom and globally managed integration skills.
# ``skill_manage_tool`` writes custom skills to the per-user directory,
# and ``LocalSandboxProvider._build_thread_path_mappings`` mounts
# ``/mnt/skills/custom`` to that same per-user dir. Without this
@ -327,6 +336,22 @@ def _resolve_skills_path(path: str) -> str:
return str(user_custom_dir / custom_relative)
return str(user_custom_dir)
if relative == "integrations" or relative.startswith("integrations/"):
from deerflow.config.paths import get_paths
paths = get_paths()
integrations_dir = paths.integration_skills_dir()
integrations_relative = relative[len("integrations") :].lstrip("/")
if not integrations_relative:
return str(integrations_dir)
# Defense-in-depth: even though _reject_path_traversal runs upstream for
# sandbox callers, confirm the resolved path stays within the global
# integration dir so a lexical ``../`` cannot escape it here.
resolved = (integrations_dir / integrations_relative).resolve()
if not resolved.is_relative_to(integrations_dir.resolve()):
raise PermissionError("Access denied: path traversal detected")
return str(integrations_dir / integrations_relative)
return _join_path_preserving_style(skills_host, relative)
@ -758,11 +783,11 @@ def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None)
"""Mask host absolute paths from local sandbox output using virtual paths.
Handles user-data paths (per-thread), skills paths (global + per-user
custom), and ACP workspace paths (per-thread).
custom + managed integrations), and ACP workspace paths (per-thread).
"""
# Build the ordered (host_base, virtual_base) source list. Order is
# preserved from the original implementation: skills, then per-user
# custom skills, then ACP workspace, then user-data mappings (longest
# custom/integration skills, then ACP workspace, then user-data mappings (longest
# host path first). Custom mount host paths are masked by
# LocalSandbox._reverse_resolve_paths_in_output().
sources: list[tuple[str, str]] = []
@ -782,9 +807,13 @@ def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None)
user_id = get_effective_user_id()
user_custom_dir = get_paths().user_custom_skills_dir(user_id)
integrations_dir = get_paths().integration_skills_dir()
if user_custom_dir.exists():
skills_container = _get_skills_container_path()
sources.append((str(user_custom_dir), f"{skills_container}/custom"))
if integrations_dir.exists():
skills_container = _get_skills_container_path()
sources.append((str(integrations_dir), f"{skills_container}/integrations"))
except Exception:
pass
@ -1697,6 +1726,30 @@ def _github_env_from_runtime(runtime: Runtime) -> dict[str, str] | None:
return {"GH_TOKEN": token, "GITHUB_TOKEN": token}
_LARK_CLI_COMMAND_RE = re.compile(r"(?<![A-Za-z0-9_.-])lark-cli(?![A-Za-z0-9_.-])")
def _lark_cli_env_from_runtime(runtime: Runtime, command: str, *, sandbox_paths: bool) -> dict[str, str] | None:
"""Expose Settings-page Lark auth to sandbox ``lark-cli`` commands.
Settings authorizes ``lark-cli`` under DeerFlow's per-user integration
config/data directories. Agent conversations invoke ``lark-cli`` through the
sandbox, so lark commands must receive those same directories or they see an
unrelated unauthenticated profile. Keep this scoped to commands that
actually call ``lark-cli`` so ordinary bash calls do not switch AIO into the
env-bearing execution path.
"""
if not _LARK_CLI_COMMAND_RE.search(command):
return None
try:
from deerflow.integrations.lark_cli import lark_cli_env_overlay
return lark_cli_env_overlay(resolve_runtime_user_id(runtime), sandbox_paths=sandbox_paths)
except Exception:
logger.warning("Could not build Lark CLI env overlay; running command without managed auth", exc_info=True)
return None
@tool("bash", parse_docstring=True)
def bash_tool(runtime: Runtime, description: str, command: str) -> str:
"""Execute a bash command in a Linux environment.
@ -1723,8 +1776,11 @@ def bash_tool(runtime: Runtime, description: str, command: str) -> str:
injected_env = read_active_secrets(getattr(runtime, "context", None)) or None
identity_prefix = _channel_identity_prefix(runtime)
github_env = _github_env_from_runtime(runtime)
lark_cli_env = _lark_cli_env_from_runtime(runtime, command, sandbox_paths=not is_local_sandbox(runtime))
if github_env:
injected_env = {**(injected_env or {}), **github_env}
if lark_cli_env:
injected_env = {**(injected_env or {}), **lark_cli_env}
if is_local_sandbox(runtime):
if not is_host_bash_allowed():
return f"Error: {LOCAL_HOST_BASH_DISABLED_MESSAGE}"

View File

@ -8,6 +8,7 @@ Layout::
<host_root>/public/<name>/SKILL.md global, read-only
<user_custom_root>/<name>/SKILL.md per-user, read-write
<user_integrations_root>/<provider>/<name>/SKILL.md per-user, read-only
<user_custom_root>/.history/<name>.jsonl per-user history
<user_skills_root>/_skill_states.json per-user enabled state
<global_custom_root>/<name>/SKILL.md legacy fallback, read-only
@ -82,6 +83,7 @@ class UserScopedSkillStorage(LocalSkillStorage):
self._user_id = _validate_user_id(user_id)
paths = get_paths()
self._user_custom_root: Path = paths.user_custom_skills_dir(self._user_id)
self._integrations_root: Path = paths.integration_skills_dir()
self._user_skills_root: Path = paths.user_skills_dir(self._user_id)
self._global_custom_root: Path = self._host_root / SkillCategory.CUSTOM.value
self._skill_states_file: Path = self._user_skills_root / "_skill_states.json"
@ -256,8 +258,11 @@ class UserScopedSkillStorage(LocalSkillStorage):
is_global_custom_fallback = (self._global_custom_root / normalized_name / SKILL_MD_FILE).exists()
if is_global_public:
raise ValueError(f"'{name}' is a built-in skill. Use the skill_manage tool to create your own version — it will shadow the built-in one.")
is_integration = any((candidate / SKILL_MD_FILE).exists() for candidate in self._integrations_root.glob(f"*/{normalized_name}") if candidate.is_dir())
if is_global_custom_fallback:
raise ValueError(f"'{name}' is a legacy shared skill (not editable). To customise it, create your own version with the same name — it will shadow the shared one.")
if is_integration:
raise ValueError(f"'{name}' is a managed integration skill and cannot be edited. Create a custom skill with another name if you need a modified workflow.")
raise FileNotFoundError(f"Custom skill '{name}' not found.")
def _iter_skill_files(self) -> Iterable[tuple[SkillCategory, Path, Path]]:
@ -271,7 +276,17 @@ class UserScopedSkillStorage(LocalSkillStorage):
dir_names.clear()
yield SkillCategory.PUBLIC, public_path, Path(current_root) / SKILL_MD_FILE
# 2. Custom skills: prefer user-level directory
# 2. Managed integration skills: globally installed, read-only. Their
# enabled state is still merged from this user's _skill_states.json.
integration_path = self._integrations_root
if integration_path.exists() and integration_path.is_dir():
for current_root, dir_names, file_names in os.walk(integration_path, followlinks=True):
dir_names[:] = sorted(name for name in dir_names if not name.startswith("."))
if SKILL_MD_FILE not in file_names:
continue
yield SkillCategory.INTEGRATION, integration_path, Path(current_root) / SKILL_MD_FILE
# 3. Custom skills: prefer user-level directory
user_custom_exists = False
user_custom_path = self._user_custom_root
if user_custom_path.exists() and user_custom_path.is_dir():
@ -283,7 +298,7 @@ class UserScopedSkillStorage(LocalSkillStorage):
user_custom_exists = True
yield SkillCategory.CUSTOM, user_custom_path, Path(current_root) / SKILL_MD_FILE
# 3. Fallback: if user has no custom skills, load from global custom
# 4. Fallback: if user has no custom skills, load from global custom
# as LEGACY (read-only) so legacy skills are visible but not
# editable/deletable by the user. LEGACY skills are mounted at
# /mnt/skills/legacy/<name>/ in the sandbox so their supporting
@ -370,6 +385,10 @@ class UserScopedSkillStorage(LocalSkillStorage):
"""Host path to this user's custom skills root directory."""
return self._user_custom_root
def get_user_integrations_root(self) -> Path:
"""Host path to this user's managed integration skills root directory."""
return self._user_integrations_root
# ------------------------------------------------------------------
# Path validation — accept per-user custom root as well as global root
# ------------------------------------------------------------------
@ -382,7 +401,7 @@ class UserScopedSkillStorage(LocalSkillStorage):
would reject them. This override allows both roots.
"""
resolved_file = skill_file.resolve()
for allowed_root in (self._host_root.resolve(), self._user_custom_root.resolve()):
for allowed_root in (self._host_root.resolve(), self._user_custom_root.resolve(), self._user_integrations_root.resolve()):
try:
resolved_file.relative_to(allowed_root)
return resolved_file

View File

@ -12,6 +12,7 @@ class SkillCategory(StrEnum):
- ``PUBLIC``: built-in skill bundled with the platform, read-only.
- ``CUSTOM``: user-authored skill that can be edited or deleted.
- ``INTEGRATION``: managed third-party integration skill, read-only.
- ``LEGACY``: global custom skill from before user-isolation migration,
presented as read-only (visible but not editable/deletable). These
skills are mounted at ``/mnt/skills/legacy/<name>/`` in the sandbox.
@ -19,6 +20,7 @@ class SkillCategory(StrEnum):
PUBLIC = "public"
CUSTOM = "custom"
INTEGRATION = "integrations"
LEGACY = "legacy"

View File

@ -0,0 +1,130 @@
"""Regression anchors: integrations router must not block the event loop.
The Lark integration handlers are async FastAPI route handlers, but the work
they dispatch includes zip reads, filesystem staging, manifest writes, and
``lark-cli`` subprocess calls. Those phases must stay behind
``asyncio.to_thread``; if a future refactor runs them inline, the strict
Blockbuster gate raises ``BlockingError`` and these anchors fail.
"""
from __future__ import annotations
import asyncio
import os
import zipfile
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.gateway.routers import integrations
from deerflow.config import paths as paths_module
from deerflow.integrations import lark_cli
pytestmark = pytest.mark.asyncio
def _skill_content(name: str) -> str:
return f"---\nname: {name}\ndescription: {name} integration skill\n---\n\n# {name}\n"
def _build_lark_archive(archive: Path) -> None:
archive.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(archive, "w") as zf:
for skill_name in lark_cli.LARK_SKILL_NAMES:
zf.writestr(f"cli-1.0.65/skills/{skill_name}/SKILL.md", _skill_content(skill_name))
zf.writestr(f"cli-1.0.65/skills/{skill_name}/references/readme.md", f"# {skill_name}\n")
def _write_stub_lark_cli(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"""#!/bin/sh
if [ "$1" = "--version" ]; then
echo "v1.0.65"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "login" ]; then
echo "{}"
exit 0
fi
if [ "$1" = "auth" ] && [ "$2" = "status" ]; then
echo '{"identities":{"user":{"userName":"Alice"}}}'
exit 0
fi
echo "{}"
exit 0
""",
encoding="utf-8",
)
path.chmod(0o755)
def _reset_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path / "home"))
monkeypatch.setattr(paths_module, "_paths", None)
async def _config(tmp_path: Path) -> SimpleNamespace:
skills_root = tmp_path / "skills"
await asyncio.to_thread((skills_root / "public").mkdir, parents=True, exist_ok=True)
await asyncio.to_thread((skills_root / "custom").mkdir, parents=True, exist_ok=True)
return SimpleNamespace(
skills=SimpleNamespace(
get_skills_path=lambda: skills_root,
container_path="/mnt/skills",
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
)
)
async def test_lark_install_route_does_not_block_event_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_reset_paths(tmp_path, monkeypatch)
config = await _config(tmp_path)
archive = tmp_path / "fixtures" / "lark-cli.zip"
await asyncio.to_thread(_build_lark_archive, archive)
async def _allow_admin(*_args, **_kwargs) -> None:
return None
refresh_calls = 0
async def _refresh_cache() -> None:
nonlocal refresh_calls
refresh_calls += 1
monkeypatch.setenv(lark_cli.LARK_CLI_SOURCE_ARCHIVE_ENV, str(archive))
monkeypatch.setattr(lark_cli, "probe_lark_cli", lambda: lark_cli.LarkCliProbe(available=True, path="/usr/bin/lark-cli", version="v1.0.65"))
monkeypatch.setattr(lark_cli, "probe_lark_auth", lambda _user_id, **_kwargs: lark_cli.LarkAuthProbe(status="not_configured", message="not configured"))
monkeypatch.setattr(integrations, "get_effective_user_id", lambda: "loop-user")
monkeypatch.setattr(integrations, "require_admin_user", _allow_admin)
monkeypatch.setattr(integrations, "refresh_skills_system_prompt_cache_async", _refresh_cache, raising=False)
response = await integrations.install_lark(request=None, config=config)
assert response.success is True
assert refresh_calls == 1
install_root = await asyncio.to_thread(lark_cli.lark_integration_root, "loop-user")
assert await asyncio.to_thread((install_root / "lark-doc" / "SKILL.md").exists)
async def test_lark_auth_complete_route_does_not_block_event_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_reset_paths(tmp_path, monkeypatch)
config = await _config(tmp_path)
cli_path = tmp_path / "bin" / "lark-cli"
await asyncio.to_thread(_write_stub_lark_cli, cli_path)
monkeypatch.setenv("PATH", f"{cli_path.parent}{os.pathsep}{os.environ.get('PATH', '')}")
monkeypatch.setattr(integrations, "get_effective_user_id", lambda: "loop-user")
response = await integrations.complete_lark_browser_auth(
request=None,
body=integrations.LarkAuthCompleteRequest(device_code="device-code"),
config=config,
)
assert response.status.cli.available is True
assert response.status.cli.version == "v1.0.65"

View File

@ -2,6 +2,7 @@
import asyncio
import importlib
import stat
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@ -119,6 +120,108 @@ def test_get_thread_mounts_uses_explicit_user_id(tmp_path, monkeypatch):
assert container_paths["/mnt/user-data/outputs"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / "outputs")
def test_get_lark_cli_runtime_mounts_uses_user_auth_dirs(tmp_path, monkeypatch):
"""Sandbox lark-cli commands must read the same auth dirs as Settings."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
lark_cli = importlib.import_module("deerflow.integrations.lark_cli")
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: "default")
runtime_dir = tmp_path / "integrations" / "lark-cli" / "sandbox-cli"
runtime_dir.mkdir(parents=True)
mounts = aio_mod.AioSandboxProvider._get_lark_cli_runtime_mounts(user_id="alice")
container_paths = {container_path: (host_path, read_only) for host_path, container_path, read_only in mounts}
assert container_paths[lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR] == (
str(tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "config"),
True,
)
assert container_paths[lark_cli.LARK_CLI_SANDBOX_DATA_DIR] == (
str(tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "data"),
False,
)
assert stat.S_IMODE((tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "config").stat().st_mode) == 0o700
assert stat.S_IMODE((tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "data").stat().st_mode) == 0o700
assert container_paths["/mnt/integrations/lark-cli/runtime"] == (
str(runtime_dir),
True,
)
def test_get_user_skill_mounts_mounts_only_global_integrations(tmp_path, monkeypatch):
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
skills_root = tmp_path / "skills"
(skills_root / "public").mkdir(parents=True)
config = SimpleNamespace(
skills=SimpleNamespace(
get_skills_path=lambda: skills_root,
container_path="/mnt/skills",
)
)
monkeypatch.setattr(aio_mod, "get_app_config", lambda: config)
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path / "home"))
alice = {container: host for host, container, _read_only in aio_mod.AioSandboxProvider._get_user_skill_mounts(user_id="alice")}
bob = {container: host for host, container, _read_only in aio_mod.AioSandboxProvider._get_user_skill_mounts(user_id="bob")}
assert set(alice) == {"/mnt/skills/integrations"}
assert set(bob) == {"/mnt/skills/integrations"}
assert alice["/mnt/skills/integrations"] == bob["/mnt/skills/integrations"]
assert alice["/mnt/skills/integrations"] == str(tmp_path / "home" / "integrations" / "skills")
def test_get_extra_mounts_provisioner_payload_has_unique_container_paths(tmp_path, monkeypatch, provisioner_module):
"""Full AIO mount composition must not send duplicate paths to provisioner."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
lark_cli = importlib.import_module("deerflow.integrations.lark_cli")
remote_backend = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
skills_root = tmp_path / "skills"
(skills_root / "public").mkdir(parents=True)
home = tmp_path / "home"
config = SimpleNamespace(
skills=SimpleNamespace(
get_skills_path=lambda: skills_root,
container_path="/mnt/skills",
)
)
runtime_dir = home / "integrations" / "lark-cli" / "sandbox-cli"
runtime_dir.mkdir(parents=True)
monkeypatch.setattr(aio_mod, "get_app_config", lambda: config)
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=home))
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: "default")
monkeypatch.setattr(aio_mod, "user_should_see_legacy_skills", lambda *_args, **_kwargs: False)
provider = _make_provider(tmp_path)
mounts = provider._get_extra_mounts("thread-1", user_id="alice")
container_paths = [container for _host, container, _read_only in mounts]
assert len(container_paths) == len(set(container_paths))
assert "/mnt/skills/custom" in container_paths
assert "/mnt/skills/integrations" in container_paths
assert lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR in container_paths
assert lark_cli.LARK_CLI_SANDBOX_DATA_DIR in container_paths
assert lark_cli.LARK_CLI_SANDBOX_RUNTIME_DIR in container_paths
payload = remote_backend._provisioner_extra_mounts_payload(mounts)
payload_paths = [str(item["container_path"]) for item in payload]
assert len(payload_paths) == len(set(payload_paths))
provisioner_module.DEER_FLOW_HOST_BASE_DIR = str(home)
validated = provisioner_module._validated_extra_mounts([provisioner_module.ExtraMount(**item) for item in payload])
validated_paths = [mount.container_path for mount in validated]
assert len(validated_paths) == len(set(validated_paths))
assert set(validated_paths) == {
"/mnt/acp-workspace",
"/mnt/skills/custom",
"/mnt/skills/integrations",
lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR,
lark_cli.LARK_CLI_SANDBOX_DATA_DIR,
lark_cli.LARK_CLI_SANDBOX_RUNTIME_DIR,
}
def test_join_host_path_preserves_windows_drive_letter_style():
base = r"C:\Users\demo\deer-flow\backend\.deer-flow"
@ -405,6 +508,7 @@ def test_remote_backend_create_forwards_effective_user_id(monkeypatch):
"thread_id": "thread-42",
"user_id": "user-7",
"include_legacy_skills": True,
"provision_lark_cli_runtime": False,
}
@ -435,6 +539,62 @@ def test_remote_backend_create_prefers_explicit_user_id(monkeypatch):
assert posted["json"]["include_legacy_skills"] is False
def test_create_sandbox_requests_runtime_when_lark_installed(tmp_path, monkeypatch):
"""The provider must request lark-cli runtime provisioning when Lark is installed."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
provider = _make_provider(tmp_path)
provider._config = {"replicas": 3}
provider._thread_locks = {}
provider._warm_pool = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
provider._last_activity = {}
provider._lock = aio_mod.threading.Lock()
captured: dict = {}
def _create(thread_id, sandbox_id, *, extra_mounts=None, user_id=None, provision_lark_cli_runtime=False):
captured["provision_lark_cli_runtime"] = provision_lark_cli_runtime
return aio_mod.SandboxInfo(sandbox_id=sandbox_id, sandbox_url="http://sandbox")
provider._backend = SimpleNamespace(create=_create, destroy=MagicMock(), discover=MagicMock(return_value=None))
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda *_a, **_k: True)
monkeypatch.setattr(provider, "_get_extra_mounts", lambda *_a, **_k: [])
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_lark_integration_active", staticmethod(lambda user_id=None: True))
monkeypatch.setattr(provider, "_register_created_sandbox", lambda *a, **k: "sandbox-lark")
provider._create_sandbox("thread-lark", "sandbox-lark", user_id="alice")
assert captured["provision_lark_cli_runtime"] is True
def test_create_sandbox_skips_runtime_when_lark_absent(tmp_path, monkeypatch):
"""No runtime provisioning request when the Lark skill pack is not installed."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
provider = _make_provider(tmp_path)
provider._config = {"replicas": 3}
provider._thread_locks = {}
provider._warm_pool = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
provider._last_activity = {}
provider._lock = aio_mod.threading.Lock()
captured: dict = {}
def _create(thread_id, sandbox_id, *, extra_mounts=None, user_id=None, provision_lark_cli_runtime=False):
captured["provision_lark_cli_runtime"] = provision_lark_cli_runtime
return aio_mod.SandboxInfo(sandbox_id=sandbox_id, sandbox_url="http://sandbox")
provider._backend = SimpleNamespace(create=_create, destroy=MagicMock(), discover=MagicMock(return_value=None))
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda *_a, **_k: True)
monkeypatch.setattr(provider, "_get_extra_mounts", lambda *_a, **_k: [])
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_lark_integration_active", staticmethod(lambda user_id=None: False))
monkeypatch.setattr(provider, "_register_created_sandbox", lambda *a, **k: "sandbox-nolark")
provider._create_sandbox("thread-nolark", "sandbox-nolark", user_id="alice")
assert captured["provision_lark_cli_runtime"] is False
# ── Sandbox client teardown (#2872) ──────────────────────────────────────────

View File

@ -8,23 +8,28 @@ import sys
from pathlib import Path
def _gateway_import_env() -> dict[str, str]:
backend_root = Path(__file__).resolve().parents[1]
harness_root = backend_root / "packages" / "harness"
python_path_entries = [str(backend_root), str(harness_root)]
if existing_python_path := os.environ.get("PYTHONPATH"):
python_path_entries.append(existing_python_path)
return {**os.environ, "PYTHONPATH": os.pathsep.join(python_path_entries)}
def test_gateway_app_imports_first_without_subagent_import_cycle() -> None:
"""The replay gateway imports app.gateway.app in a clean process."""
backend_root = Path(__file__).resolve().parents[1]
env = {**os.environ, "PYTHONPATH": str(backend_root)}
result = subprocess.run(
[sys.executable, "-c", "from app.gateway.app import app"],
capture_output=True,
text=True,
env=env,
env=_gateway_import_env(),
)
assert result.returncode == 0, result.stderr
def test_subagent_package_public_executor_exports_are_lazy_importable() -> None:
"""The package-level executor exports must not re-enter their own import."""
backend_root = Path(__file__).resolve().parents[1]
env = {**os.environ, "PYTHONPATH": str(backend_root)}
result = subprocess.run(
[
sys.executable,
@ -33,7 +38,7 @@ def test_subagent_package_public_executor_exports_are_lazy_importable() -> None:
],
capture_output=True,
text=True,
env=env,
env=_gateway_import_env(),
)
assert result.returncode == 0, result.stderr
assert "SubagentExecutor SubagentResult" in result.stdout

File diff suppressed because it is too large Load Diff

View File

@ -544,6 +544,35 @@ class TestMultipleMounts:
class TestLocalSandboxProviderMounts:
def test_thread_mappings_mount_global_integrations_for_every_user(self, tmp_path):
from deerflow.config.paths import Paths
paths = Paths(base_dir=tmp_path / "home")
skills_dir = tmp_path / "skills"
(skills_dir / "public").mkdir(parents=True)
(skills_dir / "custom").mkdir()
config = SimpleNamespace(
skills=SimpleNamespace(
container_path="/mnt/skills",
get_skills_path=lambda: skills_dir,
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
)
)
with (
patch("deerflow.config.get_app_config", return_value=config),
patch("deerflow.config.paths.get_paths", return_value=paths),
):
alice = LocalSandboxProvider._build_thread_path_mappings("thread-a", user_id="alice")
bob = LocalSandboxProvider._build_thread_path_mappings("thread-b", user_id="bob")
alice_integrations = next(mapping for mapping in alice if mapping.container_path == "/mnt/skills/integrations")
bob_integrations = next(mapping for mapping in bob if mapping.container_path == "/mnt/skills/integrations")
expected = str(tmp_path / "home" / "integrations" / "skills")
assert alice_integrations.local_path == expected
assert bob_integrations.local_path == expected
assert alice_integrations.read_only is True
def test_setup_path_mappings_uses_configured_skills_container_path_as_reserved_prefix(self, tmp_path):
skills_dir = tmp_path / "skills"
skills_dir.mkdir()

View File

@ -66,6 +66,31 @@ class TestMakeSafeUserId:
make_safe_user_id("")
class TestValidateIntegrationId:
def test_accepts_dotted_integration_id(self):
from deerflow.config.paths import _validate_integration_id
assert _validate_integration_id("lark-cli") == "lark-cli"
assert _validate_integration_id("some.integration") == "some.integration"
@pytest.mark.parametrize("integration_id", [".", ".."])
def test_rejects_dot_and_dotdot(self, integration_id):
from deerflow.config.paths import _validate_integration_id
with pytest.raises(ValueError, match="Invalid integration_id"):
_validate_integration_id(integration_id)
@pytest.mark.parametrize("integration_id", [".", ".."])
def test_host_integration_config_dir_rejects_dot_traversal(self, paths: Paths, integration_id):
with pytest.raises(ValueError, match="Invalid integration_id"):
paths.host_user_integration_config_dir("alice", integration_id)
@pytest.mark.parametrize("integration_id", [".", ".."])
def test_host_integration_data_dir_rejects_dot_traversal(self, paths: Paths, integration_id):
with pytest.raises(ValueError, match="Invalid integration_id"):
paths.host_user_integration_data_dir("alice", integration_id)
class TestUserDir:
def test_user_dir(self, paths: Paths):
assert paths.user_dir("alice") == paths.base_dir / "users" / "alice"

View File

@ -0,0 +1,28 @@
"""Keep Gateway/provisioner extra-mount allowlists in lockstep."""
from __future__ import annotations
import ast
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def _literal_assignment(path: Path, name: str):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in tree.body:
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
return ast.literal_eval(node.value)
raise AssertionError(f"{name} not found in {path}")
def test_gateway_and_provisioner_extra_mount_contracts_match() -> None:
gateway_path = REPO_ROOT / "backend/packages/harness/deerflow/community/aio_sandbox/remote_backend.py"
provisioner_path = REPO_ROOT / "docker/provisioner/app.py"
gateway_paths = _literal_assignment(gateway_path, "_PROVISIONER_EXTRA_MOUNT_PATHS")
provisioner_paths = _literal_assignment(provisioner_path, "ALLOWED_EXTRA_MOUNT_PATHS")
assert gateway_paths == provisioner_paths
assert "/mnt/integrations/lark-cli/runtime" in gateway_paths
assert _literal_assignment(provisioner_path, "MAX_EXTRA_MOUNTS") == 9

View File

@ -1,5 +1,6 @@
"""Regression tests for provisioner three-way skills + PVC volume support."""
import pytest
# ── _build_volumes ─────────────────────────────────────────────────────
@ -113,6 +114,67 @@ class TestBuildVolumes:
assert volumes[0].name == "skills"
assert volumes[-1].name == "user-data"
def test_extra_mount_uses_hostpath_when_userdata_pvc_is_disabled(self, provisioner_module):
"""Controlled extra mounts should become extra hostPath volumes by default."""
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = ""
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
extra_mounts = [
provisioner_module.ExtraMount(
host_path="/state/users/alice/integrations/lark-cli/config",
container_path="/mnt/integrations/lark-cli/config",
read_only=False,
)
]
volumes = provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
# skills-public + skills-custom + user-data (3 base) + 1 extra volume.
assert len(volumes) == 4
extra_vol = volumes[-1]
assert extra_vol.name == "extra-0"
assert extra_vol.host_path.path == "/state/users/alice/integrations/lark-cli/config"
assert extra_vol.host_path.type == "DirectoryOrCreate"
def test_extra_mount_uses_userdata_pvc_when_configured(self, provisioner_module):
"""PVC mode should use the same DeerFlow data PVC for runtime config mounts."""
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
extra_mounts = [
provisioner_module.ExtraMount(
host_path="/state/users/alice/integrations/lark-cli/config",
container_path="/mnt/integrations/lark-cli/config",
read_only=False,
)
]
volumes = provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
# skills-public + skills-custom + user-data (3 base) + 1 extra volume.
assert len(volumes) == 4
extra_vol = volumes[-1]
assert extra_vol.name == "extra-0"
assert extra_vol.persistent_volume_claim is not None
assert extra_vol.persistent_volume_claim.claim_name == "userdata-pvc"
assert extra_vol.host_path is None
def test_extra_mount_rejects_paths_outside_deerflow_state(self, provisioner_module):
"""Provisioner must not accept arbitrary hostPath mounts from clients."""
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
extra_mounts = [
provisioner_module.ExtraMount(
host_path="/etc",
container_path="/mnt/integrations/lark-cli/config",
read_only=False,
)
]
with pytest.raises(provisioner_module.HTTPException) as exc_info:
provisioner_module._build_volumes("thread-1", extra_mounts=extra_mounts)
assert exc_info.value.status_code == 400
# ── _build_volume_mounts ───────────────────────────────────────────────
@ -226,6 +288,64 @@ class TestBuildVolumeMounts:
userdata_mount = mounts[-1]
assert userdata_mount.sub_path == "deer-flow/users/default/threads/thread-42/user-data"
# ── Managed integration extra mounts ───────────────────────────────
def test_extra_mount_adds_volume_mount(self, provisioner_module):
"""Controlled extra mounts should be mounted at their requested container path."""
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = ""
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
extra_mounts = [
provisioner_module.ExtraMount(
host_path="/state/users/alice/integrations/lark-cli/config",
container_path="/mnt/integrations/lark-cli/config",
read_only=False,
)
]
mounts = provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
extra_mount = mounts[-1]
assert extra_mount.name == "extra-0"
assert extra_mount.mount_path == "/mnt/integrations/lark-cli/config"
assert extra_mount.read_only is False
assert extra_mount.sub_path is None
def test_extra_mount_uses_pvc_subpath(self, provisioner_module):
"""PVC extra mounts should point at the same user-scoped DeerFlow path."""
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = "userdata-pvc"
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
extra_mounts = [
provisioner_module.ExtraMount(
host_path="/state/users/alice/integrations/lark-cli/config",
container_path="/mnt/integrations/lark-cli/config",
read_only=False,
)
]
mounts = provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
extra_mount = mounts[-1]
assert extra_mount.name == "extra-0"
assert extra_mount.sub_path == "deer-flow/users/alice/integrations/lark-cli/config"
def test_extra_mount_rejects_unknown_container_path(self, provisioner_module):
"""Only first-party managed mount paths are accepted."""
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
extra_mounts = [
provisioner_module.ExtraMount(
host_path="/state/users/alice/integrations/lark-cli/config",
container_path="/mnt/secrets",
read_only=False,
)
]
with pytest.raises(provisioner_module.HTTPException) as exc_info:
provisioner_module._build_volume_mounts("thread-1", extra_mounts=extra_mounts)
assert exc_info.value.status_code == 400
# ── _build_pod integration ─────────────────────────────────────────────
@ -293,6 +413,37 @@ class TestBuildPodVolumes:
userdata_mount = pod.spec.containers[0].volume_mounts[-1]
assert userdata_mount.sub_path == "deer-flow/users/user-7/threads/thread-1/user-data"
def test_pod_includes_extra_mounts(self, provisioner_module):
"""Provisioner-created pods should include managed integration runtime mounts."""
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = ""
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
extra_mounts = [
provisioner_module.ExtraMount(
host_path="/state/users/alice/integrations/lark-cli/config",
container_path="/mnt/integrations/lark-cli/config",
read_only=False,
),
provisioner_module.ExtraMount(
host_path="/state/users/alice/integrations/lark-cli/data",
container_path="/mnt/integrations/lark-cli/data",
read_only=False,
),
]
pod = provisioner_module._build_pod(
"sandbox-1",
"thread-1",
user_id="alice",
extra_mounts=extra_mounts,
)
# skills-public + skills-custom + user-data (3 base) + 2 extra mounts.
assert len(pod.spec.volumes) == 5
mount_paths = {mount.mount_path for mount in pod.spec.containers[0].volume_mounts}
assert "/mnt/integrations/lark-cli/config" in mount_paths
assert "/mnt/integrations/lark-cli/data" in mount_paths
def test_pod_three_way_skills_mount_paths(self, provisioner_module):
"""Ensure public/custom/legacy mount paths are correct."""
provisioner_module.SKILLS_PVC_NAME = ""
@ -315,3 +466,98 @@ class TestBuildPodVolumes:
pod = provisioner_module._build_pod("sandbox-1", "thread-1", user_id="user-7")
skills_mount = pod.spec.containers[0].volume_mounts[0]
assert skills_mount.sub_path == "deer-flow/users/user-7/threads/thread-1/skills"
class TestLarkCliInitContainer:
"""Init-container + emptyDir provisioning of the sandbox lark-cli runtime."""
def test_no_init_container_when_image_unset(self, provisioner_module):
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = ""
provisioner_module.LARK_CLI_INIT_IMAGE = ""
pod = provisioner_module._build_pod(
"sandbox-1",
"thread-1",
provision_lark_cli_runtime=True,
)
assert not pod.spec.init_containers
volume_names = {v.name for v in pod.spec.volumes}
assert provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME not in volume_names
def test_no_init_container_when_flag_disabled(self, provisioner_module):
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = ""
provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
pod = provisioner_module._build_pod(
"sandbox-1",
"thread-1",
provision_lark_cli_runtime=False,
)
assert not pod.spec.init_containers
def test_init_container_and_emptydir_when_enabled(self, provisioner_module):
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = ""
provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
pod = provisioner_module._build_pod(
"sandbox-1",
"thread-1",
provision_lark_cli_runtime=True,
)
# emptyDir volume shared by init + sandbox containers.
runtime_volumes = [v for v in pod.spec.volumes if v.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME]
assert len(runtime_volumes) == 1
assert runtime_volumes[0].empty_dir is not None
# Exactly one init container, pointing at the configured image and
# writing the runtime path.
assert pod.spec.init_containers is not None
assert len(pod.spec.init_containers) == 1
init = pod.spec.init_containers[0]
assert init.image == "deer-flow/lark-cli-init:v1.0.65"
init_mount = init.volume_mounts[0]
assert init_mount.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME
assert init_mount.mount_path == provisioner_module.LARK_CLI_RUNTIME_CONTAINER_PATH
assert init_mount.read_only is False
# Sandbox container gets a read-only runtime mount at the same path.
sandbox_runtime_mounts = [m for m in pod.spec.containers[0].volume_mounts if m.name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME]
assert len(sandbox_runtime_mounts) == 1
assert sandbox_runtime_mounts[0].mount_path == provisioner_module.LARK_CLI_RUNTIME_CONTAINER_PATH
assert sandbox_runtime_mounts[0].read_only is True
def test_runtime_extra_mount_dropped_when_init_container_enabled(self, provisioner_module):
provisioner_module.SKILLS_PVC_NAME = ""
provisioner_module.USERDATA_PVC_NAME = ""
provisioner_module.DEER_FLOW_HOST_BASE_DIR = "/state"
provisioner_module.LARK_CLI_INIT_IMAGE = "deer-flow/lark-cli-init:v1.0.65"
extra_mounts = [
provisioner_module.ExtraMount(
host_path="/state/users/alice/integrations/lark-cli/config",
container_path="/mnt/integrations/lark-cli/config",
read_only=False,
),
provisioner_module.ExtraMount(
host_path="/state/integrations/lark-cli/sandbox-cli",
container_path="/mnt/integrations/lark-cli/runtime",
read_only=True,
),
]
pod = provisioner_module._build_pod(
"sandbox-1",
"thread-1",
user_id="alice",
extra_mounts=extra_mounts,
provision_lark_cli_runtime=True,
)
# The credential config mount stays; the hostPath runtime extra mount is
# replaced by the emptyDir supplied by the init container (so the runtime
# path is not backed by an extra-* hostPath volume).
runtime_mounts = [m for m in pod.spec.containers[0].volume_mounts if m.mount_path == "/mnt/integrations/lark-cli/runtime"]
assert len(runtime_mounts) == 1
assert runtime_mounts[0].name == provisioner_module.LARK_CLI_RUNTIME_VOLUME_NAME
mount_paths = {m.mount_path for m in pod.spec.containers[0].volume_mounts}
assert "/mnt/integrations/lark-cli/config" in mount_paths

View File

@ -165,11 +165,12 @@ def test_create_delegates_to_provisioner_create(monkeypatch, expected_user_id):
backend = RemoteSandboxBackend("http://provisioner:8002")
expected = SandboxInfo(sandbox_id="abc123", sandbox_url="http://k3s:31001")
def mock_create(thread_id: str, sandbox_id: str, extra_mounts=None, *, user_id=None):
def mock_create(thread_id: str, sandbox_id: str, extra_mounts=None, *, user_id=None, provision_lark_cli_runtime=False):
assert thread_id == "thread-1"
assert sandbox_id == "abc123"
assert extra_mounts == [("/host", "/container", False)]
assert user_id == expected_user_id
assert provision_lark_cli_runtime is True
return expected
monkeypatch.setattr(backend, "_provisioner_create", mock_create)
@ -179,6 +180,7 @@ def test_create_delegates_to_provisioner_create(monkeypatch, expected_user_id):
"abc123",
extra_mounts=[("/host", "/container", False)],
user_id=expected_user_id,
provision_lark_cli_runtime=True,
)
assert result == expected
@ -194,6 +196,7 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch):
"thread_id": "thread-1",
"user_id": "test-user-autouse",
"include_legacy_skills": True,
"provision_lark_cli_runtime": False,
}
assert timeout == 30
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
@ -205,6 +208,103 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch):
assert info.sandbox_url == "http://k3s:31001"
def test_provisioner_create_forwards_supported_extra_mounts(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
def mock_post(url: str, json: dict, timeout: int, headers=None):
assert url == "http://provisioner:8002/api/sandboxes"
assert json["include_legacy_skills"] is False
assert json["extra_mounts"] == [
{
"host_path": "/state/users/alice/skills/integrations",
"container_path": "/mnt/skills/integrations",
"read_only": True,
},
{
"host_path": "/state/users/alice/integrations/lark-cli/config",
"container_path": "/mnt/integrations/lark-cli/config",
"read_only": False,
},
]
assert timeout == 30
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
monkeypatch.setattr(requests, "post", mock_post)
backend._provisioner_create(
"thread-1",
"abc123",
extra_mounts=[
("/state/users/alice/threads/thread-1/user-data/workspace", "/mnt/user-data/workspace", False),
("/skills", "/mnt/skills", True),
("/state/users/alice/skills/integrations", "/mnt/skills/integrations", True),
("/state/users/alice/integrations/lark-cli/config", "/mnt/integrations/lark-cli/config", False),
],
user_id="alice",
)
def test_provisioner_create_strips_runtime_mount_when_init_container_enabled(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
captured: dict = {}
def mock_post(url: str, json: dict, timeout: int, headers=None):
captured.update(json)
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
monkeypatch.setattr(requests, "post", mock_post)
backend._provisioner_create(
"thread-1",
"abc123",
extra_mounts=[
("/state/users/alice/integrations/lark-cli/config", "/mnt/integrations/lark-cli/config", False),
("/state/users/alice/integrations/lark-cli/data", "/mnt/integrations/lark-cli/data", False),
("/state/integrations/lark-cli/sandbox-cli", "/mnt/integrations/lark-cli/runtime", True),
],
user_id="alice",
provision_lark_cli_runtime=True,
)
assert captured["provision_lark_cli_runtime"] is True
container_paths = {mount["container_path"] for mount in captured["extra_mounts"]}
# The init container supplies the runtime, so its mount is dropped, but the
# per-user credential mounts are still forwarded.
assert "/mnt/integrations/lark-cli/runtime" not in container_paths
assert "/mnt/integrations/lark-cli/config" in container_paths
assert "/mnt/integrations/lark-cli/data" in container_paths
def test_provisioner_create_keeps_runtime_mount_when_init_container_disabled(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
captured: dict = {}
def mock_post(url: str, json: dict, timeout: int, headers=None):
captured.update(json)
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
monkeypatch.setattr(requests, "post", mock_post)
backend._provisioner_create(
"thread-1",
"abc123",
extra_mounts=[
("/state/integrations/lark-cli/sandbox-cli", "/mnt/integrations/lark-cli/runtime", True),
],
user_id="alice",
provision_lark_cli_runtime=False,
)
assert captured["provision_lark_cli_runtime"] is False
container_paths = {mount["container_path"] for mount in captured["extra_mounts"]}
assert "/mnt/integrations/lark-cli/runtime" in container_paths
def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
@ -216,6 +316,7 @@ def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch):
"thread_id": None,
"user_id": "test-user-autouse",
"include_legacy_skills": False,
"provision_lark_cli_runtime": False,
}
assert timeout == 30
return _StubResponse(payload={"sandbox_id": "anon123", "sandbox_url": "http://k3s:31002"})

View File

@ -10,6 +10,7 @@ from deerflow.sandbox.tools import (
VIRTUAL_PATH_PREFIX,
_apply_cwd_prefix,
_compiled_mask_patterns,
_extract_skill_name_from_skills_path,
_get_custom_mount_for_path,
_get_custom_mounts,
_is_acp_workspace_path,
@ -258,6 +259,26 @@ def test_mask_local_paths_no_thread_data_still_masks_skills() -> None:
assert "/mnt/skills/a/b.md" in masked
def test_mask_local_paths_hides_global_integration_skill_paths(tmp_path: Path) -> None:
from deerflow.config.paths import Paths
paths = Paths(base_dir=tmp_path)
integration_dir = tmp_path / "integrations" / "skills" / "lark-cli" / "lark-doc"
integration_dir.mkdir(parents=True)
output = f"Reading: {integration_dir / 'SKILL.md'}"
with (
patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"),
patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"),
patch("deerflow.config.paths.get_paths", return_value=paths),
patch("deerflow.runtime.user_context.get_effective_user_id", return_value="alice"),
):
masked = mask_local_paths_in_output(output, _THREAD_DATA)
assert str(integration_dir) not in masked
assert "/mnt/skills/integrations/lark-cli/lark-doc/SKILL.md" in masked
# ---------- _reject_path_traversal ----------
@ -372,6 +393,42 @@ def test_resolve_skills_path_resolves_root() -> None:
assert resolved == "/home/user/deer-flow/skills"
def test_extract_skill_name_from_integration_skill_path() -> None:
with patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"):
assert _extract_skill_name_from_skills_path("/mnt/skills/integrations/lark-cli/lark-doc/SKILL.md") == "lark-doc"
assert _extract_skill_name_from_skills_path("/mnt/skills/integrations/lark-cli") is None
def test_resolve_skills_path_resolves_global_integration_skills(tmp_path: Path) -> None:
from deerflow.config.paths import Paths
paths = Paths(base_dir=tmp_path)
expected = tmp_path / "integrations" / "skills" / "lark-cli" / "lark-doc" / "SKILL.md"
with (
patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"),
patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"),
patch("deerflow.config.paths.get_paths", return_value=paths),
patch("deerflow.runtime.user_context.get_effective_user_id", return_value="alice"),
):
resolved = _resolve_skills_path("/mnt/skills/integrations/lark-cli/lark-doc/SKILL.md")
assert resolved == str(expected)
def test_resolve_skills_path_blocks_integration_traversal(tmp_path: Path) -> None:
from deerflow.config.paths import Paths
paths = Paths(base_dir=tmp_path)
with (
patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"),
patch("deerflow.sandbox.tools._get_skills_host_path", return_value="/home/user/deer-flow/skills"),
patch("deerflow.config.paths.get_paths", return_value=paths),
patch("deerflow.runtime.user_context.get_effective_user_id", return_value="alice"),
):
with pytest.raises(PermissionError, match="path traversal detected"):
_resolve_skills_path("/mnt/skills/integrations/../../etc/passwd")
def test_resolve_skills_path_raises_when_not_configured() -> None:
"""Should raise FileNotFoundError when skills directory is not available."""
with (

View File

@ -136,6 +136,24 @@ class TestSkillLoading:
assert len(public_skills) == 1
assert public_skills[0].name == "deep-research"
def test_managed_integration_skills_are_global_but_enabled_per_user(self, base_dir: Path, skills_root: Path, config):
integration_dir = base_dir / "integrations" / "skills" / "lark-cli" / "lark-doc"
integration_dir.mkdir(parents=True)
(integration_dir / "SKILL.md").write_text(_skill_content("lark-doc"), encoding="utf-8")
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=base_dir)):
alice = UserScopedSkillStorage("alice", host_path=str(skills_root), app_config=config)
bob = UserScopedSkillStorage("bob", host_path=str(skills_root), app_config=config)
alice.set_skill_enabled_state("lark-doc", False)
alice_skill = next(skill for skill in alice.load_skills(enabled_only=False) if skill.name == "lark-doc")
bob_skill = next(skill for skill in bob.load_skills(enabled_only=False) if skill.name == "lark-doc")
assert alice_skill.category == SkillCategory.INTEGRATION
assert alice_skill.skill_file == integration_dir / "SKILL.md"
assert alice_skill.enabled is False
assert bob_skill.enabled is True
def test_public_skill_package_children_are_not_registered(self, user_storage: UserScopedSkillStorage, skills_root: Path):
public_dir = skills_root / "public" / "reviewer"
fixture_dir = public_dir / "evals" / "fixtures" / "injection"

View File

@ -46,6 +46,10 @@ services:
environment:
- K8S_NAMESPACE=deer-flow
- SANDBOX_IMAGE=enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
# Optional lark-cli init image (Pattern A). Empty ⇒ legacy runtime mount.
# Set to a published tag (e.g. deer-flow/lark-cli-init:v1.0.65) to provision
# the sandbox lark-cli runtime via an init container + emptyDir.
- LARK_CLI_INIT_IMAGE=${LARK_CLI_INIT_IMAGE:-}
# Host paths for K8s HostPath volumes (must be absolute paths accessible by K8s node)
# On Docker Desktop/OrbStack, use your actual host paths like /Users/username/...
# Set these in your shell before running docker-compose:
@ -146,6 +150,8 @@ services:
APT_MIRROR: ${APT_MIRROR:-}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20}
UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple}
NPM_REGISTRY: ${NPM_REGISTRY:-}
LARK_CLI_NPM_VERSION: ${LARK_CLI_NPM_VERSION:-1.0.65}
container_name: deer-flow-gateway
# Startup logic lives in docker/dev-entrypoint.sh — UV_EXTRAS validation,
# `uv sync --all-packages`, .venv self-heal, and uvicorn handoff. Keeps

View File

@ -88,6 +88,8 @@ services:
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20}
UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple}
UV_EXTRAS: ${UV_EXTRAS:-}
NPM_REGISTRY: ${NPM_REGISTRY:-}
LARK_CLI_NPM_VERSION: ${LARK_CLI_NPM_VERSION:-1.0.65}
container_name: deer-flow-gateway
# Gateway hosts the agent runtime with in-process RunManager + StreamBridge
# singletons -- run state lives in this worker's memory. Default to a single
@ -156,6 +158,10 @@ services:
environment:
- K8S_NAMESPACE=deer-flow
- SANDBOX_IMAGE=enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
# Optional lark-cli init image (Pattern A). Empty ⇒ legacy runtime mount.
# Set to a published tag (e.g. deer-flow/lark-cli-init:v1.0.65) to provision
# the sandbox lark-cli runtime via an init container + emptyDir.
- LARK_CLI_INIT_IMAGE=${LARK_CLI_INIT_IMAGE:-}
- SKILLS_HOST_PATH=${DEER_FLOW_REPO_ROOT}/skills
- THREADS_HOST_PATH=${DEER_FLOW_HOME}/threads
- DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_HOME}

View File

@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
#
# DeerFlow "lark-cli init" image (Pattern A).
#
# Purpose: provide the sandbox `lark-cli` runtime binary to a Kubernetes sandbox
# Pod via an init container + shared `emptyDir`, instead of downloading Linux
# binaries from GitHub at install time and mounting them via hostPath/PVC.
#
# At BUILD time (network available) this image downloads and SHA-256-verifies the
# official `larksuite/cli` Linux release binaries and stages the runtime layout:
#
# /opt/lark-cli/bin/lark-cli # arch-dispatch launcher (uname -m)
# /opt/lark-cli/linux-amd64/lark-cli
# /opt/lark-cli/linux-arm64/lark-cli
# /opt/lark-cli/.deerflow-lark-cli-runtime.json # {"version": "vX.Y.Z"}
#
# This layout is byte-identical to what the Gateway writer
# (`_write_lark_cli_sandbox_launcher`) produces and what
# `_validate_lark_cli_sandbox_runtime` enforces, so the sandbox PATH contract
# (`/mnt/integrations/lark-cli/runtime/bin/lark-cli`) is unchanged.
#
# At RUN time the default command copies `/opt/lark-cli/.` into the shared
# emptyDir mounted at `/mnt/integrations/lark-cli/runtime` and exits.
#
# The image tag should encode the lark-cli version so it can be bumped
# independently of the upstream `all-in-one-sandbox` image.
#
# Build (defaults to the pinned version below):
# docker build -t deer-flow/lark-cli-init:v1.0.65 \
# --build-arg LARK_CLI_VERSION=v1.0.65 docker/lark-cli-init
FROM debian:bookworm-slim AS builder
ARG APT_MIRROR
# Version tag on the larksuite/cli GitHub releases (with or without leading "v").
ARG LARK_CLI_VERSION=v1.0.65
RUN if [ -n "${APT_MIRROR}" ]; then \
sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \
sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \
fi
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY build-runtime.sh /usr/local/bin/build-runtime.sh
RUN chmod +x /usr/local/bin/build-runtime.sh \
&& LARK_CLI_VERSION="${LARK_CLI_VERSION}" /usr/local/bin/build-runtime.sh /opt/lark-cli
# ── Final image: minimal, just the staged runtime + a copy entrypoint ────────
FROM debian:bookworm-slim
COPY --from=builder /opt/lark-cli /opt/lark-cli
COPY entrypoint.sh /usr/local/bin/lark-cli-init
RUN chmod +x /usr/local/bin/lark-cli-init
# Destination is the emptyDir the provisioner mounts into both this init
# container and the main sandbox container.
ENV LARK_CLI_RUNTIME_DEST=/mnt/integrations/lark-cli/runtime
ENTRYPOINT ["/usr/local/bin/lark-cli-init"]

View File

@ -0,0 +1,63 @@
# lark-cli init image (Pattern A)
This image provisions the sandbox `lark-cli` runtime binary into a Kubernetes
sandbox Pod via an **init container + shared `emptyDir`**, instead of the Gateway
downloading Linux binaries from GitHub at install time and mounting them via
hostPath/PVC.
See the design at
[`docs/superpowers/specs/2026-07-21-lark-sandbox-init-container-design.md`](../../docs/superpowers/specs/2026-07-21-lark-sandbox-init-container-design.md).
## What it does
- **Build time** (network available): downloads and SHA-256-verifies the official
`larksuite/cli` Linux release binaries and stages the runtime layout under
`/opt/lark-cli`:
```
/opt/lark-cli/bin/lark-cli # arch-dispatch launcher (uname -m)
/opt/lark-cli/linux-amd64/lark-cli
/opt/lark-cli/linux-arm64/lark-cli
/opt/lark-cli/.deerflow-lark-cli-runtime.json # {"version": "vX.Y.Z"}
```
This is byte-identical to what the Gateway writer
(`_write_lark_cli_sandbox_launcher`) produces and what
`_validate_lark_cli_sandbox_runtime` enforces, so the sandbox PATH contract
(`/mnt/integrations/lark-cli/runtime/bin/lark-cli`) is unchanged.
- **Run time**: copies `/opt/lark-cli/.` into the emptyDir mounted at
`${LARK_CLI_RUNTIME_DEST}` (default `/mnt/integrations/lark-cli/runtime`) and
exits `0`.
## Build
```bash
docker build -t deer-flow/lark-cli-init:v1.0.65 \
--build-arg LARK_CLI_VERSION=v1.0.65 \
docker/lark-cli-init
```
The tag should encode the lark-cli version so it can be bumped independently of
the upstream `all-in-one-sandbox` sandbox image.
## Wiring it into the provisioner
The init-container runtime path is **opt-in** and off by default. Enable it by
publishing this image and pointing the provisioner at it:
- Set `LARK_CLI_INIT_IMAGE` on the provisioner service to the published tag
(e.g. `deer-flow/lark-cli-init:v1.0.65`). Empty ⇒ legacy hostPath / Gateway
download path (no behavior change).
- When set, the provisioner adds a `lark-cli-runtime` `emptyDir` volume, an
`lark-cli-init` init container, and a read-only runtime mount on the sandbox
container — and ignores any `/mnt/integrations/lark-cli/runtime` hostPath/PVC
extra mount (the init container supersedes it). The per-user `config` / `data`
credential mounts are unchanged.
- The provisioner reports whether it is configured via `GET /api/capabilities`
(`{"lark_cli_init_image": true|false}`), which the Gateway surfaces as the
Lark integration sandbox-runtime readiness signal in `/api/integrations/lark/status`.
> Publishing note: this repository currently ships only backend/frontend images.
> Publishing a `lark-cli-init` tag is a fast-follow; until then the feature stays
> behind the empty-default `LARK_CLI_INIT_IMAGE`.

View File

@ -0,0 +1,87 @@
#!/bin/sh
# Stage the DeerFlow sandbox lark-cli runtime layout from official release
# binaries. Runs at image BUILD time (network available).
#
# Usage: LARK_CLI_VERSION=v1.0.65 build-runtime.sh /opt/lark-cli
#
# Produces:
# <dest>/bin/lark-cli arch-dispatch launcher (uname -m)
# <dest>/linux-amd64/lark-cli
# <dest>/linux-arm64/lark-cli
# <dest>/.deerflow-lark-cli-runtime.json {"version": "vX.Y.Z"}
#
# The layout mirrors the Gateway writer (_write_lark_cli_sandbox_launcher) and
# satisfies _validate_lark_cli_sandbox_runtime, so the sandbox PATH contract is
# unchanged.
set -eu
DEST="${1:?destination directory required}"
VERSION="${LARK_CLI_VERSION:?LARK_CLI_VERSION required}"
REPO="larksuite/cli"
ARCHES="amd64 arm64"
# Normalize to a leading-v tag and a bare version.
case "$VERSION" in
v*) TAG="$VERSION" ;;
*) TAG="v$VERSION" ;;
esac
BARE="${TAG#v}"
BASE_URL="https://github.com/${REPO}/releases/download/${TAG}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "Downloading lark-cli ${TAG} release checksums..."
curl -fsSL "${BASE_URL}/checksums.txt" -o "${WORK}/checksums.txt"
mkdir -p "${DEST}/bin"
for arch in $ARCHES; do
asset="lark-cli-${BARE}-linux-${arch}.tar.gz"
echo "Downloading ${asset}..."
curl -fsSL "${BASE_URL}/${asset}" -o "${WORK}/${asset}"
expected="$(awk -v f="${asset}" '$2 == f || $2 == "*"f {print $1}' "${WORK}/checksums.txt" | head -n1)"
if [ -z "$expected" ]; then
echo "ERROR: no checksum for ${asset} in checksums.txt" >&2
exit 1
fi
actual="$(sha256sum "${WORK}/${asset}" | awk '{print $1}')"
if [ "$expected" != "$actual" ]; then
echo "ERROR: checksum mismatch for ${asset} (expected ${expected}, got ${actual})" >&2
exit 1
fi
mkdir -p "${DEST}/linux-${arch}"
# Extract only the single lark-cli executable from the archive.
tar -xzf "${WORK}/${asset}" -C "${WORK}"
found="$(find "${WORK}" -type f -name lark-cli ! -path "${DEST}/*" | head -n1)"
if [ -z "$found" ]; then
echo "ERROR: lark-cli executable not found in ${asset}" >&2
exit 1
fi
install -m 0755 "$found" "${DEST}/linux-${arch}/lark-cli"
rm -f "$found"
done
# Arch-dispatch launcher. Kept byte-identical to
# LARK_CLI_SANDBOX_LAUNCHER_SCRIPT in
# backend/packages/harness/deerflow/integrations/lark_cli.py
# (a unit test asserts the two never drift).
cat > "${DEST}/bin/lark-cli" <<'LAUNCHER'
#!/bin/sh
set -eu
case "$(uname -m)" in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
*) echo "Unsupported sandbox architecture: $(uname -m)" >&2; exit 126 ;;
esac
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec "$script_dir/../linux-$arch/lark-cli" "$@"
LAUNCHER
chmod 0755 "${DEST}/bin/lark-cli"
printf '{\n "version": "%s"\n}\n' "$TAG" > "${DEST}/.deerflow-lark-cli-runtime.json"
echo "Staged lark-cli ${TAG} runtime at ${DEST}:"
ls -R "${DEST}"

View File

@ -0,0 +1,24 @@
#!/bin/sh
# DeerFlow lark-cli init container entrypoint (Pattern A).
#
# Copies the staged sandbox runtime layout into the shared emptyDir the
# provisioner mounts into both this init container and the main sandbox
# container, then exits. The sandbox container then finds the launcher at
# ${LARK_CLI_RUNTIME_DEST}/bin/lark-cli — exactly where
# lark_cli_env_overlay(sandbox_paths=True) points PATH.
set -eu
SRC="/opt/lark-cli"
DEST="${LARK_CLI_RUNTIME_DEST:-/mnt/integrations/lark-cli/runtime}"
if [ ! -x "${SRC}/bin/lark-cli" ]; then
echo "ERROR: staged runtime missing at ${SRC}/bin/lark-cli" >&2
exit 1
fi
mkdir -p "${DEST}"
# Copy contents (including the dotfile manifest) into the destination.
cp -a "${SRC}/." "${DEST}/"
echo "Provisioned lark-cli runtime into ${DEST}:"
ls -R "${DEST}"

View File

@ -145,6 +145,7 @@ The provisioner is configured via environment variables (set in [docker-compose-
|----------|---------|-------------|
| `K8S_NAMESPACE` | `deer-flow` | Kubernetes namespace for sandbox resources |
| `SANDBOX_IMAGE` | `enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest` | AIO-compatible container image for sandbox Pods |
| `LARK_CLI_INIT_IMAGE` | empty (feature off) | Optional lark-cli init image (Pattern A). When set, sandbox Pods requesting the lark-cli runtime get an init container + shared `emptyDir` that provisions `lark-cli`, instead of a hostPath/PVC runtime mount. See [`docker/lark-cli-init`](../lark-cli-init/README.md) |
| `SKILLS_HOST_PATH` | - | **Host machine** path to skills directory (must be absolute) |
| `THREADS_HOST_PATH` | - | **Host machine** path to threads data directory (must be absolute) |
| `SKILLS_PVC_NAME` | empty (use hostPath) | PVC name for skills volume; when set, sandbox Pods use PVC instead of hostPath |
@ -163,6 +164,29 @@ For persistent dependencies, build an image that extends the default `all-in-one
See [Building a Custom AIO Sandbox Image](../../backend/docs/CONFIGURATION.md#building-a-custom-aio-sandbox-image) for the runtime contract and a minimal Dockerfile example.
### Lark CLI sandbox runtime (Pattern A)
Agents run `lark-cli` **inside** the sandbox, so the binary must exist in the
sandbox container. Instead of the Gateway downloading Linux binaries from GitHub
at install time and mounting them via hostPath/PVC, the provisioner can inject
`lark-cli` with an **init container + shared `emptyDir`**:
1. Publish a lark-cli init image (see [`docker/lark-cli-init`](../lark-cli-init/README.md))
and set `LARK_CLI_INIT_IMAGE` on the provisioner.
2. When a sandbox is created with `provision_lark_cli_runtime: true` (the Gateway
sends this automatically once the managed Lark skill pack is installed), the
Pod gets a `lark-cli-runtime` `emptyDir`, an `lark-cli-init` init container
that copies the runtime into it, and a read-only runtime mount on the sandbox
container at `/mnt/integrations/lark-cli/runtime`. Any hostPath/PVC extra
mount at that path is dropped (the init container supersedes it); the per-user
`config`/`data` credential mounts are unchanged.
`GET /api/capabilities` returns `{"lark_cli_init_image": true|false}` so the
Gateway can surface a sandbox-runtime readiness signal in
`/api/integrations/lark/status` — a green UI can't then hide a chat-time
`lark-cli: command not found`.
### PVC User-Data Upgrade Note
Older provisioner versions mounted PVC user-data from `threads/{thread_id}/user-data`. The user-scoped layout mounts from `deer-flow/users/{user_id}/threads/{thread_id}/user-data`.

View File

@ -31,6 +31,7 @@ from __future__ import annotations
import logging
import os
import posixpath
import re
import secrets
import time
@ -59,6 +60,13 @@ SANDBOX_IMAGE = os.environ.get(
"SANDBOX_IMAGE",
"enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest",
)
# Optional "lark-cli init" image (Pattern A). When set, sandbox Pods get an init
# container + shared emptyDir that provisions the lark-cli runtime binary, instead
# of a hostPath/PVC runtime mount fed by a Gateway-side GitHub download. Empty ⇒
# feature off (legacy behavior).
LARK_CLI_INIT_IMAGE = os.environ.get("LARK_CLI_INIT_IMAGE", "")
LARK_CLI_RUNTIME_CONTAINER_PATH = "/mnt/integrations/lark-cli/runtime"
LARK_CLI_RUNTIME_VOLUME_NAME = "lark-cli-runtime"
SKILLS_HOST_PATH = os.environ.get("SKILLS_HOST_PATH", "/skills")
THREADS_HOST_PATH = os.environ.get("THREADS_HOST_PATH", "/.deer-flow/threads")
DEER_FLOW_HOST_BASE_DIR = os.environ.get("DEER_FLOW_HOST_BASE_DIR", "/.deer-flow")
@ -78,6 +86,15 @@ if SANDBOX_SERVICE_TYPE not in {"NodePort", "ClusterIP"}:
SAFE_THREAD_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
SAFE_USER_ID_PATTERN = r"^[A-Za-z0-9_\-]+$"
DEFAULT_USER_ID = "default"
MAX_EXTRA_MOUNTS = 9
ALLOWED_EXTRA_MOUNT_PATHS = {
"/mnt/acp-workspace",
"/mnt/skills/custom",
"/mnt/skills/integrations",
"/mnt/integrations/lark-cli/config",
"/mnt/integrations/lark-cli/data",
"/mnt/integrations/lark-cli/runtime",
}
# Path to the kubeconfig *inside* the provisioner container.
# Typically the host's ~/.kube/config is mounted here.
@ -111,6 +128,109 @@ def join_host_path(base: str, *parts: str) -> str:
return str(result)
def _host_base_dir_for_extra_mounts() -> str:
"""Return the host-visible DeerFlow state root used for controlled mounts."""
if DEER_FLOW_HOST_BASE_DIR:
return os.path.normpath(DEER_FLOW_HOST_BASE_DIR)
normalized_threads = os.path.normpath(THREADS_HOST_PATH)
if os.path.basename(normalized_threads) == "threads":
return os.path.dirname(normalized_threads)
return ""
def _is_path_under_base(path: str, base: str) -> bool:
"""Return whether *path* is inside *base* after normalization."""
if not base:
return False
try:
return os.path.commonpath([os.path.normpath(path), os.path.normpath(base)]) == os.path.normpath(base)
except ValueError:
return False
def _normalize_extra_mount_container_path(container_path: str) -> str:
normalized = posixpath.normpath(container_path)
if not normalized.startswith("/"):
raise HTTPException(status_code=400, detail=f"Extra mount path must be absolute: {container_path}")
if normalized not in ALLOWED_EXTRA_MOUNT_PATHS:
raise HTTPException(status_code=400, detail=f"Unsupported extra mount path: {container_path}")
return normalized
def _validated_extra_mounts(extra_mounts: list["ExtraMount"] | None) -> list["ExtraMount"]:
"""Validate extra mounts before converting them into K8s hostPath/PVC mounts."""
if not extra_mounts:
return []
if len(extra_mounts) > MAX_EXTRA_MOUNTS:
raise HTTPException(status_code=400, detail=f"Too many extra mounts; max is {MAX_EXTRA_MOUNTS}")
host_base_dir = _host_base_dir_for_extra_mounts()
seen_container_paths: set[str] = set()
validated: list[ExtraMount] = []
for mount in extra_mounts:
host_path = os.path.normpath(mount.host_path)
if not os.path.isabs(host_path):
raise HTTPException(status_code=400, detail=f"Extra mount host path must be absolute: {mount.host_path}")
if not _is_path_under_base(host_path, host_base_dir):
raise HTTPException(status_code=400, detail=f"Extra mount host path is outside DeerFlow state: {mount.host_path}")
container_path = _normalize_extra_mount_container_path(mount.container_path)
if container_path in seen_container_paths:
raise HTTPException(status_code=400, detail=f"Duplicate extra mount path: {container_path}")
seen_container_paths.add(container_path)
validated.append(
ExtraMount(
host_path=host_path,
container_path=container_path,
read_only=mount.read_only,
)
)
return validated
def _extra_mount_volume_name(index: int) -> str:
return f"extra-{index}"
def _lark_cli_runtime_enabled(provision_lark_cli_runtime: bool) -> bool:
"""Whether to provision the lark-cli runtime via init container + emptyDir."""
return bool(LARK_CLI_INIT_IMAGE) and provision_lark_cli_runtime
def _runtime_provided_extra_mounts(
extra_mounts: list["ExtraMount"] | None,
*,
provision_lark_cli_runtime: bool,
) -> list["ExtraMount"]:
"""Drop the lark-cli runtime extra mount when the init container supersedes it.
The init container + emptyDir provides ``/mnt/integrations/lark-cli/runtime``,
so a hostPath/PVC mount at the same path would collide. The per-user
``config`` / ``data`` credential mounts are left untouched.
"""
if not extra_mounts or not _lark_cli_runtime_enabled(provision_lark_cli_runtime):
return list(extra_mounts or [])
return [
mount
for mount in extra_mounts
if posixpath.normpath(mount.container_path) != LARK_CLI_RUNTIME_CONTAINER_PATH
]
def _extra_mount_pvc_sub_path(host_path: str) -> str:
host_base_dir = _host_base_dir_for_extra_mounts()
if not _is_path_under_base(host_path, host_base_dir):
raise HTTPException(status_code=400, detail=f"Extra mount host path is outside DeerFlow state: {host_path}")
rel_path = os.path.relpath(os.path.normpath(host_path), host_base_dir)
rel_parts = [part for part in rel_path.replace(os.sep, "/").split("/") if part and part != "."]
if not rel_parts or any(part == ".." for part in rel_parts):
raise HTTPException(status_code=400, detail=f"Invalid extra mount host path: {host_path}")
return posixpath.join("deer-flow", *rel_parts)
# ── K8s client setup ────────────────────────────────────────────────────
core_v1: k8s_client.CoreV1Api | None = None
@ -219,11 +339,22 @@ async def verify_api_key(request: Request, call_next):
# ── Request / Response models ───────────────────────────────────────────
class ExtraMount(BaseModel):
host_path: str
container_path: str
read_only: bool = False
class CreateSandboxRequest(BaseModel):
sandbox_id: str
thread_id: str = Field(pattern=SAFE_THREAD_ID_PATTERN)
thread_id: str | None = Field(default=None, pattern=SAFE_THREAD_ID_PATTERN)
user_id: str = Field(default=DEFAULT_USER_ID, pattern=SAFE_USER_ID_PATTERN)
extra_mounts: list[ExtraMount] = Field(default_factory=list)
include_legacy_skills: bool = False
# When true (and LARK_CLI_INIT_IMAGE is configured), provision the sandbox
# lark-cli runtime via an init container + emptyDir instead of a runtime
# hostPath/PVC extra mount.
provision_lark_cli_runtime: bool = False
class SandboxResponse(BaseModel):
@ -252,11 +383,53 @@ def _sandbox_url(sandbox_id: str, node_port: int | None = None) -> str:
return f"http://{NODE_HOST}:{node_port}"
def _build_extra_volumes(extra_mounts: list[ExtraMount] | None = None) -> list[k8s_client.V1Volume]:
volumes: list[k8s_client.V1Volume] = []
for index, mount in enumerate(_validated_extra_mounts(extra_mounts)):
if USERDATA_PVC_NAME:
volumes.append(
k8s_client.V1Volume(
name=_extra_mount_volume_name(index),
persistent_volume_claim=k8s_client.V1PersistentVolumeClaimVolumeSource(
claim_name=USERDATA_PVC_NAME,
),
)
)
continue
volumes.append(
k8s_client.V1Volume(
name=_extra_mount_volume_name(index),
host_path=k8s_client.V1HostPathVolumeSource(
path=mount.host_path,
type="Directory" if mount.read_only else "DirectoryOrCreate",
),
)
)
return volumes
def _build_extra_volume_mounts(extra_mounts: list[ExtraMount] | None = None) -> list[k8s_client.V1VolumeMount]:
mounts: list[k8s_client.V1VolumeMount] = []
for index, mount in enumerate(_validated_extra_mounts(extra_mounts)):
volume_mount = k8s_client.V1VolumeMount(
name=_extra_mount_volume_name(index),
mount_path=mount.container_path,
read_only=mount.read_only,
)
if USERDATA_PVC_NAME:
volume_mount.sub_path = _extra_mount_pvc_sub_path(mount.host_path)
mounts.append(volume_mount)
return mounts
def _build_volumes(
thread_id: str,
user_id: str = DEFAULT_USER_ID,
*,
include_legacy_skills: bool = False,
extra_mounts: list[ExtraMount] | None = None,
provision_lark_cli_runtime: bool = False,
) -> list[k8s_client.V1Volume]:
"""Build volume list: PVC when configured, otherwise hostPath.
@ -343,6 +516,21 @@ def _build_volumes(
)
volumes.append(userdata_vol)
volumes.extend(
_build_extra_volumes(
_runtime_provided_extra_mounts(
extra_mounts,
provision_lark_cli_runtime=provision_lark_cli_runtime,
)
)
)
if _lark_cli_runtime_enabled(provision_lark_cli_runtime):
volumes.append(
k8s_client.V1Volume(
name=LARK_CLI_RUNTIME_VOLUME_NAME,
empty_dir=k8s_client.V1EmptyDirVolumeSource(),
)
)
return volumes
@ -351,6 +539,8 @@ def _build_volume_mounts(
user_id: str = DEFAULT_USER_ID,
*,
include_legacy_skills: bool = False,
extra_mounts: list[ExtraMount] | None = None,
provision_lark_cli_runtime: bool = False,
) -> list[k8s_client.V1VolumeMount]:
"""Build volume mount list, mirroring three-way skills layout.
@ -405,18 +595,71 @@ def _build_volume_mounts(
if USERDATA_PVC_NAME:
userdata_mount.sub_path = f"deer-flow/users/{user_id}/threads/{thread_id}/user-data"
mounts.append(userdata_mount)
mounts.extend(
_build_extra_volume_mounts(
_runtime_provided_extra_mounts(
extra_mounts,
provision_lark_cli_runtime=provision_lark_cli_runtime,
)
)
)
if _lark_cli_runtime_enabled(provision_lark_cli_runtime):
mounts.append(
k8s_client.V1VolumeMount(
name=LARK_CLI_RUNTIME_VOLUME_NAME,
mount_path=LARK_CLI_RUNTIME_CONTAINER_PATH,
read_only=True,
)
)
return mounts
def _build_lark_cli_init_containers(
provision_lark_cli_runtime: bool,
) -> list[k8s_client.V1Container]:
"""Init container that copies the lark-cli runtime into the shared emptyDir."""
if not _lark_cli_runtime_enabled(provision_lark_cli_runtime):
return []
return [
k8s_client.V1Container(
name="lark-cli-init",
image=LARK_CLI_INIT_IMAGE,
image_pull_policy="IfNotPresent",
env=[
k8s_client.V1EnvVar(
name="LARK_CLI_RUNTIME_DEST",
value=LARK_CLI_RUNTIME_CONTAINER_PATH,
)
],
volume_mounts=[
k8s_client.V1VolumeMount(
name=LARK_CLI_RUNTIME_VOLUME_NAME,
mount_path=LARK_CLI_RUNTIME_CONTAINER_PATH,
read_only=False,
)
],
security_context=k8s_client.V1SecurityContext(
privileged=False,
allow_privilege_escalation=False,
),
)
]
def _build_pod(
sandbox_id: str,
thread_id: str,
user_id: str = DEFAULT_USER_ID,
*,
include_legacy_skills: bool = False,
extra_mounts: list[ExtraMount] | None = None,
provision_lark_cli_runtime: bool = False,
) -> k8s_client.V1Pod:
"""Construct a Pod manifest for a single sandbox."""
init_containers = (
_build_lark_cli_init_containers(provision_lark_cli_runtime) or None
)
return k8s_client.V1Pod(
metadata=k8s_client.V1ObjectMeta(
name=_pod_name(sandbox_id),
@ -477,6 +720,8 @@ def _build_pod(
thread_id,
user_id=user_id,
include_legacy_skills=include_legacy_skills,
extra_mounts=extra_mounts,
provision_lark_cli_runtime=provision_lark_cli_runtime,
),
security_context=k8s_client.V1SecurityContext(
privileged=False,
@ -484,10 +729,13 @@ def _build_pod(
),
)
],
init_containers=init_containers,
volumes=_build_volumes(
thread_id,
user_id=user_id,
include_legacy_skills=include_legacy_skills,
extra_mounts=extra_mounts,
provision_lark_cli_runtime=provision_lark_cli_runtime,
),
restart_policy="Always",
),
@ -573,6 +821,17 @@ async def health():
return {"status": "ok"}
@app.get("/api/capabilities")
async def capabilities():
"""Report provisioner-side capabilities the Gateway cannot infer statically.
``lark_cli_init_image`` reflects whether a lark-cli init image is configured,
which the Gateway surfaces as the Lark integration sandbox-runtime readiness
signal so a green UI can't hide a chat-time ``command not found``.
"""
return {"lark_cli_init_image": bool(LARK_CLI_INIT_IMAGE)}
@app.post("/api/sandboxes", response_model=SandboxResponse)
def create_sandbox(req: CreateSandboxRequest):
"""Create a sandbox Pod + Service for *sandbox_id*.
@ -581,16 +840,18 @@ def create_sandbox(req: CreateSandboxRequest):
(idempotent).
"""
sandbox_id = req.sandbox_id
thread_id = req.thread_id
thread_id = req.thread_id or sandbox_id
user_id = req.user_id
include_legacy_skills = req.include_legacy_skills
provision_lark_cli_runtime = req.provision_lark_cli_runtime
logger.info(
"Received request to create sandbox '%s' for thread '%s' user '%s' include_legacy_skills=%s",
"Received request to create sandbox '%s' for thread '%s' user '%s' include_legacy_skills=%s provision_lark_cli_runtime=%s",
sandbox_id,
thread_id,
user_id,
include_legacy_skills,
_lark_cli_runtime_enabled(provision_lark_cli_runtime),
)
# ── Fast path: sandbox already exists ────────────────────────────
@ -611,6 +872,8 @@ def create_sandbox(req: CreateSandboxRequest):
thread_id,
user_id=user_id,
include_legacy_skills=include_legacy_skills,
extra_mounts=req.extra_mounts,
provision_lark_cli_runtime=provision_lark_cli_runtime,
),
)
logger.info(f"Created Pod {_pod_name(sandbox_id)}")

View File

@ -55,7 +55,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
- `workspace/` — Chat page components (messages, artifacts, settings)
- `landing/` — Landing page sections
- `docs/` — Docs / MDX rendering components
- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `input-polish/` (pre-send draft rewrite API), `voice-input/` (browser speech-recognition helpers), `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `integrations/` (managed third-party integration status/install clients such as Lark CLI), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `input-polish/` (pre-send draft rewrite API), `voice-input/` (browser speech-recognition helpers), `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
- **`hooks/`** — Shared React hooks
- **`lib/`** — Utilities (`cn()` from clsx + tailwind-merge)
- **`content/`** — MDX content (blog posts, docs) rendered by the app

View File

@ -0,0 +1,36 @@
export function POST() {
return Response.json({
success: true,
message: "Lark/Feishu authorization completed.",
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_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: "Alice",
verified: true,
},
sandbox_runtime_mode: "init-container",
sandbox_runtime_ready: true,
sandbox_runtime_detail: null,
},
});
}

View File

@ -0,0 +1,9 @@
export function POST() {
return Response.json({
verification_url: "https://open.feishu.cn/auth/mock-device",
device_code: "mock-device-code",
expires_in: 600,
user_code: null,
hint: null,
});
}

View File

@ -0,0 +1,36 @@
export function POST() {
return Response.json({
success: true,
message: "Lark/Feishu connection setup completed.",
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_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

@ -0,0 +1,10 @@
export function POST() {
return Response.json({
verification_url: "https://open.feishu.cn/page/cli?user_code=config",
device_code: "mock-config-device-code",
expires_in: 600,
interval: 5,
user_code: "config",
brand: "feishu",
});
}

View File

@ -0,0 +1,39 @@
const installedSkills = ["lark-doc", "lark-im", "lark-shared", "lark-sheets"];
export function POST() {
return Response.json({
success: true,
installed_skills: installedSkills,
message: `Installed ${installedSkills.length} Lark/Feishu skills.`,
status: {
installed: true,
version: "v1.0.65",
manifest_version: "v1.0.65",
latest_available_version: "v1.0.65",
runtime_version_mismatch: false,
app_configured: false,
app_id: null,
app_brand: null,
skills_expected: 27,
skills_installed: installedSkills.length,
installed_skills: installedSkills,
enabled_skills: installedSkills,
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_configured",
message: "lark-cli auth is not configured",
user: null,
verified: false,
},
sandbox_runtime_mode: "none",
sandbox_runtime_ready: false,
sandbox_runtime_detail: null,
},
});
}

View File

@ -0,0 +1,34 @@
const status = {
installed: false,
version: "v1.0.65",
manifest_version: null,
latest_available_version: "v1.0.65",
runtime_version_mismatch: false,
app_configured: false,
app_id: null,
app_brand: null,
skills_expected: 27,
skills_installed: 0,
installed_skills: [],
enabled_skills: [],
install_path: "/mock/integrations/skills/lark-cli",
cli: {
available: false,
path: null,
version: null,
error: "lark-cli is not on PATH",
},
auth: {
status: "unavailable",
message: "lark-cli is not installed on the Gateway",
user: null,
verified: false,
},
sandbox_runtime_mode: "none",
sandbox_runtime_ready: false,
sandbox_runtime_detail: null,
};
export function GET() {
return Response.json(status);
}

View File

@ -5,6 +5,8 @@ import { QueryClientProvider } from "@/components/query-client-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { CommandPalette } from "@/components/workspace/command-palette";
import { GatewayOfflineBanner } from "@/components/workspace/gateway-offline-banner";
import { SettingsDialogHost } from "@/components/workspace/settings";
import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
function parseSidebarOpenCookie(
@ -37,6 +39,8 @@ export async function WorkspaceContent({
</SidebarInset>
</SidebarProvider>
<CommandPalette />
<SettingsDialogHost />
<WorkspaceSettingsDeepLink />
<Toaster position="top-center" />
</QueryClientProvider>
);

View File

@ -27,14 +27,14 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts";
import { SettingsDialog } from "./settings";
import { useSettingsDialog } from "./settings";
export function CommandPalette() {
const { t } = useI18n();
const router = useRouter();
const { openSettings } = useSettingsDialog();
const [open, setOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const handleNewChat = useCallback(() => {
@ -44,8 +44,8 @@ export function CommandPalette() {
const handleOpenSettings = useCallback(() => {
setOpen(false);
setSettingsOpen(true);
}, []);
openSettings("appearance");
}, [openSettings]);
const handleShowShortcuts = useCallback(() => {
setOpen(false);
@ -72,7 +72,6 @@ export function CommandPalette() {
return (
<>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t.shortcuts.searchActions} />
<CommandList>

View File

@ -1 +1,7 @@
export { SettingsDialog } from "./settings-dialog";
export { SettingsDialog, type SettingsSection } from "./settings-dialog";
export { SettingsDialogHost } from "./settings-dialog-host";
export {
openSettingsDialog,
setSettingsDialogOpen,
useSettingsDialog,
} from "./settings-dialog-store";

View File

@ -0,0 +1,882 @@
"use client";
import {
CheckCircle2Icon,
CopyIcon,
ExternalLinkIcon,
PlugZapIcon,
RefreshCwIcon,
XCircleIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import {
LarkIntegrationRequestError,
type LarkAuthStartRequest,
type LarkAuthStartResponse,
type LarkConfigStartResponse,
type LarkIntegrationStatus,
useCompleteLarkAuthorization,
useCompleteLarkConfiguration,
useInstallLarkIntegration,
useLarkIntegrationStatus,
useStartLarkAuthorization,
useStartLarkConfiguration,
} from "@/core/integrations/lark";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
type PendingLarkFlow =
| ({ kind: "config" } & LarkConfigStartResponse)
| ({ kind: "auth" } & LarkAuthStartResponse);
type LarkAuthDomain =
| "approval"
| "apps"
| "attendance"
| "base"
| "calendar"
| "contact"
| "docs"
| "drive"
| "event"
| "im"
| "mail"
| "markdown"
| "mindnotes"
| "minutes"
| "note"
| "okr"
| "sheets"
| "slides"
| "task"
| "vc"
| "wiki"
| "all";
// Mirrors `lark-cli auth login --domain` (available business domains + all).
const LARK_AUTH_DOMAINS: LarkAuthDomain[] = [
"calendar",
"im",
"docs",
"drive",
"sheets",
"base",
"wiki",
"task",
"mail",
"vc",
"minutes",
"note",
"slides",
"markdown",
"mindnotes",
"contact",
"approval",
"attendance",
"okr",
"event",
"apps",
"all",
];
const AUTOMATIC_LARK_AUTH_WAIT_SECONDS = 8;
function splitScopes(value: string) {
return value
.split(/[\s,]+/)
.map((scope) => scope.trim())
.filter(Boolean);
}
function uniqueScopes(scopes: string[]) {
return Array.from(new Set(scopes));
}
export function IntegrationsSettingsPage() {
const { t } = useI18n();
return (
<SettingsSection
title={t.settings.integrations.title}
description={t.settings.integrations.description}
>
<LarkIntegrationCard />
</SettingsSection>
);
}
function LarkIntegrationCard() {
const { t } = useI18n();
const { user } = useAuth();
const isAdmin = user?.system_role === "admin";
const { data, isLoading, error, refetch, isFetching } =
useLarkIntegrationStatus();
const install = useInstallLarkIntegration();
const startConfig = useStartLarkConfiguration();
const completeConfig = useCompleteLarkConfiguration();
const startAuth = useStartLarkAuthorization();
const completeAuth = useCompleteLarkAuthorization();
const [pendingFlow, setPendingFlow] = useState<PendingLarkFlow | null>(null);
const [isCheckingConnection, setIsCheckingConnection] = useState(false);
const [selectedAuthDomains, setSelectedAuthDomains] = useState<
LarkAuthDomain[]
>([]);
const [customAuthScope, setCustomAuthScope] = useState("");
const browserWindowRef = useRef<Window | null>(null);
const authRequestRef = useRef<LarkAuthStartRequest>({ recommend: false });
const authToastIdRef = useRef<string | number | null>(null);
const authRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const authAttemptIdRef = useRef(0);
const authDeadlineRef = useRef(0);
const isMountedRef = useRef(true);
const connectBusy =
startConfig.isPending || completeConfig.isPending || startAuth.isPending;
const connectActionBusy = connectBusy || isCheckingConnection;
const credentialsConfigured = data?.auth.status === "authenticated";
const isConnected = credentialsConfigured && data?.auth.verified === true;
// The sandbox-runtime readiness row only applies when the sandbox actually
// runs lark-cli (AIO / provisioner modes report a non-"none" mode).
const showSandboxRuntime = !!data && data.sandbox_runtime_mode !== "none";
const trimmedCustomAuthScope = customAuthScope.trim();
const hasAdditionalPermissionRequest =
selectedAuthDomains.length > 0 || trimmedCustomAuthScope.length > 0;
const handleInstall = () => {
install.mutate(undefined, {
onSuccess: (result) => toast.success(result.message),
onError: (err) => {
if (err instanceof LarkIntegrationRequestError && err.isAdminRequired) {
toast.error(t.settings.integrations.adminRequired);
return;
}
toast.error(err instanceof Error ? err.message : String(err));
},
});
};
const clearAuthRetryTimer = () => {
if (authRetryTimeoutRef.current != null) {
clearTimeout(authRetryTimeoutRef.current);
authRetryTimeoutRef.current = null;
}
};
useEffect(
() => () => {
isMountedRef.current = false;
if (authRetryTimeoutRef.current != null) {
clearTimeout(authRetryTimeoutRef.current);
}
},
[],
);
const openPendingBrowserWindow = () => {
const browserWindow = window.open("about:blank", "_blank");
if (browserWindow) {
browserWindow.opener = null;
browserWindowRef.current = browserWindow;
}
return browserWindow;
};
const closePendingBrowserWindow = (browserWindow: Window | null) => {
if (!browserWindow) return;
browserWindow.close();
if (browserWindowRef.current === browserWindow) {
browserWindowRef.current = null;
}
};
const openAuthorizationUrl = (
url: string,
browserWindow = browserWindowRef.current,
) => {
if (browserWindow && !browserWindow.closed) {
browserWindow.location.href = url;
browserWindowRef.current = browserWindow;
return;
}
browserWindowRef.current = window.open(
url,
"_blank",
"noopener,noreferrer",
);
};
const startUserAuth = (
browserWindow = browserWindowRef.current,
request = authRequestRef.current,
) => {
startAuth.mutate(request, {
onSuccess: (result) => {
setPendingFlow({ kind: "auth", ...result });
openAuthorizationUrl(result.verification_url, browserWindow);
authToastIdRef.current = toast.info(
t.settings.integrations.lark.authStarted,
);
startAutomaticAuthorizationCheck(result);
},
onError: (err) => {
closePendingBrowserWindow(browserWindow);
toast.error(err instanceof Error ? err.message : String(err));
},
});
};
const handleContinueConnection = () => {
if (!pendingFlow || pendingFlow.kind !== "config") return;
completeConfig.mutate(
{
device_code: pendingFlow.device_code,
brand: pendingFlow.brand,
interval: pendingFlow.interval,
expires_in: pendingFlow.expires_in,
},
{
onSuccess: () => {
toast.success(t.settings.integrations.lark.connectionReady);
setPendingFlow(null);
startUserAuth(browserWindowRef.current);
},
onError: (err) => {
setPendingFlow(null);
toast.error(err instanceof Error ? err.message : String(err));
},
},
);
};
const startConnectionFlow = (
status: LarkIntegrationStatus,
browserWindow: Window | null,
) => {
if (!status.app_configured) {
startConfig.mutate(
{ brand: "feishu" },
{
onSuccess: (result) => {
setPendingFlow({ kind: "config", ...result });
openAuthorizationUrl(result.verification_url, browserWindow);
toast.success(t.settings.integrations.lark.connectionStarted);
},
onError: (err) => {
closePendingBrowserWindow(browserWindow);
toast.error(err instanceof Error ? err.message : String(err));
},
},
);
return;
}
startUserAuth(browserWindow);
};
const buildAuthRequest = (): LarkAuthStartRequest => {
const domains = selectedAuthDomains.includes("all")
? ["all"]
: uniqueScopes(selectedAuthDomains);
const scopes = uniqueScopes(splitScopes(trimmedCustomAuthScope));
return {
recommend: false,
domains,
scope: scopes.length > 0 ? scopes.join(" ") : null,
};
};
const toggleAuthDomain = (domain: LarkAuthDomain) => {
setSelectedAuthDomains((current) => {
if (domain === "all") {
return current.includes("all") ? [] : ["all"];
}
const withoutAll = current.filter((item) => item !== "all");
if (withoutAll.includes(domain)) {
return withoutAll.filter((item) => item !== domain);
}
return [...withoutAll, domain];
});
};
const handleConnect = async () => {
if (!data) return;
authRequestRef.current = buildAuthRequest();
// Pre-open the blank tab synchronously inside the click gesture. We cannot
// trust the cached auth status here: an `authenticated` cache can be stale
// (session expired server-side), and if we skipped the pre-open and then
// discovered that only after `await refetch()`, the later `window.open`
// would run outside the user gesture and be blocked by the browser. Opening
// now and closing below when it turns out unneeded keeps the popup reliable.
const browserWindow = openPendingBrowserWindow();
setIsCheckingConnection(true);
try {
const refreshed = await refetch();
const latestStatus = refreshed.data ?? data;
startConnectionFlow(latestStatus, browserWindow);
} catch (err) {
closePendingBrowserWindow(browserWindow);
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setIsCheckingConnection(false);
}
};
const completeAuthorization = (
deviceCode: string,
{ automatic, attemptId }: { automatic: boolean; attemptId?: number },
) => {
const toastOptions =
authToastIdRef.current == null
? undefined
: { id: authToastIdRef.current };
completeAuth.mutate(
{
device_code: deviceCode,
...(automatic
? { wait_timeout_seconds: AUTOMATIC_LARK_AUTH_WAIT_SECONDS }
: {}),
},
{
onSuccess: (result) => {
// react-query still fires this after the dialog unmounts; bail so we
// don't toast, setState, refetch, or reschedule a retry timer on a
// component that is gone.
if (!isMountedRef.current) {
return;
}
if (automatic && attemptId !== authAttemptIdRef.current) {
return;
}
if (result.success) {
clearAuthRetryTimer();
toast.success(result.message, toastOptions);
authToastIdRef.current = null;
setPendingFlow(null);
browserWindowRef.current = null;
return;
}
toast.info(
result.message ||
t.settings.integrations.lark.authorizationStillPending,
toastOptions,
);
if (automatic && attemptId != null) {
scheduleAuthorizationRetry(deviceCode, attemptId);
}
},
onError: (err) => {
if (!isMountedRef.current) {
return;
}
if (automatic && attemptId !== authAttemptIdRef.current) {
return;
}
if (
automatic &&
err instanceof LarkIntegrationRequestError &&
err.status === 504
) {
toast.info(
t.settings.integrations.lark.authorizationStillPending,
toastOptions,
);
if (attemptId != null) {
scheduleAuthorizationRetry(deviceCode, attemptId);
}
return;
}
toast.error(
err instanceof Error ? err.message : String(err),
toastOptions,
);
authToastIdRef.current = null;
},
},
);
};
const scheduleAuthorizationRetry = (
deviceCode: string,
attemptId: number,
) => {
clearAuthRetryTimer();
if (!isMountedRef.current) {
return;
}
if (Date.now() >= authDeadlineRef.current) {
toast.info(t.settings.integrations.lark.authorizationStillPending);
return;
}
authRetryTimeoutRef.current = setTimeout(() => {
completeAuthorization(deviceCode, { automatic: true, attemptId });
}, 1500);
};
const startAutomaticAuthorizationCheck = (result: LarkAuthStartResponse) => {
clearAuthRetryTimer();
const attemptId = authAttemptIdRef.current + 1;
authAttemptIdRef.current = attemptId;
authDeadlineRef.current =
Date.now() + Math.max(result.expires_in ?? 300, 30) * 1000;
completeAuthorization(result.device_code, { automatic: true, attemptId });
};
const handleCompleteAuth = () => {
if (!pendingFlow) return;
if (pendingFlow.kind !== "auth") {
return;
}
clearAuthRetryTimer();
authAttemptIdRef.current += 1;
completeAuthorization(pendingFlow.device_code, { automatic: false });
};
const handleCopyAuthLink = async () => {
if (!pendingFlow) return;
try {
await navigator.clipboard.writeText(pendingFlow.verification_url);
toast.success(t.clipboard.copiedToClipboard);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
}
};
const installDisabled =
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
!isAdmin ||
install.isPending;
const authDisabled =
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
!data?.installed ||
!data?.cli.available ||
connectActionBusy;
const connectButtonLabel = isCheckingConnection
? t.settings.integrations.lark.checkingConnection
: connectBusy
? t.settings.integrations.lark.authStarting
: credentialsConfigured && hasAdditionalPermissionRequest
? t.settings.integrations.lark.requestPermissions
: credentialsConfigured
? t.settings.integrations.lark.connectedAction
: t.settings.integrations.lark.connect;
const permissionDomains = LARK_AUTH_DOMAINS.map((id) => ({
id,
label: t.settings.integrations.lark.authDomains[id].label,
description: t.settings.integrations.lark.authDomains[id].description,
}));
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-primary/10 text-primary rounded-lg p-2">
<PlugZapIcon className="size-5" />
</div>
<div>
<CardTitle>{t.settings.integrations.lark.title}</CardTitle>
<CardDescription>
{t.settings.integrations.lark.description}
</CardDescription>
</div>
</div>
<CardAction>
<Button
variant="outline"
size="sm"
onClick={() => void refetch()}
disabled={isFetching}
>
<RefreshCwIcon
className={cn("size-4", isFetching && "animate-spin")}
/>
{t.settings.integrations.refresh}
</Button>
</CardAction>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : error ? (
<Alert variant="destructive">
<XCircleIcon />
<AlertTitle>{t.settings.integrations.loadFailed}</AlertTitle>
<AlertDescription>
{error instanceof Error ? error.message : String(error)}
</AlertDescription>
</Alert>
) : data ? (
<>
<div
className={cn(
"grid gap-3",
showSandboxRuntime ? "md:grid-cols-4" : "md:grid-cols-3",
)}
>
<StatusItem
label={t.settings.integrations.lark.skillPack}
ok={data.installed}
value={
data.installed
? t.settings.integrations.lark.skillsInstalled(
data.skills_installed,
data.skills_expected,
)
: t.settings.integrations.lark.notInstalled
}
/>
<StatusItem
label={t.settings.integrations.lark.gatewayCli}
ok={data.cli.available}
value={
data.cli.available
? (data.cli.version ?? t.settings.integrations.available)
: (data.cli.error ?? t.settings.integrations.unavailable)
}
/>
<StatusItem
label={t.settings.integrations.lark.auth}
ok={isConnected}
value={
data.auth.status === "authenticated"
? data.auth.verified
? (data.auth.user ?? t.settings.integrations.connected)
: data.auth.user
? t.settings.integrations.lark.authConfiguredFor(
data.auth.user,
)
: t.settings.integrations.lark.authConfigured
: t.settings.integrations.lark.authNotConfigured
}
/>
{showSandboxRuntime && (
<StatusItem
label={t.settings.integrations.lark.sandboxRuntime}
ok={data.sandbox_runtime_ready}
value={
data.sandbox_runtime_ready
? data.sandbox_runtime_mode === "init-container"
? t.settings.integrations.lark
.sandboxRuntimeInitContainer
: t.settings.integrations.lark
.sandboxRuntimeGatewayDownload
: (data.sandbox_runtime_detail ??
t.settings.integrations.lark.sandboxRuntimeNotReady)
}
/>
)}
</div>
{data.installed && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span>
{t.settings.integrations.lark.installedVersion(
data.manifest_version ?? data.version,
)}
</span>
{data.latest_available_version &&
data.latest_available_version !==
(data.manifest_version ?? data.version) && (
<span className="text-amber-600 dark:text-amber-500">
{t.settings.integrations.lark.updateAvailable(
data.latest_available_version,
)}
</span>
)}
{data.runtime_version_mismatch && (
<span className="text-amber-600 dark:text-amber-500">
{t.settings.integrations.lark.runtimeVersionMismatch}
</span>
)}
</div>
)}
<IntegrationNextStep
installed={data.installed}
cliReady={data.cli.available}
connected={isConnected}
credentialsConfigured={credentialsConfigured}
/>
{data.installed && data.cli.available && (
<div className="rounded-lg border p-3">
<div className="space-y-1">
<div className="text-sm font-medium">
{t.settings.integrations.lark.permissionTitle}
</div>
<p className="text-muted-foreground text-sm">
{t.settings.integrations.lark.permissionDescription}
</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{permissionDomains.map((domain) => {
const selected = selectedAuthDomains.includes(domain.id);
return (
<Button
key={domain.id}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
onClick={() => toggleAuthDomain(domain.id)}
disabled={connectActionBusy}
title={domain.description}
>
{domain.label}
</Button>
);
})}
</div>
<div className="mt-3 space-y-1">
<Input
value={customAuthScope}
onChange={(event) =>
setCustomAuthScope(event.currentTarget.value)
}
disabled={connectActionBusy}
placeholder={
t.settings.integrations.lark.customScopePlaceholder
}
aria-label={t.settings.integrations.lark.customScopeLabel}
/>
<p className="text-muted-foreground text-xs">
{t.settings.integrations.lark.customScopeDescription}
</p>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button onClick={handleInstall} disabled={installDisabled}>
{install.isPending ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : null}
{install.isPending
? t.settings.integrations.installing
: data.installed
? t.settings.integrations.reinstall
: t.settings.integrations.install}
</Button>
<Button
variant="outline"
onClick={() => void handleConnect()}
disabled={authDisabled}
>
{connectActionBusy ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : null}
{connectButtonLabel}
</Button>
{!isAdmin && (
<span className="text-muted-foreground text-sm">
{t.settings.integrations.adminRequired}
</span>
)}
</div>
{install.isPending && (
<Alert>
<RefreshCwIcon className="size-4 animate-spin" />
<AlertTitle>
{t.settings.integrations.lark.installingTitle}
</AlertTitle>
<AlertDescription>
{t.settings.integrations.lark.installingDescription}
</AlertDescription>
</Alert>
)}
{pendingFlow && (
<Alert>
<ExternalLinkIcon />
<AlertTitle>
{pendingFlow.kind === "config"
? t.settings.integrations.lark.openConnectionLinkTitle
: completeAuth.isPending
? t.settings.integrations.lark.waitingAuthTitle
: t.settings.integrations.lark.openAuthLinkTitle}
</AlertTitle>
<AlertDescription>
<div className="space-y-3">
<p>
{pendingFlow.kind === "config"
? t.settings.integrations.lark
.openConnectionLinkDescription
: completeAuth.isPending
? t.settings.integrations.lark.waitingAuthDescription
: t.settings.integrations.lark
.openAuthLinkDescription}
</p>
<div className="bg-muted text-foreground rounded-md px-3 py-2 text-xs break-all">
{pendingFlow.verification_url}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" asChild>
<a
href={pendingFlow.verification_url}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-4" />
{t.settings.integrations.lark.openAuthLink}
</a>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void handleCopyAuthLink()}
>
<CopyIcon className="size-4" />
{t.settings.integrations.lark.copyAuthLink}
</Button>
{pendingFlow.kind === "config" ? (
<Button
size="sm"
variant="outline"
onClick={handleContinueConnection}
disabled={completeConfig.isPending}
>
{completeConfig.isPending ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : null}
{completeConfig.isPending
? t.settings.integrations.lark
.preparingAuthorization
: t.settings.integrations.lark.continueAuth}
</Button>
) : (
<Button
size="sm"
variant="default"
onClick={handleCompleteAuth}
disabled={completeAuth.isPending}
>
{completeAuth.isPending
? t.settings.integrations.lark.completingAuth
: t.settings.integrations.lark.completeAuth}
</Button>
)}
</div>
{pendingFlow.expires_in != null && (
<p className="text-muted-foreground text-xs">
{t.settings.integrations.lark.authExpiresIn(
pendingFlow.expires_in,
)}
</p>
)}
</div>
</AlertDescription>
</Alert>
)}
</>
) : null}
</CardContent>
</Card>
);
}
function StatusItem({
label,
ok,
value,
}: {
label: string;
ok: boolean;
value: string;
}) {
const { t } = useI18n();
return (
<div className="rounded-lg border p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-sm font-medium">{label}</div>
<Badge variant={ok ? "secondary" : "outline"}>
{ok ? (
<CheckCircle2Icon className="size-3" />
) : (
<XCircleIcon className="size-3" />
)}
{ok ? t.settings.integrations.ready : t.settings.integrations.pending}
</Badge>
</div>
<div className="text-muted-foreground text-sm break-words">{value}</div>
</div>
);
}
function IntegrationNextStep({
installed,
cliReady,
connected,
credentialsConfigured,
}: {
installed: boolean;
cliReady: boolean;
connected: boolean;
credentialsConfigured: boolean;
}) {
const { t } = useI18n();
if (!installed) {
return (
<Alert>
<AlertTitle>{t.settings.integrations.lark.installNextTitle}</AlertTitle>
<AlertDescription>
{t.settings.integrations.lark.installNextDescription}
</AlertDescription>
</Alert>
);
}
if (!cliReady) {
return (
<Alert>
<AlertTitle>{t.settings.integrations.lark.cliNextTitle}</AlertTitle>
<AlertDescription>
{t.settings.integrations.lark.cliNextDescription}
</AlertDescription>
</Alert>
);
}
if (connected) {
return (
<Alert>
<CheckCircle2Icon />
<AlertTitle>{t.settings.integrations.lark.connectedTitle}</AlertTitle>
<AlertDescription>
{t.settings.integrations.lark.connectedDescription}
</AlertDescription>
</Alert>
);
}
if (credentialsConfigured) {
return (
<Alert>
<CheckCircle2Icon />
<AlertTitle>{t.settings.integrations.lark.configuredTitle}</AlertTitle>
<AlertDescription>
{t.settings.integrations.lark.configuredDescription}
</AlertDescription>
</Alert>
);
}
return (
<Alert>
<ExternalLinkIcon />
<AlertTitle>{t.settings.integrations.lark.authNextTitle}</AlertTitle>
<AlertDescription>
{t.settings.integrations.lark.authNextDescription}
</AlertDescription>
</Alert>
);
}

View File

@ -0,0 +1,25 @@
"use client";
import { SettingsDialog } from "./settings-dialog";
import {
setSettingsDialogOpen,
useSettingsDialog,
} from "./settings-dialog-store";
/**
* The single application-wide Settings dialog instance.
*
* Mounted once at the workspace root; every entry point (nav menu, command
* palette, deep link) opens it through the shared store rather than mounting
* its own dialog.
*/
export function SettingsDialogHost() {
const { open, section } = useSettingsDialog();
return (
<SettingsDialog
open={open}
onOpenChange={setSettingsDialogOpen}
defaultSection={section}
/>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
import type { SettingsSection } from "./settings-dialog";
type Listener = () => void;
type SettingsDialogState = {
open: boolean;
section: SettingsSection;
};
const listeners = new Set<Listener>();
let state: SettingsDialogState = { open: false, section: "appearance" };
function emitChange() {
for (const listener of listeners) {
listener();
}
}
function setState(next: SettingsDialogState) {
if (next.open === state.open && next.section === state.section) {
return;
}
state = next;
emitChange();
}
export function subscribeSettingsDialog(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getSettingsDialogSnapshot(): SettingsDialogState {
return state;
}
const SERVER_SNAPSHOT: SettingsDialogState = {
open: false,
section: "appearance",
};
function getServerSnapshot(): SettingsDialogState {
return SERVER_SNAPSHOT;
}
export function openSettingsDialog(section: SettingsSection) {
setState({ open: true, section });
}
export function setSettingsDialogOpen(open: boolean) {
setState({ open, section: state.section });
}
/**
* Shared open/section state for the single application-wide Settings dialog.
*
* Multiple entry points (nav menu, command palette, `?settings=` deep link)
* drive this one store instead of each mounting its own `SettingsDialog`, so
* two dialogs can never be open at once with racing per-instance flows (e.g.
* duplicate Lark auth device-code polling).
*/
export function useSettingsDialog() {
const snapshot = useSyncExternalStore(
subscribeSettingsDialog,
getSettingsDialogSnapshot,
getServerSnapshot,
);
const open = useCallback((section: SettingsSection) => {
openSettingsDialog(section);
}, []);
const setOpen = useCallback((next: boolean) => {
setSettingsDialogOpen(next);
}, []);
return {
open: snapshot.open,
section: snapshot.section,
openSettings: open,
setSettingsOpen: setOpen,
};
}

View File

@ -6,6 +6,7 @@ import {
InfoIcon,
BrainIcon,
PaletteIcon,
PlugZapIcon,
SparklesIcon,
UserIcon,
WrenchIcon,
@ -23,6 +24,7 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { ChannelsSettingsPage } from "@/components/workspace/settings/channels-settings-page";
import { IntegrationsSettingsPage } from "@/components/workspace/settings/integrations-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
@ -30,10 +32,11 @@ import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
type SettingsSection =
export type SettingsSection =
| "account"
| "appearance"
| "channels"
| "integrations"
| "memory"
| "tools"
| "skills"
@ -80,6 +83,11 @@ export function SettingsDialog(props: SettingsDialogProps) {
label: t.settings.sections.channels,
icon: CableIcon,
},
{
id: "integrations",
label: t.settings.sections.integrations,
icon: PlugZapIcon,
},
{
id: "memory",
label: t.settings.sections.memory,
@ -93,6 +101,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
t.settings.sections.account,
t.settings.sections.appearance,
t.settings.sections.channels,
t.settings.sections.integrations,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.skills,
@ -153,6 +162,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
)}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "channels" && <ChannelsSettingsPage />}
{activeSection === "integrations" && <IntegrationsSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>

View File

@ -28,7 +28,7 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import { GithubIcon } from "./github-icon";
import { SettingsDialog } from "./settings";
import { useSettingsDialog } from "./settings";
function NavMenuButtonContent({
isSidebarOpen,
@ -51,10 +51,7 @@ function NavMenuButtonContent({
}
export function WorkspaceNavMenu() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
>("appearance");
const { openSettings } = useSettingsDialog();
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
@ -65,11 +62,6 @@ export function WorkspaceNavMenu() {
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
{mounted ? (
@ -90,8 +82,7 @@ export function WorkspaceNavMenu() {
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
setSettingsOpen(true);
openSettings("appearance");
}}
>
<Settings2Icon />
@ -139,8 +130,7 @@ export function WorkspaceNavMenu() {
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("about");
setSettingsOpen(true);
openSettings("about");
}}
>
<InfoIcon />

View File

@ -0,0 +1,68 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
import {
openSettingsDialog,
type SettingsSection,
useSettingsDialog,
} from "./settings";
const SETTINGS_SECTIONS = new Set<SettingsSection>([
"account",
"appearance",
"channels",
"integrations",
"memory",
"tools",
"skills",
"notification",
"about",
]);
function asSettingsSection(value: string | null): SettingsSection | null {
if (!value) return null;
return SETTINGS_SECTIONS.has(value as SettingsSection)
? (value as SettingsSection)
: null;
}
/**
* Bridges the `?settings=<section>` query param to the shared settings dialog
* store. It does not mount its own dialog a single {@link SettingsDialogHost}
* renders the one dialog so a deep link can never race a second dialog opened
* from the nav menu or command palette.
*/
export function WorkspaceSettingsDeepLink() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { open } = useSettingsDialog();
const openedFromDeepLinkRef = useRef(false);
useEffect(() => {
const nextSection = asSettingsSection(searchParams.get("settings"));
if (nextSection) {
openedFromDeepLinkRef.current = true;
openSettingsDialog(nextSection);
}
}, [searchParams]);
useEffect(() => {
if (open || !openedFromDeepLinkRef.current) {
return;
}
openedFromDeepLinkRef.current = false;
if (searchParams.has("settings")) {
const next = new URLSearchParams(searchParams);
next.delete("settings");
const suffix = next.toString();
router.replace(suffix ? `${pathname}?${suffix}` : pathname, {
scroll: false,
});
}
}, [open, pathname, router, searchParams]);
return null;
}

View File

@ -706,6 +706,7 @@ export const enUS: Translations = {
account: "Account",
appearance: "Appearance",
channels: "Channels",
integrations: "Integrations",
memory: "Memory",
tools: "Tools",
skills: "Skills",
@ -817,6 +818,203 @@ export const enUS: Translations = {
disabled:
"Channel connections are not enabled on this server. Ask an administrator to enable channel_connections.",
},
integrations: {
title: "Integrations",
description:
"Connect third-party tools and work platforms so agents can use them directly.",
refresh: "Refresh",
install: "Install",
reinstall: "Reinstall",
installing: "Installing...",
ready: "Ready",
pending: "Pending",
available: "Available",
unavailable: "Unavailable",
connected: "Connected",
loadFailed: "Failed to load integration status",
adminRequired: "Admin privileges are required to install integrations.",
lark: {
title: "Lark / Feishu CLI",
description:
"Install the official Lark/Feishu agent skills and let agents use Lark after authorization.",
skillPack: "Skill pack",
gatewayCli: "Gateway CLI",
auth: "Auth",
sandboxRuntime: "Sandbox runtime",
sandboxRuntimeInitContainer: "Provisioned by init container",
sandboxRuntimeGatewayDownload: "Provisioned by Gateway",
sandboxRuntimeNotReady:
"Not ready — lark-cli may be missing at chat time",
notInstalled: "Not installed",
skillsInstalled: (installed, expected) =>
`${installed}/${expected} skills installed`,
installedVersion: (version) => `Installed: ${version}`,
updateAvailable: (version) =>
`Update available: ${version} — admin reinstall updates the managed Gateway CLI and skill pack`,
runtimeVersionMismatch:
"Skill pack version differs from the Gateway runtime lark-cli; admin reinstall attempts to update the managed Gateway CLI and realign the skill pack",
authNotConfigured: "Not connected",
authConfigured: "Credentials configured (not live-verified)",
authConfiguredFor: (user) =>
`${user} · credentials configured (not live-verified)`,
connect: "Connect Lark",
authStarting: "Opening connection link...",
checkingConnection: "Checking connection...",
connectedAction: "Reconnect Lark",
requestPermissions: "Request permissions",
alreadyConnected:
"Lark is already connected. If authorization expires, refresh the status and reconnect.",
connectionStarted: "Connection link opened",
connectionReady: "Connection is ready. Opening authorization...",
authStarted:
"Authorization page opened. DeerFlow will detect completion automatically.",
authorizationStillPending:
'Authorization is not complete yet. Finish it in the browser; DeerFlow keeps checking automatically. You can click "I completed authorization" if the page does not update.',
permissionTitle: "Authorization scope",
permissionDescription:
"By default, DeerFlow only completes the base sign-in and does not request any business permissions. Select the domains you need here; connected users can re-authorize to add more (scopes accumulate).",
authDomains: {
calendar: {
label: "Calendar",
description:
"Events, free/busy, RSVP, and meeting-room scheduling.",
},
im: {
label: "Messenger",
description:
"Send/reply messages, manage group chats, search history, download media.",
},
docs: {
label: "Docs",
description: "Create, read, update, and search documents.",
},
drive: {
label: "Drive",
description:
"Upload/download files, search docs & wiki, manage comments.",
},
sheets: {
label: "Sheets",
description: "Read, write, append, find, and export spreadsheets.",
},
base: {
label: "Base",
description:
"Bitable tables, fields, records, views, dashboards, and workflows.",
},
wiki: {
label: "Wiki",
description: "Knowledge spaces, nodes, and wiki documents.",
},
task: {
label: "Tasks",
description:
"Tasks, task lists, subtasks, comments, and reminders.",
},
mail: {
label: "Mail",
description:
"Browse, search, read, send, reply, forward, and manage drafts.",
},
vc: {
label: "Meetings",
description: "Meeting records, minutes artifacts, and recordings.",
},
minutes: {
label: "Minutes",
description: "Meeting minutes content and transcripts.",
},
note: {
label: "Notes",
description: "Meeting notes and related content.",
},
slides: {
label: "Slides",
description: "Presentations and slide content.",
},
markdown: {
label: "Markdown",
description:
"Create, fetch, patch, and overwrite Drive-native .md files.",
},
mindnotes: {
label: "Mind notes",
description: "Mind notes content.",
},
contact: {
label: "Contacts",
description: "Look up users by name/email/phone and read profiles.",
},
approval: {
label: "Approval",
description:
"Query and act on approval tasks; cancel and CC instances.",
},
attendance: {
label: "Attendance",
description: "Query personal attendance check-in records.",
},
okr: {
label: "OKR",
description:
"Objectives, key results, alignments, indicators, and progress.",
},
event: {
label: "Events",
description: "Subscribe to and consume real-time platform events.",
},
apps: {
label: "Apps",
description:
"Create Spark/Miaoda apps, publish sites, and manage access scope.",
},
all: {
label: "All",
description:
"Request every business domain supported by lark-cli. Use this only when the missing permission is unclear.",
},
},
customScopeLabel: "Exact OAuth scope",
customScopePlaceholder: "For example calendar:calendar.event:read",
customScopeDescription:
"Advanced: if an error reports a missing scope, paste it here. Examples: calendar:calendar.event:read, calendar:calendar.free_busy:read.",
openConnectionLinkTitle: "Continue connecting Lark",
openConnectionLinkDescription:
"The first connection needs one browser confirmation from Lark. Open the link below and finish the prompt, then return here to continue authorization.",
openAuthLinkTitle: "Authorize Lark in your browser",
openAuthLinkDescription:
"Open the link below to authorize. DeerFlow keeps checking automatically and will save the connection after approval.",
waitingAuthTitle: "Waiting for Lark authorization",
waitingAuthDescription:
"Finish authorization in the browser page that just opened. DeerFlow will update this panel automatically; the button below is only a fallback.",
openAuthLink: "Open link",
copyAuthLink: "Copy link",
completeAuth: "I completed authorization",
continueAuth: "I completed browser confirmation, continue",
preparingAuthorization: "Preparing authorization...",
completingAuth: "Checking...",
authExpiresIn: (seconds) =>
`This link expires in about ${seconds} seconds.`,
installingTitle: "Installing official skill pack",
installingDescription:
"This usually finishes within 30 seconds; slower networks may take about 1 minute. The status refreshes automatically when installation completes.",
installNextTitle: "Install the official skill pack first",
installNextDescription:
"After installation, /lark-doc, /lark-im, /lark-sheets and related skills appear in the skill index.",
cliNextTitle: "Install Gateway CLI",
cliNextDescription:
"The skill pack is installed, but the Gateway cannot find lark-cli. Admin reinstall attempts to download the managed Gateway CLI; offline deployments can use an image with @larksuite/cli built in.",
configuredTitle: "Lark credentials are configured locally",
configuredDescription:
"Credentials are present, but their current validity has not been checked with Lark. Reconnect to refresh and live-verify authorization.",
connectedTitle: "Lark authorization is live-verified",
connectedDescription:
"The current user's authorization was verified with Lark during this connection flow. Reconnect whenever you need to refresh it or add permissions.",
authNextTitle: "Complete browser authorization next",
authNextDescription:
"Click “Connect Lark”; DeerFlow checks the current status first and opens browser authorization only when disconnected or expired.",
},
},
skills: {
title: "Agent Skills",
description:

View File

@ -590,6 +590,7 @@ export interface Translations {
account: string;
appearance: string;
channels: string;
integrations: string;
memory: string;
tools: string;
skills: string;
@ -692,6 +693,105 @@ export interface Translations {
description: string;
disabled: string;
};
integrations: {
title: string;
description: string;
refresh: string;
install: string;
reinstall: string;
installing: string;
ready: string;
pending: string;
available: string;
unavailable: string;
connected: string;
loadFailed: string;
adminRequired: string;
lark: {
title: string;
description: string;
skillPack: string;
gatewayCli: string;
auth: string;
sandboxRuntime: string;
sandboxRuntimeInitContainer: string;
sandboxRuntimeGatewayDownload: string;
sandboxRuntimeNotReady: string;
notInstalled: string;
skillsInstalled: (installed: number, expected: number) => string;
installedVersion: (version: string) => string;
updateAvailable: (version: string) => string;
runtimeVersionMismatch: string;
authNotConfigured: string;
authConfigured: string;
authConfiguredFor: (user: string) => string;
connect: string;
authStarting: string;
checkingConnection: string;
connectedAction: string;
requestPermissions: string;
alreadyConnected: string;
connectionStarted: string;
connectionReady: string;
authStarted: string;
authorizationStillPending: string;
permissionTitle: string;
permissionDescription: string;
authDomains: Record<
| "approval"
| "apps"
| "attendance"
| "base"
| "calendar"
| "contact"
| "docs"
| "drive"
| "event"
| "im"
| "mail"
| "markdown"
| "mindnotes"
| "minutes"
| "note"
| "okr"
| "sheets"
| "slides"
| "task"
| "vc"
| "wiki"
| "all",
{ label: string; description: string }
>;
customScopeLabel: string;
customScopePlaceholder: string;
customScopeDescription: string;
openConnectionLinkTitle: string;
openConnectionLinkDescription: string;
openAuthLinkTitle: string;
openAuthLinkDescription: string;
waitingAuthTitle: string;
waitingAuthDescription: string;
openAuthLink: string;
copyAuthLink: string;
completeAuth: string;
continueAuth: string;
preparingAuthorization: string;
completingAuth: string;
authExpiresIn: (seconds: number) => string;
installingTitle: string;
installingDescription: string;
installNextTitle: string;
installNextDescription: string;
cliNextTitle: string;
cliNextDescription: string;
configuredTitle: string;
configuredDescription: string;
connectedTitle: string;
connectedDescription: string;
authNextTitle: string;
authNextDescription: string;
};
};
skills: {
title: string;
description: string;

View File

@ -679,6 +679,7 @@ export const zhCN: Translations = {
account: "账号",
appearance: "外观",
channels: "渠道",
integrations: "集成",
memory: "记忆",
tools: "工具",
skills: "技能",
@ -786,6 +787,188 @@ export const zhCN: Translations = {
disabled:
"当前服务器未启用渠道连接。请联系管理员开启 channel_connections。",
},
integrations: {
title: "集成",
description: "连接第三方工具和办公生态,让 Agent 能直接使用对应能力。",
refresh: "刷新",
install: "安装",
reinstall: "重新安装",
installing: "安装中...",
ready: "就绪",
pending: "待处理",
available: "可用",
unavailable: "不可用",
connected: "已连接",
loadFailed: "加载集成状态失败",
adminRequired: "需要管理员权限才能安装集成。",
lark: {
title: "Lark / 飞书 CLI",
description:
"安装官方 Lark/Feishu Agent Skills并在授权后让 Agent 直接使用飞书能力。",
skillPack: "技能包",
gatewayCli: "Gateway CLI",
auth: "授权",
sandboxRuntime: "沙箱运行时",
sandboxRuntimeInitContainer: "由 init container 提供",
sandboxRuntimeGatewayDownload: "由 Gateway 提供",
sandboxRuntimeNotReady: "未就绪 —— 对话时 lark-cli 可能不可用",
notInstalled: "尚未安装",
skillsInstalled: (installed, expected) =>
`已安装 ${installed}/${expected} 个技能`,
installedVersion: (version) => `已安装版本:${version}`,
updateAvailable: (version) =>
`有新版本:${version} —— 管理员重新安装会更新 managed Gateway CLI 和技能包`,
runtimeVersionMismatch:
"技能包版本与 Gateway 运行时 lark-cli 不一致;管理员重新安装会尝试更新 managed Gateway CLI 并重新对齐技能包",
authNotConfigured: "尚未连接",
authConfigured: "凭证已配置(未实时验证)",
authConfiguredFor: (user) => `${user} · 凭证已配置(未实时验证)`,
connect: "连接飞书",
authStarting: "正在打开连接链接...",
checkingConnection: "正在检查连接状态...",
connectedAction: "重新连接飞书",
requestPermissions: "申请新权限",
alreadyConnected:
"飞书已连接,无需重复授权。如果授权已过期,刷新状态后可重新连接。",
connectionStarted: "连接链接已打开",
connectionReady: "连接准备已完成,正在打开授权链接",
authStarted: "授权页已打开DeerFlow 会自动检测授权结果。",
authorizationStillPending:
"还没有检测到授权完成。请在浏览器完成授权DeerFlow 会继续自动检测。如果页面没有更新,可点击“我已完成授权”。",
permissionTitle: "授权范围",
permissionDescription:
"默认只完成基础登录不会申请任何业务权限。按需在这里勾选要授权的业务域已连接用户可以重新授权继续追加scope 会累积)。",
authDomains: {
calendar: {
label: "日历",
description: "日程、忙闲、日程回复与会议室预定。",
},
im: {
label: "消息",
description: "收发/回复消息、管理群聊、搜索记录、下载媒体。",
},
docs: {
label: "文档",
description: "创建、读取、编辑和搜索云文档。",
},
drive: {
label: "云空间",
description: "上传/下载文件、搜索文档与知识库、管理评论。",
},
sheets: {
label: "电子表格",
description: "读取、写入、追加、查找和导出电子表格。",
},
base: {
label: "多维表格",
description: "多维表格的表、字段、记录、视图、仪表盘与工作流。",
},
wiki: {
label: "知识库",
description: "知识空间、节点与知识库文档。",
},
task: {
label: "任务",
description: "任务、清单、子任务、评论与提醒。",
},
mail: {
label: "邮件",
description: "浏览、搜索、阅读、发送、回复、转发与管理草稿。",
},
vc: {
label: "视频会议",
description: "会议记录、纪要产物与录制。",
},
minutes: {
label: "妙记",
description: "会议纪要内容与逐字稿。",
},
note: {
label: "笔记",
description: "会议笔记及相关内容。",
},
slides: {
label: "幻灯片",
description: "演示文稿与幻灯片内容。",
},
markdown: {
label: "Markdown",
description: "创建、获取、局部修改和覆盖云盘原生 .md 文件。",
},
mindnotes: {
label: "思维笔记",
description: "思维笔记内容。",
},
contact: {
label: "通讯录",
description: "按姓名/邮箱/电话查用户并读取资料。",
},
approval: {
label: "审批",
description: "查询和处理审批任务、撤销与抄送实例。",
},
attendance: {
label: "考勤",
description: "查询个人考勤打卡记录。",
},
okr: {
label: "OKR",
description: "目标、关键结果、对齐、指标与进展。",
},
event: {
label: "实时事件",
description: "订阅并消费平台实时事件。",
},
apps: {
label: "妙搭应用",
description: "创建 Spark/妙搭应用、发布站点并管理可见范围。",
},
all: {
label: "全部",
description:
"申请 lark-cli 支持的全部业务域权限。仅在不确定缺哪个权限时使用。",
},
},
customScopeLabel: "具体 OAuth scope",
customScopePlaceholder: "例如 calendar:calendar.event:read",
customScopeDescription:
"高级用法:如果错误里给出了缺失 scope可直接填在这里。例如 calendar:calendar.event:read、calendar:calendar.free_busy:read。",
openConnectionLinkTitle: "继续完成飞书连接",
openConnectionLinkDescription:
"首次连接需要在浏览器里完成一次飞书确认。打开下面的链接按提示完成;完成后回到这里继续授权。",
openAuthLinkTitle: "在浏览器中完成飞书授权",
openAuthLinkDescription:
"打开下面的链接完成授权。DeerFlow 会持续自动检测,并在授权通过后保存连接状态。",
waitingAuthTitle: "等待飞书授权完成",
waitingAuthDescription:
"请在刚打开的浏览器页面完成授权。DeerFlow 会自动更新这里的状态;下方按钮只是兜底操作。",
openAuthLink: "打开链接",
copyAuthLink: "复制链接",
completeAuth: "我已完成授权",
continueAuth: "我已完成浏览器确认,继续授权",
preparingAuthorization: "正在准备授权...",
completingAuth: "确认中...",
authExpiresIn: (seconds) => `链接将在约 ${seconds} 秒后过期。`,
installingTitle: "正在安装官方技能包",
installingDescription:
"通常 30 秒内完成,网络较慢时可能需要约 1 分钟。安装完成后会自动刷新状态。",
installNextTitle: "先安装官方技能包",
installNextDescription:
"安装后,/lark-doc、/lark-im、/lark-sheets 等技能会出现在技能索引中。",
cliNextTitle: "需要安装 Gateway CLI",
cliNextDescription:
"技能包已安装,但 Gateway 找不到 lark-cli。管理员重新安装集成会尝试下载 managed Gateway CLI离线部署可使用内置 @larksuite/cli 的镜像。",
configuredTitle: "飞书凭证已在本地配置",
configuredDescription:
"当前只确认本地存在凭证,尚未向飞书实时验证有效性。重新连接可刷新并实时验证授权。",
connectedTitle: "飞书授权已实时验证",
connectedDescription:
"本次连接流程已向飞书验证当前用户授权。需要刷新授权或追加权限时,可重新连接。",
authNextTitle: "下一步完成浏览器授权",
authNextDescription:
"点击“连接飞书”后DeerFlow 会先检查当前状态;未连接或授权过期时会拉起浏览器授权。",
},
},
skills: {
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",

View File

@ -0,0 +1,153 @@
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type {
LarkAuthCompleteRequest,
LarkAuthCompleteResponse,
LarkAuthStartRequest,
LarkAuthStartResponse,
LarkConfigCompleteRequest,
LarkConfigCompleteResponse,
LarkConfigStartRequest,
LarkConfigStartResponse,
LarkInstallResponse,
LarkIntegrationStatus,
} from "./types";
export class LarkIntegrationRequestError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "LarkIntegrationRequestError";
this.status = status;
}
get isAdminRequired(): boolean {
return this.status === 403;
}
}
async function readErrorDetail(response: Response): Promise<string> {
const data = (await response.json().catch(() => ({}))) as {
detail?: string;
};
return data.detail ?? `HTTP ${response.status}: ${response.statusText}`;
}
export async function loadLarkIntegrationStatus(): Promise<LarkIntegrationStatus> {
const response = await fetch(
`${getBackendBaseURL()}/api/integrations/lark/status`,
);
if (!response.ok) {
throw new LarkIntegrationRequestError(
response.status,
await readErrorDetail(response),
);
}
return response.json();
}
export async function installLarkIntegration(): Promise<LarkInstallResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/integrations/lark/install`,
{
method: "POST",
},
);
if (!response.ok) {
throw new LarkIntegrationRequestError(
response.status,
await readErrorDetail(response),
);
}
return response.json();
}
export async function startLarkAuthorization(
request: LarkAuthStartRequest = {},
): Promise<LarkAuthStartResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/integrations/lark/auth/start`,
{
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 startLarkConfiguration(
request: LarkConfigStartRequest = {},
): Promise<LarkConfigStartResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/integrations/lark/config/start`,
{
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 completeLarkConfiguration(
request: LarkConfigCompleteRequest,
): Promise<LarkConfigCompleteResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/integrations/lark/config/complete`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
throw new LarkIntegrationRequestError(
response.status,
await readErrorDetail(response),
);
}
return response.json();
}
export async function completeLarkAuthorization(
request: LarkAuthCompleteRequest,
): Promise<LarkAuthCompleteResponse> {
const response = await fetch(
`${getBackendBaseURL()}/api/integrations/lark/auth/complete`,
{
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();
}

View File

@ -0,0 +1,68 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
completeLarkAuthorization,
completeLarkConfiguration,
installLarkIntegration,
loadLarkIntegrationStatus,
startLarkAuthorization,
startLarkConfiguration,
} from "./api";
export const larkIntegrationQueryKey = ["integrations", "lark"] as const;
export function useLarkIntegrationStatus() {
return useQuery({
queryKey: larkIntegrationQueryKey,
queryFn: loadLarkIntegrationStatus,
});
}
export function useInstallLarkIntegration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: installLarkIntegration,
onSuccess: async (result) => {
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
await queryClient.invalidateQueries({
queryKey: larkIntegrationQueryKey,
});
await queryClient.invalidateQueries({ queryKey: ["skills"] });
},
});
}
export function useStartLarkAuthorization() {
return useMutation({
mutationFn: startLarkAuthorization,
});
}
export function useStartLarkConfiguration() {
return useMutation({
mutationFn: startLarkConfiguration,
});
}
export function useCompleteLarkConfiguration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: completeLarkConfiguration,
onSuccess: async (result) => {
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
await queryClient.invalidateQueries({
queryKey: larkIntegrationQueryKey,
});
},
});
}
export function useCompleteLarkAuthorization() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: completeLarkAuthorization,
onSuccess: (result) => {
queryClient.setQueryData(larkIntegrationQueryKey, result.status);
},
});
}

View File

@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";

View File

@ -0,0 +1,102 @@
export interface LarkCliProbe {
available: boolean;
path: string | null;
version: string | null;
error: string | null;
}
export interface LarkAuthProbe {
status:
| "authenticated"
| "not_authorized"
| "not_configured"
| "unavailable"
| "error";
message: string | null;
user: string | null;
verified: boolean;
}
export type LarkSandboxRuntimeMode =
| "none"
| "gateway-download"
| "init-container";
export interface LarkIntegrationStatus {
installed: boolean;
version: string;
manifest_version: string | null;
latest_available_version: string | null;
runtime_version_mismatch: boolean;
app_configured: boolean;
app_id: string | null;
app_brand: string | null;
skills_expected: number;
skills_installed: number;
installed_skills: string[];
enabled_skills: string[];
install_path: string;
cli: LarkCliProbe;
auth: LarkAuthProbe;
sandbox_runtime_mode: LarkSandboxRuntimeMode;
sandbox_runtime_ready: boolean;
sandbox_runtime_detail: string | null;
}
export interface LarkInstallResponse {
success: boolean;
installed_skills: string[];
message: string;
status: LarkIntegrationStatus;
}
export interface LarkAuthStartRequest {
recommend?: boolean;
domains?: string[];
scope?: string | null;
}
export interface LarkAuthStartResponse {
verification_url: string;
device_code: string;
expires_in: number | null;
user_code: string | null;
hint: string | null;
}
export interface LarkConfigStartRequest {
brand?: "feishu" | "lark";
}
export interface LarkConfigStartResponse {
verification_url: string;
device_code: string;
expires_in: number | null;
interval: number | null;
user_code: string | null;
brand: "feishu" | "lark";
}
export interface LarkConfigCompleteRequest {
device_code: string;
brand: "feishu" | "lark";
interval: number | null;
expires_in: number | null;
}
export interface LarkConfigCompleteResponse {
success: boolean;
message: string;
status: LarkIntegrationStatus;
}
export interface LarkAuthCompleteRequest {
device_code: string;
wait_timeout_seconds?: number;
}
export interface LarkAuthCompleteResponse {
success: boolean;
message: string;
status: LarkIntegrationStatus;
}

View File

@ -0,0 +1,156 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
test.describe("Integrations settings", () => {
test("opens integrations settings from a query-string deep link", async ({
page,
}) => {
mockLangGraphAPI(page);
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();
});
test("keeps a single settings dialog across deep link and nav menu openings", async ({
page,
}) => {
mockLangGraphAPI(page);
// Deep link opens the shared dialog on Integrations.
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();
await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(1);
// Close the modal before using the sidebar. While the modal is open, the
// background is intentionally inert and Playwright should not be able to
// click sidebar controls there.
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(0);
// Opening again from the nav menu must still use the same shared host, not
// mount a second SettingsDialog instance.
const sidebar = page.locator("[data-sidebar='sidebar']");
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
await page.getByRole("menuitem", { name: "Settings" }).click();
// Exactly one Settings dialog is mounted/visible at any time.
await expect(page.getByRole("dialog", { name: "Settings" })).toHaveCount(1);
});
test("can install the Lark integration skill pack from settings", async ({
page,
}) => {
mockLangGraphAPI(page);
let authStartRequest: unknown;
const authCompleteRequests: unknown[] = [];
await page.route(
"**/api/integrations/lark/auth/complete",
async (route) => {
authCompleteRequests.push(route.request().postDataJSON());
await route.fallback();
},
);
await page.route("**/api/integrations/lark/config/start", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
verification_url: "about:blank",
device_code: "mock-config-device-code",
expires_in: 600,
interval: 5,
user_code: "config",
brand: "feishu",
}),
});
});
await page.route("**/api/integrations/lark/auth/start", async (route) => {
authStartRequest = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
verification_url: "https://open.feishu.cn/auth/mock-device",
device_code: "mock-device-code",
expires_in: 600,
user_code: null,
hint: null,
}),
});
});
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
await page.getByRole("menuitem", { name: "Settings" }).click();
const dialog = page.getByRole("dialog", { name: "Settings" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Integrations" }).click();
await expect(dialog.getByText("Lark / Feishu CLI")).toBeVisible();
await expect(
dialog.getByText("Install the official skill pack first"),
).toBeVisible();
await dialog.getByRole("button", { name: "Install" }).click();
await expect(
page.getByText("Installed 3 Lark/Feishu skills."),
).toBeVisible();
// Sandbox-runtime readiness row surfaces once the init-container runtime is
// reported ready, so a green UI can't hide a chat-time command-not-found.
await expect(dialog.getByText("Sandbox runtime")).toBeVisible();
await expect(
dialog.getByText("Provisioned by init container"),
).toBeVisible();
await dialog.getByRole("button", { name: "Calendar" }).click();
await dialog
.getByLabel("Exact OAuth scope")
.fill("calendar:calendar.event:read");
await dialog.getByRole("button", { name: "Connect Lark" }).click();
await expect(dialog.getByText("about:blank")).toBeVisible();
await expect(dialog.getByText(/app configuration/i)).toHaveCount(0);
await dialog
.getByRole("button", {
name: "I completed browser confirmation, continue",
})
.click();
await expect
.poll(() => authStartRequest)
.toMatchObject({
recommend: false,
domains: ["calendar"],
scope: "calendar:calendar.event:read",
});
await expect
.poll(() => authCompleteRequests)
.toContainEqual({
device_code: "mock-device-code",
wait_timeout_seconds: 8,
});
await expect(
dialog.getByText("Lark authorization is live-verified"),
).toBeVisible();
await expect(
page.getByText("Authorization page opened. Waiting for completion..."),
).toHaveCount(0);
await dialog.getByRole("button", { name: "Calendar" }).click();
await dialog.getByLabel("Exact OAuth scope").fill("");
await dialog.getByRole("button", { name: "Reconnect Lark" }).click();
await expect(
dialog.getByText("https://open.feishu.cn/auth/mock-device"),
).toBeVisible();
});
});

View File

@ -258,6 +258,42 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
max_file_size: 50 * 1024 * 1024,
max_total_size: 100 * 1024 * 1024,
};
let larkIntegrationStatus = {
installed: false,
version: "v1.0.65",
manifest_version: null as string | null,
latest_available_version: "v1.0.65" as string | null,
runtime_version_mismatch: false,
app_configured: false,
app_id: null as string | null,
app_brand: null as string | null,
skills_expected: 27,
skills_installed: 0,
installed_skills: [] as string[],
enabled_skills: [] as string[],
install_path: "/tmp/deer-flow/integrations/skills/lark-cli",
cli: {
available: false,
path: null as string | null,
version: null as string | null,
error: "lark-cli is not on PATH" as string | null,
},
auth: {
status: "unavailable",
message: "lark-cli is not installed on the Gateway" as string | null,
user: null as string | null,
verified: false,
},
sandbox_runtime_mode: "init-container" as
| "none"
| "gateway-download"
| "init-container",
sandbox_runtime_ready: false,
sandbox_runtime_detail:
"The provisioner has no lark-cli init image configured (LARK_CLI_INIT_IMAGE)." as
| string
| null,
};
const featureFlags = {
agentsApiEnabled: options?.features?.agentsApiEnabled ?? true,
browserControlEnabled: options?.features?.browserControlEnabled ?? true,
@ -1066,6 +1102,149 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
void page.route("**/api/integrations/lark/status", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(larkIntegrationStatus),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/install", (route) => {
if (route.request().method() === "POST") {
larkIntegrationStatus = {
installed: true,
version: "v1.0.65",
manifest_version: "v1.0.65",
latest_available_version: "v1.0.65",
runtime_version_mismatch: false,
app_configured: false,
app_id: null,
app_brand: null,
skills_expected: 27,
skills_installed: 3,
installed_skills: ["lark-doc", "lark-im", "lark-shared"],
enabled_skills: ["lark-doc", "lark-im", "lark-shared"],
install_path: "/tmp/deer-flow/integrations/skills/lark-cli",
cli: {
available: true,
path: "/usr/bin/lark-cli",
version: "lark-cli version v1.0.65",
error: null,
},
auth: {
status: "not_configured",
message: "lark-cli auth is not configured",
user: null,
verified: false,
},
sandbox_runtime_mode: "init-container",
sandbox_runtime_ready: true,
sandbox_runtime_detail: null,
};
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
installed_skills: ["lark-doc", "lark-im", "lark-shared"],
message: "Installed 3 Lark/Feishu skills.",
status: larkIntegrationStatus,
}),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/config/start", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
verification_url: "https://open.feishu.cn/page/cli?user_code=config",
device_code: "mock-config-device-code",
expires_in: 600,
interval: 5,
user_code: "config",
brand: "feishu",
}),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/config/complete", (route) => {
if (route.request().method() === "POST") {
larkIntegrationStatus = {
...larkIntegrationStatus,
app_configured: true,
app_id: "cli_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 connection setup completed.",
status: larkIntegrationStatus,
}),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/auth/start", (route) => {
if (route.request().method() === "POST") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
verification_url: "https://open.feishu.cn/auth/mock-device",
device_code: "mock-device-code",
expires_in: 600,
user_code: null,
hint: null,
}),
});
}
return route.fallback();
});
void page.route("**/api/integrations/lark/auth/complete", (route) => {
if (route.request().method() === "POST") {
larkIntegrationStatus = {
...larkIntegrationStatus,
auth: {
status: "authenticated",
message: "Lark/Feishu authorization is live-verified.",
user: "Alice",
verified: true,
},
};
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Lark/Feishu authorization completed.",
status: larkIntegrationStatus,
}),
});
}
return route.fallback();
});
// Follow-up suggestions — input box auto-suggest after AI response
void page.route("**/api/threads/*/suggestions", (route) => {
if (route.request().method() === "POST") {

View File

@ -0,0 +1,57 @@
import { afterEach, expect, test } from "@rstest/core";
import {
getSettingsDialogSnapshot,
openSettingsDialog,
setSettingsDialogOpen,
subscribeSettingsDialog,
} from "@/components/workspace/settings/settings-dialog-store";
afterEach(() => {
// Reset shared module state between tests.
setSettingsDialogOpen(false);
});
test("starts closed on the default section", () => {
expect(getSettingsDialogSnapshot()).toEqual({
open: false,
section: "appearance",
});
});
test("openSettingsDialog opens on the requested section", () => {
openSettingsDialog("integrations");
expect(getSettingsDialogSnapshot()).toEqual({
open: true,
section: "integrations",
});
});
test("setSettingsDialogOpen(false) keeps the last section", () => {
openSettingsDialog("channels");
setSettingsDialogOpen(false);
expect(getSettingsDialogSnapshot()).toEqual({
open: false,
section: "channels",
});
});
test("notifies subscribers only on real state changes", () => {
let notifications = 0;
const unsubscribe = subscribeSettingsDialog(() => {
notifications += 1;
});
openSettingsDialog("integrations");
// Opening again on the same section is a no-op and must not re-notify.
openSettingsDialog("integrations");
expect(notifications).toBe(1);
openSettingsDialog("memory");
expect(notifications).toBe(2);
unsubscribe();
openSettingsDialog("about");
// No further notifications after unsubscribe.
expect(notifications).toBe(2);
});

View File

@ -0,0 +1,309 @@
import { beforeEach, describe, expect, rs, test } from "@rstest/core";
rs.mock("@/core/api/fetcher", () => ({
fetch: rs.fn(),
}));
rs.mock("@/core/config", () => ({
getBackendBaseURL: () => "/backend",
}));
import { fetch as fetcher } from "@/core/api/fetcher";
import {
completeLarkAuthorization,
completeLarkConfiguration,
installLarkIntegration,
LarkIntegrationRequestError,
loadLarkIntegrationStatus,
startLarkAuthorization,
startLarkConfiguration,
} from "@/core/integrations/lark/api";
const mockedFetch = rs.mocked(fetcher);
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
statusText: status >= 400 ? "Bad Request" : "OK",
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
mockedFetch.mockReset();
});
describe("lark integration api", () => {
test("loads status", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
installed: false,
version: "v1.0.65",
manifest_version: null,
latest_available_version: "v1.0.65",
runtime_version_mismatch: false,
app_configured: false,
app_id: null,
app_brand: null,
skills_expected: 27,
skills_installed: 0,
installed_skills: [],
enabled_skills: [],
install_path: "/tmp/lark-cli",
cli: { available: false, path: null, version: null, error: "missing" },
auth: { status: "unavailable", message: "missing", user: null },
sandbox_runtime_mode: "init-container",
sandbox_runtime_ready: false,
sandbox_runtime_detail: "init image not configured",
}),
);
await expect(loadLarkIntegrationStatus()).resolves.toMatchObject({
installed: false,
version: "v1.0.65",
sandbox_runtime_mode: "init-container",
sandbox_runtime_ready: false,
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/integrations/lark/status",
);
});
test("installs integration", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
success: true,
installed_skills: ["lark-doc"],
message: "Installed 1 Lark/Feishu skills.",
status: {
installed: true,
version: "v1.0.65",
manifest_version: "v1.0.65",
latest_available_version: "v1.0.65",
runtime_version_mismatch: false,
app_configured: false,
app_id: null,
app_brand: null,
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_configured",
message: "not configured",
user: null,
},
},
}),
);
await expect(installLarkIntegration()).resolves.toMatchObject({
success: true,
installed_skills: ["lark-doc"],
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/integrations/lark/install",
{ method: "POST" },
);
});
test("surfaces admin-required install errors", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(403, { detail: "Admin privileges required." }),
);
const promise = installLarkIntegration();
await expect(promise).rejects.toMatchObject({
name: "LarkIntegrationRequestError",
status: 403,
isAdminRequired: true,
message: "Admin privileges required.",
});
await expect(promise).rejects.toBeInstanceOf(LarkIntegrationRequestError);
});
test("starts browser authorization", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
verification_url: "https://open.feishu.cn/auth/mock",
device_code: "device-code",
expires_in: 600,
user_code: null,
hint: null,
}),
);
await expect(
startLarkAuthorization({
recommend: true,
domains: ["calendar"],
scope: "calendar:calendar.event:read",
}),
).resolves.toEqual({
verification_url: "https://open.feishu.cn/auth/mock",
device_code: "device-code",
expires_in: 600,
user_code: null,
hint: null,
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/integrations/lark/auth/start",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommend: true,
domains: ["calendar"],
scope: "calendar:calendar.event:read",
}),
},
);
});
test("starts connection setup", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
verification_url: "https://open.feishu.cn/page/cli?user_code=config",
device_code: "config-device-code",
expires_in: 600,
interval: 5,
user_code: "config",
brand: "feishu",
}),
);
await expect(startLarkConfiguration({ brand: "feishu" })).resolves.toEqual({
verification_url: "https://open.feishu.cn/page/cli?user_code=config",
device_code: "config-device-code",
expires_in: 600,
interval: 5,
user_code: "config",
brand: "feishu",
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/integrations/lark/config/start",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand: "feishu" }),
},
);
});
test("completes connection setup", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
success: true,
message: "Lark/Feishu connection setup completed.",
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_mock",
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(
completeLarkConfiguration({
device_code: "config-device-code",
brand: "feishu",
interval: 5,
expires_in: 600,
}),
).resolves.toMatchObject({
success: true,
status: { app_configured: true, app_id: "cli_mock" },
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/integrations/lark/config/complete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_code: "config-device-code",
brand: "feishu",
interval: 5,
expires_in: 600,
}),
},
);
});
test("completes browser authorization", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
success: true,
message: "Lark/Feishu authorization completed.",
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_mock",
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: "authenticated",
message: "ok",
user: "Alice",
},
},
}),
);
await expect(
completeLarkAuthorization({ device_code: "device-code" }),
).resolves.toMatchObject({
success: true,
status: { auth: { status: "authenticated", user: "Alice" } },
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/integrations/lark/auth/complete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code: "device-code" }),
},
);
});
});