Skip to content
Closed
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions changelog.d/587-list-bots-approval-tag.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`list_bots` tags a Bot whose outbound message waits on the owner's decision with a count (`Writer (Write, 1 to approve)`), so a coordinator sees who is blocked instead of an idle-looking roster. The tag is a count only — never the held message's text — and clears once the owner decides ([#587](https://github.com/ThinkFlowLab/FlyBot/issues/587)).
10 changes: 9 additions & 1 deletion core/src/host/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,17 @@ export function install(rt) {
if (subtreeOnly && !inSubtree(self, bot.id)) return true
return false
}
// A held send waits on the owner, not on the Bot: a coordinator reading the
// roster sees who is blocked on a decision — a count only, never the held
// message's text (#587).
const heldSends = {}
for (const approval of Object.values(state.flybot?.approvals ?? {})) {
if (approval.status !== 'pending' || typeof approval.request?.from !== 'string') continue
heldSends[approval.request.from] = (heldSends[approval.request.from] ?? 0) + 1
}
const lines = Object.values(state.bots).filter(listed).map(bot => {
const model = bot.appliedModel ?? bot.model
const tags = [bot.role, model ? `model ${model}` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
const tags = [bot.role, model ? `model ${model}` : '', heldSends[bot.id] ? `${heldSends[bot.id]} to approve` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : ''
return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}`
})
Expand Down
17 changes: 17 additions & 0 deletions core/test/flybot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,23 @@ test('a hold middleware queues the send until the owner approves it in the UI',
await assert.rejects(fb.approvals.decide(second.id, 'approve'), /already rejected/)
})

test('#587: list_bots marks a Bot whose send waits on the owner with a count, never the text', async (t) => {
const { fb, tool, received, stop } = await start()
t.after(stop)
fb.bot.send.use({ name: 'hold-all', handle: () => ({ action: 'hold', reason: 'check first' }) })
const out = await tool('message_bot', { to: 'Coder', message: 'secret plan' }, 'b')
assert.match(out, /^Held for the user's approval/)
assert.deepEqual(received('c'), [], 'the send stays undelivered while it waits')
const listed = await tool('list_bots', {}, 'a')
assert.match(listed, /- Writer \(Write, 1 to approve\)/, 'a coordinator sees the waiting Bot in the roster')
assert.ok(!listed.includes('secret plan'), 'the tag is a count only, never the held message text')
const [pending] = fb.approvals.list()
await fb.approvals.decide(pending.id, 'approve')
assert.match(received('c')[0], /secret plan/, 'an approved hold dispatches')
const after = await tool('list_bots', {}, 'a')
assert.ok(!after.includes('to approve'), 'a decided hold leaves no tag')
})

test('middleware priority orders the chain without naming other plugins; before/after still win', async () => {
const m = (name, extra = {}) => ({ name, ...extra })
const names = list => orderMiddleware(list).map(x => x.name)
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ external backend or task-specific session mapping; see the
### Team state and access

- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \<inviter\>**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list.
- `list_bots` (`src/host/tools.js`) tags a Bot with `N to approve` while one of its outbound sends is held for the owner's decision (#587): a count of that Bot's pending held sends (`state.flybot.approvals`), never the held message's text, so a coordinator reading the roster sees who is blocked and the tag clears once the owner decides. Covered by the roster case in `core/test/flybot.test.js`.
- The Main Bot list is deployment-wide (#613): `set-main` requires the actor to own `body.id` and, when `body.replace` names the Main Bot whose place the target takes, that Bot too, so a member cannot swap another owner's Main Bot for their own or demote it (both are `bot/forbidden`); `roster.setMain` keeps the same invariant for every caller, refusing a `replace` across owners and leaving `state.mainBotIds` unchanged. A swap inside one owner's team, the plain promotion of a Bot the caller owns, and the deployment-wide list itself are unchanged.

- Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in actor-authored `send` / `room-post` / `answer-question` / `schedule-create` text must resolve for the author (`sessionOwnedBy`; `schedule-create` authz keys off `body.target`). Bot-authored group posts and Bot-to-Bot / own-chat followups drop chips so they cannot tunnel a private chat into another Session. Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled.
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots
A tool whose `visibleTo` says no is taken out of that Bot's list with `agent.ctx.tools.restrict({ deny })` (`hidePluginTools`). DSH's `tools.schemas(agent)` lists only what the Agent sees, so the tools the current mask hides count as known when the mask is recomputed; otherwise every other resolve lifts the mask and the chat shows `Tool added` / `Tool removed` on each turn (#276). The mask is recomputed when the Agent is created, on every `resolveAgent` (delivery), and when a turn starts in the Session (a `session/event` `turn/start`), so a Bot a plugin adopts only after its Agent was created — the tree taking a Main Bot made after the server started — gets its tools on the next turn, with no restart (#594). The names of these gated tools go to the client as the roster's `pluginTools` (`rt.pluginToolNames`), and the client's `ContextCell` hides `Tool added` / `Tool removed` rows for them in every chat, so the first mask leaves no line either (#419).
5. Reach (#90): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read grants through `flybot.bot.hasGrant` / `hasAnyGrant`, `BotView.granted`, `flybot.grants` and the `bot.grants` event. A group's mention receipt (`mentionReceipt`) turns a not-shared refusal (`cannot message their Bots`, #273) into its short invite-or-add hint.
6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode.
An `allow` verdict may say `autoSent: true` (with `recipient`) when it skips a hold only because the owner turned Auto-send on; the host then marks an auto-reply's turn Auto-sent (#417). It confers nothing. A `hold` verdict may carry `recipient`, who gets the message as the owner knows them (a person's name, trimmed, at most 120 characters); the record keeps it as `recipientName` and the cards name it (#418). A `flybot.approvals` entry with `inline: true` and a `policy` is not an overlay card: it draws the decision on a held reply's own bubble in the chat (`held-reply.js`, props `{ held, decide, call, actions }`), and the overlay skips it. Approval claims (`claimApprovals`, `chatOpened`) live in `approval-claims.js` and are re-exported by `flybot-slots.js`.
An `allow` verdict may say `autoSent: true` (with `recipient`) when it skips a hold only because the owner turned Auto-send on; the host then marks an auto-reply's turn Auto-sent (#417). It confers nothing. A `hold` verdict may carry `recipient`, who gets the message as the owner knows them (a person's name, trimmed, at most 120 characters); the record keeps it as `recipientName` and the cards name it (#418). A Bot's held sends are also visible to Bot coordination: `list_bots` tags the waiting Bot with `N to approve` (a count only, `src/host/tools.js`, #587). A `flybot.approvals` entry with `inline: true` and a `policy` is not an overlay card: it draws the decision on a held reply's own bubble in the chat (`held-reply.js`, props `{ held, decide, call, actions }`), and the overlay skips it. Approval claims (`claimApprovals`, `chatOpened`) live in `approval-claims.js` and are re-exported by `flybot-slots.js`.
7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels.
8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group.
9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. `upsertFace` also takes `lead` (`{ handle, picked, canPick, supported }`, #476) and a member `role` label.
Expand Down
Loading
Loading