Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/948-room-only-bot-1to1-line.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot that joined a face-to-face room by its number can write its 1:1 line to the seat it shares that room with: the room is the only consent such a Bot has, so the host's owner policy now reads it instead of refusing a Bot that holds no person-grant, and the line goes out naming that room so Bot Social checks the same consent — with no shared room the refusal is the same sentence as before ([#948](https://github.com/ThinkFlowLab/FlyBot/issues/948)).
36 changes: 30 additions & 6 deletions core/src/host/reach.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,27 @@ function remoteInRoom(state, roomId, peer) {
})
}

// #948: the room a send already belongs to, when the caller names none. A Bot that joined a room by
// its number holds a `roomOnly` identity: no person-grant, and the one face-to-face room it came into
// that way is its whole consent. A 1:1 line to the seat it shares that room with was refused by a
// policy that can read only grants, because reach() reads a room only when the caller names one and the
// id is worked out later, in the transport. This is that room discovered instead of passed — reach()'s
// own allowance (`inRoom` + `remoteInRoom`) behind the same identity test the plugin uses
// (`isCodeJoined` and no grant at all, its `roomOnly`), and exact: that Bot, never any Bot of that home,
// only while the seat is still in the room, and only a face-to-face room, whose seats a number or a link
// admitted. A Bot that holds a grant is judged as it always was, and an explicit `roomId` still wins.
export function sharedRoom(state, botId, peer) {
if (typeof botId !== 'string' || state.bots?.[botId] === undefined) return undefined
if (hasAnyGrant(state, botId)) return undefined
const key = handleKey(peer)
if (!key) return undefined
const room = Object.values(state.rooms ?? {}).find(candidate => candidate?.origin === 'face-to-face'
&& (candidate.members ?? []).includes(botId)
&& isCodeJoined(state, candidate.id, botId)
&& remotesInRoom(candidate).some(seat => handleKey(seat.handle || seat.id) === key))
return room === undefined ? undefined : String(room.id)
}

// The seats each link of this room admitted: the invite's own `homes` (home → the handles it seated)
// is written on the accept and survives even when the seat record itself did not keep the ticket.
// #929: a link accepted before that field was written keeps the same fact in `acceptedBy` + `seats`, and
Expand Down Expand Up @@ -321,12 +342,15 @@ export function install(rt) {
rt.isGranted = (botId, peer, roomId) => isGranted(state, botId, peer, roomId)
rt.isCodeJoined = (roomId, botId) => isCodeJoined(state, roomId, botId)
rt.mayShareRoom = (room, botId) => mayShareRoom(state, room, botId)
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,
scope: msg.meta?.scope,
})
rt.reach = (msg = {}) => {
const from = msg.remote?.handle ? { kind: 'remote', handle: msg.remote.handle } : (msg.from ?? msg.fromId)
const to = msg.to ?? msg.toId
const named = msg.meta?.roomId ?? msg.roomId ?? msg.fromRoomId ?? msg.deliverArgs?.roomId ?? msg.deliverArgs?.fromRoomId
// #948: a local Bot's line to a friend's Bot is judged under the room the two of them are still in,
// even when the send names no room: that room is the consent, and the transport names it outbound.
const roomId = named ?? (isRemoteRef(to) && !isRemoteRef(from) ? sharedRoom(state, from, to) : undefined)
return reach(state, { from, to, roomId, scope: msg.meta?.scope })
}
rt.ticketPeers = () => ticketPeers(state)

rt.setGrants = async (botId, list) => {
Expand Down
48 changes: 48 additions & 0 deletions core/test/reach-policy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,54 @@ test('an ungranted Bot cannot message a friend\'s Bot; a granted one can', async
assert.equal(out.length, 2)
})

// #948: a Bot that joined a room by its number holds a `roomOnly` identity and no person-grant, so a
// 1:1 line to the seat it shares that room with was refused before any transport could name the room.
// The room is that identity's whole consent, so the policy reads it instead of waiting for the caller
// to pass it: the same allowance reach() applies to an explicit `roomId`, discovered and exact.
test('#948: a room-only Bot may line the seat it shares a room with, and the room is the only consent', async (t) => {
const state = team()
state.rooms.r = {
id: 'r', origin: 'face-to-face', members: ['a2'], participants: [
{ kind: 'human', id: 'alice', role: 'admin', joinedSeq: 0 },
{ kind: 'bot', id: 'a2', role: 'member', joinedSeq: 0, viaCode: true },
{ kind: 'bot', remote: true, handle: '@coder.bob.home', joinedSeq: 0 },
],
}
const { fb, state: live } = await start(t, { state })
const out = []
fb.transports.add('bot-social', { send: async msg => { out.push(msg.to.handle); return { exchangeId: `r${out.length}` } } })
const seat = { kind: 'remote', handle: '@coder.bob.home' }
const refused = 'A2 is not shared with that friend, so it cannot message their Bots. To allow it, share A2 with them in its settings → Sharing.'
assert.equal((await fb.bot.send({ from: 'a2', to: seat, text: 'the line into our room' })).action, 'allow')
assert.deepEqual(out, ['@coder.bob.home'], 'it left, and the transport named the room it belongs to')
out.length = 0
const other = await fb.bot.send({ from: 'a2', to: { kind: 'remote', handle: '@writer.bob.home' }, text: 'not seated here' })
assert.equal(other.action, 'deny', 'the exact Bot, never any Bot of that home')
assert.equal(other.reason, refused, 'and the sentence a user sees is the one from before #948, word for word')
assert.deepEqual(out, [])
live.rooms.r.participants[2].leftSeq = 3
const left = await fb.bot.send({ from: 'a2', to: seat, text: 'after the seat left' })
assert.equal(left.action, 'deny', 'the seat it shares is the consent, and it is gone with the seat')
assert.equal(left.reason, refused)
assert.deepEqual(out, [])
// #948's scope is the face-to-face room, whose seats are admitted by a number or a link: a room of
// this home is opened and closed by its members, and that path keeps asking for a grant or an
// explicit roomId. Same pair, same handles, only the room's kind differs.
live.rooms.g = {
id: 'g', members: ['a2'], participants: [
{ kind: 'human', id: 'alice', role: 'admin', joinedSeq: 0 },
{ kind: 'bot', id: 'a2', role: 'member', joinedSeq: 0 },
{ kind: 'bot', remote: true, handle: '@coder.bob.home', joinedSeq: 0 },
],
}
const group = await fb.bot.send({ from: 'a2', to: seat, text: 'a room of this home' })
assert.equal(group.action, 'deny', 'only a face-to-face room is read here')
assert.equal(group.reason, refused)
assert.deepEqual(out, [])
assert.equal(reach(live, { from: 'a2', to: seat, roomId: 'g' }).action, 'allow',
'an explicit roomId still names its room, face-to-face or not')
})

test('a held message is judged again when it is approved: revoke while it waited means refused', async (t) => {
const state = team()
grant(state, 'a', 'bob')
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core
| `core/src/host/prompt.js` | The Bot system prompt; a Main Bot's section is headed `You are the Main Bot` and uses that one term, the one the UI shows (#276). The team rules state the idle default (#699): a Bot does nothing until the user addresses it, it is @-mentioned in a group, or its parent gives it a task, and a coordinator acts on a user task rather than on its own initiative — an addressed Bot answers as before. |
| `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. |
| `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `<owner>'s Chief` / `<name> · <owner>'s Bot`, `<name> · <owner>`, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). |
| `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). |
| `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). `rt.reach` also reads the room a send already belongs to when the caller names none (#948): `sharedRoom` applies only to the `roomOnly` identity — the Bot holds no grant at all and came into that **face-to-face** room by its number (`isCodeJoined`) — and looks for the one such room where the **exact** target is still seated; a Bot that holds a grant, or a room of this home, is judged as it always was and keeps asking for an explicit `roomId`, so a Bot that joined a room by number — a `roomOnly` identity with no person-grant — may line that seat, which is the same allowance `reach()` already applies to an explicit `roomId`; the room itself is named outbound by the transport, never assumed here. Tests: `core/test/reach-policy.test.js`. |
| `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part (#354, #473, see **Scheduled tasks: edit, pause, missed runs** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. |
| `core/src/host/automation-default.js` | Automation tasks on by default (#378): once per profile, `offerBundle` asks DSH's plugin manager to switch on `@deepseek-ai/dsh-experimental-schedule-bundle` and writes the offer to `dsh-defaults.json` next to the team. Tests: `core/test/automation-default.test.js`. |
| `core/src/host/notify.js` | Out-of-app delivery, first half (#355): a transport-agnostic adapter with Slack's `chat.postMessage` shape, one key per reminder so a retried send cannot duplicate, exponential backoff that honours `Retry-After`, three attempts at most, a structural failure not retried, and zero transport calls while the channel is off. Ships `fakeSlack` for tests; nothing calls it until the real Slack transport lands (see **Out-of-app delivery, first half**). Tests: `core/test/notify.test.js`. |
Expand Down
Loading
Loading