Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,9 +402,9 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi

`src/host/connectors.js` mounts one MCP server per connector through DSH's own MCP client, so a Bot's tools are `mcp__github__*` and `mcp__composio__*` **for the owner's own use of every Bot, and for other people's use of a Bot only when the owner allowed it** (per-Bot access, below). Composio is a gateway: one API key (`COMPOSIO_API_KEY`) and a list of apps the user picked; the host asks Composio's tool-router for a session limited to those apps (workbench off) and mounts the session's MCP address. The session is made once per connect or app-list change and kept as `state.connectors.composio.session` (`{ id, apps }`: never the key and, since #335, never the session's address): the key check, the mount and Try again reuse it while the apps are the same and `GET /tool_router/session/{id}` (`x-api-key`) still finds it, else a new one is made. The mount's address is the `mcp.url` that answer carries (Composio's reference lists it as required in both the create and the get answer), taken only when it is https on the API's own host and held in memory for that mount; a session whose answer names no usable address is treated as gone. Why the address is not stored (#335): Composio's docs show the session address always used together with the key header and say to treat the exported address-and-headers config as a secret, but they do not say the address alone cannot authenticate, so it is not kept in `state.json` (backups, exports and anything that reads the profile) and is rebuilt from the id and the key at each mount. A record an older version wrote with `url` is rewritten without it at start (`scrubAddresses`), and the in-memory address is also taken out of a connector error (`[session address]`) before it is shown or logged. A superseded session (the old one after Choose apps or a new key, the new one when saving it failed, the kept one on Disconnect) is deleted with `DELETE /tool_router/session/{id}`, using the key it was made with; the delete is best effort and never an error for the user, and stopping the host leaves the session for the next start. Composio asks for each app's sign-in the first time a Bot needs it, in the chat. A stable `flybot-<uuid>` user id kept in `state.connectors.composio` (kept across Disconnect) is what Composio ties signed-in apps to. Rules: the key is a secret kept for all of the owner's Bots like any key the owner saves (#545; until then a connector saved it with scope `[]`, no Bot's shell), sent only as the `x-api-key` MCP header and to the API address, never written to `state.json`, a profile or an error (errors go through `redactSecrets`); the API address must be https, the answer's MCP address must be https on the API's own host, and the session request does not follow redirects; the key is proved by creating the session before it is saved; `connector-apps` changes the app list without the key and keeps the old list when Composio refuses the new one; `connector-app-search { id, query, token? }` searches Composio's app list (`GET /toolkits?search=&sort_by=usage&limit=20`, `x-api-key`, no redirects) with the pasted `token` when given (not saved) or the saved key, and returns only `{ slug, name }` with slugs a session can take; `connector-app-status { id }` reads the kept session's apps (`GET /tool_router/session/{id}/toolkits?toolkits=…`) and returns `{ [slug]: 'ready' | 'sign-in' }`, ready when the app needs no sign-in or has an `ACTIVE` connected account (#359); `connector-*` writes are `SHARED_BLOCKED`; `github` and `composio` are reserved capability names; `composio: false` hides the connector. Which Composio tools run without asking is DSH's normal tool approval, not a FlyBot gate (a per-app approval is a later step).

Feishu (Lark) is the first **sender** connector (#1263): a group's custom-bot webhook is one HTTPS POST into the instance's own group, so nothing is mounted, no Bot's tool list changes, and `connector-access.js`'s `CONNECTOR_IDS` does not list it. Its definition carries `kind: 'sender'`, which the view reports and the client reads instead of hard-coding an id. A sender stops at the status: a saved webhook reads `ready` without posting, because the test POST belongs to the **connect** step, so a restart never messages the group; `connector-retry` ("Try again") is the one path that proves it again. The webhook is `FEISHU_WEBHOOK`, saved through `secret-set` **before** the probe — so a refusal stays on the row as `error` with the reason and `redactSecrets` can mask it, the way a failed mount does for GitHub — and it must be https on the configured `feishu.api` origin, else `A Feishu webhook must be https on <origin>`: the URL is the credential, so it may never be posted to another host and never appears in `state.json`, the view or a warn line. `connector-send { id, text }` posts one message on demand and records `lastOkAt` / `lastError` in `state.connectors.feishu`; nothing posts room traffic by itself. Feishu answers HTTP 200 with the verdict in the body (`code: 0` success, `9499` a rate limit, `19021` a refused signature), so `feishuPost` maps the body into notify.js's vocabulary and the retry policy stays `notify.js`'s `createNotifier` (3 attempts, backoff, honour `retry-after`, structural errors not retried) rather than a second one written here; `connector-send` uses a fresh idempotency key per call, so two messages with the same text are two messages. The connect probe and a send both carry a deadline (15 s and 60 s) and `withDeadline` races the fetch, so a fetch that ignores its abort signal cannot hang either. `feishu: false` hides the row; `connector-send` is in `SHARED_BLOCKED` like the other connector writes.
Feishu (Lark) started as the first **sender** connector (#1263), and its webhook mode still is one: a group's custom-bot webhook is one HTTPS POST into the instance's own group, so nothing is mounted, no Bot's tool list changes, and `connector-access.js`'s `CONNECTOR_IDS` does not list it. The definition is a channel today, not a sender (#1266, below): the view reports the kind and the client reads it instead of hard-coding an id, and the channel kind is what makes the row offer Choose Bots and `reconcile()` open the socket, while webhook mode is the same row with nothing to dial. A webhook stops at the status: a saved webhook reads `ready` without posting, because the test POST belongs to the **connect** step, so a restart never messages the group; `connector-retry` ("Try again") is the one path that proves it again. The webhook is `FEISHU_WEBHOOK`, saved through `secret-set` **before** the probe — so a refusal stays on the row as `error` with the reason and `redactSecrets` can mask it, the way a failed mount does for GitHub — and it must be https on the configured `feishu.api` origin, else `A Feishu webhook must be https on <origin>`: the URL is the credential, so it may never be posted to another host and never appears in `state.json`, the view or a warn line. `connector-send { id, text }` posts one message on demand and records `lastOkAt` / `lastError` in `state.connectors.feishu`; nothing posts room traffic by itself. Feishu answers HTTP 200 with the verdict in the body (`code: 0` success, `9499` a rate limit, `19021` a refused signature), so `feishuPost` maps the body into notify.js's vocabulary and the retry policy stays `notify.js`'s `createNotifier` (3 attempts, backoff, honour `retry-after`, structural errors not retried) rather than a second one written here; `connector-send` uses a fresh idempotency key per call, so two messages with the same text are two messages. The connect probe and a send both carry a deadline (15 s and 60 s) and `withDeadline` races the fetch, so a fetch that ignores its abort signal cannot hang either. `feishu: false` hides the row; `connector-send` is in `SHARED_BLOCKED` like the other connector writes.

The same row carries a Feishu message coming **in** (#1266): `inbound` reports `off|connecting|ready|error`, the last event, the last error and whether the app pair is `set` or `missing` — never a value. `src/host/feishu-inbound.js` owns the policy: off unless `feishu.enabled` is true (an off connector dials nothing), the owner is the only sender that may start a turn (an unset or empty `feishu.ownerOpenId` refuses everyone, and an empty `open_id` never compares equal), a bounded ring of 512 `event_id`s makes a replay land once — the id enters it only after `deliver` has returned, so a handover that threw is retried instead of being refused as a duplicate for ever — and one socket per connector shares a start. The socket itself is injected as `rt.feishuTransport` (`connect(onEvent) -> { close }`) so the client behind it is one adapter, and it is read again on every start rather than copied while the modules install — an adapter that arrives later comes up on the row's **Try again**, which retries the sender and the socket together; a stop that lands while a socket dials hangs up the one that arrives instead of adopting it. `src/host/feishu-sdk.js` is the default adapter over the official `@larksuiteoapi/node-sdk`, imported only when a socket is dialled and reading the app pair at that moment, so a host that never turns the long connection on pays nothing for the package; the message lands through `remote-chat.deliver`, the seam a friend's Bot already uses, and the answer leaves again through the sender this module registers in that seam under the `feishu` plugin, posting on the same `FEISHU_WEBHOOK` `feishuPost` has used since phase 1 (a chat an older build opened without the plugin is refreshed on its next message) — one path in, one path out. `FEISHU_APP_ID` + `FEISHU_APP_SECRET` are read as one credential through the same `rt.secretValue`, and a half-saved pair reports `missing` and dials nothing.
The same row carries a Feishu message coming **in** (#1266): `inbound` reports `off|connecting|ready|error`, the last event, the last error and whether the app pair is `set` or `missing` — never a value. `src/host/feishu-inbound.js` owns the policy: off unless the row has app mode connected with both halves saved (`mode: 'app'` on the record and an app pair `appOf()` can assemble, below — no config switch of its own), so an off connector dials nothing. The owner is the only sender that may start a turn: `feishu.ownerOpenId` from config is one half of that gate and the record's `allowed` guest list (below) the other, and an empty `open_id` never compares equal to an unset owner. A bounded ring of 512 `event_id`s makes a replay land once — the id enters it only after `deliver` has returned, so a handover that threw is retried instead of being refused as a duplicate for ever — and one socket per connector shares a start. The socket itself is injected as `rt.feishuTransport` (`connect(onEvent) -> { close }`) so the client behind it is one adapter, and it is read again on every start rather than copied while the modules install — an adapter that arrives later comes up on the row's **Try again**, which retries the sender and the socket together; a stop that lands while a socket dials hangs up the one that arrives instead of adopting it. `src/host/feishu-sdk.js` is the default adapter over the official `@larksuiteoapi/node-sdk`, imported only when a socket is dialled and reading the app pair at that moment, so a host that never turns the long connection on pays nothing for the package; the message lands through `remote-chat.deliver`, the seam a friend's Bot already uses, and the answer leaves again through the sender this module registers in that seam under the `feishu` plugin, posting on the same `FEISHU_WEBHOOK` `feishuPost` has used since phase 1 (a chat an older build opened without the plugin is refreshed on its next message) — one path in, one path out. `FEISHU_APP_SECRET` is the only half read as a secret through `rt.secretValue`, the App ID travels in the connector record (below), and a half-saved pair reports `missing` and dials nothing.

The connector row is where Feishu is configured (#1266, owner's word 「飞书是做成一个连接器,左下角点击连接应用的时候可以连接」), so the definition is no longer one secret and one mode: `feishu` is `kind: 'channel'`, declares `credentials` (`FEISHU_MODES` — a custom-bot **webhook** with one masked URL, outbound only as phase 1, or a self-built **app** with App ID + App Secret) instead of the singular `secret`, and `reconcile()` branches on `kind` — a channel is a socket whose lifecycle is the row's status (`off|connecting|ready|error`), while every other connector is still an MCP client. App mode proves the pair with one `POST /open-apis/auth/v3/tenant_access_token/internal` that is then discarded (the socket dials with its own credentials, so the token never reaches a row, a log line or an error), keeps `app_id` in the record because it is not a secret, and puts `app_secret` only in `secrets.json`; a half-saved pair is `missing` and dials nothing. The record also carries the guest list the inbound gate reads — `allowed: [{ openId, name? }]`, nobody by default, changed only through `connector-allow` / `connector-block` (both `SHARED_BLOCKED` like the rest of the `connector-*` writes, both routed in `channel.js`), so a stranger's message starts no turn and appears on the row as one refused sender. `Disconnect` closes the socket and clears both credentials. `feishu` joins `github` and `composio` in `RESERVED_NAMES`, so a saved capability cannot take the row's id.

Expand Down
Loading