diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be74c8ea..4bcc9b1b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to FlyBot (earlier: DS Bot) are listed here. The format foll ### Added +- **Published Bots (#51).** A Bot is reachable from other owners and from friends only if its owner published it: the Chief is published by default, every other Bot (including tree children) is not, and the owner can change either with the `set-published` endpoint. Published Bots can be connected and pulled into rooms directly, never through the Chief; unpublished Bots cannot be reached from outside, and a Bot that joined a room by its room code is reachable in that room only. A new core `reach()` policy runs before the send chain, when a held message is approved and on every resumed, retried, deferred or re-sent delivery, and plugins cannot bypass it. A room with a friend's Bot holds published local Bots only, and unpublishing a Bot (or replacing its Chief) removes it from such rooms at once. `@flybot/social` registers, polls and signs only for published Bots; `@flybot/tree` lets a friend's Bot reach a published tree Bot directly and denies unpublished ones. New core patch `0013-published-reach`; plugins read `flybot.bot.isPublished`, `BotView.published` and the `bot.published` event. ([#51](https://github.com/ThinkFlowLab/FlyBot/issues/51), builds on #82) - **Native rooms take human, Chief, and published friend Bots.** A room can list people (Alice, Bob), each side’s designated Chief, and a friend’s Bot that was published to friends (`social/remote-bots`). Remotes show as ` · 's Bot` (Chief: `'s Chief`) with an owner badge. The owner of an invited Bot sees **Shared room from \**, only lines from that Bot’s join seq, and can remove their Bot. Unpublished specialists stay private (host-enforced). Replies still go through Bot Social’s outbound hold. Team mode stays disabled. ([#50](https://github.com/ThinkFlowLab/FlyBot/issues/50)) - **Friends' Bots pending approvals:** outbound drafts Bot Social holds (`pending_approval`) show a persistent badge and Approve / Reject in Friends' Bots. Inbound messages still arrive immediately; Auto-accept still skips the hold. (#69) - Plugins can write to the host diagnostics log with `flybot.warn` (Settings → Feedback). `@flybot/social` uses this when start fails. ([#68](https://github.com/ThinkFlowLab/FlyBot/issues/68)) diff --git a/README.md b/README.md index 61d362cc8..7de2183aa 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Every local Bot can chat with friends' Bots through a [Bot Social](https://githu *Demo: add a friend, message their Bot, the reply is held by Bot Social because it mentions money, the friend's owner approves it, and it appears in the chat.* [Watch the full video (MP4)](docs/images/demo-social.mp4) - **One-click onboarding.** On first start with a `server` configured, FlyBot calls `POST /v1/onboard/flybot`, creates your home (`@.home`), and stores credentials in `$DSH_HOME/bot/flybot-social.json` (mode 600). -- **Members.** Each local Bot becomes `@.` on the server. New Bots join automatically; deleted ones are revoked. +- **Members.** Each *published* local Bot becomes `@.` on the server. Your Chief is published by default; any other Bot joins when you publish it, and deleted ones are revoked. Unpublished Bots have no outside identity: friends cannot reach them and they cannot message friends. - **Friends' Bots sidebar.** Friends appear under **Friends' Bots**, grouped by home, with a remote badge. Click one to open a normal chat. Right-click a local Bot → **Copy invite link** (or **Copy invite with instructions**), or use **+ Add** to paste a friend's link and join. - **Approvals.** Inbound messages from a friend arrive immediately. Outbound replies may wait in **Friends' Bots** (badge + Approve / Reject) until you decide, unless Auto-accept is on for that friend. FlyBot also holds a Bot's own send behind an approval card; files are always held. - **Tools.** Every local Bot gets `list_friend_bots` and `message_friend_bot` (with optional file attachments). @@ -118,7 +118,7 @@ Point FlyBot at that server in `~/.dsh/profiles/web/cordis.patch.yml`, then rest displayName: Your Name ``` -On first start FlyBot registers your home and every local Bot. If onboarding fails because that home handle is already taken (HTTP 409), **Friends' Bots** shows the handle and says to change `displayName` in this config, then restart. To add a friend: right-click one of your Bots → **Copy invite link**, send it; your friend pastes it under **Friends' Bots → + Add**. +On first start FlyBot registers your home and every published local Bot (the Chief by default). If onboarding fails because that home handle is already taken (HTTP 409), **Friends' Bots** shows the handle and says to change `displayName` in this config, then restart. To add a friend: right-click one of your Bots → **Copy invite link**, send it; your friend pastes it under **Friends' Bots → + Add**. A friend's inbound messages arrive immediately. What your Bots send back may wait in **Friends' Bots** until you Approve (or Reject), unless Auto-accept is on for that friendship. If a friend sees no reply, look for the **to approve** badge there. diff --git a/README.zh.md b/README.zh.md index 7b230fb06..5512da496 100644 --- a/README.zh.md +++ b/README.zh.md @@ -61,7 +61,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab *演示:添加好友、给对方的 Bot 发消息,回复因含金额被 Bot Social 暂扣,好友的主人批准后送达对话。* [观看完整视频(MP4)](docs/images/demo-social.mp4) - **一键入驻。** 配置好 `server` 后首次启动会调用 `POST /v1/onboard/flybot`,创建家(`@.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。 -- **成员。** 每个本地 Bot 在服务器上是 `@.`;新建自动加入,删除会撤销。 +- **成员。** 每个**已发布**的本地 Bot 在服务器上是 `@.`。Chief 默认已发布,其他 Bot 在你发布后加入,删除会撤销。未发布的 Bot 没有对外身份:朋友够不到它,它也不能给朋友发消息。 - **「好友的 Bot」侧栏。** 按家分组,带远端标记;点开就是普通对话。本地 Bot 右键 → **复制邀请链接**(或带说明的邀请),或用 **+ 添加** 粘贴好友的链接加入。 - **审批。** 好友发来的消息会立刻送达。你的 Bot 发出的回复可能停在 **「好友的 Bot」**(角标 + 批准 / 拒绝),除非对该好友开了自动接受。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。 - **工具。** 每个本地 Bot 都有 `list_friend_bots` 与 `message_friend_bot`(可带附件)。 @@ -113,7 +113,7 @@ export PORT=8787 displayName: 你的名字 ``` -首次启动会登记你家和每个本地 Bot。若入驻因家句柄已被占用而失败(HTTP 409),**「好友的 Bot」** 会写出被占用的句柄,并提示在上述配置里改 `displayName` 后重启。添加好友:右键某个 Bot → **复制邀请链接**,对方在 **「好友的 Bot」→ + 添加** 里粘贴。 +首次启动会登记你家和每个已发布的本地 Bot(默认是 Chief)。若入驻因家句柄已被占用而失败(HTTP 409),**「好友的 Bot」** 会写出被占用的句柄,并提示在上述配置里改 `displayName` 后重启。添加好友:右键某个 Bot → **复制邀请链接**,对方在 **「好友的 Bot」→ + 添加** 里粘贴。 好友发来的消息会立刻送达。你的 Bot 回过去的内容可能停在 **「好友的 Bot」** 里等你批准(或拒绝),除非对该好友开了自动接受。如果对方只看见自己的话,先看侧栏的 **待批准** 角标。 diff --git a/core/index.js b/core/index.js index 46cb19235..6f275b131 100644 --- a/core/index.js +++ b/core/index.js @@ -7,6 +7,7 @@ import * as store from './src/host/store.js' import * as memberAuth from './src/host/member-auth.js' import * as authz from './src/host/authz.js' import * as roomMembership from './src/host/room-membership.js' +import * as reach from './src/host/reach.js' import * as models from './src/host/models.js' import * as permissions from './src/host/permissions.js' import * as turns from './src/host/turns.js' @@ -45,7 +46,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] +const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 0d770e9b8..90b1968a0 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -13,7 +13,8 @@ // Install order matters: right after member-auth, so rt.authzEnforced sees the same // state.authSessions the sign-in surface writes, and before any module it gates. -import { currentParticipants, isDesignatedChief, roomVisibleTo } from './room-membership.js' +import { currentParticipants, roomVisibleTo } from './room-membership.js' +import { isPublished } from './reach.js' // Endpoints a request without an actor may still reach. auth-invite keeps the // bootstrap rule in member-auth (first invite signs the installing human in). @@ -37,7 +38,7 @@ const BOT_TARGETED = new Set([ 'memory-view', 'memory-ask', 'memory-forget', 'schedules', 'schedule-create', 'schedule-delete', 'add-agent', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', - 'stop-member-work', 'set-main', + 'stop-member-work', 'set-main', 'set-published', ]) // Endpoints whose body names a room. @@ -92,7 +93,8 @@ export function install(rt) { if (bot.ownerId === actor.memberId) return true const owners = new Set(humans) owners.add(actor.memberId) - return isDesignatedChief(state, botId) && owners.has(bot.ownerId) + // Another owner's Bot joins only if its owner published it (the Chief by default, #51). + return isPublished(state, botId) && owners.has(bot.ownerId) } // A sessionId may be a Bot's own chat, an earlier part, or a room Session: resolve diff --git a/core/src/host/channel.js b/core/src/host/channel.js index fd3a384ff..94a10e37e 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -38,7 +38,8 @@ export function install(rt) { // `sessionId` is the Bot's current own-chat part; `parts` its earlier ones. // `waiting`: the Bot holds its greeting until the user picks its model. bots: Object.values(state.bots).map(({ segments, kickoff, ...bot }) => ({ - ...bot, sessionId: rt.chatOf(bot.id), parts: (segments ?? []).map(segment => segment.sessionId), ...(kickoff ? { waiting: true } : {}), + // `published`: whether other owners and friends can reach it (#51), the effective value. + ...bot, published: rt.isPublished?.(bot.id) === true, sessionId: rt.chatOf(bot.id), parts: (segments ?? []).map(segment => segment.sessionId), ...(kickoff ? { waiting: true } : {}), })), // `parts`: the members' hidden group Sessions and their earlier segments, for usage. rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, ...room }) => { @@ -217,6 +218,11 @@ export function install(rt) { replace: typeof body.replace === 'string' ? body.replace : undefined, }) return ok(view()) + case 'set-published': + // The owner's choice (#51): true, false, or null for the default (the Chief only). + await rt.load() + await rt.setPublished(String(body.id ?? body.botId ?? ''), body.published === undefined ? true : body.published) + return ok(view(actor)) case 'create-room': { await rt.load() const room = await rt.createRoom({ diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 815869ce4..e6ccf6147 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -93,7 +93,7 @@ export function install(rt) { const emit = (name, payload) => { try { ctx.emit?.(name, payload) } catch (error) { warn('flybot: %s listener failed: %s', name, error) } } // ---------------------------------------------------------------- events - const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set() } + const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set(), 'bot.published': new Set() } const changes = [] let changeSeq = 0 // A new host process starts a new epoch: the browser resets its watermark on a change. @@ -111,7 +111,7 @@ export function install(rt) { // Cordis-style subscription, returns a disposer. 'change' listeners hear their own // plugin's fields unless they pass { all: true }; 'bot.removed' hears every removal. const onFor = plugin => (event, fn, { all = false } = {}) => { - if (!listeners[event]) throw new Error(`flybot: unknown event ${event}; use 'change', 'bot.created', or 'bot.removed'`) + if (!listeners[event]) throw new Error(`flybot: unknown event ${event}; use 'change', 'bot.created', 'bot.removed', or 'bot.published'`) const listener = event === 'change' && !all ? change => { if (change.plugin === plugin) fn(change) } : fn listeners[event].add(listener) return () => listeners[event].delete(listener) @@ -122,6 +122,7 @@ export function install(rt) { const team = () => Object.values(state.bots).filter(bot => bot.remote === undefined) const view = bot => (bot === undefined ? undefined : { id: bot.id, name: bot.name, role: bot.role, isMain: rt.isMain(bot.id), createdBy: bot.createdBy ?? null, + published: rt.isPublished?.(bot.id) === true, systemPrompt: bot.systemPrompt ?? '', fields: clone(bot.fields ?? {}), }) const persist = () => { void rt.save().catch(error => warn('flybot: save failed: %s', error)) } @@ -308,7 +309,10 @@ export function install(rt) { // then it is dispatched; a failed dispatch stays visible for Retry. const release = async (approval) => { let verdict - try { verdict = await evaluate(approval.request, { after: approval.by }) } catch (error) { verdict = { action: 'deny', reason: error?.message ?? String(error) } } + // Approved or not, the owner policy is asked again: the hold may be hours old. + const owner = ownerPolicy(approval.request) + if (owner.action === 'deny') verdict = owner + else try { verdict = await evaluate(approval.request, { after: approval.by }) } catch (error) { verdict = { action: 'deny', reason: error?.message ?? String(error) } } if (verdict.action === 'deny') { approval.status = 'denied' approval.error = verdict.reason @@ -371,7 +375,13 @@ export function install(rt) { outcomes.set(exchangeId, outcome) if (outcomes.size > 500) outcomes.delete(outcomes.keys().next().value) } + // The owner policy (reach.js) sits before the chain and is not a middleware: no verdict, bypass + // or priority of a plugin can skip it. It judges the message as it is now, so a Bot that was + // unpublished since it was written is refused. + const ownerPolicy = msg => rt.reach?.(msg) ?? { action: 'allow' } const run = async (msg) => { + const owner = ownerPolicy(msg) + if (owner.action === 'deny') return { action: 'deny', reason: owner.reason, by: owner.by } const verdict = await evaluate(msg) if (verdict.action === 'deny') return { action: 'deny', reason: verdict.reason, by: verdict.by } if (verdict.action === 'hold') { @@ -437,6 +447,18 @@ export function install(rt) { && !args.flybotChecked && !args.resent && !args.resumed && !args.retried && !args.deferred innerDeliver = rt.deliver rt.deliver = async (args) => { + // Everything that is not a first, checked send still carries its sender: resumed, retried, + // deferred and re-sent deliveries (and kickoffs, notices) are judged by the owner policy + // now, since the Bot they name may have been unpublished, replaced or removed since. + if (!gated(args) && !args.flybotChecked && (args.remote?.handle || args.fromId !== undefined)) { + const owner = ownerPolicy(args) + if (owner.action === 'deny') { + const exchangeId = args.exchangeId ?? randomUUID() + noteOutcome(exchangeId, { action: 'deny', reason: owner.reason, by: owner.by }) + warn('flybot: delivery to %s refused: %s', args.toId, owner.reason) + return exchangeId + } + } if (!gated(args)) return innerDeliver(args) const exchangeId = args.exchangeId ?? randomUUID() const msg = { from: args.fromId, to: args.toId, target: targetOf(args.toId), text: args.text, role: args.role, hop: args.hop ?? 0, via: 'ds-bot', exchangeId, deliverArgs: { ...args, exchangeId } } @@ -642,6 +664,8 @@ export function install(rt) { list: () => team().map(view), find: name => view(rt.findBot?.(name)), isRemote, + // Whether the Bot is reachable from another owner (the owner's choice; the Chief by default). + isPublished: id => rt.isPublished?.(id) === true, of: sessionId => view(rt.botOf(rt.selfOf(sessionId))), create: spec => create({ plugin, ...spec }), setSystemPrompt, @@ -688,5 +712,6 @@ export function install(rt) { // A local Bot's answer to a friend's Bot (turns.js): back through bot.send, so the // plugin's middleware still decides (auto-accept, or the approval hold). const replyRemote = ({ from, handle, home, text, replyTo }) => send({ from, to: { kind: 'remote', handle, ...(home ? { home } : {}) }, text, role: 'reply', via: 'auto-reply', meta: { replyTo } }) + rt.flybotPublishedChanged = botId => { const bot = rt.botOf(botId); if (bot !== undefined && bot.remote === undefined) fire('bot.published', view(bot)) } Object.assign(rt, { flybot, flybotReplyRemote: replyRemote, flybotEndpoint: endpoint, flybotRemoteMembers: remoteMembers.list, flybotSend: send, flybotIsRead: isReadEndpoint, flybotHideTools: hidePluginTools }) } diff --git a/core/src/host/reach.js b/core/src/host/reach.js new file mode 100644 index 000000000..d8dd3e03b --- /dev/null +++ b/core/src/host/reach.js @@ -0,0 +1,129 @@ +// Reach policy (#51): who may be reached from outside their owner. One owner- and +// publication-aware check that sits OUTSIDE the plugin send chain, so a middleware's +// `allow` or `bypass` verdict can never skip it. hooks.js runs it before bot.send's +// chain, again when an approved hold is released, and on every other delivery that +// carries a sender (resumed, retried, deferred, re-sent), so a queued message is judged +// by what is true when it is delivered, not when it was written. +// +// A Bot is reachable from another owner only if it is *published*: +// state.bots[id].published true | false, set by its owner (rt.setPublished) +// unset the owner's designated Chief is published, every other Bot is not +// A Bot added to one room by its room code (participant `viaCode: true`, #83) is +// reachable only for that room, and only while it is a current member. +// A Bot whose owner is not a team member (removed owner) is never published. +// +// The rules (from → to), `deny` is `{ action: 'deny', reason, by: 'owner-policy' }`: +// remote → local the local Bot must be published (a friend cannot reach anything else) +// local → remote the sending Bot must be published, or a code-joined member of the room +// the message belongs to (an unpublished Bot has no outside identity) +// local → local same owner: allowed. Other owner: the sender and the target must be +// published, or both current members of the room the message belongs to +// no sender the owner or the system: allowed + +import { currentParticipants, isDesignatedChief } from './room-membership.js' + +export const POLICY = 'owner-policy' + +const isRemoteRef = ref => (ref !== null && typeof ref === 'object' && ref.kind === 'remote') || (typeof ref === 'string' && ref.startsWith('@')) +const deny = reason => ({ action: 'deny', reason, by: POLICY }) +const ALLOW = Object.freeze({ action: 'allow' }) + +const ownerKnown = (state, bot) => Boolean(bot?.ownerId && state.members?.[bot.ownerId]) + +// The Bot's published state now: the owner's explicit choice, else the Chief default. +export function isPublished(state, botId) { + const bot = state.bots?.[botId] + if (bot === undefined || bot.remote !== undefined || !ownerKnown(state, bot)) return false + if (typeof bot.published === 'boolean') return bot.published + return isDesignatedChief(state, botId) +} + +// A Bot that joined this room with its room code: reachable only here, only while a member. +export function isCodeJoined(state, roomId, botId) { + const room = state.rooms?.[String(roomId ?? '')] + return room !== undefined && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote && p.id === botId && p.viaCode === true) +} + +// May this local Bot take part in a room that has a remote participant or more than one +// owner? Published Bots may; a code-joined Bot may in its own room only. +export function mayShareRoom(state, room, botId) { + return isPublished(state, botId) || isCodeJoined(state, room?.id, botId) +} + +const inRoom = (state, roomId, botId) => { + const room = state.rooms?.[String(roomId ?? '')] + return room !== undefined && (room.members ?? []).includes(botId) +} + +export function reach(state, { from, to, roomId } = {}) { + const fromRemote = isRemoteRef(from) + const toRemote = isRemoteRef(to) + if (fromRemote && toRemote) return deny('one side must be a local Bot') + if (fromRemote) { + const target = state.bots?.[to] + if (target === undefined) return ALLOW // unknown targets are refused where they are resolved + if (isPublished(state, to)) return ALLOW + return deny(`${target.name} is not published, so it cannot be reached from outside`) + } + if (from === undefined || from === null) return ALLOW + const sender = state.bots?.[from] + if (sender === undefined) return ALLOW + if (toRemote) { + if (isPublished(state, from)) return ALLOW + if (roomId !== undefined && isCodeJoined(state, roomId, from)) return ALLOW + return deny(`${sender.name} is not published, so it cannot message friends' Bots`) + } + const target = state.bots?.[to] + if (target === undefined) return ALLOW + if (sender.ownerId === target.ownerId) return ALLOW + if (!ownerKnown(state, sender) || !ownerKnown(state, target)) return deny('that Bot has no owner on this team') + if (isPublished(state, from) && isPublished(state, to)) return ALLOW + if (roomId !== undefined && inRoom(state, roomId, from) && inRoom(state, roomId, to)) return ALLOW + return deny(`${target.name} is not published, so it cannot be reached by another owner's Bot`) +} + +export function install(rt) { + const { state } = rt + + rt.isPublished = botId => isPublished(state, botId) + rt.isCodeJoined = (roomId, botId) => isCodeJoined(state, roomId, botId) + rt.mayShareRoom = (room, botId) => mayShareRoom(state, room, botId) + // A bot.send message or a deliver() argument set, in either shape. + rt.reach = (msg = {}) => reach(state, { + from: msg.remote?.handle ? { kind: 'remote', handle: msg.remote.handle } : (msg.from ?? msg.fromId), + to: msg.to ?? msg.toId, + roomId: msg.meta?.roomId ?? msg.roomId ?? msg.fromRoomId ?? msg.deliverArgs?.roomId ?? msg.deliverArgs?.fromRoomId, + }) + + // The owner's choice: true or false; null puts the Bot back on the default (Chief only). + rt.setPublished = async (botId, value) => { + const bot = state.bots?.[botId] + if (bot === undefined || bot.remote !== undefined) throw Object.assign(new Error('Unknown Bot'), { code: 'bot/not-found' }) + if (value !== true && value !== false && value !== null) throw Object.assign(new Error('published must be true, false or null'), { code: 'bot/invalid' }) + const before = isPublished(state, botId) + if (value === null) delete bot.published + else bot.published = value + const after = isPublished(state, botId) + if (before !== after) await rt.reachChanged(botId) + await rt.save() + return after + } + + // Route eligibility changed (unpublish, Chief replaced, owner removed): a Bot that may no + // longer be in a room with remote participants or other owners leaves it at once. + rt.reachChanged = async (botId) => { + await rt.evictUnshared?.(botId) + rt.flybotPublishedChanged?.(botId) + } + const syncChief = rt.syncDesignatedChief + if (typeof syncChief === 'function') { + rt.syncDesignatedChief = (...args) => { + const before = new Map(Object.keys(state.bots ?? {}).map(id => [id, isPublished(state, id)])) + const changed = syncChief(...args) + if (changed) { + for (const [id, was] of before) if (was !== isPublished(state, id)) void rt.reachChanged(id).catch(error => rt.warn?.('ds-bot: reach change for %s failed: %s', id, error)) + } + return changed + } + } +} diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 497735b5c..68b463ad0 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -21,15 +21,19 @@ // (for an invited owner, their Bot's interval). Leaving closes the interval and // withdraws all history access. Rejoining opens a new interval at the then-current // seq; prior intervals do not come back. Non-members cannot read or inject. -// Unpublished specialists stay owner-private. Only a designated Chief or a Bot -// published to friends (`state.flybot.remote`, cleanRemote) may be added across -// owners. +// Unpublished specialists stay owner-private. Across owners (and with remote +// participants) only a *published* local Bot (the owner's choice; the designated +// Chief by default, see reach.js) or a Bot that joined by room code (`viaCode`, #83; +// reachable in this room only, while a member), and a Bot published to friends +// (`state.flybot.remote`, cleanRemote) may be added. A Bot that stops qualifying +// (unpublished, Chief replaced) leaves such rooms at once (rt.evictUnshared). // // Inbound shared rooms (`origin: 'inbound-share'`) are the remote owner's projection // of a friend's room that invited their published Bot. import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' import { cleanName, fenceUntrusted } from './delivery.js' +import { isPublished, mayShareRoom } from './reach.js' const KINDS = new Set(['human', 'bot']) const ROLES = new Set(['admin', 'member']) @@ -274,7 +278,7 @@ export function deriveParticipants(room, state) { return [...humans, ...bots] } -function permittedBot(state, room, botId, humans) { +function permittedBot(state, room, botId, humans, { viaCode = false } = {}) { const bot = state.bots?.[botId] if (!bot) return false const owners = new Set(humans) @@ -284,7 +288,7 @@ function permittedBot(state, room, botId, humans) { } if (state.bots[botId]?.ownerId) owners.add(bot.ownerId) const cross = owners.size > 1 - if (cross) return isDesignatedChief(state, botId) && humans.has(bot.ownerId) + if (cross) return (isPublished(state, botId) && humans.has(bot.ownerId)) || viaCode return bot.ownerId === [...owners][0] } @@ -378,12 +382,19 @@ export function install(rt) { return { ...line, who: name, ...(id ? { authorId: id, authorKind: 'human' } : {}) } } - function assertAddable(room, draft, actor) { + const REMOTE_ROOM_MESSAGE = 'Only a published Bot (or one that joined with the room code) may be in a room with friends\' Bots' + const hasRemote = room => currentParticipants(room).some(isRemoteParticipant) + // Local members that may not stay in a room with remote participants or other owners. + const unsharable = room => currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote && !mayShareRoom(state, room, p.id)) + + // `viaCode` (a room-code join, #83) is an internal option: it is never read from a request body. + function assertAddable(room, draft, actor, { viaCode = false } = {}) { if (!KINDS.has(draft.kind)) throw invalid('Participant kind must be human or bot') if (isRemoteDraft(draft)) { const published = publishedRemoteOf(state, draft.handle || draft.id) if (!published) throw forbidden('Only a Chief or a Bot published to friends may join a shared room') if (participantOf(room, 'bot', published.handle)) throw invalid('That participant is already in the room') + if (unsharable(room).length > 0) throw forbidden(REMOTE_ROOM_MESSAGE) return published } if (typeof draft.id !== 'string' || draft.id === '') throw invalid('A participant needs an id') @@ -396,7 +407,7 @@ export function install(rt) { if (humans.size > 1) { for (const p of currentParticipants(room).filter(item => item.kind === 'bot' && !item.remote)) { const ownerId = state.bots?.[p.id]?.ownerId - if (!isDesignatedChief(state, p.id) || !humans.has(ownerId)) { + if (!mayShareRoom(state, room, p.id) || !humans.has(ownerId)) { throw forbidden('Specialists stay in their owner\'s private rooms; a cross-owner room takes each side\'s Chief') } } @@ -408,23 +419,26 @@ export function install(rt) { const humans = new Set(currentParticipants(room).filter(p => p.kind === 'human').map(p => p.id)) if (actor?.memberId) humans.add(actor.memberId) humans.add(bot.ownerId) - if (!permittedBot(state, room, draft.id, humans)) { - if (isSpecialist(state, draft.id) && (isCrossOwnerRoom(room, state) || humans.size > 1)) { + if (!permittedBot(state, room, draft.id, humans, { viaCode })) { + if (!isPublished(state, draft.id) && (isCrossOwnerRoom(room, state) || humans.size > 1)) { throw forbidden('Specialists stay in their owner\'s private rooms; a cross-owner room takes each side\'s Chief') } - throw forbidden('Only a designated Chief of a room member may join a shared room') + throw forbidden('Only a published Bot of a room member may join a shared room') } + if (hasRemote(room) && !viaCode && !isPublished(state, draft.id)) throw forbidden(REMOTE_ROOM_MESSAGE) const nextOwners = new Set(humans) if (nextOwners.size > 1) { for (const p of currentParticipants(room).filter(item => item.kind === 'bot' && !item.remote)) { - if (isSpecialist(state, p.id)) { + if (!mayShareRoom(state, room, p.id)) { throw forbidden('Specialists stay in their owner\'s private rooms; a cross-owner room takes each side\'s Chief') } } } } - rt.addRoomParticipant = function addRoomParticipant(roomId, draft, actor) { + // `opts.viaCode`: the Bot joins with the room's code (#83): it needs no publication, and is + // reachable in this room only, while it is a member. Internal: request bodies cannot set it. + rt.addRoomParticipant = function addRoomParticipant(roomId, draft, actor, opts = {}) { return withRoomLock(async () => { const room = rt.roomOf(roomId) if (room === undefined) throw invalid('Unknown group chat') @@ -433,7 +447,8 @@ export function install(rt) { throw forbidden('Only a room admin can add participants') } const kind = draft.kind - const published = assertAddable(room, draft, actor) + const viaCode = opts?.viaCode === true && draft.kind === 'bot' && !isRemoteDraft(draft) + const published = assertAddable(room, draft, actor, { viaCode }) const role = draft.role === 'admin' ? 'admin' : 'member' const invitedBy = actor?.memberId const invitedByName = actor ? displayNameFor(state, actor.memberId, actor.displayName) : undefined @@ -493,6 +508,7 @@ export function install(rt) { displayName, joinedSeq: line.seq, joinedAt: invitedAt, + ...(viaCode ? { viaCode: true } : {}), ...(invitedBy && kind === 'bot' ? { invitedBy, invitedByName, invitedAt } : {}), } room.participants.push(record) @@ -654,6 +670,8 @@ export function install(rt) { throw forbidden('Specialists stay in their owner\'s private rooms; a cross-owner room takes each side\'s Chief') } } + // A room with a friend's Bot takes only published local Bots (a room code cannot be used here). + if (hasRemote(room) && unsharable(room).length > 0) throw forbidden(REMOTE_ROOM_MESSAGE) syncMembers(room) return room } @@ -666,15 +684,23 @@ export function install(rt) { })) } + // Room lines go out to the friends' Bots in the room, checked per author and per recipient + // (#51): a line written by a local Bot that may not share the room never leaves, a friend's + // Bot that is no longer published to us gets nothing, and the sending Bot is one that may + // speak outside (bot.send then runs the reach policy on every single send). rt.fanoutRoomLine = async function fanoutRoomLine(room, line) { const remotes = currentParticipants(room).filter(isRemoteParticipant) if (remotes.length === 0 || !rt.flybotSend) return - const from = (room.admin && rt.botOf?.(room.admin) ? room.admin : (room.members ?? []).find(id => rt.botOf?.(id))) + const localAuthor = line.authorKind === 'bot' && !line.remote && rt.botOf?.(line.authorId ?? line.botId) ? (line.authorId ?? line.botId) : undefined + if (localAuthor !== undefined && !mayShareRoom(state, room, localAuthor)) return + const speakers = [room.admin, ...(room.members ?? [])].filter(id => id && rt.botOf?.(id) && mayShareRoom(state, room, id)) + const from = speakers[0] if (!from) return const who = cleanName(line.who || 'member') const body = line.text ? fenceUntrusted(who, line.text) : `[${cleanName(line.kind || 'event')}]` for (const remote of remotes) { if (line.authorId === remote.id || line.authorId === remote.handle) continue + if (!publishedRemoteOf(state, remote.handle)) continue try { await rt.flybotSend({ from, @@ -690,6 +716,29 @@ export function install(rt) { } } + // A Bot that no longer qualifies (unpublished, Chief replaced, owner removed) leaves every room + // that has friends' Bots or more than one owner, at once; without an id, every such Bot does. + rt.evictUnshared = async function evictUnshared(botId) { + let changed = false + for (const room of Object.values(state.rooms ?? {})) { + if (!Array.isArray(room.participants) || !(hasRemote(room) || isCrossOwnerRoom(room, state))) continue + for (const me of currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote && (botId === undefined || p.id === botId))) { + if (mayShareRoom(state, room, me.id)) continue + await withRoomLock(async () => { + if (me.leftSeq !== undefined) return + const line = append(room, { kind: 'left', who: me.displayName, authorId: me.id, authorKind: 'bot', text: '' }) + me.leftSeq = line.seq + me.leftAt = Date.now() + if (room.seen) delete room.seen[me.id] + rt.dropGroupSession?.(room, me.id) + syncMembers(room) + changed = true + }) + } + } + if (changed) await rt.save() + } + rt.ingestRemoteRoomLine = async function ingestRemoteRoomLine({ handle, text } = {}) { const id = String(handle ?? '') if (!id) return diff --git a/core/test/reach-policy.test.js b/core/test/reach-policy.test.js new file mode 100644 index 000000000..7a00f0b32 --- /dev/null +++ b/core/test/reach-policy.test.js @@ -0,0 +1,266 @@ +// #51: published Bots, the reach policy, and where it is enforced. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime, provideFlyBot } from '../index.js' +import { fakeContext } from './fake-ctx.js' +import { isPublished, reach } from '../src/host/reach.js' + +const bot = (id, ownerId, extra = {}) => ({ id, sessionId: id, name: id.toUpperCase(), instructions: '', createdAt: 1, ownerId, ...extra }) + +// Two owners, each with a Chief (published by default) and a specialist. +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a', 'b'], + members: { + alice: { id: 'alice', createdAt: 1, designatedChiefId: 'a' }, + bob: { id: 'bob', createdAt: 1, designatedChiefId: 'b' }, + }, + bots: { a: bot('a', 'alice'), a2: bot('a2', 'alice'), b: bot('b', 'bob'), b2: bot('b2', 'bob') }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + + +async function start(t, { config = {}, state = team() } = {}) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-reach-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(state)) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (id) => { + if (!agents.has(id)) { + const calls = [] + agents.set(id, { + session: { id, requestHeader: () => undefined }, id, calls, status: 'idle', + followup(m) { calls.push(['followup', m]) }, steer(m) { calls.push(['steer', m]) }, + inject() {}, cancel() {}, async whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} }, + }) + } + return agents.get(id) + } + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home, ...config }) + provideFlyBot(ctx, rt.flybot) + await rt.load() + const received = id => (agents.get(id)?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + return { rt, fb: rt.flybot, ctx, received, state: rt.state } +} + +test('published: false by default, true for the designated Chief, the owner can override either way', () => { + const s = team() + assert.equal(isPublished(s, 'a'), true) + assert.equal(isPublished(s, 'a2'), false) + s.bots.a2.published = true + s.bots.a.published = false + assert.equal(isPublished(s, 'a2'), true) + assert.equal(isPublished(s, 'a'), false) + delete s.bots.a.published + assert.equal(isPublished(s, 'a'), true) + assert.equal(isPublished(s, 'nobody'), false) + assert.equal(isPublished(s, '@coder.bob.home'), false) + // A Bot whose owner left the team is never published, even by choice or as a Chief. + delete s.members.alice + assert.equal(isPublished(s, 'a'), false) + assert.equal(isPublished(s, 'a2'), false) +}) + +test('reach matrix: remote to local needs a published target; local to remote needs a published sender', () => { + const s = team() + const remote = { kind: 'remote', handle: '@coder.bob.home' } + assert.equal(reach(s, { from: remote, to: 'a' }).action, 'allow', 'a friend reaches a Chief directly') + const denied = reach(s, { from: remote, to: 'a2' }) + assert.equal(denied.action, 'deny') + assert.equal(denied.by, 'owner-policy') + s.bots.a2.published = true + assert.equal(reach(s, { from: remote, to: 'a2' }).action, 'allow', 'a published specialist is reached without its Chief') + assert.equal(reach(s, { from: 'a', to: remote }).action, 'allow') + s.bots.a2.published = false + assert.equal(reach(s, { from: 'a2', to: remote }).action, 'deny') + assert.equal(reach(s, { from: 'a2', to: '@coder.bob.home' }).action, 'deny', 'a plain handle is a remote address too') + assert.equal(reach(s, { from: remote, to: remote }).action, 'deny') + assert.equal(reach(s, { from: undefined, to: 'a2' }).action, 'allow', 'the owner and the system have no sender Bot') + assert.equal(reach(s, { from: remote, to: 'nobody' }).action, 'allow', 'an unknown target is refused where it is resolved') +}) + +test('reach matrix: other owners reach only published Bots, never through the Chief; same owner is free', () => { + const s = team() + assert.equal(reach(s, { from: 'a', to: 'b' }).action, 'allow') + assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'deny') + assert.equal(reach(s, { from: 'a2', to: 'b' }).action, 'deny', 'the sender must be published too') + assert.equal(reach(s, { from: 'a2', to: 'a' }).action, 'allow', 'one owner\'s Bots talk freely') + s.bots.b2.published = true + assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'allow', 'a published non-Chief is reached directly') + assert.equal(reach(s, { from: 'a2', to: 'b2' }).action, 'deny') + assert.equal(reach(s, { from: 'b2', to: 'a2' }).action, 'deny') + delete s.bots.b2.published + // Chief replaced: the old Chief stops being published at once, the new one starts. + s.members.bob.designatedChiefId = 'b2' + assert.equal(reach(s, { from: 'a', to: 'b' }).action, 'deny') + assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'allow') + // Removed owner. + delete s.members.bob + assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'deny') +}) + +test('reach matrix: a code-joined Bot is reachable inside its room only, while a member', () => { + const s = team() + s.rooms.r = { + id: 'r', members: ['a', 'b2'], participants: [ + { kind: 'human', id: 'alice', role: 'admin', joinedSeq: 0 }, + { kind: 'bot', id: 'a', role: 'admin', joinedSeq: 0 }, + { kind: 'bot', id: 'b2', role: 'member', joinedSeq: 0, viaCode: true }, + ], + } + assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'deny', 'outside the room it is unreachable') + assert.equal(reach(s, { from: 'a', to: 'b2', roomId: 'r' }).action, 'allow') + assert.equal(reach(s, { from: 'b2', to: 'a', roomId: 'r' }).action, 'allow') + assert.equal(reach(s, { from: 'b2', to: 'a', roomId: 'other' }).action, 'deny') + assert.equal(reach(s, { from: 'b2', to: { kind: 'remote', handle: '@coder.bob.home' }, roomId: 'r' }).action, 'allow') + assert.equal(reach(s, { from: 'b2', to: { kind: 'remote', handle: '@coder.bob.home' } }).action, 'deny') + s.rooms.r.participants[2].leftSeq = 3 + s.rooms.r.members = ['a'] + assert.equal(reach(s, { from: 'a', to: 'b2', roomId: 'r' }).action, 'deny', 'after it leaves it is unreachable again') +}) + +test('bot.send: the policy runs before the chain, and no allow or bypass verdict skips it', async (t) => { + const { fb, received } = await start(t, { config: { flybot: { bypass: { greedy: ['owner-policy', 'social'] } } } }) + const seen = [] + fb.bot.send.use({ name: 'greedy', priority: 100, handle: (m) => { seen.push(m.to); return { action: 'allow', bypass: ['owner-policy', 'social'] } } }) + const denied = await fb.bot.send({ from: 'a', to: 'b2', text: 'hello specialist' }) + assert.equal(denied.action, 'deny') + assert.equal(denied.by, 'owner-policy') + assert.deepEqual(seen, [], 'the chain never saw it') + assert.deepEqual(received('b2'), []) + const ok = await fb.bot.send({ from: 'a', to: 'b', text: 'hello chief' }) + assert.equal(ok.action, 'allow') + assert.equal(seen.length, 1) + assert.match(received('b')[0], /hello chief/) +}) + +test('message_bot of another owner\'s unpublished Bot is refused; the owner publishing it opens the way', async (t) => { + const { fb, rt, received } = await start(t) + assert.equal((await fb.bot.send({ from: 'a', to: 'b2', text: 'one' })).action, 'deny') + await rt.setPublished('b2', true) + assert.equal((await fb.bot.send({ from: 'a', to: 'b2', text: 'two' })).action, 'allow') + assert.match(received('b2')[0], /two/) + await rt.setPublished('b2', false) + assert.equal((await fb.bot.send({ from: 'a', to: 'b2', text: 'three' })).action, 'deny') + assert.equal(received('b2').length, 1) +}) + +test('remote senders reach published Bots only', async (t) => { + const { fb, rt, received } = await start(t) + const friend = { kind: 'remote', handle: '@coder.bob.home', name: 'Coder' } + assert.equal((await fb.bot.send({ from: friend, to: 'a2', text: 'hi spec' })).action, 'deny') + assert.deepEqual(received('a2'), []) + assert.equal((await fb.bot.send({ from: friend, to: 'a', text: 'hi chief' })).action, 'allow') + await rt.setPublished('a2', true) + assert.equal((await fb.bot.send({ from: friend, to: 'a2', text: 'hi again' })).action, 'allow') + assert.match(received('a2')[0], /hi again/) +}) + +test('an unpublished Bot cannot message a friend\'s Bot; a published one can', async (t) => { + const { fb, rt } = await start(t) + const out = [] + fb.transports.add('bot-social', { send: async msg => { out.push(msg.to.handle); return { exchangeId: `r${out.length}` } } }) + const remote = { kind: 'remote', handle: '@coder.bob.home' } + assert.equal((await fb.bot.send({ from: 'a2', to: remote, text: 'x' })).action, 'deny') + assert.deepEqual(out, []) + assert.equal((await fb.bot.send({ from: 'a', to: remote, text: 'y' })).action, 'allow') + await rt.setPublished('a2', true) + assert.equal((await fb.bot.send({ from: 'a2', to: remote, text: 'z' })).action, 'allow') + assert.equal(out.length, 2) +}) + +test('a held message is judged again when it is approved: unpublished while it waited means refused', async (t) => { + const { fb, rt, received } = await start(t) + await rt.setPublished('b2', true) + fb.bot.send.use({ name: 'hold-all', handle: () => ({ action: 'hold', reason: 'check' }) }) + const sent = await fb.bot.send({ from: 'a', to: 'b2', text: 'held plan' }) + assert.equal(sent.action, 'hold') + await rt.setPublished('b2', false) + const decided = await fb.approvals.decide(sent.approvalId, 'approve') + assert.equal(decided.status, 'denied') + assert.match(decided.error, /not published/) + assert.deepEqual(received('b2'), []) +}) + +test('resumed, retried, deferred and re-sent deliveries are judged by what is true now', async (t) => { + const { rt, received } = await start(t) + const flags = [['resumed', 'x1'], ['retried', 'x2'], ['deferred', 'x3'], ['resent', 'x4']] + for (const [flag, id] of flags) { + await rt.deliver({ fromId: 'a', toId: 'b2', role: 'request', text: `${flag} while unpublished`, exchangeId: id, [flag]: true }) + } + assert.deepEqual(received('b2'), [], 'none of the flagged paths gets around the policy') + await rt.setPublished('b2', true) + for (const [flag, id] of flags) { + await rt.deliver({ fromId: 'a', toId: 'b2', role: 'request', text: `${flag} after publishing`, exchangeId: `${id}-ok`, [flag]: true }) + } + assert.equal(received('b2').length, 4) + // The same sender and target in one owner's team are not affected. + await rt.deliver({ fromId: 'a', toId: 'a2', role: 'request', text: 'same owner', resumed: true }) + assert.match(received('a2')[0], /same owner/) +}) + +test('a plain delivery from a remote sender to an unpublished Bot is refused', async (t) => { + const { rt, received } = await start(t) + await rt.deliver({ toId: 'a2', role: 'request', text: 'hi', remote: { handle: '@coder.bob.home', name: 'Coder' }, untrusted: true, resumed: true }) + assert.deepEqual(received('a2'), []) + await rt.deliver({ toId: 'a', role: 'request', text: 'hi chief', remote: { handle: '@coder.bob.home', name: 'Coder' }, untrusted: true, resumed: true }) + assert.match(received('a')[0], /hi chief/) +}) + +test('setPublished: validates, keeps the default with null, emits bot.published, and is the owner\'s call', async (t) => { + const { fb, rt } = await start(t) + const events = [] + fb.on('bot.published', bot => events.push([bot.id, bot.published])) + assert.equal(fb.bot.get('a').published, true) + assert.equal(fb.bot.get('a2').published, false) + assert.equal(fb.bot.isPublished('a'), true) + assert.equal(fb.bot.isPublished('@x.y'), false) + await rt.setPublished('a2', true) + await rt.setPublished('a2', true) + await rt.setPublished('a', false) + await rt.setPublished('a', null) + assert.deepEqual(events, [['a2', true], ['a', false], ['a', true]]) + await assert.rejects(rt.setPublished('a2', 'yes'), /true, false or null/) + await assert.rejects(rt.setPublished('nobody', true), /Unknown Bot/) +}) + +test('changing the designated Chief changes who is published, and the event says so', async (t) => { + const solo = team() + delete solo.members.bob + solo.bots.b.ownerId = 'alice' + solo.bots.b2.ownerId = 'alice' + solo.mainBotIds = ['a'] + const { fb, rt, state } = await start(t, { state: solo }) + const events = [] + fb.on('bot.published', bot => events.push([bot.id, bot.published])) + assert.equal(fb.bot.isPublished('a'), true) + state.mainBotIds = ['a2'] + assert.equal(rt.syncDesignatedChief(), true) + await new Promise(resolve => setTimeout(resolve, 20)) + assert.equal(fb.bot.isPublished('a2'), true) + assert.equal(fb.bot.isPublished('a'), false) + assert.deepEqual(events.sort(), [['a', false], ['a2', true]]) +}) + +test('the set-published endpoint is the owner\'s: members cannot publish each other\'s Bots', async (t) => { + const { rt } = await start(t) + rt.authzEnforced = () => true + const alice = { memberId: 'alice' } + assert.equal(rt.authorize(alice, 'set-published', { id: 'a2' }).ok, true) + assert.equal(rt.authorize(alice, 'set-published', { id: 'b2' }).ok, false) +}) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index ddfef0ed7..9242493be 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -17,6 +17,7 @@ import { roomVisibleTo, viewerRole, } from '../src/host/room-membership.js' +import { install as installReach, reach } from '../src/host/reach.js' import { remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js' import { mentionRows } from '../src/client/at-menu.js' import { indexRoster } from '../src/client/sources.js' @@ -49,6 +50,7 @@ function host(t, saved) { installMemberAuth(rt) installAuthz(rt) installRooms(rt) + installReach(rt) t.after(async () => { await Promise.all(disposers.splice(0).map(cleanup => cleanup())) rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) @@ -362,3 +364,141 @@ test('channel: each viewer sees the room under the documented rule', async (t) = const eve = { memberId: 'owner-9' } assert.equal(roomVisibleTo(rt.state, eve, 'war'), false) }) + +// ---- #51: a room with friends' Bots takes published local Bots only + +// The war room starts with both Chiefs; these tests want one owner and the friend's Bot. +const soloWar = (rt) => { + const room = rt.state.rooms.war + room.participants = room.participants.filter(p => p.id !== 'chief-b') + room.members = room.members.filter(id => id !== 'chief-b') +} + +const withRemote = async (rt, actorA) => { + soloWar(rt) + publish(rt) + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) +} + +test('#51: an unpublished local Bot cannot be added to a room that has a friend\'s Bot', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + await withRemote(rt, actorA) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA), /published Bot/) + assert.equal(rt.state.rooms.war.members.includes('spec-a'), false) + // The owner's choice lets the same Bot in. + await rt.setPublished('spec-a', true) + await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) + assert.equal(rt.state.rooms.war.members.includes('spec-a'), true) +}) + +test('#51: a friend\'s Bot cannot be added to a room that holds an unpublished local Bot', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + soloWar(rt) + publish(rt) + await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA), /published Bot/) + assert.equal(rt.state.rooms.war.participants.some(p => p.remote), false) + await rt.setPublished('spec-a', true) + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + assert.equal(rt.state.rooms.war.participants.some(p => p.remote), true) +}) + +test('#51: creating a room with a friend\'s Bot and an unpublished member is refused', async (t) => { + const { rt } = host(t, TEAM) + rt.createRoom = undefined + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + const room = { id: 'new', members: [], participants: [], createdAt: 1 } + const drafts = [ + { kind: 'human', id: 'owner-1', role: 'admin' }, + { kind: 'bot', id: 'spec-a' }, + { kind: 'bot', remote: true, handle: '@coder.bob.home' }, + ] + assert.throws(() => rt.applyParticipants(room, drafts, actorA), /published Bot/) + rt.state.bots['spec-a'].published = true + assert.doesNotThrow(() => rt.applyParticipants({ id: 'new', members: [], participants: [], createdAt: 1 }, drafts, actorA)) +}) + +test('#51: unpublishing a Bot, or replacing its Chief, takes it out of rooms with friends at once', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + await withRemote(rt, actorA) + await rt.setPublished('spec-a', true) + await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) + const room = rt.state.rooms.war + assert.deepEqual(room.members.filter(id => id.startsWith('spec') || id.startsWith('chief')).sort(), ['chief-a', 'spec-a']) + await rt.setPublished('spec-a', false) + assert.equal(room.members.includes('spec-a'), false) + assert.ok(room.log.some(line => line.kind === 'left' && line.authorId === 'spec-a')) + assert.ok(room.participants.find(p => p.id === 'spec-a').leftSeq !== undefined) + // The Chief default: turning the Chief off takes it out too, and the remote stays. + await rt.setPublished('chief-a', false) + assert.equal(room.members.includes('chief-a'), false) + assert.equal(room.participants.some(p => p.remote && p.leftSeq === undefined), true) +}) + +test('#51: unpublishing leaves private rooms (no friends, one owner) alone', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, undefined).catch(() => {}) + const room = rt.state.rooms.war + room.participants.push({ kind: 'bot', id: 'spec-a', role: 'member', displayName: 'Writer A', joinedSeq: 0, joinedAt: 1 }) + room.members.push('spec-a') + await rt.setPublished('spec-a', true) + await rt.setPublished('spec-a', false) + assert.equal(room.members.includes('spec-a'), true) +}) + +test('#51: fanout sends from a published Bot, skips lines of Bots that may not share, and skips friends no longer published', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + await withRemote(rt, actorA) + const room = rt.state.rooms.war + // An unpublished specialist's line never leaves (it is not even a member now). + await rt.fanoutRoomLine(room, { seq: 5, who: 'Writer A', text: 'draft', authorKind: 'bot', authorId: 'spec-a' }) + assert.equal(sent.length, 0) + await rt.fanoutRoomLine(room, { seq: 6, who: 'Alice', text: 'hello', authorKind: 'human', authorId: 'owner-1' }) + assert.equal(sent.length, 1) + assert.equal(sent[0].from, 'chief-a') + // The admin Chief is no longer published: someone who is still published speaks, never it. + await rt.setPublished('chief-a', false) + await rt.fanoutRoomLine(room, { seq: 7, who: 'Alice', text: 'again', authorKind: 'human', authorId: 'owner-1' }) + assert.ok(sent.slice(1).every(msg => msg.from !== 'chief-a')) + // The friend takes the Bot off their published list: nothing goes there. + const before = sent.length + publish(rt, []) + await rt.fanoutRoomLine(room, { seq: 8, who: 'Alice', text: 'anyone?', authorKind: 'human', authorId: 'owner-1' }) + assert.equal(sent.length, before) +}) + +test('#51: a room code lets an unpublished Bot in, for that room only (internal option, never a request field)', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + await withRemote(rt, actorA) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a', viaCode: true }, actorA), /published Bot/) + await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA, { viaCode: true }) + const entry = rt.state.rooms.war.participants.find(p => p.id === 'spec-a') + assert.equal(entry.viaCode, true) + const remote = { kind: 'remote', handle: '@coder.bob.home' } + assert.equal(reach(rt.state, { from: 'spec-a', to: remote, roomId: 'war' }).action, 'allow') + assert.equal(reach(rt.state, { from: 'spec-a', to: remote, roomId: 'elsewhere' }).action, 'deny') + assert.equal(reach(rt.state, { from: remote, to: 'spec-a' }).action, 'deny') + assert.equal(reach(rt.state, { from: 'spec-a', to: remote }).action, 'deny') + // It stays while it is a member and needs no publication; an unpublish of others does not touch it. + await rt.setPublished('chief-a', false) + assert.equal(rt.state.rooms.war.members.includes('spec-a'), true) +}) diff --git a/core/test/room-membership.test.js b/core/test/room-membership.test.js index 5232a42da..886f13b56 100644 --- a/core/test/room-membership.test.js +++ b/core/test/room-membership.test.js @@ -8,6 +8,7 @@ import { fakeContext } from './fake-ctx.js' import { install as installStore } from '../src/host/store.js' import { install as installMemberAuth } from '../src/host/member-auth.js' import { install as installAuthz } from '../src/host/authz.js' +import { install as installReach } from '../src/host/reach.js' import { install as installRooms, currentParticipants, isCrossOwnerRoom, linesVisibleTo, roomVisibleTo } from '../src/host/room-membership.js' // Room membership (#50): humans and Chiefs share a room with distinct authorship. // Team mode stays disabled — these tests cover the membership surface only. @@ -38,6 +39,7 @@ function host(t, saved) { installMemberAuth(rt) installAuthz(rt) installRooms(rt) + installReach(rt) t.after(async () => { await Promise.all(disposers.splice(0).map(cleanup => cleanup())) rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) @@ -239,6 +241,28 @@ test('a specialist cannot join a cross-owner room', async (t) => { assert.equal(isCrossOwnerRoom(rt.state.rooms.shared, rt.state), true) }) +test('#51: a published specialist may join a cross-owner room, and unpublishing takes it out at once', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + const create = { members: ['chief-a', 'spec-b'], participants: [{ kind: 'human', id: 'owner-1' }, { kind: 'human', id: 'owner-2' }, { kind: 'bot', id: 'chief-a' }, { kind: 'bot', id: 'spec-b' }] } + assert.equal(rt.authorize(actorA, 'create-room', create).ok, false) + await rt.setPublished('spec-b', true) + assert.equal(rt.authorize(actorA, 'create-room', create).ok, true) + await rt.addRoomParticipant('shared', { kind: 'bot', id: 'spec-b' }, actorA) + assert.equal(rt.state.rooms.shared.members.includes('spec-b'), true) + // One unpublished member in the room also keeps another specialist out. + await assert.rejects(() => rt.addRoomParticipant('shared', { kind: 'bot', id: 'spec-a' }, actorA), /Specialists stay/) + await rt.setPublished('spec-b', false) + assert.equal(rt.state.rooms.shared.members.includes('spec-b'), false) + assert.ok(rt.state.rooms.shared.log.some(line => line.kind === 'left' && line.authorId === 'spec-b')) + // A Chief replaced by its owner's choice leaves too; the room itself stays. + await rt.setPublished('chief-b', false) + assert.equal(rt.state.rooms.shared.members.includes('chief-b'), false) + assert.ok(rt.state.rooms.shared.participants.some(p => p.kind === 'human' && p.id === 'owner-2' && p.leftSeq === undefined)) +}) + test('existing single-owner groups still display and accept posts', async (t) => { const { rt } = host(t, TEAM) await rt.load() diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index a60144306..4be408ae7 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -31,6 +31,8 @@ export interface BotView { name: string role?: string isMain: boolean + /** Reachable from other owners and friends (the owner's choice; the Chief by default). Others' unpublished Bots cannot be messaged. */ + published: boolean createdBy: BotId | null /** Text prompt.js appends to this Bot's system prompt (bot.setSystemPrompt). */ systemPrompt: string @@ -60,6 +62,8 @@ export interface FlyBotEvents { /** A new local Bot (UI, create_bot, or a plugin's bot.create); payload is its record. */ 'bot.created': BotView 'bot.removed': BotRemoved + /** A Bot's effective published state flipped (set by its owner, or the Chief changed); payload is its record. */ + 'bot.published': BotView } export type On = (event: K, listener: (payload: FlyBotEvents[K]) => void, options?: { all?: boolean }) => () => void @@ -193,6 +197,8 @@ export interface FlyBot { find(name: string): BotView | undefined /** True for a remote address ({ kind: 'remote' } or '@handle'); local ids are never remote. */ isRemote(ref: unknown): boolean + /** Whether the Bot is published (reachable from outside its owner). false for remote or unknown ids. */ + isPublished(id: BotId): boolean /** The Bot a Session (own chat, group, or earlier part) belongs to. */ of(sessionId: string): BotView | undefined /** Returns the new Bot's id. */ diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index 425689908..8af6d569a 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -10,6 +10,7 @@ owned_paths: - core/test/identity.test.js - core/test/room-membership.test.js - core/test/remote-room.test.js + - core/test/reach-policy.test.js - ds-bot/ - ds-bot.pin.json - patches/ds-bot/ @@ -28,7 +29,8 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c - `core/dev/` — harness packages, mock model, test bench, smoke test. - `core/test/identity.test.js` — the state identity schema tests (store `state.json` v2, #47). - `core/test/room-membership.test.js` — typed room participants, authorship and the membership-window history rule (#50). -- `core/test/remote-room.test.js` — published remote participants, per-viewer presentation and unpublished-Bot refusal. +- `core/test/remote-room.test.js` — published remote participants, per-viewer presentation and unpublished-Bot refusal; #51 room rules (a room with friends' Bots takes published local Bots only, unpublish evicts, fanout checks author and recipient, room-code Bots). +- `core/test/reach-policy.test.js` — #51 reach policy: the published default, the allow/deny matrix per direction, no bypass, hold release and flagged deliveries re-checked, setPublished and its event. - Pin (`ds-bot.pin.json`) and ordered patches (`patches/ds-bot/`). ## Does not own @@ -47,7 +49,8 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c | `ds-bot.pin.json` | Upstream pin. | | `patches/ds-bot/` | Ordered patch topics. | | `scripts/ds-bot-patches.mjs` | generate / check patches. | -| `core/src/host/room-membership.js` | Typed room participants (human / local bot / published remote), authorship, membership-window history (#50). | +| `core/src/host/room-membership.js` | Typed room participants (human / local bot / published remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take published local Bots only and evict a Bot that stops qualifying (#51). | +| `core/src/host/reach.js` | The `published` flag and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#51); `rt.setPublished`, `rt.isPublished`. | | `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. | ## Entry points @@ -62,7 +65,7 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c 2. `core/` == `ds-bot/` + `patches/ds-bot/`; regenerate after every `core/` change. 3. `client.js` matches `core/src/client/`; `npm run build` + `--check`. 4. Never touch the user's `~/.dsh`. -5. 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) 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. 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` / published 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 designated Chief; a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and unpublished specialists stay private; `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Still not team mode — see [team](team.md). +5. 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) 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. 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` / published 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 *published* Bots (#51: the owner's choice, the designated Chief by default); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and unpublished Bots stay private, and a room with a friend's Bot holds only published 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 \**. Still not team mode — see [team](team.md). Published reach (#51) adds host module `src/host/reach.js`, installed after room-membership: `state.bots[id].published` (true / false, unset = the designated Chief is published, every other Bot is not; a Bot whose owner is not a member never is), set by its owner with the `set-published` endpoint (`rt.setPublished`), exposed as `BotView.published`, `flybot.bot.isPublished` and the `bot.published` event. `reach()` is the owner policy: a friend's Bot may reach only published local Bots, only a published Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides are published 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. Unpublishing a Bot, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). 6. Host diagnostics never depend on `ctx.logger`: DSH may export no logger, and a test or plugin-local host may carry one without `warn`. `rt.warn` still writes stderr and the diagnostics log in that case (`src/host/diagnostics.js`). Plugins reach the same log through `flybot.warn` (#68); see [plugin-runtime](plugin-runtime.md). 7. `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). 8. A friend's remote Bot is stored and delivered by its handle (`@.` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9). diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 7a008c77e..98920b8b9 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -46,7 +46,8 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 2. Slots/registry must keep the client build green (`npm run build`). 3. Types in `flybot.d.ts` stay in sync with hooks/slots. 4. Plugins report a host-visible failure with `flybot.warn` (Settings → Feedback / `diagnostics.jsonl`). They must not depend on `ctx.logger`: DSH may export none, and `flybot.warn` delegates to `rt.warn`. -5. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. +5. Reach (#51): 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 the published state through `flybot.bot.isPublished(id)`, `BotView.published` and the `bot.published` event (absent on an older core: treat as unknown). +6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. ## Tests diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 036af62df..6f15dd1f4 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -37,7 +37,8 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 1. Social code stays inside `plugins/social/`. 2. Uses documented plugin APIs only. 3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it. -4. When start fails (including Bot Social onboarding HTTP 409 `handle_taken`), Friends' Bots never shows the host's `Unknown FlyBot endpoint social/…` message. `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal, `flybot.warn` records the failure in diagnostics, and a 409 names the taken handle (`@.home`) and says to change `displayName` in the flybot-social config. The client also maps `bot/not-found` on `social/*` to that idle state. +4. Only **published** Bots (`flybot.bot.isPublished`, #51: the owner's choice, the Chief by default) have a Bot Social identity in use: `register` / `registerAll` skip an unpublished Bot, `pollOnce` reads inboxes of published Bots only, sends are signed only by published Bots (the middleware denies others), and invites / joining name a published Bot. Publishing a Bot registers it (`bot.published`); unpublishing keeps its identity on the server, unused, and does not revoke it. A core without `bot.isPublished` keeps the older all-Bots behavior. +5. When start fails (including Bot Social onboarding HTTP 409 `handle_taken`), Friends' Bots never shows the host's `Unknown FlyBot endpoint social/…` message. `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal, `flybot.warn` records the failure in diagnostics, and a 409 names the taken handle (`@.home`) and says to change `displayName` in the flybot-social config. The client also maps `bot/not-found` on `social/*` to that idle state. ## Tests diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 25f0867b9..061d30416 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -63,19 +63,19 @@ Central authorization in `core/src/host/authz.js`, installed after member-auth: - When enforced, every Bot / room / session an endpoint touches must belong to the signed-in actor (`bot/forbidden` otherwise). `body.actorId` still grants nothing. - Shared resources that cannot yet be isolated per owner (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, capability library writes) are **explicitly blocked** with `bot/blocked-shared` while more than one member exists — not hidden only in the UI. - `create-bot` stamps `ownerId` from the actor; `view()` and `live()` filter to that owner's bots/rooms. -- **Not done here:** cross-owner task execution, filesystem sandbox redesign, Chief-to-Chief routing (#51). Team mode stays disabled; #16 stays open. +- **Not done here:** cross-owner task execution and filesystem sandbox redesign (reach is #51, below). Team mode stays disabled; #16 stays open. ## Room membership foundation (landed, #50) Typed participants on a native room, in `core/src/host/room-membership.js`, installed after groups: - A room may carry `participants`: `{ kind: 'human'|'bot', id, role: 'admin'|'member', displayName, joinedSeq, joinedAt, leftSeq?, leftAt?, invitedBy? }`. A **published remote Bot** is the same kind with `remote: true`, `handle`, `ownerHome`, `ownerName`, `isChief?`. `room.members` stays the current *local* bot ids so existing single-owner groups keep their shape. Rooms without `participants` stay legacy: the owner is implicit and `who: 'User'` is that owner. -- Across owners, the only legal *local* agent is each human's designated Chief. A specialist that was **not** published to friends cannot be added (enforced on the host, not only hidden in the UI). A friend's Bot listed by `@flybot/social` `social/remote-bots` (the `cleanRemote` records) may be added; unpublished specialists stay owner-private. +- Across owners, a *local* agent must be **published** (#51; the owner's choice, the designated Chief by default) or have joined this room by its room code (#83, this room only). An unpublished Bot cannot be added, and cannot be in a room that has another owner or a friend's Bot (enforced on the host, not only hidden in the UI; unpublishing takes it out at once). A friend's Bot listed by `@flybot/social` `social/remote-bots` (the `cleanRemote` records) may be added; unpublished specialists stay owner-private. - Every posted line carries `authorId` + `authorKind` + a display snapshot (`who`). The signed-in actor wins; `body.authorId` grants nothing. Peer names are sanitized (`cleanPeerName` / `cleanRemote`). - **History-visibility rule (membership window):** only a *current* participant — or the owner of an invited Bot — may read a room. They see lines with `seq >=` the `joinedSeq` of their open interval (for an invited owner, their Bot's interval). Leaving closes the interval and withdraws all history access. Rejoining opens a new interval at the then-current seq; prior intervals do not come back. Non-members cannot read or inject. - Endpoints: `room-history`, `room-post`; `create-room` / `update-room` accept `participants` / `addParticipants` / `removeParticipants`. `view()` presents each viewer under the table below. Inbound projections (`origin: 'inbound-share'`, `rt.recordInboundSharedRoom`) are the remote owner's copy of a friend's room that invited their published Bot. - Replies that leave through Bot Social still follow that plugin's outbound hold (`pending_approval` / Friends' Bots “N to approve”). The shared-room view surfaces those items; nothing is auto-sent. -- **Not team mode:** no flag/config/env. Cross-owner *task* execution and Chief-to-Chief routing stay #51. The gates below remain unsatisfied. +- **Not team mode:** no flag/config/env. Cross-owner *task* execution stays out of scope; who may reach whom is the published rule below (#51). The gates below remain unsatisfied. ### Per-viewer display (native room) @@ -94,7 +94,7 @@ Typed participants on a native room, in `core/src/host/room-membership.js`, inst | Designated Chief → specialists | The Chief is the root of its owner's tree; specialists are its children (or descendants). Specialists inherit their scope only from their Chief's owner. | | Specialist → owner | A specialist belongs to exactly one owner's tree. There are no ownerless or shared specialists. | | Authority follows the human | Ownership is an assignment to a person, not to a Bot name. Renaming, replacing or disabling a Bot never transfers authority by itself. | -| Chief ↔ Chief | The only legal cross-owner hop. Each Chief remains accountable to its own owner. | +| Published ↔ published | The only legal cross-owner hop (#51): a Bot another owner (or a friend) can reach is one its owner **published**. The Chief is published by default, any other Bot only if its owner chooses, and nothing is routed through the Chief. Each Bot remains accountable to its own owner. | ## Room visibility (membership window landed, #50; published remotes follow-up) @@ -102,7 +102,7 @@ Typed participants on a native room, in `core/src/host/room-membership.js`, inst | --- | --- | | Owner's private rooms (their 1:1 chats with their Chief and specialists) | That owner, their designated Chief, their specialists. Everyone else — including another owner's Chief — is outside the scope. | | Shared rooms (group chats both sides belong to) | Their members under the shipped group rules (member speaks, group admin manages). Team administration manages the shared room only; it never opens another owner's private rooms. | -| Cross-owner exchange | Stays at Chief level (invariant 4): each Chief sees the exchange and reports to its **own** owner; nothing lands in a specialist of the other side, and no shared room is repurposed as a back door. | +| Cross-owner exchange | Stays between published Bots (invariant 4): each side reports to its **own** owner; nothing lands in an unpublished Bot of the other side, and no shared room is repurposed as a back door. | Shipped room privacy (a group never reads a Bot's private chat) is unchanged; this table only adds the second human boundary on top of it. @@ -110,8 +110,8 @@ Shipped room privacy (a group never reads a Bot's private chat) is unchanged; th 1. **Owner scope is the unit of authority.** A human's rights stop at their own scope. Team administration over shared surfaces never grants another owner's private scope. 2. **One designated Chief per owner.** Specialists carry Chief-derived rights only; a specialist's authority is never its own. -3. **Specialists are owner-private.** No specialist may read or write another owner's rooms, Bots, memory, files or approvals — including "just to look" or "just to relay". -4. **Chief-only cross-owner traffic.** Cross-owner requests terminate at a designated Chief on each side; a Chief never forwards them downward into another owner's specialist as if they were its own owner's request. +3. **Unpublished Bots are owner-private.** No Bot may read or write another owner's rooms, Bots, memory, files or approvals — including "just to look" or "just to relay"; another owner's *unpublished* Bot cannot be messaged at all, and a published one is reached only as a Bot (its own answer), never as a window into its owner's scope. +4. **Published-only cross-owner traffic.** Cross-owner requests (and friends' requests) go to a *published* Bot, which the owner chose to expose (the Chief by default). They are not forced through the Chief, and a Bot never forwards them into another owner's unpublished Bot as if they were its own owner's request. The check is the core's `reach()` owner policy: it runs before the `bot.send` chain, again when a held message is approved, and on every resumed, retried, deferred or re-sent delivery, and no plugin verdict or bypass can skip it. 5. **No forwarding to owner approval.** No route may turn another human's request into owner approval by forwarding it. An approval is created only by the owner whose authority is spent, for the exact action; receipt, relay, or a Chief's word never becomes approval. 6. **Approval authority stays with the human.** A Chief may ask its own owner, present options, and act on an approval it actually received from that owner. It cannot mint an approval, replay another owner's approval, or treat silence as consent. 7. **Team mode is disabled** until every gate below passes. @@ -126,9 +126,9 @@ Planned rules for team mode. Default is **deny**: a route is allowed only if a r | Actor | Own Chief | Own specialist (same tree) | Other owner's Chief | Other owner's specialist | Other owner's human | Shared team surfaces | | --- | --- | --- | --- | --- | --- | --- | -| Human owner | Allowed — directs its Chief | Allowed — directs its specialists | Not directly; goes Chief → Chief | Forbidden | Forbidden | Allowed as team admin (shared only; never another owner's private scope) | -| Designated Chief | — (it is the Chief) | Allowed — delegate, aggregate, report down | Allowed — Chief ↔ Chief only, both sides stay accountable to their own owners | Forbidden | Forbidden — may present to *its own* owner only | Allowed as team admin within shared surfaces only | -| Specialist | Allowed — report upward | Allowed — parent ↔ direct child (existing tree rule) | Forbidden | Forbidden | Forbidden | Speak only where its Chief placed it; no admin rights, no cross-owner relay | +| Human owner | Allowed — directs its Chief | Allowed — directs its specialists | Not directly; goes through their own published Bot | Forbidden — unless published, then via their own Bot | Forbidden | Allowed as team admin (shared only; never another owner's private scope) | +| Designated Chief | — (it is the Chief) | Allowed — delegate, aggregate, report down | Allowed — both are published by default; both sides stay accountable to their own owners | Allowed only if that specialist is published; otherwise forbidden | Forbidden — may present to *its own* owner only | Allowed as team admin within shared surfaces only | +| Specialist | Allowed — report upward | Allowed — parent ↔ direct child (existing tree rule) | Allowed only if the specialist itself is published | Allowed only if both are published | Forbidden | Speak only where its Chief placed it; no admin rights, no cross-owner relay | ### Worked examples @@ -141,6 +141,7 @@ Planned rules for team mode. Default is **deny**: a route is allowed only if a r | e | Specialist B forwards "Owner B approved this" to Chief A, which then spends Owner A's authority | **Forbidden** — forwarding cannot escalate | Invariants 5–6; approval only comes from the owner whose authority is spent, for that exact action — a relayed request or forwarded approval is never approval. | | f | Owner A disables their Chief | Owner scope fails closed | See [Ownership changes](#ownership-changes-disabled--replaced-chiefs); no specialist auto-promotes. | | g | Owner A's seat is transferred to Owner C | Whole scope moves with the person | Identity model: *authority follows the human*, not the Bot name. | +| h | Chief A (or a friend's Bot) messages Owner B's specialist B2 directly | **Allowed if B2 is published**, **forbidden if not** | #51; the owner's `published` choice, no redirect through Chief B. Unpublishing B2 later refuses messages still queued or held for it. | ### Approval authority diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 19b764ea1..2a43cdcfc 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -32,6 +32,7 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). 1. Tree code stays inside `plugins/tree/`; no edits to `ds-bot/` or `core/src/`. 2. Uses documented plugin APIs only (hooks/slots). +3. Reach (#51): tree never routes an outside sender through the root. A remote sender (friend's Bot) reaching a tree Bot is denied if `flybot.bot.isPublished` says it is not published (new and adopted Bots start unpublished; the Chief is published by default) and is not the tree's call if it is, so a published Bot is reached directly, at any depth. Published Bots on both sides are not the tree's call across trees or to non-tree Bots (the core's owner policy decides); inside one tree the parent/child rule is unchanged, and `stopped` / `report` notices are unchanged. On a core without `isPublished` the remote-sender rule gives no opinion. ## Tests diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 923bc2db9..8b049448a 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -1,20 +1,21 @@ FlyBot patch 0002-flybot-api-host on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 8 file(s). diff --git a/index.js b/index.js -index 9cdb56ac60dda8bb9f364eb0791908a867412210..46cb1923507ce3b89827c5dd52b8d86a06683cf4 100644 +index 9cdb56ac60dda8bb9f364eb0791908a867412210..6f275b131462facfa0e83d05128f1a883ba82ce2 100644 --- a/index.js +++ b/index.js -@@ -4,6 +4,9 @@ +@@ -4,6 +4,10 @@ import { join } from 'node:path' import * as diagnostics from './src/host/diagnostics.js' import * as store from './src/host/store.js' +import * as memberAuth from './src/host/member-auth.js' +import * as authz from './src/host/authz.js' +import * as roomMembership from './src/host/room-membership.js' ++import * as reach from './src/host/reach.js' import * as models from './src/host/models.js' import * as permissions from './src/host/permissions.js' import * as turns from './src/host/turns.js' -@@ -29,6 +32,8 @@ import * as schedules from './src/host/schedules.js' +@@ -29,6 +33,8 @@ import * as schedules from './src/host/schedules.js' import * as usage from './src/host/usage.js' import * as update from './src/host/update.js' import * as dshUpdate from './src/host/dsh-update.js' @@ -23,12 +24,12 @@ index 9cdb56ac60dda8bb9f364eb0791908a867412210..46cb1923507ce3b89827c5dd52b8d86a import * as channel from './src/host/channel.js' export { COLORS } from './src/host/constants.js' -@@ -40,10 +45,30 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses +@@ -40,10 +46,30 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses // the functions other modules call; they call them through `rt` at run time, never during // install. Listeners run and tools are listed in registration order, so this order holds: // usage meters model requests after every listener that rewrites them. -const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] ++const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) @@ -58,10 +59,10 @@ index 9cdb56ac60dda8bb9f364eb0791908a867412210..46cb1923507ce3b89827c5dd52b8d86a export function createRuntime(ctx, config = {}) { diff --git a/src/host/hooks.js b/src/host/hooks.js new file mode 100644 -index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb3f8f1657 +index 0000000000000000000000000000000000000000..e6ccf6147c70272d0c2275b5bacb3280ef8b7835 --- /dev/null +++ b/src/host/hooks.js -@@ -0,0 +1,692 @@ +@@ -0,0 +1,717 @@ +// FlyBot hooks: the `flybot` service feature plugins (plugins/social, plugins/tree) +// build on. Installed after resume.js, so its gate around rt.deliver is the outermost +// wrapper: every Bot-to-Bot message, including message_bot, stop_bot, and replies, @@ -157,7 +158,7 @@ index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb + const emit = (name, payload) => { try { ctx.emit?.(name, payload) } catch (error) { warn('flybot: %s listener failed: %s', name, error) } } + + // ---------------------------------------------------------------- events -+ const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set() } ++ const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set(), 'bot.published': new Set() } + const changes = [] + let changeSeq = 0 + // A new host process starts a new epoch: the browser resets its watermark on a change. @@ -175,7 +176,7 @@ index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb + // Cordis-style subscription, returns a disposer. 'change' listeners hear their own + // plugin's fields unless they pass { all: true }; 'bot.removed' hears every removal. + const onFor = plugin => (event, fn, { all = false } = {}) => { -+ if (!listeners[event]) throw new Error(`flybot: unknown event ${event}; use 'change', 'bot.created', or 'bot.removed'`) ++ if (!listeners[event]) throw new Error(`flybot: unknown event ${event}; use 'change', 'bot.created', 'bot.removed', or 'bot.published'`) + const listener = event === 'change' && !all ? change => { if (change.plugin === plugin) fn(change) } : fn + listeners[event].add(listener) + return () => listeners[event].delete(listener) @@ -186,6 +187,7 @@ index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb + const team = () => Object.values(state.bots).filter(bot => bot.remote === undefined) + const view = bot => (bot === undefined ? undefined : { + id: bot.id, name: bot.name, role: bot.role, isMain: rt.isMain(bot.id), createdBy: bot.createdBy ?? null, ++ published: rt.isPublished?.(bot.id) === true, + systemPrompt: bot.systemPrompt ?? '', fields: clone(bot.fields ?? {}), + }) + const persist = () => { void rt.save().catch(error => warn('flybot: save failed: %s', error)) } @@ -372,7 +374,10 @@ index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb + // then it is dispatched; a failed dispatch stays visible for Retry. + const release = async (approval) => { + let verdict -+ try { verdict = await evaluate(approval.request, { after: approval.by }) } catch (error) { verdict = { action: 'deny', reason: error?.message ?? String(error) } } ++ // Approved or not, the owner policy is asked again: the hold may be hours old. ++ const owner = ownerPolicy(approval.request) ++ if (owner.action === 'deny') verdict = owner ++ else try { verdict = await evaluate(approval.request, { after: approval.by }) } catch (error) { verdict = { action: 'deny', reason: error?.message ?? String(error) } } + if (verdict.action === 'deny') { + approval.status = 'denied' + approval.error = verdict.reason @@ -435,7 +440,13 @@ index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb + outcomes.set(exchangeId, outcome) + if (outcomes.size > 500) outcomes.delete(outcomes.keys().next().value) + } ++ // The owner policy (reach.js) sits before the chain and is not a middleware: no verdict, bypass ++ // or priority of a plugin can skip it. It judges the message as it is now, so a Bot that was ++ // unpublished since it was written is refused. ++ const ownerPolicy = msg => rt.reach?.(msg) ?? { action: 'allow' } + const run = async (msg) => { ++ const owner = ownerPolicy(msg) ++ if (owner.action === 'deny') return { action: 'deny', reason: owner.reason, by: owner.by } + const verdict = await evaluate(msg) + if (verdict.action === 'deny') return { action: 'deny', reason: verdict.reason, by: verdict.by } + if (verdict.action === 'hold') { @@ -501,6 +512,18 @@ index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb + && !args.flybotChecked && !args.resent && !args.resumed && !args.retried && !args.deferred + innerDeliver = rt.deliver + rt.deliver = async (args) => { ++ // Everything that is not a first, checked send still carries its sender: resumed, retried, ++ // deferred and re-sent deliveries (and kickoffs, notices) are judged by the owner policy ++ // now, since the Bot they name may have been unpublished, replaced or removed since. ++ if (!gated(args) && !args.flybotChecked && (args.remote?.handle || args.fromId !== undefined)) { ++ const owner = ownerPolicy(args) ++ if (owner.action === 'deny') { ++ const exchangeId = args.exchangeId ?? randomUUID() ++ noteOutcome(exchangeId, { action: 'deny', reason: owner.reason, by: owner.by }) ++ warn('flybot: delivery to %s refused: %s', args.toId, owner.reason) ++ return exchangeId ++ } ++ } + if (!gated(args)) return innerDeliver(args) + const exchangeId = args.exchangeId ?? randomUUID() + const msg = { from: args.fromId, to: args.toId, target: targetOf(args.toId), text: args.text, role: args.role, hop: args.hop ?? 0, via: 'ds-bot', exchangeId, deliverArgs: { ...args, exchangeId } } @@ -706,6 +729,8 @@ index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb + list: () => team().map(view), + find: name => view(rt.findBot?.(name)), + isRemote, ++ // Whether the Bot is reachable from another owner (the owner's choice; the Chief by default). ++ isPublished: id => rt.isPublished?.(id) === true, + of: sessionId => view(rt.botOf(rt.selfOf(sessionId))), + create: spec => create({ plugin, ...spec }), + setSystemPrompt, @@ -752,6 +777,7 @@ index 0000000000000000000000000000000000000000..815869ce4211af08643c220cbbeab6cb + // A local Bot's answer to a friend's Bot (turns.js): back through bot.send, so the + // plugin's middleware still decides (auto-accept, or the approval hold). + const replyRemote = ({ from, handle, home, text, replyTo }) => send({ from, to: { kind: 'remote', handle, ...(home ? { home } : {}) }, text, role: 'reply', via: 'auto-reply', meta: { replyTo } }) ++ rt.flybotPublishedChanged = botId => { const bot = rt.botOf(botId); if (bot !== undefined && bot.remote === undefined) fire('bot.published', view(bot)) } + Object.assign(rt, { flybot, flybotReplyRemote: replyRemote, flybotEndpoint: endpoint, flybotRemoteMembers: remoteMembers.list, flybotSend: send, flybotIsRead: isReadEndpoint, flybotHideTools: hidePluginTools }) +} diff --git a/src/host/roster.js b/src/host/roster.js diff --git a/patches/ds-bot/0004-flybot-api-types.patch b/patches/ds-bot/0004-flybot-api-types.patch index 14102b312..f1fea0ca3 100644 --- a/patches/ds-bot/0004-flybot-api-types.patch +++ b/patches/ds-bot/0004-flybot-api-types.patch @@ -2,10 +2,10 @@ FlyBot patch 0004-flybot-api-types on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Ge diff --git a/types/flybot.d.ts b/types/flybot.d.ts new file mode 100644 -index 0000000000000000000000000000000000000000..a60144306cfc08e649a38f734758af9e4fb1b0cf +index 0000000000000000000000000000000000000000..4be408ae71ab2e4a79296fe0086165b507b4b39d --- /dev/null +++ b/types/flybot.d.ts -@@ -0,0 +1,333 @@ +@@ -0,0 +1,339 @@ +// FlyBot extension API (version 1). Implemented in src/host/hooks.js (host) and +// src/client/flybot-registry.js + flybot-slots.js (browser). Feature plugins declare +// `inject: ['flybot']` and use `ctx.flybot`. @@ -39,6 +39,8 @@ index 0000000000000000000000000000000000000000..a60144306cfc08e649a38f734758af9e + name: string + role?: string + isMain: boolean ++ /** Reachable from other owners and friends (the owner's choice; the Chief by default). Others' unpublished Bots cannot be messaged. */ ++ published: boolean + createdBy: BotId | null + /** Text prompt.js appends to this Bot's system prompt (bot.setSystemPrompt). */ + systemPrompt: string @@ -68,6 +70,8 @@ index 0000000000000000000000000000000000000000..a60144306cfc08e649a38f734758af9e + /** A new local Bot (UI, create_bot, or a plugin's bot.create); payload is its record. */ + 'bot.created': BotView + 'bot.removed': BotRemoved ++ /** A Bot's effective published state flipped (set by its owner, or the Chief changed); payload is its record. */ ++ 'bot.published': BotView +} +export type On = (event: K, listener: (payload: FlyBotEvents[K]) => void, options?: { all?: boolean }) => () => void + @@ -201,6 +205,8 @@ index 0000000000000000000000000000000000000000..a60144306cfc08e649a38f734758af9e + find(name: string): BotView | undefined + /** True for a remote address ({ kind: 'remote' } or '@handle'); local ids are never remote. */ + isRemote(ref: unknown): boolean ++ /** Whether the Bot is published (reachable from outside its owner). false for remote or unknown ids. */ ++ isPublished(id: BotId): boolean + /** The Bot a Session (own chat, group, or earlier part) belongs to. */ + of(sessionId: string): BotView | undefined + /** Returns the new Bot's id. */ diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 2aa1d1a95..7a0fb1aa0 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -1,7 +1,7 @@ FlyBot patch 0005-social-seams on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 5 file(s). diff --git a/src/host/channel.js b/src/host/channel.js -index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805ec76c4d38 100644 +index 40d43de581abae0bcc9a8fd485816117e48e8bea..94a10e37e36b4b24a9724973e7488459f654cee0 100644 --- a/src/host/channel.js +++ b/src/host/channel.js @@ -28,7 +28,8 @@ export function install(rt) { @@ -14,7 +14,15 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e revision: state.revision, workspaceId: state.workspaceId ?? null, // `mainBotId` is the first Main Bot, which the sidebar shows as its tile. -@@ -43,10 +44,24 @@ export function install(rt) { +@@ -37,16 +38,31 @@ export function install(rt) { + // `sessionId` is the Bot's current own-chat part; `parts` its earlier ones. + // `waiting`: the Bot holds its greeting until the user picks its model. + bots: Object.values(state.bots).map(({ segments, kickoff, ...bot }) => ({ +- ...bot, sessionId: rt.chatOf(bot.id), parts: (segments ?? []).map(segment => segment.sessionId), ...(kickoff ? { waiting: true } : {}), ++ // `published`: whether other owners and friends can reach it (#51), the effective value. ++ ...bot, published: rt.isPublished?.(bot.id) === true, sessionId: rt.chatOf(bot.id), parts: (segments ?? []).map(segment => segment.sessionId), ...(kickoff ? { waiting: true } : {}), + })), + // `parts`: the members' hidden group Sessions and their earlier segments, for usage. rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, ...room }) => { const parts = [...Object.values(sessions ?? {}), ...Object.values(segments ?? {}).flat().map(segment => segment.sessionId)] const last = (log ?? []).filter(line => line.kind === undefined).at(-1) @@ -40,7 +48,7 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e // The team's DSH Agent (at most one): plain dsh Sessions, not Bots. agents: Object.values(state.agents), // Key names and who may use them; values never leave the host. -@@ -54,7 +69,8 @@ export function install(rt) { +@@ -54,7 +70,8 @@ export function install(rt) { capabilities: rt.capabilityLibraryView(), secretRequests: state.secretRequests ?? {}, colors: [...COLORS, ...EXTRA_COLORS], @@ -50,7 +58,7 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +81,59 @@ export function install(rt) { +@@ -65,22 +82,59 @@ export function install(rt) { memory: rt.memoryEnabled !== false, // Set while another dsh host holds the team (store.js): why this one cannot change it. readOnly: rt.readOnly() ?? null, @@ -120,7 +128,7 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e function setPrefs(body) { if (body.theme !== undefined) { -@@ -100,20 +153,26 @@ export function install(rt) { +@@ -100,20 +154,26 @@ export function install(rt) { return rt.save() } @@ -151,7 +159,7 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e case 'models': await rt.catalog({ fresh: true }) return ok({ modelsKey: modelsKey() }) -@@ -141,7 +200,7 @@ export function install(rt) { +@@ -141,7 +201,7 @@ export function install(rt) { return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null }))) case 'create-bot': { await rt.bootstrap() @@ -160,8 +168,15 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e return ok(bot) } case 'update-bot': -@@ -160,7 +219,12 @@ export function install(rt) { +@@ -158,9 +218,19 @@ export function install(rt) { + replace: typeof body.replace === 'string' ? body.replace : undefined, + }) return ok(view()) ++ case 'set-published': ++ // The owner's choice (#51): true, false, or null for the default (the Chief only). ++ await rt.load() ++ await rt.setPublished(String(body.id ?? body.botId ?? ''), body.published === undefined ? true : body.published) ++ return ok(view(actor)) case 'create-room': { await rt.load() - const room = await rt.createRoom({ members: Array.isArray(body.members) ? body.members : [], name: body.name, ...(typeof body.admin === 'string' ? { admin: body.admin } : {}) }) @@ -174,7 +189,7 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e if (body.greet === true) await rt.greetRoom(room.id).catch(error => rt.warn('ds-bot: greeting in %s failed: %s', room.name, error)) return ok(room) } -@@ -175,8 +239,24 @@ export function install(rt) { +@@ -175,8 +245,24 @@ export function install(rt) { add: Array.isArray(body.add) ? body.add : undefined, remove: Array.isArray(body.remove) ? body.remove : undefined, workDir: body.workDir, @@ -201,7 +216,7 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e } case 'folders': case 'folder-pick': -@@ -281,6 +361,18 @@ export function install(rt) { +@@ -281,6 +367,18 @@ export function install(rt) { // A Bot id or an earlier part of its own chat means the current part. const past = rt.pastOwners.get(body.sessionId) const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId @@ -220,7 +235,7 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,7 +411,37 @@ export function install(rt) { +@@ -319,7 +417,37 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -258,7 +273,7 @@ index 40d43de581abae0bcc9a8fd485816117e48e8bea..fd3a384ffdaaa87fa485afb1da90805e return fail(`Unknown endpoint ${endpoint}`, 'bot/not-found') } } catch (error) { -@@ -331,20 +453,52 @@ export function install(rt) { +@@ -331,20 +459,52 @@ export function install(rt) { } // An exact Fetch route on the shared /api channel; the browser authenticates with its session cookie. diff --git a/patches/ds-bot/0011-owner-scoped-access.patch b/patches/ds-bot/0011-owner-scoped-access.patch index 4e6287830..00148f4a6 100644 --- a/patches/ds-bot/0011-owner-scoped-access.patch +++ b/patches/ds-bot/0011-owner-scoped-access.patch @@ -2,10 +2,10 @@ FlyBot patch 0011-owner-scoped-access on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). diff --git a/src/host/authz.js b/src/host/authz.js new file mode 100644 -index 0000000000000000000000000000000000000000..0d770e9b8d1c85007a0d9b05b3d9ee234917b655 +index 0000000000000000000000000000000000000000..90b1968a067749c69e7aa8116a8f62301917c993 --- /dev/null +++ b/src/host/authz.js -@@ -0,0 +1,253 @@ +@@ -0,0 +1,255 @@ +// Owner-scoped access (#49): default-deny authorization for the /api/bot channel. +// Once a member session exists on the host, every Bot, room and session an endpoint +// touches must belong to the signed-in actor; shared resources that cannot yet be @@ -21,7 +21,8 @@ index 0000000000000000000000000000000000000000..0d770e9b8d1c85007a0d9b05b3d9ee23 +// Install order matters: right after member-auth, so rt.authzEnforced sees the same +// state.authSessions the sign-in surface writes, and before any module it gates. + -+import { currentParticipants, isDesignatedChief, roomVisibleTo } from './room-membership.js' ++import { currentParticipants, roomVisibleTo } from './room-membership.js' ++import { isPublished } from './reach.js' + +// Endpoints a request without an actor may still reach. auth-invite keeps the +// bootstrap rule in member-auth (first invite signs the installing human in). @@ -45,7 +46,7 @@ index 0000000000000000000000000000000000000000..0d770e9b8d1c85007a0d9b05b3d9ee23 + 'memory-view', 'memory-ask', 'memory-forget', + 'schedules', 'schedule-create', 'schedule-delete', + 'add-agent', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent', -+ 'stop-member-work', 'set-main', ++ 'stop-member-work', 'set-main', 'set-published', +]) + +// Endpoints whose body names a room. @@ -100,7 +101,8 @@ index 0000000000000000000000000000000000000000..0d770e9b8d1c85007a0d9b05b3d9ee23 + if (bot.ownerId === actor.memberId) return true + const owners = new Set(humans) + owners.add(actor.memberId) -+ return isDesignatedChief(state, botId) && owners.has(bot.ownerId) ++ // Another owner's Bot joins only if its owner published it (the Chief by default, #51). ++ return isPublished(state, botId) && owners.has(bot.ownerId) + } + + // A sessionId may be a Bot's own chat, an earlier part, or a room Session: resolve diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index e84380e2d..e9b91eeaa 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -354,10 +354,10 @@ index 7dbcc2cabf67187336152d01270d381f5bedda79..da2a182aa438c3aeb188908e137db2b8 })) diff --git a/src/host/room-membership.js b/src/host/room-membership.js new file mode 100644 -index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e294c84d0e +index 0000000000000000000000000000000000000000..68b463ad088efd116bce0473778e793f36d5465b --- /dev/null +++ b/src/host/room-membership.js -@@ -0,0 +1,776 @@ +@@ -0,0 +1,825 @@ +// Room membership (#50/#50 follow-up): typed human, local-bot and published remote-bot +// participants, room roles, persistent author ids, membership-window history. This is +// not team mode — the gates in docs/subsystems/team.md stay unsatisfied. @@ -381,15 +381,19 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 +// (for an invited owner, their Bot's interval). Leaving closes the interval and +// withdraws all history access. Rejoining opens a new interval at the then-current +// seq; prior intervals do not come back. Non-members cannot read or inject. -+// Unpublished specialists stay owner-private. Only a designated Chief or a Bot -+// published to friends (`state.flybot.remote`, cleanRemote) may be added across -+// owners. ++// Unpublished specialists stay owner-private. Across owners (and with remote ++// participants) only a *published* local Bot (the owner's choice; the designated ++// Chief by default, see reach.js) or a Bot that joined by room code (`viaCode`, #83; ++// reachable in this room only, while a member), and a Bot published to friends ++// (`state.flybot.remote`, cleanRemote) may be added. A Bot that stops qualifying ++// (unpublished, Chief replaced) leaves such rooms at once (rt.evictUnshared). +// +// Inbound shared rooms (`origin: 'inbound-share'`) are the remote owner's projection +// of a friend's room that invited their published Bot. + +import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { cleanName, fenceUntrusted } from './delivery.js' ++import { isPublished, mayShareRoom } from './reach.js' + +const KINDS = new Set(['human', 'bot']) +const ROLES = new Set(['admin', 'member']) @@ -634,7 +638,7 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + return [...humans, ...bots] +} + -+function permittedBot(state, room, botId, humans) { ++function permittedBot(state, room, botId, humans, { viaCode = false } = {}) { + const bot = state.bots?.[botId] + if (!bot) return false + const owners = new Set(humans) @@ -644,7 +648,7 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + } + if (state.bots[botId]?.ownerId) owners.add(bot.ownerId) + const cross = owners.size > 1 -+ if (cross) return isDesignatedChief(state, botId) && humans.has(bot.ownerId) ++ if (cross) return (isPublished(state, botId) && humans.has(bot.ownerId)) || viaCode + return bot.ownerId === [...owners][0] +} + @@ -738,12 +742,19 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + return { ...line, who: name, ...(id ? { authorId: id, authorKind: 'human' } : {}) } + } + -+ function assertAddable(room, draft, actor) { ++ const REMOTE_ROOM_MESSAGE = 'Only a published Bot (or one that joined with the room code) may be in a room with friends\' Bots' ++ const hasRemote = room => currentParticipants(room).some(isRemoteParticipant) ++ // Local members that may not stay in a room with remote participants or other owners. ++ const unsharable = room => currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote && !mayShareRoom(state, room, p.id)) ++ ++ // `viaCode` (a room-code join, #83) is an internal option: it is never read from a request body. ++ function assertAddable(room, draft, actor, { viaCode = false } = {}) { + if (!KINDS.has(draft.kind)) throw invalid('Participant kind must be human or bot') + if (isRemoteDraft(draft)) { + const published = publishedRemoteOf(state, draft.handle || draft.id) + if (!published) throw forbidden('Only a Chief or a Bot published to friends may join a shared room') + if (participantOf(room, 'bot', published.handle)) throw invalid('That participant is already in the room') ++ if (unsharable(room).length > 0) throw forbidden(REMOTE_ROOM_MESSAGE) + return published + } + if (typeof draft.id !== 'string' || draft.id === '') throw invalid('A participant needs an id') @@ -756,7 +767,7 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + if (humans.size > 1) { + for (const p of currentParticipants(room).filter(item => item.kind === 'bot' && !item.remote)) { + const ownerId = state.bots?.[p.id]?.ownerId -+ if (!isDesignatedChief(state, p.id) || !humans.has(ownerId)) { ++ if (!mayShareRoom(state, room, p.id) || !humans.has(ownerId)) { + throw forbidden('Specialists stay in their owner\'s private rooms; a cross-owner room takes each side\'s Chief') + } + } @@ -768,23 +779,26 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + const humans = new Set(currentParticipants(room).filter(p => p.kind === 'human').map(p => p.id)) + if (actor?.memberId) humans.add(actor.memberId) + humans.add(bot.ownerId) -+ if (!permittedBot(state, room, draft.id, humans)) { -+ if (isSpecialist(state, draft.id) && (isCrossOwnerRoom(room, state) || humans.size > 1)) { ++ if (!permittedBot(state, room, draft.id, humans, { viaCode })) { ++ if (!isPublished(state, draft.id) && (isCrossOwnerRoom(room, state) || humans.size > 1)) { + throw forbidden('Specialists stay in their owner\'s private rooms; a cross-owner room takes each side\'s Chief') + } -+ throw forbidden('Only a designated Chief of a room member may join a shared room') ++ throw forbidden('Only a published Bot of a room member may join a shared room') + } ++ if (hasRemote(room) && !viaCode && !isPublished(state, draft.id)) throw forbidden(REMOTE_ROOM_MESSAGE) + const nextOwners = new Set(humans) + if (nextOwners.size > 1) { + for (const p of currentParticipants(room).filter(item => item.kind === 'bot' && !item.remote)) { -+ if (isSpecialist(state, p.id)) { ++ if (!mayShareRoom(state, room, p.id)) { + throw forbidden('Specialists stay in their owner\'s private rooms; a cross-owner room takes each side\'s Chief') + } + } + } + } + -+ rt.addRoomParticipant = function addRoomParticipant(roomId, draft, actor) { ++ // `opts.viaCode`: the Bot joins with the room's code (#83): it needs no publication, and is ++ // reachable in this room only, while it is a member. Internal: request bodies cannot set it. ++ rt.addRoomParticipant = function addRoomParticipant(roomId, draft, actor, opts = {}) { + return withRoomLock(async () => { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') @@ -793,7 +807,8 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + throw forbidden('Only a room admin can add participants') + } + const kind = draft.kind -+ const published = assertAddable(room, draft, actor) ++ const viaCode = opts?.viaCode === true && draft.kind === 'bot' && !isRemoteDraft(draft) ++ const published = assertAddable(room, draft, actor, { viaCode }) + const role = draft.role === 'admin' ? 'admin' : 'member' + const invitedBy = actor?.memberId + const invitedByName = actor ? displayNameFor(state, actor.memberId, actor.displayName) : undefined @@ -853,6 +868,7 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + displayName, + joinedSeq: line.seq, + joinedAt: invitedAt, ++ ...(viaCode ? { viaCode: true } : {}), + ...(invitedBy && kind === 'bot' ? { invitedBy, invitedByName, invitedAt } : {}), + } + room.participants.push(record) @@ -1014,6 +1030,8 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + throw forbidden('Specialists stay in their owner\'s private rooms; a cross-owner room takes each side\'s Chief') + } + } ++ // A room with a friend's Bot takes only published local Bots (a room code cannot be used here). ++ if (hasRemote(room) && unsharable(room).length > 0) throw forbidden(REMOTE_ROOM_MESSAGE) + syncMembers(room) + return room + } @@ -1026,15 +1044,23 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + })) + } + ++ // Room lines go out to the friends' Bots in the room, checked per author and per recipient ++ // (#51): a line written by a local Bot that may not share the room never leaves, a friend's ++ // Bot that is no longer published to us gets nothing, and the sending Bot is one that may ++ // speak outside (bot.send then runs the reach policy on every single send). + rt.fanoutRoomLine = async function fanoutRoomLine(room, line) { + const remotes = currentParticipants(room).filter(isRemoteParticipant) + if (remotes.length === 0 || !rt.flybotSend) return -+ const from = (room.admin && rt.botOf?.(room.admin) ? room.admin : (room.members ?? []).find(id => rt.botOf?.(id))) ++ const localAuthor = line.authorKind === 'bot' && !line.remote && rt.botOf?.(line.authorId ?? line.botId) ? (line.authorId ?? line.botId) : undefined ++ if (localAuthor !== undefined && !mayShareRoom(state, room, localAuthor)) return ++ const speakers = [room.admin, ...(room.members ?? [])].filter(id => id && rt.botOf?.(id) && mayShareRoom(state, room, id)) ++ const from = speakers[0] + if (!from) return + const who = cleanName(line.who || 'member') + const body = line.text ? fenceUntrusted(who, line.text) : `[${cleanName(line.kind || 'event')}]` + for (const remote of remotes) { + if (line.authorId === remote.id || line.authorId === remote.handle) continue ++ if (!publishedRemoteOf(state, remote.handle)) continue + try { + await rt.flybotSend({ + from, @@ -1050,6 +1076,29 @@ index 0000000000000000000000000000000000000000..497735b5caeb0319640300f4b22967e2 + } + } + ++ // A Bot that no longer qualifies (unpublished, Chief replaced, owner removed) leaves every room ++ // that has friends' Bots or more than one owner, at once; without an id, every such Bot does. ++ rt.evictUnshared = async function evictUnshared(botId) { ++ let changed = false ++ for (const room of Object.values(state.rooms ?? {})) { ++ if (!Array.isArray(room.participants) || !(hasRemote(room) || isCrossOwnerRoom(room, state))) continue ++ for (const me of currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote && (botId === undefined || p.id === botId))) { ++ if (mayShareRoom(state, room, me.id)) continue ++ await withRoomLock(async () => { ++ if (me.leftSeq !== undefined) return ++ const line = append(room, { kind: 'left', who: me.displayName, authorId: me.id, authorKind: 'bot', text: '' }) ++ me.leftSeq = line.seq ++ me.leftAt = Date.now() ++ if (room.seen) delete room.seen[me.id] ++ rt.dropGroupSession?.(room, me.id) ++ syncMembers(room) ++ changed = true ++ }) ++ } ++ } ++ if (changed) await rt.save() ++ } ++ + rt.ingestRemoteRoomLine = async function ingestRemoteRoomLine({ handle, text } = {}) { + const id = String(handle ?? '') + if (!id) return @@ -1224,10 +1273,10 @@ index adc5416982fe43238970440a857d8fd6040f9b2a..5593b3d423c793bc638cbda59daedee2 assert.deepEqual(names(mentionRows(roster, 'chief-2')), ['King', 'Writer']) diff --git a/test/remote-room.test.js b/test/remote-room.test.js new file mode 100644 -index 0000000000000000000000000000000000000000..ddfef0ed777d2b9f71a0e39242e0eb70ef25636e +index 0000000000000000000000000000000000000000..9242493be795f0fc6b697d39e2c77a3a520c9d62 --- /dev/null +++ b/test/remote-room.test.js -@@ -0,0 +1,364 @@ +@@ -0,0 +1,504 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' @@ -1247,6 +1296,7 @@ index 0000000000000000000000000000000000000000..ddfef0ed777d2b9f71a0e39242e0eb70 + roomVisibleTo, + viewerRole, +} from '../src/host/room-membership.js' ++import { install as installReach, reach } from '../src/host/reach.js' +import { remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js' +import { mentionRows } from '../src/client/at-menu.js' +import { indexRoster } from '../src/client/sources.js' @@ -1279,6 +1329,7 @@ index 0000000000000000000000000000000000000000..ddfef0ed777d2b9f71a0e39242e0eb70 + installMemberAuth(rt) + installAuthz(rt) + installRooms(rt) ++ installReach(rt) + t.after(async () => { + await Promise.all(disposers.splice(0).map(cleanup => cleanup())) + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) @@ -1592,12 +1643,150 @@ index 0000000000000000000000000000000000000000..ddfef0ed777d2b9f71a0e39242e0eb70 + const eve = { memberId: 'owner-9' } + assert.equal(roomVisibleTo(rt.state, eve, 'war'), false) +}) ++ ++// ---- #51: a room with friends' Bots takes published local Bots only ++ ++// The war room starts with both Chiefs; these tests want one owner and the friend's Bot. ++const soloWar = (rt) => { ++ const room = rt.state.rooms.war ++ room.participants = room.participants.filter(p => p.id !== 'chief-b') ++ room.members = room.members.filter(id => id !== 'chief-b') ++} ++ ++const withRemote = async (rt, actorA) => { ++ soloWar(rt) ++ publish(rt) ++ await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) ++} ++ ++test('#51: an unpublished local Bot cannot be added to a room that has a friend\'s Bot', async (t) => { ++ const { rt } = host(t, TEAM) ++ await rt.load() ++ const { alice } = await signAliceAndBob(rt) ++ const actorA = await rt.authenticate(alice.sessionToken) ++ await withRemote(rt, actorA) ++ await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA), /published Bot/) ++ assert.equal(rt.state.rooms.war.members.includes('spec-a'), false) ++ // The owner's choice lets the same Bot in. ++ await rt.setPublished('spec-a', true) ++ await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) ++ assert.equal(rt.state.rooms.war.members.includes('spec-a'), true) ++}) ++ ++test('#51: a friend\'s Bot cannot be added to a room that holds an unpublished local Bot', async (t) => { ++ const { rt } = host(t, TEAM) ++ await rt.load() ++ const { alice } = await signAliceAndBob(rt) ++ const actorA = await rt.authenticate(alice.sessionToken) ++ soloWar(rt) ++ publish(rt) ++ await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) ++ await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA), /published Bot/) ++ assert.equal(rt.state.rooms.war.participants.some(p => p.remote), false) ++ await rt.setPublished('spec-a', true) ++ await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) ++ assert.equal(rt.state.rooms.war.participants.some(p => p.remote), true) ++}) ++ ++test('#51: creating a room with a friend\'s Bot and an unpublished member is refused', async (t) => { ++ const { rt } = host(t, TEAM) ++ rt.createRoom = undefined ++ await rt.load() ++ const { alice } = await signAliceAndBob(rt) ++ const actorA = await rt.authenticate(alice.sessionToken) ++ publish(rt) ++ const room = { id: 'new', members: [], participants: [], createdAt: 1 } ++ const drafts = [ ++ { kind: 'human', id: 'owner-1', role: 'admin' }, ++ { kind: 'bot', id: 'spec-a' }, ++ { kind: 'bot', remote: true, handle: '@coder.bob.home' }, ++ ] ++ assert.throws(() => rt.applyParticipants(room, drafts, actorA), /published Bot/) ++ rt.state.bots['spec-a'].published = true ++ assert.doesNotThrow(() => rt.applyParticipants({ id: 'new', members: [], participants: [], createdAt: 1 }, drafts, actorA)) ++}) ++ ++test('#51: unpublishing a Bot, or replacing its Chief, takes it out of rooms with friends at once', async (t) => { ++ const { rt } = host(t, TEAM) ++ await rt.load() ++ const { alice } = await signAliceAndBob(rt) ++ const actorA = await rt.authenticate(alice.sessionToken) ++ await withRemote(rt, actorA) ++ await rt.setPublished('spec-a', true) ++ await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) ++ const room = rt.state.rooms.war ++ assert.deepEqual(room.members.filter(id => id.startsWith('spec') || id.startsWith('chief')).sort(), ['chief-a', 'spec-a']) ++ await rt.setPublished('spec-a', false) ++ assert.equal(room.members.includes('spec-a'), false) ++ assert.ok(room.log.some(line => line.kind === 'left' && line.authorId === 'spec-a')) ++ assert.ok(room.participants.find(p => p.id === 'spec-a').leftSeq !== undefined) ++ // The Chief default: turning the Chief off takes it out too, and the remote stays. ++ await rt.setPublished('chief-a', false) ++ assert.equal(room.members.includes('chief-a'), false) ++ assert.equal(room.participants.some(p => p.remote && p.leftSeq === undefined), true) ++}) ++ ++test('#51: unpublishing leaves private rooms (no friends, one owner) alone', async (t) => { ++ const { rt } = host(t, TEAM) ++ await rt.load() ++ await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, undefined).catch(() => {}) ++ const room = rt.state.rooms.war ++ room.participants.push({ kind: 'bot', id: 'spec-a', role: 'member', displayName: 'Writer A', joinedSeq: 0, joinedAt: 1 }) ++ room.members.push('spec-a') ++ await rt.setPublished('spec-a', true) ++ await rt.setPublished('spec-a', false) ++ assert.equal(room.members.includes('spec-a'), true) ++}) ++ ++test('#51: fanout sends from a published Bot, skips lines of Bots that may not share, and skips friends no longer published', async (t) => { ++ const { rt, sent } = host(t, TEAM) ++ await rt.load() ++ const { alice } = await signAliceAndBob(rt) ++ const actorA = await rt.authenticate(alice.sessionToken) ++ await withRemote(rt, actorA) ++ const room = rt.state.rooms.war ++ // An unpublished specialist's line never leaves (it is not even a member now). ++ await rt.fanoutRoomLine(room, { seq: 5, who: 'Writer A', text: 'draft', authorKind: 'bot', authorId: 'spec-a' }) ++ assert.equal(sent.length, 0) ++ await rt.fanoutRoomLine(room, { seq: 6, who: 'Alice', text: 'hello', authorKind: 'human', authorId: 'owner-1' }) ++ assert.equal(sent.length, 1) ++ assert.equal(sent[0].from, 'chief-a') ++ // The admin Chief is no longer published: someone who is still published speaks, never it. ++ await rt.setPublished('chief-a', false) ++ await rt.fanoutRoomLine(room, { seq: 7, who: 'Alice', text: 'again', authorKind: 'human', authorId: 'owner-1' }) ++ assert.ok(sent.slice(1).every(msg => msg.from !== 'chief-a')) ++ // The friend takes the Bot off their published list: nothing goes there. ++ const before = sent.length ++ publish(rt, []) ++ await rt.fanoutRoomLine(room, { seq: 8, who: 'Alice', text: 'anyone?', authorKind: 'human', authorId: 'owner-1' }) ++ assert.equal(sent.length, before) ++}) ++ ++test('#51: a room code lets an unpublished Bot in, for that room only (internal option, never a request field)', async (t) => { ++ const { rt } = host(t, TEAM) ++ await rt.load() ++ const { alice } = await signAliceAndBob(rt) ++ const actorA = await rt.authenticate(alice.sessionToken) ++ await withRemote(rt, actorA) ++ await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a', viaCode: true }, actorA), /published Bot/) ++ await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA, { viaCode: true }) ++ const entry = rt.state.rooms.war.participants.find(p => p.id === 'spec-a') ++ assert.equal(entry.viaCode, true) ++ const remote = { kind: 'remote', handle: '@coder.bob.home' } ++ assert.equal(reach(rt.state, { from: 'spec-a', to: remote, roomId: 'war' }).action, 'allow') ++ assert.equal(reach(rt.state, { from: 'spec-a', to: remote, roomId: 'elsewhere' }).action, 'deny') ++ assert.equal(reach(rt.state, { from: remote, to: 'spec-a' }).action, 'deny') ++ assert.equal(reach(rt.state, { from: 'spec-a', to: remote }).action, 'deny') ++ // It stays while it is a member and needs no publication; an unpublish of others does not touch it. ++ await rt.setPublished('chief-a', false) ++ assert.equal(rt.state.rooms.war.members.includes('spec-a'), true) ++}) diff --git a/test/room-membership.test.js b/test/room-membership.test.js new file mode 100644 -index 0000000000000000000000000000000000000000..5232a42dacd48ddf69fe4a14fa5c95375934ba73 +index 0000000000000000000000000000000000000000..886f13b56d7b5606af8fd11ed123a0fda4bdd870 --- /dev/null +++ b/test/room-membership.test.js -@@ -0,0 +1,394 @@ +@@ -0,0 +1,418 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' @@ -1608,6 +1797,7 @@ index 0000000000000000000000000000000000000000..5232a42dacd48ddf69fe4a14fa5c9537 +import { install as installStore } from '../src/host/store.js' +import { install as installMemberAuth } from '../src/host/member-auth.js' +import { install as installAuthz } from '../src/host/authz.js' ++import { install as installReach } from '../src/host/reach.js' +import { install as installRooms, currentParticipants, isCrossOwnerRoom, linesVisibleTo, roomVisibleTo } from '../src/host/room-membership.js' +// Room membership (#50): humans and Chiefs share a room with distinct authorship. +// Team mode stays disabled — these tests cover the membership surface only. @@ -1638,6 +1828,7 @@ index 0000000000000000000000000000000000000000..5232a42dacd48ddf69fe4a14fa5c9537 + installMemberAuth(rt) + installAuthz(rt) + installRooms(rt) ++ installReach(rt) + t.after(async () => { + await Promise.all(disposers.splice(0).map(cleanup => cleanup())) + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }) @@ -1839,6 +2030,28 @@ index 0000000000000000000000000000000000000000..5232a42dacd48ddf69fe4a14fa5c9537 + assert.equal(isCrossOwnerRoom(rt.state.rooms.shared, rt.state), true) +}) + ++test('#51: a published specialist may join a cross-owner room, and unpublishing takes it out at once', async (t) => { ++ const { rt } = host(t, TEAM) ++ await rt.load() ++ const { alice } = await signAliceAndBob(rt) ++ const actorA = await rt.authenticate(alice.sessionToken) ++ const create = { members: ['chief-a', 'spec-b'], participants: [{ kind: 'human', id: 'owner-1' }, { kind: 'human', id: 'owner-2' }, { kind: 'bot', id: 'chief-a' }, { kind: 'bot', id: 'spec-b' }] } ++ assert.equal(rt.authorize(actorA, 'create-room', create).ok, false) ++ await rt.setPublished('spec-b', true) ++ assert.equal(rt.authorize(actorA, 'create-room', create).ok, true) ++ await rt.addRoomParticipant('shared', { kind: 'bot', id: 'spec-b' }, actorA) ++ assert.equal(rt.state.rooms.shared.members.includes('spec-b'), true) ++ // One unpublished member in the room also keeps another specialist out. ++ await assert.rejects(() => rt.addRoomParticipant('shared', { kind: 'bot', id: 'spec-a' }, actorA), /Specialists stay/) ++ await rt.setPublished('spec-b', false) ++ assert.equal(rt.state.rooms.shared.members.includes('spec-b'), false) ++ assert.ok(rt.state.rooms.shared.log.some(line => line.kind === 'left' && line.authorId === 'spec-b')) ++ // A Chief replaced by its owner's choice leaves too; the room itself stays. ++ await rt.setPublished('chief-b', false) ++ assert.equal(rt.state.rooms.shared.members.includes('chief-b'), false) ++ assert.ok(rt.state.rooms.shared.participants.some(p => p.kind === 'human' && p.id === 'owner-2' && p.leftSeq === undefined)) ++}) ++ +test('existing single-owner groups still display and accept posts', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() diff --git a/patches/ds-bot/0013-published-reach.patch b/patches/ds-bot/0013-published-reach.patch new file mode 100644 index 000000000..0d48df227 --- /dev/null +++ b/patches/ds-bot/0013-published-reach.patch @@ -0,0 +1,409 @@ +FlyBot patch 0013-published-reach on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; 2 file(s). + +diff --git a/src/host/reach.js b/src/host/reach.js +new file mode 100644 +index 0000000000000000000000000000000000000000..d8dd3e03b706a38296e2486b8e0cd7482c25d37f +--- /dev/null ++++ b/src/host/reach.js +@@ -0,0 +1,129 @@ ++// Reach policy (#51): who may be reached from outside their owner. One owner- and ++// publication-aware check that sits OUTSIDE the plugin send chain, so a middleware's ++// `allow` or `bypass` verdict can never skip it. hooks.js runs it before bot.send's ++// chain, again when an approved hold is released, and on every other delivery that ++// carries a sender (resumed, retried, deferred, re-sent), so a queued message is judged ++// by what is true when it is delivered, not when it was written. ++// ++// A Bot is reachable from another owner only if it is *published*: ++// state.bots[id].published true | false, set by its owner (rt.setPublished) ++// unset the owner's designated Chief is published, every other Bot is not ++// A Bot added to one room by its room code (participant `viaCode: true`, #83) is ++// reachable only for that room, and only while it is a current member. ++// A Bot whose owner is not a team member (removed owner) is never published. ++// ++// The rules (from → to), `deny` is `{ action: 'deny', reason, by: 'owner-policy' }`: ++// remote → local the local Bot must be published (a friend cannot reach anything else) ++// local → remote the sending Bot must be published, or a code-joined member of the room ++// the message belongs to (an unpublished Bot has no outside identity) ++// local → local same owner: allowed. Other owner: the sender and the target must be ++// published, or both current members of the room the message belongs to ++// no sender the owner or the system: allowed ++ ++import { currentParticipants, isDesignatedChief } from './room-membership.js' ++ ++export const POLICY = 'owner-policy' ++ ++const isRemoteRef = ref => (ref !== null && typeof ref === 'object' && ref.kind === 'remote') || (typeof ref === 'string' && ref.startsWith('@')) ++const deny = reason => ({ action: 'deny', reason, by: POLICY }) ++const ALLOW = Object.freeze({ action: 'allow' }) ++ ++const ownerKnown = (state, bot) => Boolean(bot?.ownerId && state.members?.[bot.ownerId]) ++ ++// The Bot's published state now: the owner's explicit choice, else the Chief default. ++export function isPublished(state, botId) { ++ const bot = state.bots?.[botId] ++ if (bot === undefined || bot.remote !== undefined || !ownerKnown(state, bot)) return false ++ if (typeof bot.published === 'boolean') return bot.published ++ return isDesignatedChief(state, botId) ++} ++ ++// A Bot that joined this room with its room code: reachable only here, only while a member. ++export function isCodeJoined(state, roomId, botId) { ++ const room = state.rooms?.[String(roomId ?? '')] ++ return room !== undefined && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote && p.id === botId && p.viaCode === true) ++} ++ ++// May this local Bot take part in a room that has a remote participant or more than one ++// owner? Published Bots may; a code-joined Bot may in its own room only. ++export function mayShareRoom(state, room, botId) { ++ return isPublished(state, botId) || isCodeJoined(state, room?.id, botId) ++} ++ ++const inRoom = (state, roomId, botId) => { ++ const room = state.rooms?.[String(roomId ?? '')] ++ return room !== undefined && (room.members ?? []).includes(botId) ++} ++ ++export function reach(state, { from, to, roomId } = {}) { ++ const fromRemote = isRemoteRef(from) ++ const toRemote = isRemoteRef(to) ++ if (fromRemote && toRemote) return deny('one side must be a local Bot') ++ if (fromRemote) { ++ const target = state.bots?.[to] ++ if (target === undefined) return ALLOW // unknown targets are refused where they are resolved ++ if (isPublished(state, to)) return ALLOW ++ return deny(`${target.name} is not published, so it cannot be reached from outside`) ++ } ++ if (from === undefined || from === null) return ALLOW ++ const sender = state.bots?.[from] ++ if (sender === undefined) return ALLOW ++ if (toRemote) { ++ if (isPublished(state, from)) return ALLOW ++ if (roomId !== undefined && isCodeJoined(state, roomId, from)) return ALLOW ++ return deny(`${sender.name} is not published, so it cannot message friends' Bots`) ++ } ++ const target = state.bots?.[to] ++ if (target === undefined) return ALLOW ++ if (sender.ownerId === target.ownerId) return ALLOW ++ if (!ownerKnown(state, sender) || !ownerKnown(state, target)) return deny('that Bot has no owner on this team') ++ if (isPublished(state, from) && isPublished(state, to)) return ALLOW ++ if (roomId !== undefined && inRoom(state, roomId, from) && inRoom(state, roomId, to)) return ALLOW ++ return deny(`${target.name} is not published, so it cannot be reached by another owner's Bot`) ++} ++ ++export function install(rt) { ++ const { state } = rt ++ ++ rt.isPublished = botId => isPublished(state, botId) ++ rt.isCodeJoined = (roomId, botId) => isCodeJoined(state, roomId, botId) ++ rt.mayShareRoom = (room, botId) => mayShareRoom(state, room, botId) ++ // A bot.send message or a deliver() argument set, in either shape. ++ rt.reach = (msg = {}) => reach(state, { ++ from: msg.remote?.handle ? { kind: 'remote', handle: msg.remote.handle } : (msg.from ?? msg.fromId), ++ to: msg.to ?? msg.toId, ++ roomId: msg.meta?.roomId ?? msg.roomId ?? msg.fromRoomId ?? msg.deliverArgs?.roomId ?? msg.deliverArgs?.fromRoomId, ++ }) ++ ++ // The owner's choice: true or false; null puts the Bot back on the default (Chief only). ++ rt.setPublished = async (botId, value) => { ++ const bot = state.bots?.[botId] ++ if (bot === undefined || bot.remote !== undefined) throw Object.assign(new Error('Unknown Bot'), { code: 'bot/not-found' }) ++ if (value !== true && value !== false && value !== null) throw Object.assign(new Error('published must be true, false or null'), { code: 'bot/invalid' }) ++ const before = isPublished(state, botId) ++ if (value === null) delete bot.published ++ else bot.published = value ++ const after = isPublished(state, botId) ++ if (before !== after) await rt.reachChanged(botId) ++ await rt.save() ++ return after ++ } ++ ++ // Route eligibility changed (unpublish, Chief replaced, owner removed): a Bot that may no ++ // longer be in a room with remote participants or other owners leaves it at once. ++ rt.reachChanged = async (botId) => { ++ await rt.evictUnshared?.(botId) ++ rt.flybotPublishedChanged?.(botId) ++ } ++ const syncChief = rt.syncDesignatedChief ++ if (typeof syncChief === 'function') { ++ rt.syncDesignatedChief = (...args) => { ++ const before = new Map(Object.keys(state.bots ?? {}).map(id => [id, isPublished(state, id)])) ++ const changed = syncChief(...args) ++ if (changed) { ++ for (const [id, was] of before) if (was !== isPublished(state, id)) void rt.reachChanged(id).catch(error => rt.warn?.('ds-bot: reach change for %s failed: %s', id, error)) ++ } ++ return changed ++ } ++ } ++} +diff --git a/test/reach-policy.test.js b/test/reach-policy.test.js +new file mode 100644 +index 0000000000000000000000000000000000000000..7a00f0b32ca7e21d91fa8927740f2c1607b373a3 +--- /dev/null ++++ b/test/reach-policy.test.js +@@ -0,0 +1,266 @@ ++// #51: published Bots, the reach policy, and where it is enforced. ++import assert from 'node:assert/strict' ++import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' ++import { tmpdir } from 'node:os' ++import { join } from 'node:path' ++import { test } from 'node:test' ++import { createRuntime, provideFlyBot } from '../index.js' ++import { fakeContext } from './fake-ctx.js' ++import { isPublished, reach } from '../src/host/reach.js' ++ ++const bot = (id, ownerId, extra = {}) => ({ id, sessionId: id, name: id.toUpperCase(), instructions: '', createdAt: 1, ownerId, ...extra }) ++ ++// Two owners, each with a Chief (published by default) and a specialist. ++const team = () => ({ ++ version: 2, ++ revision: 1, ++ mainBotIds: ['a', 'b'], ++ members: { ++ alice: { id: 'alice', createdAt: 1, designatedChiefId: 'a' }, ++ bob: { id: 'bob', createdAt: 1, designatedChiefId: 'b' }, ++ }, ++ bots: { a: bot('a', 'alice'), a2: bot('a2', 'alice'), b: bot('b', 'bob'), b2: bot('b2', 'bob') }, ++ rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, ++}) ++ ++ ++async function start(t, { config = {}, state = team() } = {}) { ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-reach-')) ++ writeFileSync(join(home, 'state.json'), JSON.stringify(state)) ++ const ctx = fakeContext() ++ const agents = new Map() ++ const agentOf = (id) => { ++ if (!agents.has(id)) { ++ const calls = [] ++ agents.set(id, { ++ session: { id, requestHeader: () => undefined }, id, calls, status: 'idle', ++ followup(m) { calls.push(['followup', m]) }, steer(m) { calls.push(['steer', m]) }, ++ inject() {}, cancel() {}, async whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} }, ++ }) ++ } ++ return agents.get(id) ++ } ++ Object.assign(ctx, { ++ llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, ++ workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, ++ sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, ++ sessionTitle: { rename: async () => {} }, ++ sessions: { flush: async () => {} }, ++ }) ++ const rt = createRuntime(ctx, { home, ...config }) ++ provideFlyBot(ctx, rt.flybot) ++ await rt.load() ++ const received = id => (agents.get(id)?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) ++ t.after(async () => { ++ await ctx.dispose() ++ rmSync(home, { recursive: true, force: true, maxRetries: 5 }) ++ }) ++ return { rt, fb: rt.flybot, ctx, received, state: rt.state } ++} ++ ++test('published: false by default, true for the designated Chief, the owner can override either way', () => { ++ const s = team() ++ assert.equal(isPublished(s, 'a'), true) ++ assert.equal(isPublished(s, 'a2'), false) ++ s.bots.a2.published = true ++ s.bots.a.published = false ++ assert.equal(isPublished(s, 'a2'), true) ++ assert.equal(isPublished(s, 'a'), false) ++ delete s.bots.a.published ++ assert.equal(isPublished(s, 'a'), true) ++ assert.equal(isPublished(s, 'nobody'), false) ++ assert.equal(isPublished(s, '@coder.bob.home'), false) ++ // A Bot whose owner left the team is never published, even by choice or as a Chief. ++ delete s.members.alice ++ assert.equal(isPublished(s, 'a'), false) ++ assert.equal(isPublished(s, 'a2'), false) ++}) ++ ++test('reach matrix: remote to local needs a published target; local to remote needs a published sender', () => { ++ const s = team() ++ const remote = { kind: 'remote', handle: '@coder.bob.home' } ++ assert.equal(reach(s, { from: remote, to: 'a' }).action, 'allow', 'a friend reaches a Chief directly') ++ const denied = reach(s, { from: remote, to: 'a2' }) ++ assert.equal(denied.action, 'deny') ++ assert.equal(denied.by, 'owner-policy') ++ s.bots.a2.published = true ++ assert.equal(reach(s, { from: remote, to: 'a2' }).action, 'allow', 'a published specialist is reached without its Chief') ++ assert.equal(reach(s, { from: 'a', to: remote }).action, 'allow') ++ s.bots.a2.published = false ++ assert.equal(reach(s, { from: 'a2', to: remote }).action, 'deny') ++ assert.equal(reach(s, { from: 'a2', to: '@coder.bob.home' }).action, 'deny', 'a plain handle is a remote address too') ++ assert.equal(reach(s, { from: remote, to: remote }).action, 'deny') ++ assert.equal(reach(s, { from: undefined, to: 'a2' }).action, 'allow', 'the owner and the system have no sender Bot') ++ assert.equal(reach(s, { from: remote, to: 'nobody' }).action, 'allow', 'an unknown target is refused where it is resolved') ++}) ++ ++test('reach matrix: other owners reach only published Bots, never through the Chief; same owner is free', () => { ++ const s = team() ++ assert.equal(reach(s, { from: 'a', to: 'b' }).action, 'allow') ++ assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'deny') ++ assert.equal(reach(s, { from: 'a2', to: 'b' }).action, 'deny', 'the sender must be published too') ++ assert.equal(reach(s, { from: 'a2', to: 'a' }).action, 'allow', 'one owner\'s Bots talk freely') ++ s.bots.b2.published = true ++ assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'allow', 'a published non-Chief is reached directly') ++ assert.equal(reach(s, { from: 'a2', to: 'b2' }).action, 'deny') ++ assert.equal(reach(s, { from: 'b2', to: 'a2' }).action, 'deny') ++ delete s.bots.b2.published ++ // Chief replaced: the old Chief stops being published at once, the new one starts. ++ s.members.bob.designatedChiefId = 'b2' ++ assert.equal(reach(s, { from: 'a', to: 'b' }).action, 'deny') ++ assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'allow') ++ // Removed owner. ++ delete s.members.bob ++ assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'deny') ++}) ++ ++test('reach matrix: a code-joined Bot is reachable inside its room only, while a member', () => { ++ const s = team() ++ s.rooms.r = { ++ id: 'r', members: ['a', 'b2'], participants: [ ++ { kind: 'human', id: 'alice', role: 'admin', joinedSeq: 0 }, ++ { kind: 'bot', id: 'a', role: 'admin', joinedSeq: 0 }, ++ { kind: 'bot', id: 'b2', role: 'member', joinedSeq: 0, viaCode: true }, ++ ], ++ } ++ assert.equal(reach(s, { from: 'a', to: 'b2' }).action, 'deny', 'outside the room it is unreachable') ++ assert.equal(reach(s, { from: 'a', to: 'b2', roomId: 'r' }).action, 'allow') ++ assert.equal(reach(s, { from: 'b2', to: 'a', roomId: 'r' }).action, 'allow') ++ assert.equal(reach(s, { from: 'b2', to: 'a', roomId: 'other' }).action, 'deny') ++ assert.equal(reach(s, { from: 'b2', to: { kind: 'remote', handle: '@coder.bob.home' }, roomId: 'r' }).action, 'allow') ++ assert.equal(reach(s, { from: 'b2', to: { kind: 'remote', handle: '@coder.bob.home' } }).action, 'deny') ++ s.rooms.r.participants[2].leftSeq = 3 ++ s.rooms.r.members = ['a'] ++ assert.equal(reach(s, { from: 'a', to: 'b2', roomId: 'r' }).action, 'deny', 'after it leaves it is unreachable again') ++}) ++ ++test('bot.send: the policy runs before the chain, and no allow or bypass verdict skips it', async (t) => { ++ const { fb, received } = await start(t, { config: { flybot: { bypass: { greedy: ['owner-policy', 'social'] } } } }) ++ const seen = [] ++ fb.bot.send.use({ name: 'greedy', priority: 100, handle: (m) => { seen.push(m.to); return { action: 'allow', bypass: ['owner-policy', 'social'] } } }) ++ const denied = await fb.bot.send({ from: 'a', to: 'b2', text: 'hello specialist' }) ++ assert.equal(denied.action, 'deny') ++ assert.equal(denied.by, 'owner-policy') ++ assert.deepEqual(seen, [], 'the chain never saw it') ++ assert.deepEqual(received('b2'), []) ++ const ok = await fb.bot.send({ from: 'a', to: 'b', text: 'hello chief' }) ++ assert.equal(ok.action, 'allow') ++ assert.equal(seen.length, 1) ++ assert.match(received('b')[0], /hello chief/) ++}) ++ ++test('message_bot of another owner\'s unpublished Bot is refused; the owner publishing it opens the way', async (t) => { ++ const { fb, rt, received } = await start(t) ++ assert.equal((await fb.bot.send({ from: 'a', to: 'b2', text: 'one' })).action, 'deny') ++ await rt.setPublished('b2', true) ++ assert.equal((await fb.bot.send({ from: 'a', to: 'b2', text: 'two' })).action, 'allow') ++ assert.match(received('b2')[0], /two/) ++ await rt.setPublished('b2', false) ++ assert.equal((await fb.bot.send({ from: 'a', to: 'b2', text: 'three' })).action, 'deny') ++ assert.equal(received('b2').length, 1) ++}) ++ ++test('remote senders reach published Bots only', async (t) => { ++ const { fb, rt, received } = await start(t) ++ const friend = { kind: 'remote', handle: '@coder.bob.home', name: 'Coder' } ++ assert.equal((await fb.bot.send({ from: friend, to: 'a2', text: 'hi spec' })).action, 'deny') ++ assert.deepEqual(received('a2'), []) ++ assert.equal((await fb.bot.send({ from: friend, to: 'a', text: 'hi chief' })).action, 'allow') ++ await rt.setPublished('a2', true) ++ assert.equal((await fb.bot.send({ from: friend, to: 'a2', text: 'hi again' })).action, 'allow') ++ assert.match(received('a2')[0], /hi again/) ++}) ++ ++test('an unpublished Bot cannot message a friend\'s Bot; a published one can', async (t) => { ++ const { fb, rt } = await start(t) ++ const out = [] ++ fb.transports.add('bot-social', { send: async msg => { out.push(msg.to.handle); return { exchangeId: `r${out.length}` } } }) ++ const remote = { kind: 'remote', handle: '@coder.bob.home' } ++ assert.equal((await fb.bot.send({ from: 'a2', to: remote, text: 'x' })).action, 'deny') ++ assert.deepEqual(out, []) ++ assert.equal((await fb.bot.send({ from: 'a', to: remote, text: 'y' })).action, 'allow') ++ await rt.setPublished('a2', true) ++ assert.equal((await fb.bot.send({ from: 'a2', to: remote, text: 'z' })).action, 'allow') ++ assert.equal(out.length, 2) ++}) ++ ++test('a held message is judged again when it is approved: unpublished while it waited means refused', async (t) => { ++ const { fb, rt, received } = await start(t) ++ await rt.setPublished('b2', true) ++ fb.bot.send.use({ name: 'hold-all', handle: () => ({ action: 'hold', reason: 'check' }) }) ++ const sent = await fb.bot.send({ from: 'a', to: 'b2', text: 'held plan' }) ++ assert.equal(sent.action, 'hold') ++ await rt.setPublished('b2', false) ++ const decided = await fb.approvals.decide(sent.approvalId, 'approve') ++ assert.equal(decided.status, 'denied') ++ assert.match(decided.error, /not published/) ++ assert.deepEqual(received('b2'), []) ++}) ++ ++test('resumed, retried, deferred and re-sent deliveries are judged by what is true now', async (t) => { ++ const { rt, received } = await start(t) ++ const flags = [['resumed', 'x1'], ['retried', 'x2'], ['deferred', 'x3'], ['resent', 'x4']] ++ for (const [flag, id] of flags) { ++ await rt.deliver({ fromId: 'a', toId: 'b2', role: 'request', text: `${flag} while unpublished`, exchangeId: id, [flag]: true }) ++ } ++ assert.deepEqual(received('b2'), [], 'none of the flagged paths gets around the policy') ++ await rt.setPublished('b2', true) ++ for (const [flag, id] of flags) { ++ await rt.deliver({ fromId: 'a', toId: 'b2', role: 'request', text: `${flag} after publishing`, exchangeId: `${id}-ok`, [flag]: true }) ++ } ++ assert.equal(received('b2').length, 4) ++ // The same sender and target in one owner's team are not affected. ++ await rt.deliver({ fromId: 'a', toId: 'a2', role: 'request', text: 'same owner', resumed: true }) ++ assert.match(received('a2')[0], /same owner/) ++}) ++ ++test('a plain delivery from a remote sender to an unpublished Bot is refused', async (t) => { ++ const { rt, received } = await start(t) ++ await rt.deliver({ toId: 'a2', role: 'request', text: 'hi', remote: { handle: '@coder.bob.home', name: 'Coder' }, untrusted: true, resumed: true }) ++ assert.deepEqual(received('a2'), []) ++ await rt.deliver({ toId: 'a', role: 'request', text: 'hi chief', remote: { handle: '@coder.bob.home', name: 'Coder' }, untrusted: true, resumed: true }) ++ assert.match(received('a')[0], /hi chief/) ++}) ++ ++test('setPublished: validates, keeps the default with null, emits bot.published, and is the owner\'s call', async (t) => { ++ const { fb, rt } = await start(t) ++ const events = [] ++ fb.on('bot.published', bot => events.push([bot.id, bot.published])) ++ assert.equal(fb.bot.get('a').published, true) ++ assert.equal(fb.bot.get('a2').published, false) ++ assert.equal(fb.bot.isPublished('a'), true) ++ assert.equal(fb.bot.isPublished('@x.y'), false) ++ await rt.setPublished('a2', true) ++ await rt.setPublished('a2', true) ++ await rt.setPublished('a', false) ++ await rt.setPublished('a', null) ++ assert.deepEqual(events, [['a2', true], ['a', false], ['a', true]]) ++ await assert.rejects(rt.setPublished('a2', 'yes'), /true, false or null/) ++ await assert.rejects(rt.setPublished('nobody', true), /Unknown Bot/) ++}) ++ ++test('changing the designated Chief changes who is published, and the event says so', async (t) => { ++ const solo = team() ++ delete solo.members.bob ++ solo.bots.b.ownerId = 'alice' ++ solo.bots.b2.ownerId = 'alice' ++ solo.mainBotIds = ['a'] ++ const { fb, rt, state } = await start(t, { state: solo }) ++ const events = [] ++ fb.on('bot.published', bot => events.push([bot.id, bot.published])) ++ assert.equal(fb.bot.isPublished('a'), true) ++ state.mainBotIds = ['a2'] ++ assert.equal(rt.syncDesignatedChief(), true) ++ await new Promise(resolve => setTimeout(resolve, 20)) ++ assert.equal(fb.bot.isPublished('a2'), true) ++ assert.equal(fb.bot.isPublished('a'), false) ++ assert.deepEqual(events.sort(), [['a', false], ['a2', true]]) ++}) ++ ++test('the set-published endpoint is the owner\'s: members cannot publish each other\'s Bots', async (t) => { ++ const { rt } = await start(t) ++ rt.authzEnforced = () => true ++ const alice = { memberId: 'alice' } ++ assert.equal(rt.authorize(alice, 'set-published', { id: 'a2' }).ok, true) ++ assert.equal(rt.authorize(alice, 'set-published', { id: 'b2' }).ok, false) ++}) diff --git a/patches/ds-bot/README.md b/patches/ds-bot/README.md index a579a2fa1..c4e9c4d0c 100644 --- a/patches/ds-bot/README.md +++ b/patches/ds-bot/README.md @@ -19,6 +19,7 @@ FlyBot's core is **pristine upstream ds-bot plus this ordered patch series** (#3 | `0007-version-and-tooling` | Version `0.1.5-flybot.1`, `package.json`/lockfile, `scripts/local-ci.sh`, the install doctor (`scripts/install-doctor.mjs`) | | `0008-client-bundle` | The generated `client.js` (rebuild with `npm run build` in `core/`) | | `0009-identity-schema` | The state identity schema tests (team members, owner-bound Bots, #47) | +| `0013-published-reach` | `src/host/reach.js`, the owner policy that decides which Bots other owners and friends can reach (`published`, #51), and its tests | `0006` is retired: its files (`plugins/social` and its tests) moved out of `core/` to the peer plugin `plugins/social` (#34 PR 2), so no patch carries them. The numbers are not reused. diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index b00de615b..8a4c80313 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -96,5 +96,9 @@ "test/room-membership.test.js", "test/at-menu.test.js", "test/remote-room.test.js" + ], + "0013-published-reach": [ + "src/host/reach.js", + "test/reach-policy.test.js" ] } diff --git a/plugins/social/README.md b/plugins/social/README.md index 574cec52e..ba5a64733 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -10,8 +10,10 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root Friends' Bots shows **Social is not running:** with the reason — including the taken handle and to change `displayName` in the flybot-social config — instead of the host's `Unknown FlyBot endpoint` message. The same failure is written to Settings → Feedback. -- **Members**: each local Bot becomes `@.` (`add_home_member`), stored as field - `social.member`; new Bots join on `bot.created`, deleted ones are revoked on `bot.removed`. +- **Members**: each *published* local Bot becomes `@.` (`add_home_member`), stored as field + `social.member`; a Bot is published by its owner (the Chief is by default, #51), registers when published + (`bot.published`), and deleted ones are revoked on `bot.removed`. Unpublished Bots get no identity, are not polled + and cannot sign sends or take invites; unpublishing keeps the identity unused instead of revoking it. - **Remote roster**: `list_remote_bots` → friends' Bots grouped by home; sidebar section `flybot.sidebar.section#social` with a remote badge, `@` rows via `flybot.composer.mention#social`. Remote Bots never enter `bot.list()`. @@ -22,7 +24,7 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root the `bot-social` transport sends it signed as the member (`send_as_member`). - **Inbound**: each member's inbox is polled; messages reach that Bot's chat as untrusted text (no owner approval). - **Outbound hold (Bot Social)**: a draft the server keeps as `pending_approval` is listed on `social/status` (`pendingApprovals`, `approvals`) and in **Friends' Bots** (badge + Approve / Reject). The owner's click calls `decide_approval` (confirm code used in the same step). This is separate from FlyBot's local approval card. Auto-accept on a friendship skips the hold for later messages. -- **Tools** (every local Bot): `list_friend_bots`, `message_friend_bot`. +- **Tools** (every local Bot; an unpublished Bot's send is refused): `list_friend_bots`, `message_friend_bot`. - **Legacy**: with `token` + `botId` and no home, the per-bot identity path (`start_conversation` / `send_message`) still works. diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index ed05c906d..e817b723c 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -285,10 +285,18 @@ export function createSocial(service, config = {}, { client: given, home: givenH // ------------------------------------------------------------ members const memberOf = botId => flybot.fields.get(botId, 'member') + // A Bot gets an outside identity only if its owner published it (#51): only published Bots + // are registered, polled and sign sends. A core without bot.isPublished has no such rule. + const published = botId => flybot.bot.isPublished?.(botId) !== false + // The identity a published Bot signs and polls with; an unpublished Bot has none to use + // (an identity it already holds is kept on the server, unused, so republishing needs no new handle). + const activeMember = botId => (published(botId) ? memberOf(botId) : undefined) + const unpublishedMessage = bot => `${bot.name} is not published: publish it before it can be invited, friend other Bots or be reached` // One registration per Bot at a time; a taken handle retries with the Bot's id suffix; // a Bot removed while it was registering has its new identity revoked. const registering = new Map() function register(bot) { + if (!bot || !published(bot.id)) return Promise.resolve(undefined) if (!home || memberOf(bot.id) || flybot.readOnly?.()) return Promise.resolve(memberOf(bot.id)) if (!registering.has(bot.id)) { registering.set(bot.id, registerOnce(bot).finally(() => registering.delete(bot.id))) @@ -333,7 +341,7 @@ export function createSocial(service, config = {}, { client: given, home: givenH flybot.fields.set(bot.id, 'member', undefined) } else owners.set(id, bot) } - for (const bot of flybot.bot.list()) { + for (const bot of flybot.bot.list().filter(item => published(item.id))) { try { await register(bot) } catch (error) { if (/401|403|token|unauthori[sz]ed/i.test(String(error?.message ?? error))) throw error warn(`${bot.name} not registered`, error) @@ -341,6 +349,8 @@ export function createSocial(service, config = {}, { client: given, home: givenH } } disposers.push(flybot.on('bot.created', bot => { void register(bot).catch(error => warn('register failed', error)) })) + // Publishing gives the Bot its identity; unpublishing only stops it being used. + disposers.push(flybot.on?.('bot.published', bot => { if (bot.published) void register(bot).catch(error => warn('register failed', error)) }) ?? (() => {})) disposers.push(flybot.on('bot.removed', ({ bot }) => { const member = bot?.fields?.[NS]?.member if (home && member) void client.callTool('remove_home_member', { home: home.id, member: member.bot_id }).catch(error => warn('revoke failed', error)) @@ -405,6 +415,7 @@ export function createSocial(service, config = {}, { client: given, home: givenH priority: flybot.bot.send.priority?.APPROVAL ?? 900, async handle(msg) { if (msg.target?.remote !== true || (msg.to?.transport ?? 'bot-social') !== 'bot-social') return undefined + if (typeof msg.from === 'string' && !published(msg.from)) return { action: 'deny', reason: `${flybot.bot.get(msg.from)?.name ?? 'this Bot'} is not published, so it cannot message friends' Bots` } const bot = await remoteBot(msg.target.handle) if (bot === undefined) return { action: 'deny', reason: `${msg.target.handle} is not a Bot of a friend on Bot Social` } if (home && typeof msg.from === 'string' && !covered(await friendEntry(bot.home), msg.from)) { @@ -439,8 +450,8 @@ export function createSocial(service, config = {}, { client: given, home: givenH const bot = await remoteBot(msg.target.handle) if (bot === undefined) throw new Error(`${msg.target.handle} is no longer reachable`) if (home) { - const member = typeof msg.from === 'string' ? memberOf(msg.from) ?? await register(flybot.bot.get(msg.from)) : undefined - if (!member) throw new Error('the sending Bot has no Bot Social identity') + const member = typeof msg.from === 'string' ? activeMember(msg.from) ?? await register(flybot.bot.get(msg.from)) : undefined + if (!member) throw new Error(published(msg.from) ? 'the sending Bot has no Bot Social identity' : 'the sending Bot is not published') const attachments = await uploadAll(msg.attachments, member.bot_id) const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to: bot.handle, text: msg.text, ...(attachments.length ? { attachments } : {}) }) if (sent?.message?.status === 'pending_approval') pendingCache.at = 0 @@ -476,13 +487,13 @@ export function createSocial(service, config = {}, { client: given, home: givenH const team = flybot.bot.list() // Only the Bots the friendship covers can sign; the one used before goes first. const entry = (await remoteBots()).homes.find(item => item.bots.some(bot => bot.handle === target)) - const order = [team.find(bot => bot.id === settings.via[target]), team.find(bot => bot.isMain), ...team].filter(bot => bot && covered(entry, bot.id)) + const order = [team.find(bot => bot.id === settings.via[target]), team.find(bot => bot.isMain), ...team].filter(bot => bot && published(bot.id) && covered(entry, bot.id)) const tried = new Set() let lastError for (const bot of order) { if (tried.has(bot.id)) continue tried.add(bot.id) - const member = memberOf(bot.id) ?? await register(bot).catch(() => undefined) + const member = activeMember(bot.id) ?? await register(bot).catch(() => undefined) if (!member) continue try { const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to: target, text }) @@ -526,11 +537,11 @@ export function createSocial(service, config = {}, { client: given, home: givenH } async function pollOnce() { const targets = home - ? flybot.bot.list().filter(bot => memberOf(bot.id)).map(bot => ({ local: bot, botId: memberOf(bot.id).bot_id })) + ? flybot.bot.list().filter(bot => activeMember(bot.id)).map(bot => ({ local: bot, botId: memberOf(bot.id).bot_id })) : [{ local: (config.localBot ? flybot.bot.find(config.localBot) : undefined) ?? flybot.bot.list().find(bot => bot.isMain), botId: config.botId }] let delivered = 0 for (const { local, botId } of targets) { - if (local === undefined) continue + if (local === undefined || !published(local.id)) continue const key = botId ?? 'default' const saved = settings.cursors[key] const result = await client.callTool('list_inbox', { peek: true, ...(saved !== undefined ? { since_cursor: saved } : {}), ...(botId ? { bot_id: botId } : {}) }) @@ -673,6 +684,7 @@ export function createSocial(service, config = {}, { client: given, home: givenH if (!home) throw new Error('Bot Social is not connected: there is no FlyBot home yet') const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : undefined if (!bot) throw new Error('Choose one of your Bots to invite a friend to') + if (!published(bot.id)) throw new Error(unpublishedMessage(bot)) const member = await register(bot) if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`) const invite = await client.callTool('create_invite', { bot_id: member.bot_id, max_uses: 1, expires_in_hours: 168, auto_accept: true, offered_capabilities: ['chat.public'], requested_capabilities: ['chat.public'], ...(message ? { message: String(message).slice(0, 280) } : {}) }) @@ -688,7 +700,7 @@ export function createSocial(service, config = {}, { client: given, home: givenH return { connected: Boolean(home), home: home ? norm(home.handle) : undefined, - bots: home ? flybot.bot.list().map(bot => ({ id: bot.id, name: bot.name, isMain: bot.isMain === true, handle: memberOf(bot.id)?.handle ? norm(memberOf(bot.id).handle) : undefined })) : [], + bots: home ? flybot.bot.list().map(bot => ({ id: bot.id, name: bot.name, isMain: bot.isMain === true, published: published(bot.id), handle: activeMember(bot.id)?.handle ? norm(activeMember(bot.id).handle) : undefined })) : [], pendingApprovals: approvals.length, approvals, } @@ -707,6 +719,7 @@ export function createSocial(service, config = {}, { client: given, home: givenH const team = flybot.bot.list() const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : team.find(item => item.isMain) ?? team[0] if (!bot) throw new Error('Choose which of your Bots joins') + if (!published(bot.id)) throw new Error(unpublishedMessage(bot)) const member = await register(bot) if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`) from = { from_bot_id: member.bot_id } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 85bfba076..04605ab93 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -72,11 +72,12 @@ function mockServer({ token = 'owner-token', friends = true } = {}) { return { fetch, calls, events, members, uploads, tools, onboarded: () => onboarded } } -async function team() { +// The Writer is published (#51: only published Bots get an outside identity) unless a test says otherwise. +async function team({ writerPublished = true } = {}) { const home = mkdtempSync(join(tmpdir(), 'flybot-social-')) writeFileSync(join(home, 'state.json'), JSON.stringify({ version: 1, mainBotIds: ['a'], - bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2 } }, + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2, ...(writerPublished ? { published: true } : {}) } }, rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, })) const ctx = fakeContext() @@ -145,13 +146,18 @@ test('first start onboards in one call, keeps the credentials file, and register assert.equal(server.calls.filter(c => c.name === 'add_home_member').length, 2) }) -test('new local Bots join the home and removed ones are revoked', async (t) => { +test('new local Bots join the home once published, and removed ones are revoked', async (t) => { const { ctx, call, stop } = await team() t.after(stop) const server = mockServer() const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) t.after(() => social.dispose()) const id = await ctx.flybot.scope('tree').bot.create({ name: 'Child', fields: { parent: 'a' } }) + // A new Bot is unpublished: no outside identity until its owner publishes it (#51). + await settle() + assert.equal(ctx.flybot.scope('social').fields.get(id, 'member'), undefined) + assert.equal(server.calls.some(c => c.name === 'add_home_member'), false) + assert.equal((await call('set-published', { id, published: true })).ok, true) for (let i = 0; i < 100 && !ctx.flybot.scope('social').fields.get(id, 'member'); i++) await settle() const member = ctx.flybot.scope('social').fields.get(id, 'member') assert.equal(member.handle, '@child.danyang.home') @@ -160,6 +166,46 @@ test('new local Bots join the home and removed ones are revoked', async (t) => { assert.deepEqual(server.calls.at(-1), { url: 'https://s/mcp', name: 'remove_home_member', args: { home: 'h1', member: member.bot_id } }) }) +test('#51: an unpublished Bot is not registered, not polled and cannot sign or invite; unpublishing stops use at once', async (t) => { + const { ctx, tool, call, received, stop } = await team({ writerPublished: false }) + t.after(stop) + const server = mockServer() + const polled = [] + server.tools.list_inbox = (a) => { polled.push(a.bot_id); return { events: [], next_cursor: 1 } } + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => social.dispose()) + await social.registerAll() + const scope = ctx.flybot.scope('social') + assert.equal(scope.fields.get('a', 'member')?.handle, '@chief.danyang.home', 'the Chief is published by default') + assert.equal(scope.fields.get('b', 'member'), undefined, 'the unpublished Writer got no identity') + assert.deepEqual(server.calls.filter(c => c.name === 'add_home_member').map(c => c.args.name), ['chief']) + await social.pollInbox() + assert.deepEqual(polled, ['m1'], 'only the published Bot\'s inbox is read') + // It cannot message a friend's Bot or be invited. + await assert.rejects(tool('message_friend_bot', { to: '@coder.bob.home', message: 'hi' }, 'b'), /not published/) + assert.equal(ctx.flybot.approvals.list().length, 0) + assert.equal(server.calls.some(c => c.name === 'send_as_member'), false) + // The user's own chat with a friend's Bot is signed by a published Bot, never the unpublished one. + await social.sendChat({ handle: '@coder.bob.home', text: 'x' }) + assert.equal(server.calls.filter(c => c.name === 'send_as_member').at(-1).args.from_bot_id, 'm1') + const status = (await call('flybot/social/status', {})).value + assert.deepEqual(status.bots.map(b => [b.id, b.published, b.handle]), [['a', true, '@chief.danyang.home'], ['b', false, undefined]]) + assert.equal((await call('flybot/social/create-invite', { botId: 'b' })).ok, false) + // Publishing registers it; unpublishing keeps the identity but stops polling and signing. + assert.equal((await call('set-published', { id: 'b', published: true })).ok, true) + for (let i = 0; i < 100 && !scope.fields.get('b', 'member'); i++) await settle() + assert.equal(scope.fields.get('b', 'member').handle, '@writer.danyang.home') + polled.length = 0 + await social.pollInbox() + assert.deepEqual(polled.sort(), ['m1', 'm2']) + assert.equal((await call('set-published', { id: 'b', published: false })).ok, true) + polled.length = 0 + await social.pollInbox() + assert.deepEqual(polled, ['m1']) + assert.deepEqual(scope.fields.get('b', 'member'), { bot_id: 'm2', handle: '@writer.danyang.home' }) + assert.deepEqual(received.get('b') ?? [], []) +}) + test('any local Bot can message any Bot of a friend home; the send is held, then signed as the member', async (t) => { const { ctx, tool, stop } = await team() t.after(stop) diff --git a/plugins/tree/README.md b/plugins/tree/README.md index 79ba9ada5..9ef41a70a 100644 --- a/plugins/tree/README.md +++ b/plugins/tree/README.md @@ -9,7 +9,7 @@ Recursive bot tree plugin for FlyBot (built on ds-bot / DeepSeek Harness). - **Migration:** a separate `Root` auto-created by earlier versions (parent `null`, not a Main Bot) hands its children to Chief, loses its tree fields and tree prompt, and gets `fields['flybot-tree'].retired = true` so adoption skips it. The bot itself is **never** deleted (bot-im / the user decide). Idempotent. - **Adoption (`adoptExisting`, default true):** every local bot with no tree fields that is not retired and not another Main Bot (e.g. existing 研究员, 数据分析师, 文案, 程序员) becomes a child of Chief at startup and on `bot.created` when the creator set no `parent`. Chief's `maxChildren` is respected: extra bots stay unattached and a warning is logged. Adopted bots keep their own prompt; the tree block is added. Message rules apply to them like any tree child (Chief ↔ them; they → only Chief and their own children). - **Prompt injection:** the tree text sits in a marked block (`…`) inside each bot's `systemPrompt`; only that block is replaced, so Chief's own prompt text is never overwritten. Chief gets the coordinator rules for its direct children. -- `bot.send` middleware `tree`: inside the tree a bot may message only its parent and direct children (`deny` otherwise). A non-root tree bot may not message local bots outside the tree; the root (Chief) may message any local bot. Local bots outside the tree may message only the root. Remote traffic and outside-to-outside sends get no opinion (`undefined`, social decides). +- `bot.send` middleware `tree`: inside the tree a bot may message only its parent and direct children (`deny` otherwise). A non-root tree bot may not message local bots outside the tree; the root (Chief) may message any local bot. Local bots outside the tree may message only the root. Remote targets and outside-to-outside sends get no opinion (`undefined`, social decides). **Published Bots (#51):** a friend's (remote) sender reaches a tree bot only if the core says it is published (`flybot.bot.isPublished`): new and adopted bots are unpublished, the Chief is published by default, and the owner can publish any bot. A published bot is reached directly at any depth, never redirected through the root; an unpublished one is denied (`tree: this bot is not published…`). Published bots on both sides are not the tree's call across trees or to non-tree bots (the core's owner policy decides); inside one tree the parent/child rule is unchanged, as are `stopped` / `report` notices. A published child's prompt tells it to answer outside senders directly, without a trace line, and to delegate only to its own children. On a core without `isPublished`, the remote-sender rule gives no opinion. - **Control notices bypass tree policy:** `role: 'stopped'` (stop_bot) and `role: 'report'` are never denied by the tree middleware, so a Main Bot can stop any tree bot. - **Deleting a bot:** its children re-attach to its parent and every moved child (and its subtree) gets a fresh prompt/trace. `maxChildren` is not enforced on reparenting (no child is dropped); an overflow is logged as a warning and `create_child_bot` refuses new children there until it is back under the limit. - **`create_child_bot` is serialized per parent:** parallel calls never exceed `maxChildren` or create duplicate-name siblings. @@ -78,6 +78,7 @@ Top level: roots first, then members whose parent is missing (orphans). Siblings | `bot.send.use({ name, before, after, handle })` | tree bot → parent/direct child only; local non-tree bot → tree root only; non-root tree bot → local non-tree bot **deny**, root (Chief) → any local bot ok; `bypass:['social']` only if `bypassSocial: true` | | `tools.register` | `visibleTo(botId)` sync; `execute(args, { botId })`. JSON Schema `minLength`/`maxLength` are dropped by register, so `execute` validates them (`validateArgs`) | | browser | `ctx.flybot.bots()` items carry `fields: { [plugin]: {...} }` (read `fields['flybot-tree']`); tree built in-plugin | +| remote senders | `typeof from !== 'string'` or `bot.isRemote(from)`: denied toward an unpublished tree bot (`bot.isPublished(id) === false`), else no opinion; never redirected to the root | | remote targets | `target.remote === true` (`{ handle, home }`), fallback `bot.isRemote(to)`; never tree members: passed to social | | `bot.created` | handled by social; tree does not subscribe | | root | ds-bot's Main Bot (Chief) adopted with `treeRole: 'root'` (`ensureRoot`, idempotent); fallback `Root` only without a Main Bot; earlier auto-created `Root` migrated, not deleted | diff --git a/plugins/tree/index.js b/plugins/tree/index.js index 461a62de4..db1f968c1 100644 --- a/plugins/tree/index.js +++ b/plugins/tree/index.js @@ -14,7 +14,7 @@ export function apply(ctx, config = {}) { const warn = (...args) => (ctx.logger?.('flybot-tree')?.warn ?? console.warn)(...args) const tree = createTree(fb, { limits: resolveLimits(config), rootName: config.rootName, rootBotId: config.rootBotId, adoptExisting: config.adoptExisting !== false, warn }) const disposers = [ - fb.useSend('tree', treeSendMiddleware(tree, { bypassSocial: config.bypassSocial === true, isRemote: fb.isRemote })), + fb.useSend('tree', treeSendMiddleware(tree, { bypassSocial: config.bypassSocial === true, isRemote: fb.isRemote, isPublished: fb.isPublished })), ...registerTreeTools(fb, tree), fb.onFieldChange(change => tree.onFieldChange(change)), fb.onBotRemoved(e => tree.onBotRemoved(e)), diff --git a/plugins/tree/src/adapter.js b/plugins/tree/src/adapter.js index 03a42fd1c..a1e2f7c3d 100644 --- a/plugins/tree/src/adapter.js +++ b/plugins/tree/src/adapter.js @@ -10,6 +10,7 @@ // bot.send.use({ name, priority?, before?, after?, handle(msg) }) -> dispose (bands: bot.send.priority) // msg.target: { remote: false, id } | { remote: true, handle, home } // bot.isRemote(ref) -> boolean ({ kind: 'remote' } or '@handle'; local ids never) +// bot.isPublished(id) -> boolean (the owner's choice, the Chief by default; may be absent on an older core) // tools.register({ name, description, parameters, visibleTo(botId) SYNC, execute(args, { botId }) }) // NOTE: JSON Schema minLength/maxLength are dropped by register; execute() validates them. export const NS = 'flybot-tree' @@ -45,6 +46,9 @@ export function createAdapter(flybot) { fn({ botId: e?.botId ?? e?.bot?.id, parent: own?.parent ?? (own?.treeRole !== undefined ? null : undefined) }) }), isRemote: ref => bot.isRemote?.(ref) === true, + // true/false from the core's published flag (#51); undefined on a core without it, where tree + // keeps its older behavior (nothing is known to be published). + isPublished: id => (typeof bot.isPublished === 'function' ? bot.isPublished(id) === true : undefined), useSend: (name, handle) => bot.send.use({ name, priority: (bot.send.priority ?? SEND_PRIORITY_FALLBACK).ROUTING, handle }), registerTool: spec => tools.register(spec), } diff --git a/plugins/tree/src/middleware.js b/plugins/tree/src/middleware.js index 6d7918621..fa6e15fcc 100644 --- a/plugins/tree/src/middleware.js +++ b/plugins/tree/src/middleware.js @@ -1,33 +1,48 @@ // bot.send middleware for tree Bots: a tree Bot may message only its parent and direct children. // - Remote targets (msg.target.remote === true, else bot.isRemote(to)) are never tree members: // no opinion (undefined), left to social; their fields are not read. +// - A REMOTE sender (a friend's Bot) may reach a tree Bot only if the core says it is published +// (flybot.bot.isPublished, #51): an unpublished tree Bot is denied, a published one is reached +// directly, never through the root. Tree does not redirect it. // - A non-root tree Bot sending to a LOCAL Bot outside the tree is denied; the root (Chief, the // user's main bot) may message any local bot. // - A local non-tree sender may reach only the tree root; other tree nodes are denied. -// - Remote senders and non-tree -> non-tree traffic are not the tree's call: undefined. +// - Published Bots on both sides (the owner chose to make them reachable) are not the tree's call +// across trees or to non-tree Bots: undefined, and the core's owner policy decides who may +// reach whom. Inside one tree the parent/child rule still holds. +// - Non-tree -> non-tree traffic is not the tree's call: undefined. // - Control notices (role 'stopped' from stop_bot, 'report' from groups) bypass tree policy: // a Main Bot / admin must be able to stop or report to any tree bot (ThinkFlowLab/FlyBot#15). // - Allowed edges: undefined, or allow + bypass:['social'] when config.bypassSocial === true. const DENY_EDGE = 'tree: a bot may message only its parent and its direct children' const DENY_INTO = 'tree: bots outside the tree may message only the tree root' +export const DENY_UNPUBLISHED = 'tree: this bot is not published, so it cannot be reached from outside' export const CONTROL_ROLES = new Set(['stopped', 'report']) const DENY_OUTSIDE = 'tree: a tree bot may not message local bots outside the tree' -export function treeSendMiddleware(tree, { bypassSocial = false, isRemote = () => false } = {}) { +// `isPublished(id)`: true / false from the core, undefined where the core has no such flag. +export function treeSendMiddleware(tree, { bypassSocial = false, isRemote = () => false, isPublished = () => undefined } = {}) { + const published = id => isPublished(id) === true return function treeSend(msg) { const from = msg?.from const to = msg?.target && msg.target.remote === false ? msg.target.id : msg?.to ?? msg?.target?.id if (!from || !to) return undefined + // A remote sender is judged first: it never reaches the control-role bypass below. + if (typeof from !== 'string' || isRemote(from)) { + if (typeof to === 'string' && tree.isMember(to) && isPublished(to) === false) return { action: 'deny', reason: DENY_UNPUBLISHED } + return undefined + } if (CONTROL_ROLES.has(msg.role)) return bypassSocial ? { action: 'allow', bypass: ['social'] } : undefined if (msg.target?.remote === true || isRemote(to)) return undefined - if (typeof from !== 'string' || isRemote(from)) return undefined const { fromIn, toIn, allowed } = tree.isTreeEdge(from, to) + const bothPublished = published(from) && published(to) if (!fromIn) { if (!toIn) return undefined // non-tree traffic: not the tree's call - return tree.isRoot(to) ? undefined : { action: 'deny', reason: DENY_INTO } + if (tree.isRoot(to) || bothPublished) return undefined + return { action: 'deny', reason: DENY_INTO } } - if (!toIn) return tree.isRoot(from) ? undefined : { action: 'deny', reason: DENY_OUTSIDE } // the root (Chief) may message any local bot - if (!allowed) return { action: 'deny', reason: DENY_EDGE } + if (!toIn) return tree.isRoot(from) || bothPublished ? undefined : { action: 'deny', reason: DENY_OUTSIDE } // the root (Chief) may message any local bot + if (!allowed) return bothPublished && tree.rootOf(from) !== tree.rootOf(to) ? undefined : { action: 'deny', reason: DENY_EDGE } return bypassSocial ? { action: 'allow', bypass: ['social'] } : undefined } } diff --git a/plugins/tree/src/prompt.js b/plugins/tree/src/prompt.js index bd0c82798..29058a0f4 100644 --- a/plugins/tree/src/prompt.js +++ b/plugins/tree/src/prompt.js @@ -8,6 +8,8 @@ export function traceRule(trace, root) { if (root) return `Do not start your replies with a trace or path line (e.g. "${trace} → "). ${NO_TRACE}` return `Only when you answer your parent bot in the tree, start that answer with a one-line trace of the path the request took, e.g. "${trace}". ${NO_TRACE}` } +// A published tree Bot can be contacted from outside the tree (a friend's Bot, another owner's Bot). +export const OUTSIDE_RULE = 'If your owner has published you, a bot outside the tree (a friend\'s bot, or another owner\'s bot) may message you directly. Answer that sender directly, with no trace line, and delegate only to your own children, never to bots outside the tree.' export function systemPrompt({ name, role, trace, children, root = false }) { const head = root ? [`Bot tree: you (${name}) are also the ROOT of a recursive bot tree of child bots. For tasks you delegate into the tree, follow these tree rules.`, `Your position: ${trace}`] @@ -17,7 +19,7 @@ export function systemPrompt({ name, role, trace, children, root = false }) { return [...head, '', 'You have no children: do the work yourself and answer concisely with concrete results.', 'Only if the task clearly splits into a separate domain you cannot cover, you may create a child with create_child_bot and delegate to it.', - 'You may message only your parent and your direct children.', tools, + 'You may message only your parent and your direct children.', ...(root ? [] : [OUTSIDE_RULE]), tools, traceRule(trace, root)].join('\n') } return [...head, '', @@ -28,7 +30,7 @@ export function systemPrompt({ name, role, trace, children, root = false }) { '3. Send each child only its own self-contained part, all in parallel, then wait.', '4. Aggregate the answers into one concise answer for your parent (or the user). If only one child answered, pass its answer through unchanged.', `5. ${traceRule(trace, root)}`, - 'You may message only your parent and your direct children.', tools].join('\n') + 'You may message only your parent and your direct children.', ...(root ? [] : [OUTSIDE_RULE]), tools].join('\n') } // The tree's text is kept in a marked block inside the bot's systemPrompt, so text written by the diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index 43074d6e7..856775309 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -163,6 +163,19 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const isMember = id => typeof id === 'string' && (fb.getField(id, 'treeRole') !== undefined || fb.getField(id, 'parent') !== undefined) const parentOf = id => fb.getField(id, 'parent') ?? null const isRoot = id => isMember(id) && (fb.getField(id, 'treeRole') === ROOT_ROLE || fb.getField(id, 'parent') == null) + // The root of the tree a member is in (itself for a root); undefined for a non-member. A cycle stops at the walk's end. + function rootOf(id) { + if (!isMember(id)) return undefined + const seen = new Set() + let at = id + while (!seen.has(at)) { + seen.add(at) + const parent = parentOf(at) + if (parent == null || !isMember(parent)) return at + at = parent + } + return at + } function isTreeEdge(from, to) { const fromIn = isMember(from), toIn = isMember(to) return { fromIn, toIn, inTree: fromIn && toIn, allowed: fromIn && toIn && (parentOf(to) === from || parentOf(from) === to) } @@ -203,5 +216,5 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis } const subscribe = fn => { listeners.add(fn); return () => listeners.delete(fn) } - return { limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, isMember, isRoot, isTreeEdge, onFieldChange, onBotRemoved, subscribe } + return { limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } } diff --git a/plugins/tree/test/host.test.js b/plugins/tree/test/host.test.js index b06d69389..b15905c26 100644 --- a/plugins/tree/test/host.test.js +++ b/plugins/tree/test/host.test.js @@ -48,11 +48,15 @@ async function start() { sessionTitle: { rename: async () => {} }, sessions: { flush: async () => {} }, }) + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (options) => { if (options.path.endsWith('bot')) route = options; return register(options) } apply(ctx, { home }) + const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) await tool('list_bots', {}, 'a') const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } - return { ctx, fb: ctx.flybot, tool, stop } + return { ctx, fb: ctx.flybot, tool, call, stop } } test('tree plugin on the real host: Chief adopted as root, create_child_bot, scoped fields, send policy, dispose', async () => { @@ -82,3 +86,31 @@ test('tree plugin on the real host: Chief adopted as root, create_child_bot, sco await h.stop() } }) + +test('#51 on the real host: a friend reaches a published tree Bot directly, never an unpublished one, and nothing goes through the root', async () => { + const h = await start() + try { + const fork = h.ctx.plugin(tree, {}) + await waitFor(() => h.fb.scope('flybot-tree').fields.get('b', 'parent') === 'a') + const friend = { kind: 'remote', handle: '@coder.bob.home', home: '@bob.home', name: 'Coder' } + const send = to => h.fb.bot.send({ from: friend, to, text: 'hello from a friend' }) + assert.equal(h.fb.bot.get('a').published, true, 'the Chief is published by default') + assert.equal(h.fb.bot.get('b').published, false, 'an adopted tree Bot is not') + assert.equal((await send('a')).action, 'allow') + const denied = await send('b') + assert.equal(denied.action, 'deny') + assert.equal(denied.by, 'owner-policy') + assert.equal((await h.call('set-published', { id: 'b', published: true })).ok, true) + assert.equal(h.fb.bot.get('b').published, true) + const direct = await send('b') + assert.equal(direct.action, 'allow', 'reached directly, not redirected to its parent') + // A deep child created under the published Bot stays unpublished. + const work = JSON.parse(await h.tool('create_child_bot', { name: 'Work', role_description: 'work' }, 'b')).created.id + assert.equal((await send(work)).action, 'deny') + assert.equal((await h.call('set-published', { id: 'b', published: false })).ok, true) + assert.equal((await send('b')).action, 'deny') + fork.dispose() + } finally { + await h.stop() + } +}) diff --git a/plugins/tree/test/mock-flybot.js b/plugins/tree/test/mock-flybot.js index 69a3b2a0c..a6d882c44 100644 --- a/plugins/tree/test/mock-flybot.js +++ b/plugins/tree/test/mock-flybot.js @@ -5,6 +5,7 @@ // - on('change'|'bot.created'|'bot.removed', fn, { all? }); 'change' is own-plugin unless all. // - bot.create(spec) is async and resolves to the new id; systemPrompt is on the Bot record. // - bot.isRemote(ref): { kind: 'remote' } or '@handle'; local ids never. +// - bot.isPublished(id) (#51): the Bot's explicit `published`, else true only for a Main Bot (the Chief). // - send targets: { remote: false, id } | { remote: true, handle, home }. // - tools.register drops JSON Schema keywords other than type/description/enum/items. const clone = v => (v === undefined ? undefined : structuredClone(v)) @@ -20,12 +21,13 @@ export function toParameterMap(p) { export function createMockFlybot() { const bots = new Map() // id -> { id, name, systemPrompt, fields: { [plugin]: {...} } } - const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set() } + const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set(), 'bot.published': new Set() } const middlewares = [] const tools = new Map() const scopes = [] let seq = 0 - const view = b => b && { id: b.id, name: b.name, isMain: b.isMain === true, createdAt: b.createdAt, createdBy: null, systemPrompt: b.systemPrompt ?? '', fields: clone(b.fields) } + const published = b => (typeof b?.published === 'boolean' ? b.published : b?.isMain === true) + const view = b => b && { id: b.id, name: b.name, isMain: b.isMain === true, published: published(b), createdAt: b.createdAt, createdBy: null, systemPrompt: b.systemPrompt ?? '', fields: clone(b.fields) } const fire = (ev, payload) => { for (const fn of [...listeners[ev]]) fn({ ...payload }) } const onFor = plugin => (ev, fn, { all = false } = {}) => { if (!listeners[ev]) throw new Error(`flybot: unknown event ${ev}`) @@ -80,6 +82,7 @@ export function createMockFlybot() { list: () => [...bots.values()].map(view), find: name => view([...bots.values()].find(b => b.name === name)), isRemote: ref => isRemote(ref) || remote.has(ref), + isPublished: id => published(bots.get(id)), create: async ({ name, systemPrompt, fields: f } = {}) => { await Promise.resolve() // really async, like the host const id = `bot${++seq}` @@ -102,12 +105,13 @@ export function createMockFlybot() { const flybot = build('shared') // Test helpers (not part of the real API). // Like ds-bot's setup: the first Bot, 'Chief', is a Main Bot (state.mainBotIds). Fires bot.created. - const addBot = ({ name, isMain = false, systemPrompt = '', fields: f = {} } = {}) => { + const addBot = ({ name, isMain = false, published: pub, systemPrompt = '', fields: f = {} } = {}) => { const id = `bot${++seq}` - bots.set(id, { id, name, isMain, createdAt: seq, systemPrompt, fields: clone(f) }) + bots.set(id, { id, name, isMain, ...(typeof pub === 'boolean' ? { published: pub } : {}), createdAt: seq, systemPrompt, fields: clone(f) }) fire('bot.created', view(bots.get(id))) return id } + const setPublished = (id, value) => { const b = bots.get(id); const before = published(b); if (value === null) delete b.published; else b.published = value; if (before !== published(b)) fire('bot.published', view(b)) } const removeBot = id => { const last = view(bots.get(id)); bots.delete(id); fire('bot.removed', { ...last, botId: id, bot: last }) } const call = async (botId, name, args = {}) => { const t = tools.get(name) @@ -116,5 +120,5 @@ export function createMockFlybot() { if (!t || !visible) throw new Error(`tool ${name} not visible to ${botId}`) return t.execute(args, { botId }) } - return { flybot, bots, tools, middlewares, scopes, remote, removeBot, addBot, call } + return { flybot, bots, tools, middlewares, scopes, remote, removeBot, addBot, setPublished, call } } diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index ad3688ffc..d14a2f55a 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import * as plugin from '../index.js' import { DEFAULT_LIMITS, resolveLimits, checkCreate } from '../src/limits.js' +import { treeSendMiddleware } from '../src/middleware.js' import { createMockFlybot } from './mock-flybot.js' import { reactStub, renderToString, findAll } from './render.js' @@ -284,12 +285,88 @@ test('non-tree local sender: only the root is reachable; remote senders unchange assert.equal(r.action, 'deny'); assert.match(r.reason, /only the tree root/) } const h = m.middlewares[0].handle - assert.equal(h({ from: { kind: 'remote', handle: '@x.peer' }, to: b, target: { remote: false, id: b } }), undefined) // remote sender - assert.equal(h({ from: '@x.peer', to: a, target: { remote: false, id: a } }), undefined) + assert.equal(h({ from: { kind: 'remote', handle: '@x.peer' }, to: m.root, target: { remote: false, id: m.root } }), undefined) // remote sender to the published Chief: not the tree's call + assert.equal(h({ from: '@x.peer', to: chief, target: { remote: false, id: chief } }), undefined) // a non-tree Bot is not the tree's call assert.equal((await send(chief, await outsider(m, 'Other'))).action, 'allow') assert.equal((await send(a, '@coder.bob.home')).action, 'allow') // remote target }) +test('#51: a remote sender reaches a published tree Bot directly and an unpublished one never; no redirect to the root', async () => { + const m = await setup() + const a = await make(m, m.root, 'A'); const b = await make(m, a, 'B') + const h = m.middlewares[0].handle + const remote = { kind: 'remote', handle: '@x.peer' } + const toward = to => h({ from: remote, to, target: { remote: false, id: to } }) + // New tree Bots are unpublished. + for (const id of [a, b]) { + assert.equal(m.flybot.bot.isPublished(id), false) + const verdict = toward(id) + assert.equal(verdict.action, 'deny'); assert.match(verdict.reason, /not published/) + } + assert.equal(toward(m.root), undefined, 'the Chief is published by default') + // Published: reached directly, even a deep descendant, and the tree gives no other target. + m.setPublished(b, true) + assert.equal(toward(b), undefined) + assert.equal((await m.flybot.bot.send({ from: remote, to: b, text: 'hi' })).action, 'allow') + assert.equal(toward(a)?.action, 'deny', 'its parent stays unpublished') + m.setPublished(b, false) + assert.equal(toward(b).action, 'deny') + // The rule also holds before the control-role bypass, and the string handle form. + assert.equal(h({ from: '@x.peer', to: b, role: 'stopped', target: { remote: false, id: b } }).action, 'deny') + assert.equal(h({ from: '@x.peer', to: b, target: { remote: false, id: b } }).action, 'deny') +}) + +test('#51: published Bots on both sides are not the tree\'s call across trees; inside one tree the edge rule holds', async () => { + const m = await setup() + const a = await make(m, m.root, 'A'); const b = await make(m, a, 'B') + const other = await outsider(m, 'Other owner Chief') + const h = m.middlewares[0].handle + const send = (from, to, extra = {}) => h({ from, to, target: { remote: false, id: to }, ...extra }) + // Unpublished: the old rules. + assert.match(send(other, b).reason, /only the tree root/) + assert.match(send(b, other).reason, /outside the tree/) + // Published on both sides: the core's owner policy decides, the tree does not force it through the root. + m.setPublished(other, true); m.setPublished(b, true) + assert.equal(send(other, b), undefined) + assert.equal(send(b, other), undefined) + // One side unpublished: still denied. + m.setPublished(b, false) + assert.match(send(other, b).reason, /only the tree root/) + // Inside one tree, publishing does not open the grandchild. + m.setPublished(b, true); m.setPublished(a, true) + assert.match(send(m.root, b).reason, /parent and its direct children/) + assert.match(send(b, m.root).reason, /parent and its direct children/) + assert.equal(send(a, b), undefined) + // Two trees: published nodes of different trees reach each other directly, unpublished ones do not. + const root2 = await outsider(m, 'Second root') + m.flybot.scope('flybot-tree').fields.set(root2, 'treeRole', 'root') + const c = await make(m, root2, 'C') + assert.equal(m.tree.rootOf(c), root2) + assert.equal(m.tree.rootOf(b), m.root) + assert.equal(m.tree.rootOf(other), undefined) + assert.match(send(b, c).reason, /parent and its direct children/) + m.setPublished(c, true) + assert.equal(send(b, c), undefined) + assert.equal(send(c, b), undefined) +}) + +test('#51: an older core without isPublished leaves the remote-sender rule as no opinion', async () => { + const m = await setup() + const a = await make(m, m.root, 'A') + const h = treeSendMiddleware(m.tree, { isRemote: ref => typeof ref === 'string' && ref.startsWith('@') }) + assert.equal(h({ from: '@x.peer', to: a, target: { remote: false, id: a } }), undefined) +}) + +test('#51: new and adopted Bots start unpublished, prompts tell a child how outside senders are answered', async () => { + const m = await setup() + const a = await make(m, m.root, 'A') + assert.equal(m.flybot.bot.isPublished(a), false) + assert.equal(m.flybot.bot.isPublished(m.root), true) + assert.match(sp(m, a), /has published you|owner has published you/) + assert.match(sp(m, a), /no trace line/) + assert.doesNotMatch(sp(m, m.root), /owner has published you/) +}) + const loadClient = () => { let mod globalThis.window = { __ModuleLoader__: { load: ({ factory }) => { mod = factory(() => reactStub) } } }