From 364e3bad4e3c5a6a2ab9a2f5f30e862436ece44b Mon Sep 17 00:00:00 2001 From: hsliu_ustc Date: Thu, 15 Oct 2026 23:56:28 +0800 Subject: [PATCH 1/3] Bind held sends and their decisions to the owning human (#52) A held send was decidable by anyone who could reach the decide endpoint: the record carried no owner, no request binding, and no origin, so 'my owner approved' in the message text was as good as an approval, and one member could decide another's queued send on a multi-member host. Holds now stamp server-derived provenance: the sender Bot's owner, a sha256 over the request content, and the origin (via/hop/audience). flybot/decide passes authz for any signed-in member, and hooks binds the decision to the sender Bot's owner's session (bot/forbidden otherwise, bot/unauthorized without a session; a body actorId grants nothing). Releasing revalidates: a changed request, a deleted sender or target Bot, a Bot that changed owners, or a demoted Chief marks the record stale instead of dispatching; deleteBot and setMain invalidate affected holds eagerly. Under enforcement the pending list shows a member only their own holds, and the approval card shows who sent the request and when it was relayed. Legacy records without the version stamp keep working and only miss the tamper check. Team mode stays disabled; this is the held-send half of approval binding (docs/subsystems/team.md gate 3). social/decide-approval stays a deployment-wide plugin write. Fixes #52 --- CHANGELOG.md | 1 + core/client.js | 11 +- core/src/client/flybot-slots.js | 6 +- core/src/client/i18n.js | 2 + core/src/host/authz.js | 3 + core/src/host/channel.js | 5 +- core/src/host/hooks.js | 124 +++++++- core/test/approval-binding.test.js | 275 ++++++++++++++++++ core/test/authz.test.js | 9 +- core/types/flybot.d.ts | 13 +- docs/subsystems/core-client.md | 4 + docs/subsystems/core-host.md | 2 +- docs/subsystems/ds-bot-core.md | 1 + docs/subsystems/plugin-runtime.md | 5 +- docs/subsystems/team.md | 12 + patches/ds-bot/0001-core-fixes.patch | 4 +- patches/ds-bot/0005-social-seams.patch | 7 +- patches/ds-bot/0008-client-bundle.patch | 15 +- .../ds-bot/files/src/client/flybot-slots.js | 6 +- patches/ds-bot/files/src/host/authz.js | 3 + patches/ds-bot/files/src/host/hooks.js | 124 +++++++- .../files/test/approval-binding.test.js | 275 ++++++++++++++++++ patches/ds-bot/files/test/authz.test.js | 9 +- patches/ds-bot/files/types/flybot.d.ts | 13 +- 24 files changed, 878 insertions(+), 51 deletions(-) create mode 100644 core/test/approval-binding.test.js create mode 100644 patches/ds-bot/files/test/approval-binding.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b71d153d..d056dac4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ New entries are written as one fragment file per change in [changelog.d/](change ### Added +- **Held sends are bound to their owner.** An approval card shows who sent the request (and when it was relayed between Bots); only the sender Bot's owner can approve, reject, or retry it, the list shows you only your own holds, and a decision is voided safely (`stale`, never sent) when the request changed after it was held or the Bot / Chief authority behind it is gone. ([#52](https://github.com/ThinkFlowLab/FlyBot/issues/52)) - **Remove a friend's Bot** from Friends' Bots hides it on this install, takes it out of rooms, and does not unfriend. It reappears only if it is granted again. Held Bot replies show in the chat as **Waiting for approval** (owner: Approve / Reject / Auto-send; others: waiting for the owner). ([#90](https://github.com/ThinkFlowLab/FlyBot/issues/90)) - **Face-to-face join registers a Bot for that room only** when it has no identity yet. `@flybot/social` calls the same `add_home_member` path used for invites, then joins. The identity is not polled, not listed for friends, and is revoked with `remove_home_member` when you leave the last face room or the room closes. A failed join that created one is revoked too so retry is clean. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83), [#89](https://github.com/ThinkFlowLab/FlyBot/issues/89), [#90](https://github.com/ThinkFlowLab/FlyBot/issues/90)) - **Face-to-face group.** People next to you join one group by typing the same 6-digit number. Joining is direct — no preview. The room is a normal group with the full member list as ` · 's Bot` (owner badges from #82), one-click **Leave**, and **Start chat** / **Enter chat** (voids the number; a 10-minute countdown while it is still a candidate). Joins, leaves, start, and peer messages render as system / attributed lines; Bot replies go through Bot Social (`pending_approval` uses the existing Approve / Reject queue). **Auto-send my Bot's room messages** in the room header (and **Approve and always auto-send in this room** / **Turn on auto-send** on a waiting draft) is one click onto `auto_approve_face_rooms`; default off, easy to turn off again. Peer text is fenced. Entry and waiting-room UI: #92. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83)) diff --git a/core/client.js b/core/client.js index c16404854..1a640607f 100644 --- a/core/client.js +++ b/core/client.js @@ -110,6 +110,8 @@ var ZH = { "Bot tree": "Bot 树", "no longer shared": "已不再共享", "Messages waiting for your approval": "等你批准的消息", + "Sent by {sender}": "由 {sender} 发送", + "Sent by {sender} · delegated": "由 {sender} 发送 · 委托转达", "Not sent to {to} · {reason}": "未发给 {to} · {reason}", "Message to {to} · {reason}": "发给 {to} 的消息 · {reason}", failed: "失败", @@ -1831,11 +1833,17 @@ function FlyBotApprovals() { pending.map((approval) => { const custom = entries2.find((entry) => entry.policy === void 0 || entry.policy === approval.by); if (custom) return (0, import_react2.createElement)("div", { key: approval.id }, (0, import_react2.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } })); - const to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to; + const to = approval.targetName ?? (typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to); return (0, import_react2.createElement)( "div", { key: approval.id, className: "bt-flybot-approval" }, (0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })), + // Where the request came from (#52): the sending Bot, and when it was relayed. + approval.senderName !== void 0 ? (0, import_react2.createElement)( + "div", + { className: "bt-flybot-approval-origin" }, + approval.request.hop > 0 ? t("Sent by {sender} · delegated", { sender: approval.senderName }) : t("Sent by {sender}", { sender: approval.senderName }) + ) : null, (0, import_react2.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), approval.request.attachments?.length ? (0, import_react2.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react2.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, (0, import_react2.createElement)( @@ -1861,6 +1869,7 @@ var FLYBOT_CSS = ` .bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } .bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } .bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } +.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } .bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } .bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } .bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 70534b5ac..49bf7530a 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -144,9 +144,12 @@ export function FlyBotApprovals() { pending.map((approval) => { const custom = entries.find(entry => entry.policy === undefined || entry.policy === approval.by) if (custom) return h('div', { key: approval.id }, h(Safe, { entry: custom, props: { approval, decide: decision => decide(approval.id, decision), call } })) - const to = typeof approval.request.to === 'object' ? approval.request.to.handle : approval.request.to + const to = approval.targetName ?? (typeof approval.request.to === 'object' ? approval.request.to.handle : approval.request.to) return h('div', { key: approval.id, className: 'bt-flybot-approval' }, h('div', { className: 'bt-flybot-approval-head' }, approval.status === 'failed' ? t('Not sent to {to} · {reason}', { to, reason: approval.error ?? t('failed') }) : t('Message to {to} · {reason}', { to, reason: approval.reason })), + // Where the request came from (#52): the sending Bot, and when it was relayed. + approval.senderName !== undefined ? h('div', { className: 'bt-flybot-approval-origin' }, + approval.request.hop > 0 ? t('Sent by {sender} · delegated', { sender: approval.senderName }) : t('Sent by {sender}', { sender: approval.senderName })) : null, h('div', { className: 'bt-flybot-approval-text' }, approval.request.text), approval.request.attachments?.length ? h('ul', { className: 'bt-flybot-approval-files', 'aria-label': t('Attached files') }, approval.request.attachments.map((file, index) => @@ -172,6 +175,7 @@ export const FLYBOT_CSS = ` .bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } .bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } .bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } +.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } .bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } .bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } .bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 9cfe561fe..d1c9a6988 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -74,6 +74,8 @@ const ZH = { 'Bot tree': 'Bot 树', 'no longer shared': '已不再共享', 'Messages waiting for your approval': '等你批准的消息', + 'Sent by {sender}': '由 {sender} 发送', + 'Sent by {sender} · delegated': '由 {sender} 发送 · 委托转达', 'Not sent to {to} · {reason}': '未发给 {to} · {reason}', 'Message to {to} · {reason}': '发给 {to} 的消息 · {reason}', failed: '失败', diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 244018652..c71212cac 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -203,6 +203,9 @@ export function install(rt) { // owners, so they follow the shared rule. if (ep.startsWith('flybot/')) { if (rt.flybotIsRead?.(ep.slice('flybot/'.length)) === true) return ALLOW + // Held-send decisions are per-owner enforced in hooks.js — the approval belongs + // to the sender Bot's owner — so deciding does not follow the shared rule (#52). + if (ep === 'flybot/decide') return ALLOW if (!alone()) return deny('bot/blocked-shared', 'This plugin endpoint cannot be scoped per owner yet, so it is unavailable while the team has more than one member') return ALLOW } diff --git a/core/src/host/channel.js b/core/src/host/channel.js index e13ea702a..7ec77c782 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -411,8 +411,9 @@ export function install(rt) { return ok(removed) } default: - // FlyBot plugins' endpoints (src/host/hooks.js). - if (endpoint.startsWith('flybot/')) return ok(await rt.flybotEndpoint(endpoint.slice('flybot/'.length), body)) + // FlyBot plugins' endpoints (src/host/hooks.js). The actor rides along so + // owner-checked endpoints (held-send decisions) can bind it (#52). + if (endpoint.startsWith('flybot/')) return ok(await rt.flybotEndpoint(endpoint.slice('flybot/'.length), body, { actor })) return fail(`Unknown endpoint ${endpoint}`, 'bot/not-found') } } catch (error) { diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 5398a8b10..b39af62fe 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -9,7 +9,7 @@ // state.flybot.approvals[id] sends held for the owner's decision // Types: ds-bot/types/flybot.d.ts. Plugins call flybot.scope('') to get the // same API with their own field namespace; the unscoped API uses namespace 'shared'. -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { mentionSpans } from '../shared/mentions.js' import { isHomeLevelRemote } from '../shared/remote-label.js' @@ -172,11 +172,14 @@ export function install(rt) { const remote = rt.botOf(id)?.remote const remoteChat = remote !== undefined const result = await deleteBot(id, ...rest) - if (!remoteChat) fire('bot.removed', { ...last, botId: id, bot: last }) + if (!remoteChat) { + fire('bot.removed', { ...last, botId: id, bot: last }) + // A hold naming this Bot as sender or target can no longer be spent (#52). + await invalidateFor([id], 'the Bot was deleted') // Deleting a friend's Bot's chat clears it; the friend's Bot is still there, so it gets a // fresh, empty chat at once. Otherwise its row waits ("opening its chat…") until the // plugin next publishes its roster, which may be hours or the next restart. - else if (typeof remote?.handle === 'string' && typeof remote.plugin === 'string') { + } else if (typeof remote?.handle === 'string' && typeof remote.plugin === 'string') { const published = (state.flybot?.remote?.[remote.plugin] ?? []).find(bot => bot.handle === remote.handle) if (published !== undefined) { await rt.remoteChats?.open({ handle: published.handle, name: published.name, home: published.home, homeName: published.homeName, plugin: remote.plugin }) @@ -186,7 +189,18 @@ export function install(rt) { return result } } - + // A demoted Main Bot's holds were queued under its Chief authority; they die with the + // role (docs/subsystems/team.md, replaced Chiefs) (#52). + const setMain = rt.setMain + if (typeof setMain === 'function') { + rt.setMain = async (id, main, opts = {}) => { + const before = [...state.mainBotIds] + const result = await setMain(id, main, opts) + const demoted = before.filter(other => !state.mainBotIds.includes(other)) + if (demoted.length > 0) await invalidateFor(demoted, 'the Chief that held this was replaced', 'from') + return result + } + } // ---------------------------------------------------------------- fields (synchronous) const fieldsFor = (plugin) => { checkName('plugin', plugin) @@ -276,6 +290,32 @@ export function install(rt) { state.flybot.approvals ??= {} return state.flybot.approvals } + // The version hash binds a decision to the exact request content: a record whose + // request changed after it was held (or across owners) is stale, never dispatched (#52). + const requestVersion = (msg) => createHash('sha256').update(JSON.stringify([ + msg?.from, msg?.to, msg?.text, msg?.role, + (Array.isArray(msg?.attachments) ? msg.attachments : []).map(({ name, size, sha256 }) => ({ name, size, sha256 })), + ])).digest('hex') + // The owner a held send belongs to is the sender Bot's owner, derived from the team + // state at the moment it matters — never from a claim inside the message (#52). + const ownerOfRequest = (request) => { + const bot = typeof request?.from === 'string' ? rt.botOf(request.from) : undefined + return bot?.ownerId ?? null + } + const nameOf = (ref) => (isRemote(ref) ? toRemote(ref)?.handle : rt.botOf(ref)?.name) ?? String(ref ?? '') + // Why a held send may no longer be spent; undefined when it still can. Old records + // from before the version stamp keep working (they only miss the tamper check). + const staleReason = (approval) => { + const request = approval?.request + if (request === undefined || request === null) return 'the held request is missing' + if (approval.version !== undefined && requestVersion(request) !== approval.version) return 'the request changed after it was held' + if (!isRemote(request.from) && (typeof request.from !== 'string' || rt.botOf(request.from) === undefined)) return 'the sender Bot no longer exists' + const owner = ownerOfRequest(request) + if (owner === null) return 'the sender Bot has no owner on this team' + if (approval.ownerId !== undefined && approval.ownerId !== owner) return 'the sender Bot changed owners after it was held' + if (!isRemote(request.to) && rt.botOf(request.to) === undefined) return 'the target Bot no longer exists' + return undefined + } const approvalListeners = new Set() const approvalsChanged = (approval) => { for (const fn of [...approvalListeners]) { @@ -283,6 +323,24 @@ export function install(rt) { } emit('flybot/approval', clone(approval)) } + // Queued work whose authority is gone does not wait forever: a hold naming one of + // these Bots as sender or target is marked stale (still listed for the audit view). + // `field` narrows the match ('from': sender-side only, e.g. a demoted Chief). + const invalidateFor = async (botIds, reason, field) => { + await rt.load() + const ids = new Set(botIds) + const hit = Object.values(store()).filter(a => a.status === 'pending' + && (ids.has(a.request?.from) && (field === undefined || field === 'from') + || ids.has(a.request?.to) && field === undefined)) + if (hit.length === 0) return + for (const approval of hit) { + approval.status = 'stale' + approval.error = reason + approval.decidedAt = Date.now() + } + await rt.save() + for (const approval of hit) approvalsChanged(approval) + } let innerDeliver const dispatch = async (msg) => { @@ -303,7 +361,18 @@ export function install(rt) { if (Object.values(all).filter(a => a.status === 'pending').length >= PENDING_MAX) { throw new HoldFull(`${PENDING_MAX} messages already wait for approval; decide those first`) } - const approval = { id: randomUUID(), status: 'pending', reason: verdict.reason, by: verdict.by, createdAt: Date.now(), request: clone(msg) } + const audience = rt.roomOf?.(msg?.meta?.sessionId)?.id + const ownerId = ownerOfRequest(msg) + const approval = { + id: randomUUID(), status: 'pending', reason: verdict.reason, by: verdict.by, createdAt: Date.now(), + request: clone(msg), + // Server-derived provenance (#52): what was held, who owns the decision, where it + // came from. Fields of msg itself (meta especially) are data, never authority. + requestId: msg.exchangeId, + version: requestVersion(msg), + origin: { via: msg.via, hop: msg.hop ?? 0, ...(audience ? { audience } : {}) }, + ...(ownerId !== null ? { ownerId } : {}), + } all[approval.id] = approval const done = Object.values(all).filter(a => a.status !== 'pending').sort((a, b) => a.createdAt - b.createdAt) for (const old of done.slice(0, Math.max(0, Object.keys(all).length - APPROVALS_KEPT))) delete all[old.id] @@ -313,8 +382,15 @@ export function install(rt) { } // An approved hold: the rest of the chain decides (a later deny or hold still counts), - // then it is dispatched; a failed dispatch stays visible for Retry. + // then it is dispatched; a failed dispatch stays visible for Retry. The hold is + // revalidated first: hours may have passed since the decision (#52). const release = async (approval) => { + const stale = staleReason(approval) + if (stale !== undefined) { + approval.status = 'stale' + approval.error = stale + return + } let verdict // Approved or not, the owner policy is asked again: the hold may be hours old. const owner = ownerPolicy(approval.request) @@ -344,15 +420,35 @@ export function install(rt) { } const approvals = { - list: ({ status = 'pending' } = {}) => Object.values(store()).filter(a => status === 'all' || (Array.isArray(status) ? status.includes(a.status) : a.status === status)).sort((a, b) => a.createdAt - b.createdAt).map(clone), + // Under member enforcement a member sees only the holds of their own Bots; the + // decision on each stays owner-checked in decide() (#52). + list: ({ status = 'pending', actor } = {}) => { + const enforced = rt.authzEnforced() + return Object.values(store()) + .filter(a => status === 'all' || (Array.isArray(status) ? status.includes(a.status) : a.status === status)) + .filter(a => { + if (!enforced || actor === undefined) return true + const owner = ownerOfRequest(a.request) + return owner !== null && owner === actor.memberId + }) + .sort((a, b) => a.createdAt - b.createdAt) + .map(a => ({ ...clone(a), ownerId: ownerOfRequest(a.request) ?? undefined, senderName: nameOf(a.request?.from), targetName: nameOf(a.request?.to) })) + }, get: id => clone(store()[id]), - // Owner only: reachable from the UI route, never a model tool. - async decide(id, decision, { note } = {}) { + // Owner only: the decision is bound to the session of the member who owns the sender + // Bot, resolved from the member-session header — a body actorId grants nothing. + // Without member sessions the single-human host decides as before (#52). + async decide(id, decision, { note, actor } = {}) { await rt.load() const approval = store()[id] if (approval === undefined) throw new Error(`flybot: no approval ${id}`) + const ownerId = ownerOfRequest(approval.request) + if (rt.authzEnforced()) { + if (actor === null || actor === undefined) throw Object.assign(new Error('Sign in first'), { code: 'bot/unauthorized' }) + if (ownerId === null || ownerId !== actor.memberId) throw Object.assign(new Error('You do not own that approval'), { code: 'bot/forbidden' }) + } if (decision === 'retry') { - if (approval.status !== 'failed') throw new Error(`flybot: only a failed send can be retried (${id} is ${approval.status})`) + if (approval.status !== 'failed') throw Object.assign(new Error(`flybot: only a failed send can be retried (${id} is ${approval.status})`), { code: 'bot/stale' }) approval.status = 'approved' delete approval.error await release(approval) @@ -360,7 +456,7 @@ export function install(rt) { approvalsChanged(approval) return clone(approval) } - if (approval.status !== 'pending') throw new Error(`flybot: approval ${id} is already ${approval.status}`) + if (approval.status !== 'pending') throw Object.assign(new Error(`flybot: approval ${id} is already ${approval.status}`), { code: 'bot/stale' }) if (decision !== 'approve' && decision !== 'reject') throw new Error('flybot: decision is approve or reject') approval.status = decision === 'approve' ? 'approved' : 'rejected' approval.decidedAt = Date.now() @@ -674,7 +770,7 @@ export function install(rt) { }, } const isReadEndpoint = name => READ_ENDPOINTS.has(name) || readHandlers.has(name) - async function endpoint(name, payload) { + async function endpoint(name, payload, { actor } = {}) { await rt.load() const body = payload ?? {} switch (name) { @@ -684,8 +780,8 @@ export function install(rt) { const since = body.epoch === changeEpoch ? Number(body.since ?? 0) : 0 return { epoch: changeEpoch, seq: changeSeq, changes: changes.filter(change => change.seq > since).map(change => structuredClone(change)) } } - case 'approvals': return approvals.list(body) - case 'decide': return approvals.decide(String(body.id ?? ''), body.decision, body) + case 'approvals': return approvals.list({ ...body, actor }) + case 'decide': return approvals.decide(String(body.id ?? ''), body.decision, { note: body.note, actor }) default: { const handler = handlers.get(name) if (handler === undefined) throw Object.assign(new Error(`Unknown FlyBot endpoint ${name}`), { code: 'bot/not-found' }) diff --git a/core/test/approval-binding.test.js b/core/test/approval-binding.test.js new file mode 100644 index 000000000..61954e042 --- /dev/null +++ b/core/test/approval-binding.test.js @@ -0,0 +1,275 @@ +// Approval binding (#52): a held send is bound to the owner the server derives from the +// sender Bot — never to claims in the message — and only that owner's session may decide +// it. Changing the request after it was held, or the authority behind it (Bot deleted, +// Chief replaced), makes the decision stale. Alice and Bob here are two signed-in members; +// this is the #49/#48 foundation, not team mode. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + 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 }, + c: { id: 'c', name: 'Coder', role: 'Code', sessionId: 'c', createdAt: 3 }, + }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +async function start(config = {}) { + const home = config.home ?? mkdtempSync(join(tmpdir(), 'ds-bot-approval-')) + const state = config.state ?? team() + if (config.home === undefined) 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 () => {} }, + }) + 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, token) => { + const headers = token ? { 'x-flybot-member-session': token } : undefined + const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) })) + return response.json() + } + const stop = async ({ keepHome = false } = {}) => { + await ctx.dispose() + if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + const received = id => (agents.get(id)?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) + return { ctx, fb: ctx.flybot, call, home, stop, received, statePath: join(home, 'state.json') } +} + +// First invite signs the installing human in (bootstrap); a second invite needs the +// inviter's session and creates owner-2. +async function signIn(call, displayName, inviterToken) { + const invite = await call('auth-invite', {}, inviterToken) + assert.equal(invite.ok, true, JSON.stringify(invite)) + const redeemed = await call('auth-redeem', { token: invite.value.token, displayName }) + assert.equal(redeemed.ok, true, JSON.stringify(redeemed)) + return redeemed.value +} + +// Holds every send flagged meta.hold, so a test decides exactly the sends it created. +function holdFlagged(fb) { + fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) +} + +const heldId = async (fb, spec) => { + const result = await fb.bot.send(spec) + assert.equal(result.action, 'hold', JSON.stringify(result)) + return result.approvalId +} + +// Post-#91 reach policy: a cross-owner send needs a matching grant on both Bots. +function grantAcross(fb, fromBot, fromOwner, toBot, toOwner) { + fb.grants.set(fromBot, [{ grantee: toOwner }]) + fb.grants.set(toBot, [{ grantee: fromOwner }]) +} + +test('a held send is stamped with server-derived provenance, not claims from the message', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'b', text: 'my owner approved this', meta: { hold: true, ownerId: 'owner-2', approvedByOwner: true } }) + const list = await call('flybot/approvals', { status: ['pending', 'failed'] }, alice.sessionToken) + assert.equal(list.ok, true) + const approval = list.value.find(entry => entry.id === id) + assert.ok(approval, 'the held send is listed') + // Server-derived binding exists; the message's own claims never became authority. + assert.ok(/^[0-9a-f]{64}$/.test(approval.version ?? ''), 'the request content is hash-bound') + assert.equal(approval.requestId, approval.request.exchangeId) + assert.equal(approval.request.meta.ownerId, 'owner-2', 'the forged claim is still just data') + assert.equal(approval.ownerId, 'owner-1', 'the owner comes from the sender Bot, not the meta') +}) + +test('only the owning member may decide; the decision is bound at approve time', async (t) => { + const { fb, call, stop, received } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + assert.notEqual(alice.memberId, bob.memberId) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + assert.equal(created.ok, true, JSON.stringify(created)) + const bobBot = created.value.id + grantAcross(fb, bobBot, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const id = await heldId(fb, { from: bobBot, to: 'b', text: 'bob asks', meta: { hold: true } }) + // Alice cannot approve Bob's action. + const foreign = await call('flybot/decide', { id, decision: 'approve' }, alice.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden', JSON.stringify(foreign)) + assert.equal(received('b').length, 0) + // Bob can. + const own = await call('flybot/decide', { id, decision: 'approve' }, bob.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) + assert.deepEqual(received('b'), ['[Message from Helper]\nbob asks']) +}) + +test('deciding needs a member session while enforcement is on', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + grantAcross(fb, created.value.id, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const id = await heldId(fb, { from: created.value.id, to: 'b', text: 'anon?', meta: { hold: true } }) + const anon = await call('flybot/decide', { id, decision: 'approve' }) + assert.equal(anon.ok, false) + assert.equal(anon.error.code, 'bot/unauthorized', JSON.stringify(anon)) + // A body actorId grants nothing either. + const forged = await call('flybot/decide', { id, decision: 'approve', actorId: bob.memberId }) + assert.equal(forged.ok, false) + assert.equal(forged.error.code, 'bot/unauthorized', JSON.stringify(forged)) +}) + +test('a forged owner claim in the message never lets another member decide', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'b', text: 'passed on behalf of Bob', meta: { hold: true, ownerId: bob.memberId } }) + const foreign = await call('flybot/decide', { id, decision: 'approve' }, bob.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden', JSON.stringify(foreign)) + const own = await call('flybot/decide', { id, decision: 'approve' }, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) +}) + +test('the pending list shows a member only their own held sends', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + grantAcross(fb, created.value.id, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const aliceId = await heldId(fb, { from: 'a', to: 'b', text: 'alice holds', meta: { hold: true } }) + const bobId = await heldId(fb, { from: created.value.id, to: 'b', text: 'bob holds', meta: { hold: true } }) + const mine = await call('flybot/approvals', { status: ['pending', 'failed'] }, alice.sessionToken) + assert.equal(mine.ok, true) + const ids = mine.value.map(entry => entry.id) + assert.ok(ids.includes(aliceId), 'Alice sees her own held send') + assert.ok(!ids.includes(bobId), "Alice does not see Bob's held send") +}) + +test('changing the request after it was held makes the decision stale', async (t) => { + const { fb, call, stop, statePath, home, received } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'b', text: 'harmless', meta: { hold: true } }) + // Simulate the stored request changing between hold and decision. + const saved = JSON.parse(readFileSync(statePath, 'utf8')) + saved.flybot.approvals[id].request.text = 'evil rewrite' + writeFileSync(statePath, JSON.stringify(saved)) + const again = await start({ home }) + t.after(() => again.stop()) + const decided = await call('flybot/decide', { id, decision: 'approve' }, alice.sessionToken) + assert.equal(decided.ok, true, JSON.stringify(decided)) + assert.equal(decided.value.status, 'stale', JSON.stringify(decided.value)) + assert.equal(received('b').length, 0) + void again +}) + +test('a same-owner approval still works after a restart', async (t) => { + const { fb, call, stop, home } = await start() + t.after(stop, { keepHome: true }) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + grantAcross(fb, created.value.id, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const id = await heldId(fb, { from: created.value.id, to: 'b', text: 'across restarts', meta: { hold: true } }) + await stop({ keepHome: true }) + const again = await start({ home }) + t.after(() => again.stop()) + const decided = await again.call('flybot/decide', { id, decision: 'approve' }, bob.sessionToken) + assert.equal(decided.ok, true, JSON.stringify(decided)) + assert.equal(decided.value.status, 'approved', JSON.stringify(decided.value)) + assert.deepEqual(again.received('b'), ['[Message from Helper]\nacross restarts']) + void alice +}) + +test('deleting a sender or target Bot invalidates its pending holds', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'c', text: 'do work', meta: { hold: true } }) + await call('delete-bot', { id: 'c' }, alice.sessionToken) + const list = await call('flybot/approvals', { status: ['pending', 'failed', 'stale'] }, alice.sessionToken) + assert.equal(list.ok, true) + const approval = list.value.find(entry => entry.id === id) + assert.ok(approval, 'the record is kept for the audit view') + assert.equal(approval.status, 'stale', JSON.stringify(approval)) + const decided = await call('flybot/decide', { id, decision: 'approve' }, alice.sessionToken) + assert.equal(decided.ok, false) + assert.match(decided.error.code, /stale|invalid/) +}) + +test('replacing a Chief invalidates the held sends written under it', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'c', text: 'chief work', meta: { hold: true } }) + const swapped = await call('set-main', { id: 'b', main: true, replace: 'a' }, alice.sessionToken) + assert.equal(swapped.ok, true, JSON.stringify(swapped)) + const list = await call('flybot/approvals', { status: ['pending', 'failed', 'stale'] }, alice.sessionToken) + assert.equal(list.ok, true) + const approval = list.value.find(entry => entry.id === id) + assert.equal(approval.status, 'stale', JSON.stringify(approval)) +}) + +test('a member whose sender Bot is gone cannot decide leftovers with an old session', async (t) => { + const { fb, call, stop, statePath } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + grantAcross(fb, created.value.id, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const id = await heldId(fb, { from: created.value.id, to: 'b', text: 'queued', meta: { hold: true } }) + await call('delete-bot', { id: created.value.id }, bob.sessionToken) + const removed = await call('auth-remove-member', { memberId: bob.memberId }, alice.sessionToken) + assert.equal(removed.ok, true, JSON.stringify(removed)) + const decided = await call('flybot/decide', { id, decision: 'approve' }, bob.sessionToken) + assert.equal(decided.ok, false) + assert.equal(decided.error.code, 'bot/unauthorized', JSON.stringify(decided)) + // The stale record stays in the team state with its attribution, but no member's list + // shows it: its sender Bot and its owner are both gone (#57 adds that surface). + const saved = JSON.parse(readFileSync(statePath, 'utf8')) + assert.equal(saved.flybot.approvals[id].status, 'stale', JSON.stringify(saved.flybot.approvals[id])) + const list = await call('flybot/approvals', { status: ['pending', 'failed', 'stale'] }, alice.sessionToken) + assert.equal(list.value.find(entry => entry.id === id), undefined) +}) diff --git a/core/test/authz.test.js b/core/test/authz.test.js index f6da3edff..731f9828f 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -225,13 +225,16 @@ test('flybot plugin endpoints follow the read/shared rules', async (t) => { const actorA = await rt.authenticate(sessionA.sessionToken) // Reads reach the plugin layer for the signed-in actor. assert.equal(rt.authorize(actorA, 'flybot/changes', {}).ok, true) - // Alone on the deployment, writes do too; with two members they are blocked like - // the other shared resources. + // Alone on the deployment, writes do too. Deciding a held send stays reachable with + // two members: hooks.js binds it to the sender Bot's owner (#52); other plugin + // writes are blocked like the other shared resources. assert.equal(rt.authorize(actorA, 'flybot/decide', { id: 'x' }).ok, true) await redeemOwner(rt) assert.equal(rt.authorize(actorA, 'flybot/changes', {}).ok, true) - assert.equal(rt.authorize(actorA, 'flybot/decide', { id: 'x' }).code, 'bot/blocked-shared') + assert.equal(rt.authorize(actorA, 'flybot/decide', { id: 'x' }).ok, true) + assert.equal(rt.authorize(actorA, 'flybot/social/decide-approval', {}).code, 'bot/blocked-shared') assert.equal(rt.authorize(null, 'flybot/changes', {}).code, 'bot/unauthorized') + assert.equal(rt.authorize(null, 'flybot/decide', { id: 'x' }).code, 'bot/unauthorized') }) test('add-agent is a shared deployment resource', async (t) => { diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index e951ca898..97346bad9 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -149,7 +149,7 @@ export type SendResult = export interface Approval { id: string - status: 'pending' | 'approved' | 'rejected' | 'failed' + status: 'pending' | 'approved' | 'rejected' | 'denied' | 'failed' | 'stale' reason: string /** Middleware that held it. */ by: string @@ -158,7 +158,18 @@ export interface Approval { note?: string error?: string exchangeId?: string + /** The held send's own exchange id (#52). */ + requestId?: string + /** sha256 over the request content; a changed request is stale, never dispatched (#52). */ + version?: string + /** Server-derived: the sender Bot's owner, who alone may decide (#52). */ + ownerId?: string + /** Where the request came from: which plugin relayed it and how many hops (#52). */ + origin?: { via: string; hop: number; audience?: string } request: SendMessage + /** List-view context, resolved server-side: sender and target names (#52). */ + senderName?: string + targetName?: string } export interface Transport { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 5da4b83e7..a5d55befb 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -39,6 +39,10 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - The committed bundle includes `flybot-slots.js` `registry()`, which exposes `BotAvatar` / `BotMark` / `lookOf` on `globalThis.__flybot__` for classic-script plugins. Behavior is specified on [plugin-runtime](plugin-runtime.md). +### Held-send approval card + +The default card (`flybot-slots.js`) shows where a held request came from: `senderName` / `targetName` resolved server-side, plus a delegated marker when the send was relayed (`hop > 0`). The strings live in `i18n.js` (`Sent by {sender}`, `Sent by {sender} · delegated`). The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). + ### Remote Bot display - 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/core-host.md b/docs/subsystems/core-host.md index 2d4fc1ac1..24b2d4e6e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -48,7 +48,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t ### Team state and access -- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). +- The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), the Bot changed owners, or its Chief was demoted (`setMain` wrapper, sender side). `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. ### Diagnostics diff --git a/docs/subsystems/ds-bot-core.md b/docs/subsystems/ds-bot-core.md index 3aac6fa07..9e8d44dbb 100644 --- a/docs/subsystems/ds-bot-core.md +++ b/docs/subsystems/ds-bot-core.md @@ -34,6 +34,7 @@ This page covers the pin, the patch format and the core's dev tooling. The gener | `ds-bot.pin.json` | Upstream pin. | | `patches/ds-bot/` | Ordered patch topics. | | `scripts/ds-bot-patches.mjs` | generate / check patches. | +| `core/src/host/hooks.js` (approvals) | Held sends bound to their owner — provenance, owner-bound decisions, stale revalidation. The authority contract lives in [team](team.md) (#52). | ## Entry points diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 9a395f960..5743894f1 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -30,8 +30,8 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots | File | Role | | --- | --- | -| `core/src/host/hooks.js` | Host hook points. | -| `core/src/client/flybot-slots.js` | Client slots. | +| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, and the owner-bound held-send approvals (#52). | +| `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`). | | `core/src/client/flybot-registry.js` | Registry. | | `core/types/flybot.d.ts` | Types. | @@ -51,6 +51,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview (Leave / Start chat / Auto-send for origin `face-to-face`). Slot `flybot.new-chat.action` adds a row to the sidebar **+** new-chat menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. +10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner, demoted Chief — `deleteBot`/`setMain` also invalidate eagerly), and the pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). ### Host Bot look for classic-script plugins diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 491bc7542..dea372b84 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -87,6 +87,16 @@ Typed participants on a native room, in `core/src/host/room-membership.js`, inst | Remote owner (owns an invited Bot, not a human member) / inbound share | Sidebar **Shared room from \** | Current participants with owner badges; who invited their Bot and when; lines from the Bot's `joinedSeq`; pending social approvals in this view | Remove their Bot (leave closes the interval); approve/reject held replies; cannot inject as themselves | | Remote Bot | Only the room lines fenced as untrusted data, from its join seq | No inviter private rooms, no ungranted specialists | Reply through Bot Social (held unless the owner auto-approves) | +## Approval binding foundation (landed, #52) + +The held-send approval (`state.flybot.approvals` in `core/src/host/hooks.js`) now binds each decision to the owner whose authority is spent, as [invariant 5](#invariants) and gate 3 require. Only the sign-in foundation of #48/#49 is assumed; team mode stays disabled. + +- **Provenance is server-derived.** A hold stamps `requestId` (the send's `exchangeId`), a sha256 `version` over the request content (from/to/text/role/attachment names), `origin` (`via`, `hop`, room audience when known), and `ownerId` — the sender Bot's owner, resolved from the team state. Claims inside the message (`meta`, body text such as "my owner approved") are data on the record, never authority. +- **Decisions are owner-bound.** `flybot/decide` passes authz for any signed-in actor; hooks.js then requires the deciding session to belong to the sender Bot's owner (`bot/forbidden` otherwise, `bot/unauthorized` with no session). One owner cannot approve, reject or retry another owner's held send, and the pending list shows a member only their own holds. +- **Revalidation at approve/execute time.** Releasing a hold re-checks the record: a changed request content, a deleted sender or target Bot, a Bot that changed owners, or a demoted Chief marks the record `stale` — it is never dispatched. `deleteBot` and Chief replacement (`setMain`) invalidate affected pending holds eagerly; a removed member's sessions cannot decide, and their holds were already stale when their last Bot was deleted (`removeMember` refuses a member that still owns one). +- **Legacy records keep working.** Holds saved before this change have no `version`; they skip only the tamper check, and their owner is derived at decide time. +- **Not done here:** the social plugin's server-side approval holds (`social/decide-approval`) stay a deployment-wide plugin write (blocked while more than one member exists); the in-chat approval cards and task-level approvals are #90/#54 work. Gate 3 is satisfied for held sends only. + ## Identity model | Relation | Rule | @@ -208,4 +218,6 @@ Owner-scoped access (#49) tests in `core/test/authz.test.js`: cross-owner denial **Room membership foundation (#50)** has tests in `core/test/room-membership.test.js`: Alice, Bob and both Chiefs keep distinct authorship; concurrent posts keep order and identity; unauthorized history reads fail; join/leave follows the membership-window rule; non-members cannot inject (forged `authorId` is ignored); existing single-owner groups still display and accept posts; a specialist cannot join a cross-owner room. +**Approval binding (#52)** has tests in `core/test/approval-binding.test.js`: the hold stamps server-derived provenance and a request-content hash while forged `meta` claims stay data; only the sender Bot's owner's session may decide (another member gets `bot/forbidden`, no session gets `bot/unauthorized`, a body `actorId` grants nothing); the pending list is per-actor; a request that changed after it was held goes `stale` and never dispatches; same-owner deciding survives a restart; deleting a sender or target Bot or replacing the Chief invalidates pending holds; and a record whose sender Bot and owner are both gone stays in state with its attribution but in no member's list. + The routing-matrix rows above remain untested because the behavior is not shipped. When enforcement work starts (#48+), each row becomes a test first, per [fail-without-change](../code-standards.md): same-owner delegation allowed (a), Chief-to-Chief allowed (b), cross-owner specialist denied (c), team-admin vs private-owner denied (d), forwarded approval rejected (e). Each must fail on the unbroken tip before implementation lands. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 2230626a7..64a6ab70f 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -41,7 +41,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -47,15 +47,29 @@ const ZH = { +@@ -47,15 +47,31 @@ const ZH = { 'Remove Main Bot role': '取消主 Bot', 'Bot settings': 'Bot 设置', 'Create group chat': '新建群聊', @@ -57,6 +57,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Bot tree': 'Bot 树', + 'no longer shared': '已不再共享', + 'Messages waiting for your approval': '等你批准的消息', ++ 'Sent by {sender}': '由 {sender} 发送', ++ 'Sent by {sender} · delegated': '由 {sender} 发送 · 委托转达', + 'Not sent to {to} · {reason}': '未发给 {to} · {reason}', + 'Message to {to} · {reason}': '发给 {to} 的消息 · {reason}', + failed: '失败', diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 72647e928..3103e699b 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -238,7 +238,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,7 +319,37 @@ export function install(rt) { +@@ -319,7 +319,38 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -271,8 +271,9 @@ diff --git a/src/host/channel.js b/src/host/channel.js + return ok(removed) + } default: -+ // FlyBot plugins' endpoints (src/host/hooks.js). -+ if (endpoint.startsWith('flybot/')) return ok(await rt.flybotEndpoint(endpoint.slice('flybot/'.length), body)) ++ // FlyBot plugins' endpoints (src/host/hooks.js). The actor rides along so ++ // owner-checked endpoints (held-send decisions) can bind it (#52). ++ if (endpoint.startsWith('flybot/')) return ok(await rt.flybotEndpoint(endpoint.slice('flybot/'.length), body, { actor })) return fail(`Unknown endpoint ${endpoint}`, 'bot/not-found') } } catch (error) { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e4c9aea37..890ad9a47 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -25,7 +25,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -84,14 +84,28 @@ var ZH = { +@@ -84,14 +84,30 @@ var ZH = { "Remove Main Bot role": "取消主 Bot", "Bot settings": "Bot 设置", "Create group chat": "新建群聊", @@ -40,6 +40,8 @@ diff --git a/client.js b/client.js + "Bot tree": "Bot 树", + "no longer shared": "已不再共享", + "Messages waiting for your approval": "等你批准的消息", ++ "Sent by {sender}": "由 {sender} 发送", ++ "Sent by {sender} · delegated": "由 {sender} 发送 · 委托转达", + "Not sent to {to} · {reason}": "未发给 {to} · {reason}", + "Message to {to} · {reason}": "发给 {to} 的消息 · {reason}", + failed: "失败", @@ -410,7 +412,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,6 +1242,311 @@ function maskOf(shape) { +@@ -1242,6 +1242,318 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -684,11 +686,17 @@ diff --git a/client.js b/client.js + pending.map((approval) => { + const custom = entries2.find((entry) => entry.policy === void 0 || entry.policy === approval.by); + if (custom) return (0, import_react2.createElement)("div", { key: approval.id }, (0, import_react2.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } })); -+ const to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to; ++ const to = approval.targetName ?? (typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to); + return (0, import_react2.createElement)( + "div", + { key: approval.id, className: "bt-flybot-approval" }, + (0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })), ++ // Where the request came from (#52): the sending Bot, and when it was relayed. ++ approval.senderName !== void 0 ? (0, import_react2.createElement)( ++ "div", ++ { className: "bt-flybot-approval-origin" }, ++ approval.request.hop > 0 ? t("Sent by {sender} · delegated", { sender: approval.senderName }) : t("Sent by {sender}", { sender: approval.senderName }) ++ ) : null, + (0, import_react2.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), + approval.request.attachments?.length ? (0, import_react2.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react2.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, + (0, import_react2.createElement)( @@ -714,6 +722,7 @@ diff --git a/client.js b/client.js +.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } +.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } +.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } ++.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } +.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } +.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } +.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 70534b5ac..49bf7530a 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -144,9 +144,12 @@ export function FlyBotApprovals() { pending.map((approval) => { const custom = entries.find(entry => entry.policy === undefined || entry.policy === approval.by) if (custom) return h('div', { key: approval.id }, h(Safe, { entry: custom, props: { approval, decide: decision => decide(approval.id, decision), call } })) - const to = typeof approval.request.to === 'object' ? approval.request.to.handle : approval.request.to + const to = approval.targetName ?? (typeof approval.request.to === 'object' ? approval.request.to.handle : approval.request.to) return h('div', { key: approval.id, className: 'bt-flybot-approval' }, h('div', { className: 'bt-flybot-approval-head' }, approval.status === 'failed' ? t('Not sent to {to} · {reason}', { to, reason: approval.error ?? t('failed') }) : t('Message to {to} · {reason}', { to, reason: approval.reason })), + // Where the request came from (#52): the sending Bot, and when it was relayed. + approval.senderName !== undefined ? h('div', { className: 'bt-flybot-approval-origin' }, + approval.request.hop > 0 ? t('Sent by {sender} · delegated', { sender: approval.senderName }) : t('Sent by {sender}', { sender: approval.senderName })) : null, h('div', { className: 'bt-flybot-approval-text' }, approval.request.text), approval.request.attachments?.length ? h('ul', { className: 'bt-flybot-approval-files', 'aria-label': t('Attached files') }, approval.request.attachments.map((file, index) => @@ -172,6 +175,7 @@ export const FLYBOT_CSS = ` .bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } .bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } .bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } +.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } .bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } .bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } .bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 244018652..c71212cac 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -203,6 +203,9 @@ export function install(rt) { // owners, so they follow the shared rule. if (ep.startsWith('flybot/')) { if (rt.flybotIsRead?.(ep.slice('flybot/'.length)) === true) return ALLOW + // Held-send decisions are per-owner enforced in hooks.js — the approval belongs + // to the sender Bot's owner — so deciding does not follow the shared rule (#52). + if (ep === 'flybot/decide') return ALLOW if (!alone()) return deny('bot/blocked-shared', 'This plugin endpoint cannot be scoped per owner yet, so it is unavailable while the team has more than one member') return ALLOW } diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 5398a8b10..b39af62fe 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -9,7 +9,7 @@ // state.flybot.approvals[id] sends held for the owner's decision // Types: ds-bot/types/flybot.d.ts. Plugins call flybot.scope('') to get the // same API with their own field namespace; the unscoped API uses namespace 'shared'. -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { mentionSpans } from '../shared/mentions.js' import { isHomeLevelRemote } from '../shared/remote-label.js' @@ -172,11 +172,14 @@ export function install(rt) { const remote = rt.botOf(id)?.remote const remoteChat = remote !== undefined const result = await deleteBot(id, ...rest) - if (!remoteChat) fire('bot.removed', { ...last, botId: id, bot: last }) + if (!remoteChat) { + fire('bot.removed', { ...last, botId: id, bot: last }) + // A hold naming this Bot as sender or target can no longer be spent (#52). + await invalidateFor([id], 'the Bot was deleted') // Deleting a friend's Bot's chat clears it; the friend's Bot is still there, so it gets a // fresh, empty chat at once. Otherwise its row waits ("opening its chat…") until the // plugin next publishes its roster, which may be hours or the next restart. - else if (typeof remote?.handle === 'string' && typeof remote.plugin === 'string') { + } else if (typeof remote?.handle === 'string' && typeof remote.plugin === 'string') { const published = (state.flybot?.remote?.[remote.plugin] ?? []).find(bot => bot.handle === remote.handle) if (published !== undefined) { await rt.remoteChats?.open({ handle: published.handle, name: published.name, home: published.home, homeName: published.homeName, plugin: remote.plugin }) @@ -186,7 +189,18 @@ export function install(rt) { return result } } - + // A demoted Main Bot's holds were queued under its Chief authority; they die with the + // role (docs/subsystems/team.md, replaced Chiefs) (#52). + const setMain = rt.setMain + if (typeof setMain === 'function') { + rt.setMain = async (id, main, opts = {}) => { + const before = [...state.mainBotIds] + const result = await setMain(id, main, opts) + const demoted = before.filter(other => !state.mainBotIds.includes(other)) + if (demoted.length > 0) await invalidateFor(demoted, 'the Chief that held this was replaced', 'from') + return result + } + } // ---------------------------------------------------------------- fields (synchronous) const fieldsFor = (plugin) => { checkName('plugin', plugin) @@ -276,6 +290,32 @@ export function install(rt) { state.flybot.approvals ??= {} return state.flybot.approvals } + // The version hash binds a decision to the exact request content: a record whose + // request changed after it was held (or across owners) is stale, never dispatched (#52). + const requestVersion = (msg) => createHash('sha256').update(JSON.stringify([ + msg?.from, msg?.to, msg?.text, msg?.role, + (Array.isArray(msg?.attachments) ? msg.attachments : []).map(({ name, size, sha256 }) => ({ name, size, sha256 })), + ])).digest('hex') + // The owner a held send belongs to is the sender Bot's owner, derived from the team + // state at the moment it matters — never from a claim inside the message (#52). + const ownerOfRequest = (request) => { + const bot = typeof request?.from === 'string' ? rt.botOf(request.from) : undefined + return bot?.ownerId ?? null + } + const nameOf = (ref) => (isRemote(ref) ? toRemote(ref)?.handle : rt.botOf(ref)?.name) ?? String(ref ?? '') + // Why a held send may no longer be spent; undefined when it still can. Old records + // from before the version stamp keep working (they only miss the tamper check). + const staleReason = (approval) => { + const request = approval?.request + if (request === undefined || request === null) return 'the held request is missing' + if (approval.version !== undefined && requestVersion(request) !== approval.version) return 'the request changed after it was held' + if (!isRemote(request.from) && (typeof request.from !== 'string' || rt.botOf(request.from) === undefined)) return 'the sender Bot no longer exists' + const owner = ownerOfRequest(request) + if (owner === null) return 'the sender Bot has no owner on this team' + if (approval.ownerId !== undefined && approval.ownerId !== owner) return 'the sender Bot changed owners after it was held' + if (!isRemote(request.to) && rt.botOf(request.to) === undefined) return 'the target Bot no longer exists' + return undefined + } const approvalListeners = new Set() const approvalsChanged = (approval) => { for (const fn of [...approvalListeners]) { @@ -283,6 +323,24 @@ export function install(rt) { } emit('flybot/approval', clone(approval)) } + // Queued work whose authority is gone does not wait forever: a hold naming one of + // these Bots as sender or target is marked stale (still listed for the audit view). + // `field` narrows the match ('from': sender-side only, e.g. a demoted Chief). + const invalidateFor = async (botIds, reason, field) => { + await rt.load() + const ids = new Set(botIds) + const hit = Object.values(store()).filter(a => a.status === 'pending' + && (ids.has(a.request?.from) && (field === undefined || field === 'from') + || ids.has(a.request?.to) && field === undefined)) + if (hit.length === 0) return + for (const approval of hit) { + approval.status = 'stale' + approval.error = reason + approval.decidedAt = Date.now() + } + await rt.save() + for (const approval of hit) approvalsChanged(approval) + } let innerDeliver const dispatch = async (msg) => { @@ -303,7 +361,18 @@ export function install(rt) { if (Object.values(all).filter(a => a.status === 'pending').length >= PENDING_MAX) { throw new HoldFull(`${PENDING_MAX} messages already wait for approval; decide those first`) } - const approval = { id: randomUUID(), status: 'pending', reason: verdict.reason, by: verdict.by, createdAt: Date.now(), request: clone(msg) } + const audience = rt.roomOf?.(msg?.meta?.sessionId)?.id + const ownerId = ownerOfRequest(msg) + const approval = { + id: randomUUID(), status: 'pending', reason: verdict.reason, by: verdict.by, createdAt: Date.now(), + request: clone(msg), + // Server-derived provenance (#52): what was held, who owns the decision, where it + // came from. Fields of msg itself (meta especially) are data, never authority. + requestId: msg.exchangeId, + version: requestVersion(msg), + origin: { via: msg.via, hop: msg.hop ?? 0, ...(audience ? { audience } : {}) }, + ...(ownerId !== null ? { ownerId } : {}), + } all[approval.id] = approval const done = Object.values(all).filter(a => a.status !== 'pending').sort((a, b) => a.createdAt - b.createdAt) for (const old of done.slice(0, Math.max(0, Object.keys(all).length - APPROVALS_KEPT))) delete all[old.id] @@ -313,8 +382,15 @@ export function install(rt) { } // An approved hold: the rest of the chain decides (a later deny or hold still counts), - // then it is dispatched; a failed dispatch stays visible for Retry. + // then it is dispatched; a failed dispatch stays visible for Retry. The hold is + // revalidated first: hours may have passed since the decision (#52). const release = async (approval) => { + const stale = staleReason(approval) + if (stale !== undefined) { + approval.status = 'stale' + approval.error = stale + return + } let verdict // Approved or not, the owner policy is asked again: the hold may be hours old. const owner = ownerPolicy(approval.request) @@ -344,15 +420,35 @@ export function install(rt) { } const approvals = { - list: ({ status = 'pending' } = {}) => Object.values(store()).filter(a => status === 'all' || (Array.isArray(status) ? status.includes(a.status) : a.status === status)).sort((a, b) => a.createdAt - b.createdAt).map(clone), + // Under member enforcement a member sees only the holds of their own Bots; the + // decision on each stays owner-checked in decide() (#52). + list: ({ status = 'pending', actor } = {}) => { + const enforced = rt.authzEnforced() + return Object.values(store()) + .filter(a => status === 'all' || (Array.isArray(status) ? status.includes(a.status) : a.status === status)) + .filter(a => { + if (!enforced || actor === undefined) return true + const owner = ownerOfRequest(a.request) + return owner !== null && owner === actor.memberId + }) + .sort((a, b) => a.createdAt - b.createdAt) + .map(a => ({ ...clone(a), ownerId: ownerOfRequest(a.request) ?? undefined, senderName: nameOf(a.request?.from), targetName: nameOf(a.request?.to) })) + }, get: id => clone(store()[id]), - // Owner only: reachable from the UI route, never a model tool. - async decide(id, decision, { note } = {}) { + // Owner only: the decision is bound to the session of the member who owns the sender + // Bot, resolved from the member-session header — a body actorId grants nothing. + // Without member sessions the single-human host decides as before (#52). + async decide(id, decision, { note, actor } = {}) { await rt.load() const approval = store()[id] if (approval === undefined) throw new Error(`flybot: no approval ${id}`) + const ownerId = ownerOfRequest(approval.request) + if (rt.authzEnforced()) { + if (actor === null || actor === undefined) throw Object.assign(new Error('Sign in first'), { code: 'bot/unauthorized' }) + if (ownerId === null || ownerId !== actor.memberId) throw Object.assign(new Error('You do not own that approval'), { code: 'bot/forbidden' }) + } if (decision === 'retry') { - if (approval.status !== 'failed') throw new Error(`flybot: only a failed send can be retried (${id} is ${approval.status})`) + if (approval.status !== 'failed') throw Object.assign(new Error(`flybot: only a failed send can be retried (${id} is ${approval.status})`), { code: 'bot/stale' }) approval.status = 'approved' delete approval.error await release(approval) @@ -360,7 +456,7 @@ export function install(rt) { approvalsChanged(approval) return clone(approval) } - if (approval.status !== 'pending') throw new Error(`flybot: approval ${id} is already ${approval.status}`) + if (approval.status !== 'pending') throw Object.assign(new Error(`flybot: approval ${id} is already ${approval.status}`), { code: 'bot/stale' }) if (decision !== 'approve' && decision !== 'reject') throw new Error('flybot: decision is approve or reject') approval.status = decision === 'approve' ? 'approved' : 'rejected' approval.decidedAt = Date.now() @@ -674,7 +770,7 @@ export function install(rt) { }, } const isReadEndpoint = name => READ_ENDPOINTS.has(name) || readHandlers.has(name) - async function endpoint(name, payload) { + async function endpoint(name, payload, { actor } = {}) { await rt.load() const body = payload ?? {} switch (name) { @@ -684,8 +780,8 @@ export function install(rt) { const since = body.epoch === changeEpoch ? Number(body.since ?? 0) : 0 return { epoch: changeEpoch, seq: changeSeq, changes: changes.filter(change => change.seq > since).map(change => structuredClone(change)) } } - case 'approvals': return approvals.list(body) - case 'decide': return approvals.decide(String(body.id ?? ''), body.decision, body) + case 'approvals': return approvals.list({ ...body, actor }) + case 'decide': return approvals.decide(String(body.id ?? ''), body.decision, { note: body.note, actor }) default: { const handler = handlers.get(name) if (handler === undefined) throw Object.assign(new Error(`Unknown FlyBot endpoint ${name}`), { code: 'bot/not-found' }) diff --git a/patches/ds-bot/files/test/approval-binding.test.js b/patches/ds-bot/files/test/approval-binding.test.js new file mode 100644 index 000000000..61954e042 --- /dev/null +++ b/patches/ds-bot/files/test/approval-binding.test.js @@ -0,0 +1,275 @@ +// Approval binding (#52): a held send is bound to the owner the server derives from the +// sender Bot — never to claims in the message — and only that owner's session may decide +// it. Changing the request after it was held, or the authority behind it (Bot deleted, +// Chief replaced), makes the decision stale. Alice and Bob here are two signed-in members; +// this is the #49/#48 foundation, not team mode. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + 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 }, + c: { id: 'c', name: 'Coder', role: 'Code', sessionId: 'c', createdAt: 3 }, + }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +async function start(config = {}) { + const home = config.home ?? mkdtempSync(join(tmpdir(), 'ds-bot-approval-')) + const state = config.state ?? team() + if (config.home === undefined) 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 () => {} }, + }) + 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, token) => { + const headers = token ? { 'x-flybot-member-session': token } : undefined + const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) })) + return response.json() + } + const stop = async ({ keepHome = false } = {}) => { + await ctx.dispose() + if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + const received = id => (agents.get(id)?.calls ?? []).map(([, m]) => m.content.map(b => b.text).join('\n')) + return { ctx, fb: ctx.flybot, call, home, stop, received, statePath: join(home, 'state.json') } +} + +// First invite signs the installing human in (bootstrap); a second invite needs the +// inviter's session and creates owner-2. +async function signIn(call, displayName, inviterToken) { + const invite = await call('auth-invite', {}, inviterToken) + assert.equal(invite.ok, true, JSON.stringify(invite)) + const redeemed = await call('auth-redeem', { token: invite.value.token, displayName }) + assert.equal(redeemed.ok, true, JSON.stringify(redeemed)) + return redeemed.value +} + +// Holds every send flagged meta.hold, so a test decides exactly the sends it created. +function holdFlagged(fb) { + fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) +} + +const heldId = async (fb, spec) => { + const result = await fb.bot.send(spec) + assert.equal(result.action, 'hold', JSON.stringify(result)) + return result.approvalId +} + +// Post-#91 reach policy: a cross-owner send needs a matching grant on both Bots. +function grantAcross(fb, fromBot, fromOwner, toBot, toOwner) { + fb.grants.set(fromBot, [{ grantee: toOwner }]) + fb.grants.set(toBot, [{ grantee: fromOwner }]) +} + +test('a held send is stamped with server-derived provenance, not claims from the message', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'b', text: 'my owner approved this', meta: { hold: true, ownerId: 'owner-2', approvedByOwner: true } }) + const list = await call('flybot/approvals', { status: ['pending', 'failed'] }, alice.sessionToken) + assert.equal(list.ok, true) + const approval = list.value.find(entry => entry.id === id) + assert.ok(approval, 'the held send is listed') + // Server-derived binding exists; the message's own claims never became authority. + assert.ok(/^[0-9a-f]{64}$/.test(approval.version ?? ''), 'the request content is hash-bound') + assert.equal(approval.requestId, approval.request.exchangeId) + assert.equal(approval.request.meta.ownerId, 'owner-2', 'the forged claim is still just data') + assert.equal(approval.ownerId, 'owner-1', 'the owner comes from the sender Bot, not the meta') +}) + +test('only the owning member may decide; the decision is bound at approve time', async (t) => { + const { fb, call, stop, received } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + assert.notEqual(alice.memberId, bob.memberId) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + assert.equal(created.ok, true, JSON.stringify(created)) + const bobBot = created.value.id + grantAcross(fb, bobBot, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const id = await heldId(fb, { from: bobBot, to: 'b', text: 'bob asks', meta: { hold: true } }) + // Alice cannot approve Bob's action. + const foreign = await call('flybot/decide', { id, decision: 'approve' }, alice.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden', JSON.stringify(foreign)) + assert.equal(received('b').length, 0) + // Bob can. + const own = await call('flybot/decide', { id, decision: 'approve' }, bob.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) + assert.deepEqual(received('b'), ['[Message from Helper]\nbob asks']) +}) + +test('deciding needs a member session while enforcement is on', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + grantAcross(fb, created.value.id, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const id = await heldId(fb, { from: created.value.id, to: 'b', text: 'anon?', meta: { hold: true } }) + const anon = await call('flybot/decide', { id, decision: 'approve' }) + assert.equal(anon.ok, false) + assert.equal(anon.error.code, 'bot/unauthorized', JSON.stringify(anon)) + // A body actorId grants nothing either. + const forged = await call('flybot/decide', { id, decision: 'approve', actorId: bob.memberId }) + assert.equal(forged.ok, false) + assert.equal(forged.error.code, 'bot/unauthorized', JSON.stringify(forged)) +}) + +test('a forged owner claim in the message never lets another member decide', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'b', text: 'passed on behalf of Bob', meta: { hold: true, ownerId: bob.memberId } }) + const foreign = await call('flybot/decide', { id, decision: 'approve' }, bob.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden', JSON.stringify(foreign)) + const own = await call('flybot/decide', { id, decision: 'approve' }, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) +}) + +test('the pending list shows a member only their own held sends', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + grantAcross(fb, created.value.id, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const aliceId = await heldId(fb, { from: 'a', to: 'b', text: 'alice holds', meta: { hold: true } }) + const bobId = await heldId(fb, { from: created.value.id, to: 'b', text: 'bob holds', meta: { hold: true } }) + const mine = await call('flybot/approvals', { status: ['pending', 'failed'] }, alice.sessionToken) + assert.equal(mine.ok, true) + const ids = mine.value.map(entry => entry.id) + assert.ok(ids.includes(aliceId), 'Alice sees her own held send') + assert.ok(!ids.includes(bobId), "Alice does not see Bob's held send") +}) + +test('changing the request after it was held makes the decision stale', async (t) => { + const { fb, call, stop, statePath, home, received } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'b', text: 'harmless', meta: { hold: true } }) + // Simulate the stored request changing between hold and decision. + const saved = JSON.parse(readFileSync(statePath, 'utf8')) + saved.flybot.approvals[id].request.text = 'evil rewrite' + writeFileSync(statePath, JSON.stringify(saved)) + const again = await start({ home }) + t.after(() => again.stop()) + const decided = await call('flybot/decide', { id, decision: 'approve' }, alice.sessionToken) + assert.equal(decided.ok, true, JSON.stringify(decided)) + assert.equal(decided.value.status, 'stale', JSON.stringify(decided.value)) + assert.equal(received('b').length, 0) + void again +}) + +test('a same-owner approval still works after a restart', async (t) => { + const { fb, call, stop, home } = await start() + t.after(stop, { keepHome: true }) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + grantAcross(fb, created.value.id, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const id = await heldId(fb, { from: created.value.id, to: 'b', text: 'across restarts', meta: { hold: true } }) + await stop({ keepHome: true }) + const again = await start({ home }) + t.after(() => again.stop()) + const decided = await again.call('flybot/decide', { id, decision: 'approve' }, bob.sessionToken) + assert.equal(decided.ok, true, JSON.stringify(decided)) + assert.equal(decided.value.status, 'approved', JSON.stringify(decided.value)) + assert.deepEqual(again.received('b'), ['[Message from Helper]\nacross restarts']) + void alice +}) + +test('deleting a sender or target Bot invalidates its pending holds', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'c', text: 'do work', meta: { hold: true } }) + await call('delete-bot', { id: 'c' }, alice.sessionToken) + const list = await call('flybot/approvals', { status: ['pending', 'failed', 'stale'] }, alice.sessionToken) + assert.equal(list.ok, true) + const approval = list.value.find(entry => entry.id === id) + assert.ok(approval, 'the record is kept for the audit view') + assert.equal(approval.status, 'stale', JSON.stringify(approval)) + const decided = await call('flybot/decide', { id, decision: 'approve' }, alice.sessionToken) + assert.equal(decided.ok, false) + assert.match(decided.error.code, /stale|invalid/) +}) + +test('replacing a Chief invalidates the held sends written under it', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + holdFlagged(fb) + const id = await heldId(fb, { from: 'a', to: 'c', text: 'chief work', meta: { hold: true } }) + const swapped = await call('set-main', { id: 'b', main: true, replace: 'a' }, alice.sessionToken) + assert.equal(swapped.ok, true, JSON.stringify(swapped)) + const list = await call('flybot/approvals', { status: ['pending', 'failed', 'stale'] }, alice.sessionToken) + assert.equal(list.ok, true) + const approval = list.value.find(entry => entry.id === id) + assert.equal(approval.status, 'stale', JSON.stringify(approval)) +}) + +test('a member whose sender Bot is gone cannot decide leftovers with an old session', async (t) => { + const { fb, call, stop, statePath } = await start() + t.after(stop) + const alice = await signIn(call, 'Alice') + const bob = await signIn(call, 'Bob', alice.sessionToken) + const created = await call('create-bot', { name: 'Helper', role: 'Dev' }, bob.sessionToken) + grantAcross(fb, created.value.id, bob.memberId, 'b', alice.memberId) + holdFlagged(fb) + const id = await heldId(fb, { from: created.value.id, to: 'b', text: 'queued', meta: { hold: true } }) + await call('delete-bot', { id: created.value.id }, bob.sessionToken) + const removed = await call('auth-remove-member', { memberId: bob.memberId }, alice.sessionToken) + assert.equal(removed.ok, true, JSON.stringify(removed)) + const decided = await call('flybot/decide', { id, decision: 'approve' }, bob.sessionToken) + assert.equal(decided.ok, false) + assert.equal(decided.error.code, 'bot/unauthorized', JSON.stringify(decided)) + // The stale record stays in the team state with its attribution, but no member's list + // shows it: its sender Bot and its owner are both gone (#57 adds that surface). + const saved = JSON.parse(readFileSync(statePath, 'utf8')) + assert.equal(saved.flybot.approvals[id].status, 'stale', JSON.stringify(saved.flybot.approvals[id])) + const list = await call('flybot/approvals', { status: ['pending', 'failed', 'stale'] }, alice.sessionToken) + assert.equal(list.value.find(entry => entry.id === id), undefined) +}) diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index f6da3edff..731f9828f 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -225,13 +225,16 @@ test('flybot plugin endpoints follow the read/shared rules', async (t) => { const actorA = await rt.authenticate(sessionA.sessionToken) // Reads reach the plugin layer for the signed-in actor. assert.equal(rt.authorize(actorA, 'flybot/changes', {}).ok, true) - // Alone on the deployment, writes do too; with two members they are blocked like - // the other shared resources. + // Alone on the deployment, writes do too. Deciding a held send stays reachable with + // two members: hooks.js binds it to the sender Bot's owner (#52); other plugin + // writes are blocked like the other shared resources. assert.equal(rt.authorize(actorA, 'flybot/decide', { id: 'x' }).ok, true) await redeemOwner(rt) assert.equal(rt.authorize(actorA, 'flybot/changes', {}).ok, true) - assert.equal(rt.authorize(actorA, 'flybot/decide', { id: 'x' }).code, 'bot/blocked-shared') + assert.equal(rt.authorize(actorA, 'flybot/decide', { id: 'x' }).ok, true) + assert.equal(rt.authorize(actorA, 'flybot/social/decide-approval', {}).code, 'bot/blocked-shared') assert.equal(rt.authorize(null, 'flybot/changes', {}).code, 'bot/unauthorized') + assert.equal(rt.authorize(null, 'flybot/decide', { id: 'x' }).code, 'bot/unauthorized') }) test('add-agent is a shared deployment resource', async (t) => { diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index e951ca898..97346bad9 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -149,7 +149,7 @@ export type SendResult = export interface Approval { id: string - status: 'pending' | 'approved' | 'rejected' | 'failed' + status: 'pending' | 'approved' | 'rejected' | 'denied' | 'failed' | 'stale' reason: string /** Middleware that held it. */ by: string @@ -158,7 +158,18 @@ export interface Approval { note?: string error?: string exchangeId?: string + /** The held send's own exchange id (#52). */ + requestId?: string + /** sha256 over the request content; a changed request is stale, never dispatched (#52). */ + version?: string + /** Server-derived: the sender Bot's owner, who alone may decide (#52). */ + ownerId?: string + /** Where the request came from: which plugin relayed it and how many hops (#52). */ + origin?: { via: string; hop: number; audience?: string } request: SendMessage + /** List-view context, resolved server-side: sender and target names (#52). */ + senderName?: string + targetName?: string } export interface Transport { From d464235ea291cc5501455d269885c8332b3b12e7 Mon Sep 17 00:00:00 2001 From: hsliu_ustc Date: Thu, 15 Oct 2026 23:28:57 +0800 Subject: [PATCH 2/3] Share private context across owners only by an explicit share (#53) On a host with member sessions, a member Bot's group Session began with its owner's own-chat brief whoever else sat in the room, the memory review read every human as 'the user' and could publish team memory from another owner's room, list_bots disclosed other owners' folders and instructions, and a chat reference chip copied any Session's snapshot to every recipient. Now the own-chat brief rides into a room with more than one owner only when the Bot's owner shares it there (share-brief, a per-room briefShared flag on the participant that leaving drops); a review that reads a cross-owner room keeps entries in the Bot's own memory and reads only its own owner as the user; list_bots shows another owner's Bot by name, role and model only; and reference chips in send and room-post must resolve through sessionOwnedBy for the author. Previously received messages are not retroactively unseen; the secrets surface stays blocked-shared. Team mode stays disabled; this is the context half of the authority contract (docs/subsystems/team.md). Fixes #53 --- CHANGELOG.md | 1 + core/src/host/authz.js | 4 + core/src/host/channel.js | 18 ++ core/src/host/groups.js | 20 +- core/src/host/memory.js | 33 ++- core/src/host/room-membership.js | 16 ++ core/src/host/tools.js | 8 +- core/test/context-sharing.test.js | 222 ++++++++++++++++++ core/test/memory.test.js | 67 ++++++ docs/subsystems/team.md | 12 + patches/ds-bot/0001-core-fixes.patch | 155 ++++++++++++ patches/ds-bot/0002-flybot-api-host.patch | 14 +- patches/ds-bot/0005-social-seams.patch | 29 ++- patches/ds-bot/0012-room-membership.patch | 40 +++- patches/ds-bot/files/src/host/authz.js | 4 + .../ds-bot/files/src/host/room-membership.js | 16 ++ .../ds-bot/files/test/context-sharing.test.js | 222 ++++++++++++++++++ 17 files changed, 864 insertions(+), 17 deletions(-) create mode 100644 core/test/context-sharing.test.js create mode 100644 patches/ds-bot/files/test/context-sharing.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d056dac4b..3da79fcf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ New entries are written as one fragment file per change in [changelog.d/](change ### Added +- **Private context stays home in shared rooms.** A Bot only carries its owner's chat brief into a group where another owner sits when the owner shares it (a per-room, revocable share from the Bot's owner — host API `share-brief` now, room UI next); memory reviews in such a room keep entries in the Bot's own memory, never the team's, and read only their own owner as "the user"; `list_bots` no longer discloses another owner's Bot folder or instructions; and referencing a chat you cannot read is refused. Previously received messages are not retroactively unseen. ([#53](https://github.com/ThinkFlowLab/FlyBot/issues/53)) - **Held sends are bound to their owner.** An approval card shows who sent the request (and when it was relayed between Bots); only the sender Bot's owner can approve, reject, or retry it, the list shows you only your own holds, and a decision is voided safely (`stale`, never sent) when the request changed after it was held or the Bot / Chief authority behind it is gone. ([#52](https://github.com/ThinkFlowLab/FlyBot/issues/52)) - **Remove a friend's Bot** from Friends' Bots hides it on this install, takes it out of rooms, and does not unfriend. It reappears only if it is granted again. Held Bot replies show in the chat as **Waiting for approval** (owner: Approve / Reject / Auto-send; others: waiting for the owner). ([#90](https://github.com/ThinkFlowLab/FlyBot/issues/90)) - **Face-to-face join registers a Bot for that room only** when it has no identity yet. `@flybot/social` calls the same `add_home_member` path used for invites, then joins. The identity is not polled, not listed for friends, and is revoked with `remove_home_member` when you leave the last face room or the room closes. A failed join that created one is revoked too so retry is clean. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83), [#89](https://github.com/ThinkFlowLab/FlyBot/issues/89), [#90](https://github.com/ThinkFlowLab/FlyBot/issues/90)) diff --git a/core/src/host/authz.js b/core/src/host/authz.js index c71212cac..fb4be93bb 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -322,6 +322,10 @@ export function install(rt) { if (ep === 'create-bot') return ALLOW + // Sharing a member Bot's brief is the Bot owner's decision, checked on the record + // in room-membership (#53); the endpoint itself only needs a signed-in actor. + if (ep === 'share-brief') return ALLOW + // Anything not in the table and not a known auth/read endpoint is denied. if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check' diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 7ec77c782..32da9948b 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -106,6 +106,16 @@ export function install(rt) { // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami']) + // A chat reference chip names a Session whose snapshot rides to every recipient of + // the message, so the author must be able to read what they share (#53). + const unshareableReference = (actor, text) => { + if (!actor || !rt.authzEnforced?.() || typeof text !== 'string') return undefined + for (const match of text.matchAll(/dsh-session:([A-Za-z0-9_-]+)/g)) { + if (!rt.sessionOwnedBy?.(actor, match[1])) return match[1] + } + return undefined + } + function setPrefs(body) { if (body.theme !== undefined) { if (typeof body.theme !== 'string' || !/^[a-z][\w-]{0,31}$/.test(body.theme)) throw invalid('Unknown theme') @@ -221,6 +231,8 @@ export function install(rt) { } case 'room-post': { await rt.load() + const leaked = unshareableReference(actor, body.text) + if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') const line = await rt.postRoomMessage(String(body.roomId ?? body.id ?? ''), { text: body.text, authorId: body.authorId, @@ -329,6 +341,8 @@ export function install(rt) { const text = String(body.text ?? '').trim() if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid') await rt.load() + const leaked = unshareableReference(actor, text) + if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') // 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 @@ -382,6 +396,10 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) + case 'share-brief': + await rt.load() + await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) + return ok(true) case 'auth-whoami': return ok(actor) case 'auth-invite': { diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 511b64132..8c191bba9 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -22,6 +22,7 @@ import { escapeRegExp, splitRef, textOf } from './text.js' import { mentionSpans } from '../shared/mentions.js' import { GROUP_REF, output } from './tools.js' import { failureLine } from './turns.js' +import { currentParticipants } from './room-membership.js' const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 @@ -124,6 +125,23 @@ export function install(rt) { // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the // first message, so it stays fixed and the prefix stays cacheable. + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { + if (!rt.authzEnforced?.()) return true + const participants = currentParticipants(room) + const owners = new Set([rt.botOf(member.id)?.ownerId]) + for (const p of participants) { + if (p.kind === 'human') owners.add(p.id) + // A granted remote Bot speaks for another home; its handle stands for that owner. + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) + } + owners.delete(undefined) + if (owners.size <= 1) return true + return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) + } + async function groupSessionFor(room, member) { room.sessions ??= {} const known = room.sessions[member.id] @@ -144,7 +162,7 @@ export function install(rt) { .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error)) await rt.save() await rt.ensureCapabilities(id) - return { id, brief: await rt.ownChatBrief(member) } + return { id, brief: briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } } // Lines from before the round's opening message are kept apart from this round, so diff --git a/core/src/host/memory.js b/core/src/host/memory.js index 6e1981df8..9b789c9d6 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import { currentParticipants } from './room-membership.js' import { defineTool } from '@deepseek-ai/dsh-tools' import { splitRef, unquote } from './text.js' import { output } from './tools.js' @@ -488,6 +489,22 @@ export function install(rt) { const writesTeam = botId => rt.isMain(botId) || Object.values(state.rooms).some(room => room.admin === botId) + // Team memory is one team's. A review that reads a room where another owner sits + // keeps entries in the Bot's own memory only (#53); ownership, not the admin role, + // decides whose memory "team" refers to there. + const teamAllowedFrom = (room, bot) => { + if (room === undefined) return true + const owners = new Set([bot.ownerId]) + for (const p of currentParticipants(room)) { + if (p.kind === 'human') owners.add(p.id) + // A granted remote Bot speaks for another home; its handle stands for that owner. + else if (p.remote) owners.add(p.handle) + else owners.add(state.bots?.[p.id]?.ownerId) + } + owners.delete(undefined) + return owners.size <= 1 + } + // `raw` names a scope: "own", "team", or a Bot. Undefined for the default. function namedScope(raw, botId) { const value = unquote(raw) @@ -1125,17 +1142,23 @@ export function install(rt) { // What is new in a group, from its log: the member's Session holds the same // messages wrapped in turn prompts. + // "The user" of a group review is the member Bot's owner; a line another human + // wrote is a peer's, however it is attributed (#53). + const isUserLine = (bot, entry) => (entry.authorKind === 'human' + ? entry.authorId !== undefined && entry.authorId === bot.ownerId + : entry.who === 'User') + function groupBatch(room, bot, since) { const log = (room.log ?? []).filter(line => line.kind === undefined && typeof line.text === 'string' && line.text.trim() !== '') // A first review reads back at least to the user's latest line, which a round of // replies from many members can push out of the last few. - const lastUser = log.findLastIndex(line => line.who === 'User') + const lastUser = log.findLastIndex(line => isUserLine(bot, line)) let start = since === undefined ? Math.max(0, Math.min(log.length - REVIEW_FIRST_LINES, lastUser === -1 ? log.length : lastUser)) : log.findIndex(line => line.seq > since) const oldest = room.log?.[0]?.seq if (since !== undefined && oldest !== undefined && oldest > since + 1) rt.warn('ds-bot: %d lines of group "%s" left its log before %s reviewed them', oldest - since - 1, room.name, bot.name) if (start === -1) start = log.length const line = (entry) => { - const who = entry.who === 'User' ? 'user' : entry.botId === bot.id ? 'you' : 'bot' + const who = isUserLine(bot, entry) ? 'user' : entry.botId === bot.id ? 'you' : 'bot' return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq } } return { @@ -1160,7 +1183,7 @@ export function install(rt) { return { texts: kept, count: kept.length } } - async function applyReview(bot, sessionId, numbered, changes, source) { + async function applyReview(bot, sessionId, numbered, changes, source, { teamAllowed = true } = {}) { const actor = { id: bot.id, name: bot.name, session: sessionId, auto: true } const done = [] let failed @@ -1168,7 +1191,7 @@ export function install(rt) { // A Bot deleted mid-review keeps no more changes, its team ones included. if (rt.botOf(bot.id) === undefined) return 0 // Its role may change mid-review too, so each change asks again. - const teamWrites = writesTeam(bot.id) + const teamWrites = teamAllowed && writesTeam(bot.id) const op = String(change?.op ?? '') const topic = op === 'add' && change?.topic !== undefined ? cleanTopic(String(change.topic)) : undefined const target = numbered[Number(change?.entry) - 1] @@ -1274,7 +1297,7 @@ export function install(rt) { const badTopic = applied.find(change => change.op === 'add' && typeof change.topic === 'string' && change.topic.trim() !== '' && cleanTopic(change.topic) === undefined) if (badTopic !== undefined) throw new Error(`the model's answer names the topic "${clip(badTopic.topic, 60)}", which cannot be a topic name`) if (rt.botOf(bot.id) === undefined) return - const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read)) + const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read), { teamAllowed: teamAllowedFrom(live, bot) }) if (rt.botOf(bot.id) === undefined) return // An answer at the limit may have left some out, so the same lines are read // again, a few times at most, while each pass still changes something. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 3710ec177..578252e11 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -642,6 +642,22 @@ export function install(rt) { return linesVisibleTo(room, actor, state) } + // The owner of a member Bot lets its brief — their own chat with it — ride into + // this one room, or takes it back (#53). Bot ownership decides, not room admin; + // the flag lives on the participant, so leaving the room drops it. + rt.setBriefShare = function setBriefShare(roomId, botId, shared, actor) { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') + const bot = state.bots?.[String(botId ?? '')] + if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot') + if (rt.authzEnforced?.() && bot.ownerId !== actor?.memberId) throw forbidden("Only the Bot's owner may share its brief") + const entry = currentParticipants(room).find(p => p.kind === 'bot' && p.id === botId) + if (entry === undefined) throw forbidden('That Bot is not in this room') + if (shared) entry.briefShared = true + else delete entry.briefShared + return rt.save() + } + rt.applyParticipants = function applyParticipants(room, drafts, actor) { if (!Array.isArray(drafts) || drafts.length === 0) return room room.participants = [] diff --git a/core/src/host/tools.js b/core/src/host/tools.js index f1703ae6e..79cf2d102 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -58,10 +58,14 @@ export function install(rt) { async execute(_args, exec) { await rt.load() const self = rt.selfOf(exec.agent?.id) + const selfOwner = rt.botOf(self)?.ownerId + // Another owner's Bot lists by name and role only: its folder and instructions + // are its owner's private setup (#53). + const foreign = bot => rt.authzEnforced?.() && selfOwner !== undefined && bot.ownerId !== undefined && bot.ownerId !== selfOwner const lines = Object.values(state.bots).filter(bot => bot.remote === undefined).map(bot => { const model = bot.appliedModel ?? bot.model - const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') - const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' + const tags = [bot.role, model ? `model ${model}` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') + const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}` }) if (lines.length === 0) return 'No Bots yet.' diff --git a/core/test/context-sharing.test.js b/core/test/context-sharing.test.js new file mode 100644 index 000000000..1c3124926 --- /dev/null +++ b/core/test/context-sharing.test.js @@ -0,0 +1,222 @@ +// Context sharing (#53): private context crosses owners only through an explicit, +// owner-granted share. A member's group brief is its owner's own chat; another +// owner's chat lines, folders and chat references never ride into a shared room +// without that share. Alice and Bob are two signed-in members on the #48/#49 +// foundation; team mode stays disabled. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'd' }, + }, + bots: { + a: { id: 'a', name: 'ChiefA', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1', workDir: '/tmp/alice-private', instructions: 'Alice secret instructions' }, + d: { id: 'd', name: 'ChiefB', role: 'Boss', sessionId: 'd', createdAt: 2, ownerId: 'owner-2', workDir: '/tmp/bob-private', instructions: 'Bob secret instructions' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 'd'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'human', id: 'owner-2', role: 'member', displayName: 'Bob', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'd', role: 'member', displayName: 'ChiefB', joinedSeq: 0, joinedAt: 1 }, + ], + }, + home: { + id: 'home', name: 'Home', members: ['a'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + ], + }, + }, + grants: { a: [{ id: 'g1', grantee: 'owner-2' }], d: [{ id: 'g2', grantee: 'owner-1' }] }, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function start(t, config = {}) { + const home = config.home ?? mkdtempSync(join(tmpdir(), 'ds-bot-context-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(config.state ?? team())) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const calls = [] + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls, status: 'idle', + followup(m) { calls.push(m) }, steer(m) { calls.push(m) }, + inject() {}, cancel() {}, async whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} }, + }) + } + return agents.get(sessionId) + } + 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: `s${(++created).toString(36)}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let created = 0 + 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 }) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + let seq = 0 + const emit = (id, type, data) => { + for (const listener of ctx.listeners.get('session/event') ?? []) { + if (listener.length >= 2) listener({ id }, { type, seq: ++seq, data }) + } + } + const tool = (name, args, sessionId, exec = {}) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId }, ...exec }) + const call = async (endpoint, payload, token) => { + const headers = token ? { 'x-flybot-member-session': token } : undefined + return (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) }))).json() + } + const signIn = async (displayName, inviterToken) => { + const invite = await call('auth-invite', {}, inviterToken) + assert.equal(invite.ok, true, JSON.stringify(invite)) + const redeemed = await call('auth-redeem', { token: invite.value.token, displayName }) + assert.equal(redeemed.ok, true, JSON.stringify(redeemed)) + return redeemed.value + } + // Drive the round's state machine: every member Session that has taken its first + // message replies at once, so the next member is called until nobody is waiting. + const roundTurns = new Map() + const memberIds = () => [...agents.keys()].filter(id => id.startsWith('s')) + const drive = (id) => { + const message = agents.get(id).calls.at(-1) + const turn = (roundTurns.get(id) ?? 0) + 1 + roundTurns.set(id, turn) + emit(id, 'turn/start', { turn }) + emit(id, 'user/message', { content: message.content, source: message.source }) + emit(id, 'assistant/message', { message: { content: [{ type: 'text', text: 'noted' }] } }) + emit(id, 'turn/end', { turn, reason: { kind: 'completed' } }) + } + const runRound = async (roomId, expected) => { + emit(roomId, 'user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + const stop = new AbortController() + t.after(() => stop.abort()) + void tool('group_relay', {}, roomId, { callId: 'c1', signal: stop.signal }).catch(() => {}) + const until = Date.now() + 3000 + while (Date.now() < until) { + const pending = memberIds().filter(id => agents.get(id).calls.length > 0 && !roundTurns.has(id)) + for (const id of pending) drive(id) + if (memberIds().length >= expected && memberIds().every(id => roundTurns.has(id))) break + await new Promise(resolve => setImmediate(resolve)) + } + return memberIds().map(id => textOf(agents.get(id).calls[0])) + } + return { ctx, call, emit, tool, agentOf, signIn, home, runRound } +} + +const textOf = message => (message?.content ?? []).map(block => block.text ?? '').join('\n') + +test('a member brings no private brief into a room where another owner sits', async (t) => { + const { runRound, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const turns = await runRound('war', 2) + assert.equal(turns.length, 2, 'both members took their first turn') + for (const first of turns) { + assert.ok(!first.includes('Your own chat with the user'), 'no own-chat brief rides into a cross-owner room') + assert.ok(!first.includes('ALICES-PRIVATE'), 'no private marker') + } + void alice +}) + +test('the owner can share the brief with one room, and take it back', async (t) => { + const { call, runRound, signIn, home } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const shared = await call('share-brief', { id: 'war', botId: 'a', share: true }, alice.sessionToken) + assert.equal(shared.ok, true, JSON.stringify(shared)) + const state = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(state.rooms.war.participants.find(p => p.id === 'a').briefShared, true) + + const turns = await runRound('war', 2) + const aliceBot = turns.find(first => first.includes('Members: the user, ChiefB')) + const bobBot = turns.find(first => first.includes('Members: the user, ChiefA')) + assert.ok(aliceBot, 'ChiefA took its turn') + assert.ok(bobBot, 'ChiefB took its turn') + assert.ok(aliceBot.includes('Your own chat with the user'), 'the owner shared the brief, so it rides along') + assert.ok(!bobBot.includes('Your own chat with the user'), "Bob's brief still stays home") + + const revoked = await call('share-brief', { id: 'war', botId: 'a', share: false }, alice.sessionToken) + assert.equal(revoked.ok, true, JSON.stringify(revoked)) + const after = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(after.rooms.war.participants.find(p => p.id === 'a').briefShared, undefined) +}) + +test('only the Bot\'s owner may share its brief', async (t) => { + const { call, signIn } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const foreign = await call('share-brief', { id: 'war', botId: 'a', share: true }, bob.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden', JSON.stringify(foreign)) + void alice +}) + +test('a single-owner room keeps the brief without any share', async (t) => { + const { runRound, signIn } = await start(t) + const alice = await signIn('Alice') + void alice + const turns = await runRound('home', 1) + assert.ok(turns[0]?.includes('Your own chat with the user'), 'own room keeps the shipped brief') +}) + +test('list_bots hides another owner\'s folder and instructions', async (t) => { + const { tool, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const seen = await tool('list_bots', {}, 'a') + assert.ok(seen.includes('ChiefB'), 'the other owner\'s Bot is still listed') + assert.ok(!seen.includes('/tmp/bob-private'), 'no folder disclosure') + assert.ok(!seen.includes('Bob secret instructions'), 'no instructions disclosure') + assert.ok(seen.includes('/tmp/alice-private'), 'own folder still shows') + assert.ok(seen.includes('Alice secret instructions'), 'own instructions still show') +}) + +test('a chat reference chip must name a chat the author may read', async (t) => { + const { call, signIn } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const foreign = await call('room-post', { id: 'war', text: 'look at @[that](dsh-session:d) please' }, alice.sessionToken) + assert.equal(foreign.ok, false, JSON.stringify(foreign)) + assert.equal(foreign.error.code, 'bot/forbidden') + const own = await call('room-post', { id: 'war', text: 'look at @[this](dsh-session:a) please' }, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) + void bob +}) + + + +test('a granted remote Bot in the room counts as another owner for the brief', async (t) => { + const state = team() + state.rooms.home.participants.push({ kind: 'bot', remote: true, handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', plugin: 'social', joinedSeq: 0, joinedAt: 2 }) + const { runRound, signIn } = await start(t, { state }) + const alice = await signIn('Alice') + void alice + const turns = await runRound('home', 1) + assert.ok(turns[0], 'the member took its turn') + assert.ok(!turns[0].includes('Your own chat with the user'), 'a remote participant is another owner: no brief without a share') +}) diff --git a/core/test/memory.test.js b/core/test/memory.test.js index fd18d6bcc..f4cd8ea88 100644 --- a/core/test/memory.test.js +++ b/core/test/memory.test.js @@ -1085,3 +1085,70 @@ test('a memory call names the current part of the Bot chat', async (t) => { assert.equal(answer.ok, true) assert.equal(requests[0].sessionId, 'b2-part2') }) + +// A cross-owner shared room (#53): "the user" of a member Bot's review is its own +// owner, and another owner's room is not the place to publish team memory. +const SHARED_ROOM_TEAM = { + version: 2, + revision: 1, + mainBotIds: ['m1'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'm1' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'x1' }, + }, + bots: { + m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', instructions: '', createdAt: 1, ownerId: 'owner-1' }, + b3: { id: 'b3', sessionId: 'b3', name: 'Coder', role: 'Code', instructions: '', createdAt: 3, ownerId: 'owner-1' }, + x1: { id: 'x1', sessionId: 'x1', name: 'Guest', role: 'Visit', instructions: '', createdAt: 4, ownerId: 'owner-2' }, + }, + rooms: { + r2: { + id: 'r2', name: 'Shared', named: true, members: ['b3', 'x1'], admin: 'b3', mode: 'everyone', notice: '', sessions: { b3: 'g3' }, + seq: 3, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'human', id: 'owner-2', role: 'member', displayName: 'Bob', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'b3', role: 'admin', displayName: 'Coder', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'x1', role: 'member', displayName: 'Guest', joinedSeq: 0, joinedAt: 1 }, + ], + log: [ + { who: 'Alice', authorKind: 'human', authorId: 'owner-1', text: 'Keep the launch on Friday', seq: 1 }, + { who: 'Bob', authorKind: 'human', authorId: 'owner-2', text: 'Bob prefers Sundays', seq: 2 }, + { who: 'Coder', botId: 'b3', text: 'Will do.', seq: 3 }, + ], + }, + }, + exchanges: [], exchangeTurns: {}, questions: {}, +} + +test('in a cross-owner room, the review reads its own owner as the user and the other owner as a peer', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(SHARED_ROOM_TEAM)) + const replies = [JSON.stringify({ changes: [{ op: 'add', text: 'The launch stays on Friday' }] })] + const { ctx, call, requests, stop } = start({ home, replies, config: { memoryReviewDelayMs: 0 } }) + t.after(() => stop()) + await call('state', {}) + ctx.emit('agent/status', { agent: { id: 'g3' }, status: 'idle' }) + for (let tries = 0; tries < 200 && requests.length === 0; tries += 1) await new Promise(resolve => setTimeout(resolve, 20)) + assert.equal(requests.length, 1) + const batch = requests[0].messages[0].content[0].text + assert.match(batch, /User: Keep the launch on Friday/, 'the owner speaks as the user') + assert.match(batch, /Bob: Bob prefers Sundays/, 'the other owner is a named peer') + assert.doesNotMatch(batch, /User: Bob prefers Sundays/) +}) + +test('a review out of a cross-owner room keeps entries out of the team memory', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(SHARED_ROOM_TEAM)) + const replies = [JSON.stringify({ changes: [{ op: 'add', text: 'The launch stays on Friday', team: true }] })] + const { ctx, call, stop } = start({ home, replies, config: { memoryReviewDelayMs: 0 } }) + t.after(() => stop()) + await call('state', {}) + ctx.emit('agent/status', { agent: { id: 'g3' }, status: 'idle' }) + for (let tries = 0; tries < 200; tries += 1) { + if (existsSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'))) break + await new Promise(resolve => setTimeout(resolve, 20)) + } + assert.equal(existsSync(join(home, 'memory', 'team', 'MEMORY.md')), false, 'no team write from another owner\'s room') + assert.match(readFileSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'), 'utf8'), /The launch stays on Friday/) +}) diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index dea372b84..21395d6bc 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -97,6 +97,16 @@ The held-send approval (`state.flybot.approvals` in `core/src/host/hooks.js`) no - **Legacy records keep working.** Holds saved before this change have no `version`; they skip only the tamper check, and their owner is derived at decide time. - **Not done here:** the social plugin's server-side approval holds (`social/decide-approval`) stay a deployment-wide plugin write (blocked while more than one member exists); the in-chat approval cards and task-level approvals are #90/#54 work. Gate 3 is satisfied for held sends only. +## Context sharing foundation (landed, #53) + +Private context crosses owners only through an explicit, owner-granted share; room membership never widens what a Bot carries or reads. Only the #48/#49/#50 foundations are assumed; team mode stays disabled. + +- **The own-chat brief is share-gated.** A member's group Session starts with its owner's own-chat brief only when the room has a single owner, or that owner called `share-brief` for this room (the `briefShared` flag lives on the room participant, so leaving drops it; `rt.setBriefShare` in `core/src/host/room-membership.js` owner-checks, the endpoint only needs a signed-in actor). A group Session created after the share is taken back carries no brief. A granted remote Bot in the room counts as another owner (its handle stands for its home), so a friend's Bot's presence also suppresses the brief. A brief already delivered is not retroactively unseen — document that to owners. +- **Memory review is owner-aware.** In `core/src/host/memory.js`, "the user" of a group review is the member Bot's owner (`isUserLine`); another owner's human is a named peer and does not get user-line retention. A review that reads a room with more than one owner cannot write the team scope (`teamAllowedFrom`) — entries land in the Bot's own memory. Entries already written are not retroactively unseen. +- **Roster disclosure narrows.** `list_bots` shows another owner's Bot by name, role and model; its folder and instructions are its owner's private setup. +- **Chat references stay owner-scoped.** A `dsh-session:` reference chip in `send` or `room-post` must name a Session the author may read (`sessionOwnedBy`); the snapshot rides to every recipient, so authorship is the gate. +- **Not done here:** reference chips are author-checked on `send` and `room-post` only — whether DSH resolves chips in `answer-question`/`schedule-create` texts and in Bot-authored messages is an open DSH-side question (tracked follow-up); the secrets surface stays `SHARED_BLOCKED` for >1 member; previously received messages cannot be retroactively unseen (stated, not engineered); no per-owner file-sandbox changes — a room `workDir` remains readable by its member Bots under DSH's own policy. + ## Identity model | Relation | Rule | @@ -220,4 +230,6 @@ Owner-scoped access (#49) tests in `core/test/authz.test.js`: cross-owner denial **Approval binding (#52)** has tests in `core/test/approval-binding.test.js`: the hold stamps server-derived provenance and a request-content hash while forged `meta` claims stay data; only the sender Bot's owner's session may decide (another member gets `bot/forbidden`, no session gets `bot/unauthorized`, a body `actorId` grants nothing); the pending list is per-actor; a request that changed after it was held goes `stale` and never dispatches; same-owner deciding survives a restart; deleting a sender or target Bot or replacing the Chief invalidates pending holds; and a record whose sender Bot and owner are both gone stays in state with its attribution but in no member's list. +**Context sharing (#53)** has tests in `core/test/context-sharing.test.js` (brief gate per room ownership, the owner-only `share-brief` round trip, the single-owner guard, `list_bots` narrowing, reference-chip authorization) and in `core/test/memory.test.js` (owner-as-user classification, the team-write gate out of a cross-owner room). + The routing-matrix rows above remain untested because the behavior is not shipped. When enforcement work starts (#48+), each row becomes a test first, per [fail-without-change](../code-standards.md): same-owner delegation allowed (a), Chief-to-Chief allowed (b), cross-owner specialist denied (c), team-admin vs private-owner denied (d), forwarded approval rejected (e). Each must fail on the unbroken tip before implementation lands. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 64a6ab70f..202f5a6a8 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -224,6 +224,37 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js diff --git a/src/host/memory.js b/src/host/memory.js --- a/src/host/memory.js +++ b/src/host/memory.js +@@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn + import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' + import { dirname, join } from 'node:path' + import { BlockAssembler } from '@deepseek-ai/dsh-llm' ++import { currentParticipants } from './room-membership.js' + import { defineTool } from '@deepseek-ai/dsh-tools' + import { splitRef, unquote } from './text.js' + import { output } from './tools.js' +@@ -488,6 +488,22 @@ export function install(rt) { + + const writesTeam = botId => rt.isMain(botId) || Object.values(state.rooms).some(room => room.admin === botId) + ++ // Team memory is one team's. A review that reads a room where another owner sits ++ // keeps entries in the Bot's own memory only (#53); ownership, not the admin role, ++ // decides whose memory "team" refers to there. ++ const teamAllowedFrom = (room, bot) => { ++ if (room === undefined) return true ++ const owners = new Set([bot.ownerId]) ++ for (const p of currentParticipants(room)) { ++ if (p.kind === 'human') owners.add(p.id) ++ // A granted remote Bot speaks for another home; its handle stands for that owner. ++ else if (p.remote) owners.add(p.handle) ++ else owners.add(state.bots?.[p.id]?.ownerId) ++ } ++ owners.delete(undefined) ++ return owners.size <= 1 ++ } ++ + // `raw` names a scope: "own", "team", or a Bot. Undefined for the default. + function namedScope(raw, botId) { + const value = unquote(raw) @@ -941,6 +941,8 @@ export function install(rt) { // desiredModel only falls back for models that are not served; so a failed call // is tried once more on the profile default. @@ -242,6 +273,59 @@ diff --git a/src/host/memory.js b/src/host/memory.js return { botId: bot.id, entries, +@@ -1123,17 +1123,23 @@ export function install(rt) { + + // What is new in a group, from its log: the member's Session holds the same + // messages wrapped in turn prompts. ++ // "The user" of a group review is the member Bot's owner; a line another human ++ // wrote is a peer's, however it is attributed (#53). ++ const isUserLine = (bot, entry) => (entry.authorKind === 'human' ++ ? entry.authorId !== undefined && entry.authorId === bot.ownerId ++ : entry.who === 'User') ++ + function groupBatch(room, bot, since) { + const log = (room.log ?? []).filter(line => line.kind === undefined && typeof line.text === 'string' && line.text.trim() !== '') + // A first review reads back at least to the user's latest line, which a round of + // replies from many members can push out of the last few. +- const lastUser = log.findLastIndex(line => line.who === 'User') ++ const lastUser = log.findLastIndex(line => isUserLine(bot, line)) + let start = since === undefined ? Math.max(0, Math.min(log.length - REVIEW_FIRST_LINES, lastUser === -1 ? log.length : lastUser)) : log.findIndex(line => line.seq > since) + const oldest = room.log?.[0]?.seq + if (since !== undefined && oldest !== undefined && oldest > since + 1) rt.warn('ds-bot: %d lines of group "%s" left its log before %s reviewed them', oldest - since - 1, room.name, bot.name) + if (start === -1) start = log.length + const line = (entry) => { +- const who = entry.who === 'User' ? 'user' : entry.botId === bot.id ? 'you' : 'bot' ++ const who = isUserLine(bot, entry) ? 'user' : entry.botId === bot.id ? 'you' : 'bot' + return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq } + } + return { +@@ -1158,7 +1158,7 @@ export function install(rt) { + return { texts: kept, count: kept.length } + } + +- async function applyReview(bot, sessionId, numbered, changes, source) { ++ async function applyReview(bot, sessionId, numbered, changes, source, { teamAllowed = true } = {}) { + const actor = { id: bot.id, name: bot.name, session: sessionId, auto: true } + const done = [] + let failed +@@ -1166,7 +1166,7 @@ export function install(rt) { + // A Bot deleted mid-review keeps no more changes, its team ones included. + if (rt.botOf(bot.id) === undefined) return 0 + // Its role may change mid-review too, so each change asks again. +- const teamWrites = writesTeam(bot.id) ++ const teamWrites = teamAllowed && writesTeam(bot.id) + const op = String(change?.op ?? '') + const topic = op === 'add' && change?.topic !== undefined ? cleanTopic(String(change.topic)) : undefined + const target = numbered[Number(change?.entry) - 1] +@@ -1272,7 +1272,7 @@ export function install(rt) { + const badTopic = applied.find(change => change.op === 'add' && typeof change.topic === 'string' && change.topic.trim() !== '' && cleanTopic(change.topic) === undefined) + if (badTopic !== undefined) throw new Error(`the model's answer names the topic "${clip(badTopic.topic, 60)}", which cannot be a topic name`) + if (rt.botOf(bot.id) === undefined) return +- const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read)) ++ const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read), { teamAllowed: teamAllowedFrom(live, bot) }) + if (rt.botOf(bot.id) === undefined) return + // An answer at the limit may have left some out, so the same lines are read + // again, a few times at most, while each pass still changes something. diff --git a/src/host/models.js b/src/host/models.js --- a/src/host/models.js +++ b/src/host/models.js @@ -536,6 +620,77 @@ diff --git a/test/memory.test.js b/test/memory.test.js const view = (await call('memory-view', { botId: 'b2' })).value assert.equal(view.error, null) assert.equal(view.summary?.text, 'You like tea.') +@@ -1076,3 +1076,70 @@ test('a memory call names the current part of the Bot chat', async (t) => { + assert.equal(answer.ok, true) + assert.equal(requests[0].sessionId, 'b2-part2') + }) ++ ++// A cross-owner shared room (#53): "the user" of a member Bot's review is its own ++// owner, and another owner's room is not the place to publish team memory. ++const SHARED_ROOM_TEAM = { ++ version: 2, ++ revision: 1, ++ mainBotIds: ['m1'], ++ members: { ++ 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'm1' }, ++ 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'x1' }, ++ }, ++ bots: { ++ m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', instructions: '', createdAt: 1, ownerId: 'owner-1' }, ++ b3: { id: 'b3', sessionId: 'b3', name: 'Coder', role: 'Code', instructions: '', createdAt: 3, ownerId: 'owner-1' }, ++ x1: { id: 'x1', sessionId: 'x1', name: 'Guest', role: 'Visit', instructions: '', createdAt: 4, ownerId: 'owner-2' }, ++ }, ++ rooms: { ++ r2: { ++ id: 'r2', name: 'Shared', named: true, members: ['b3', 'x1'], admin: 'b3', mode: 'everyone', notice: '', sessions: { b3: 'g3' }, ++ seq: 3, seen: {}, ++ participants: [ ++ { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, ++ { kind: 'human', id: 'owner-2', role: 'member', displayName: 'Bob', joinedSeq: 0, joinedAt: 1 }, ++ { kind: 'bot', id: 'b3', role: 'admin', displayName: 'Coder', joinedSeq: 0, joinedAt: 1 }, ++ { kind: 'bot', id: 'x1', role: 'member', displayName: 'Guest', joinedSeq: 0, joinedAt: 1 }, ++ ], ++ log: [ ++ { who: 'Alice', authorKind: 'human', authorId: 'owner-1', text: 'Keep the launch on Friday', seq: 1 }, ++ { who: 'Bob', authorKind: 'human', authorId: 'owner-2', text: 'Bob prefers Sundays', seq: 2 }, ++ { who: 'Coder', botId: 'b3', text: 'Will do.', seq: 3 }, ++ ], ++ }, ++ }, ++ exchanges: [], exchangeTurns: {}, questions: {}, ++} ++ ++test('in a cross-owner room, the review reads its own owner as the user and the other owner as a peer', async (t) => { ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) ++ writeFileSync(join(home, 'state.json'), JSON.stringify(SHARED_ROOM_TEAM)) ++ const replies = [JSON.stringify({ changes: [{ op: 'add', text: 'The launch stays on Friday' }] })] ++ const { ctx, call, requests, stop } = start({ home, replies, config: { memoryReviewDelayMs: 0 } }) ++ t.after(() => stop()) ++ await call('state', {}) ++ ctx.emit('agent/status', { agent: { id: 'g3' }, status: 'idle' }) ++ for (let tries = 0; tries < 200 && requests.length === 0; tries += 1) await new Promise(resolve => setTimeout(resolve, 20)) ++ assert.equal(requests.length, 1) ++ const batch = requests[0].messages[0].content[0].text ++ assert.match(batch, /User: Keep the launch on Friday/, 'the owner speaks as the user') ++ assert.match(batch, /Bob: Bob prefers Sundays/, 'the other owner is a named peer') ++ assert.doesNotMatch(batch, /User: Bob prefers Sundays/) ++}) ++ ++test('a review out of a cross-owner room keeps entries out of the team memory', async (t) => { ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) ++ writeFileSync(join(home, 'state.json'), JSON.stringify(SHARED_ROOM_TEAM)) ++ const replies = [JSON.stringify({ changes: [{ op: 'add', text: 'The launch stays on Friday', team: true }] })] ++ const { ctx, call, stop } = start({ home, replies, config: { memoryReviewDelayMs: 0 } }) ++ t.after(() => stop()) ++ await call('state', {}) ++ ctx.emit('agent/status', { agent: { id: 'g3' }, status: 'idle' }) ++ for (let tries = 0; tries < 200; tries += 1) { ++ if (existsSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'))) break ++ await new Promise(resolve => setTimeout(resolve, 20)) ++ } ++ assert.equal(existsSync(join(home, 'memory', 'team', 'MEMORY.md')), false, 'no team write from another owner\'s room') ++ assert.match(readFileSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'), 'utf8'), /The launch stays on Friday/) ++}) diff --git a/test/model-menu.test.js b/test/model-menu.test.js --- a/test/model-menu.test.js +++ b/test/model-menu.test.js diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index f062fd948..b7f44bca9 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -585,16 +585,22 @@ diff --git a/src/host/tools.js b/src/host/tools.js ctx.tools.register(defineTool({ name: 'list_bots', -@@ -46,7 +46,7 @@ export function install(rt) { +@@ -46,20 +46,26 @@ export function install(rt) { async execute(_args, exec) { await rt.load() const self = rt.selfOf(exec.agent?.id) - const lines = Object.values(state.bots).map(bot => { ++ const selfOwner = rt.botOf(self)?.ownerId ++ // Another owner's Bot lists by name and role only: its folder and instructions ++ // are its owner's private setup (#53). ++ const foreign = bot => rt.authzEnforced?.() && selfOwner !== undefined && bot.ownerId !== undefined && bot.ownerId !== selfOwner + const lines = Object.values(state.bots).filter(bot => bot.remote === undefined).map(bot => { const model = bot.appliedModel ?? bot.model - const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') - const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' -@@ -54,12 +54,14 @@ export function install(rt) { +- const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') +- const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' ++ const tags = [bot.role, model ? `model ${model}` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') ++ const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' + return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}` }) if (lines.length === 0) return 'No Bots yet.' const rooms = visibleRooms(self) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 3103e699b..5a8b1a3c6 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -97,7 +97,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +65,30 @@ export function install(rt) { +@@ -65,22 +65,40 @@ 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, @@ -135,6 +135,16 @@ diff --git a/src/host/channel.js b/src/host/channel.js - const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer']) + // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. + const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami']) ++ ++ // A chat reference chip names a Session whose snapshot rides to every recipient of ++ // the message, so the author must be able to read what they share (#53). ++ const unshareableReference = (actor, text) => { ++ if (!actor || !rt.authzEnforced?.() || typeof text !== 'string') return undefined ++ for (const match of text.matchAll(/dsh-session:([A-Za-z0-9_-]+)/g)) { ++ if (!rt.sessionOwnedBy?.(actor, match[1])) return match[1] ++ } ++ return undefined ++ } function setPrefs(body) { if (body.theme !== undefined) { @@ -192,7 +202,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js 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 +175,24 @@ export function install(rt) { +@@ -175,8 +175,26 @@ export function install(rt) { add: Array.isArray(body.add) ? body.add : undefined, remove: Array.isArray(body.remove) ? body.remove : undefined, workDir: body.workDir, @@ -209,6 +219,8 @@ diff --git a/src/host/channel.js b/src/host/channel.js + } + case 'room-post': { + await rt.load() ++ const leaked = unshareableReference(actor, body.text) ++ if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') + const line = await rt.postRoomMessage(String(body.roomId ?? body.id ?? ''), { + text: body.text, + authorId: body.authorId, @@ -219,7 +231,12 @@ diff --git a/src/host/channel.js b/src/host/channel.js } case 'folders': case 'folder-pick': -@@ -281,6 +281,18 @@ export function install(rt) { +@@ -278,9 +278,23 @@ export function install(rt) { + const text = String(body.text ?? '').trim() + if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid') + await rt.load() ++ const leaked = unshareableReference(actor, text) ++ if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') // 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 @@ -238,10 +255,14 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,7 +319,38 @@ export function install(rt) { +@@ -319,7 +319,42 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) ++ case 'share-brief': ++ await rt.load() ++ await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) ++ return ok(true) + case 'auth-whoami': + return ok(actor) + case 'auth-invite': { diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index af6948e12..6770e8c65 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -306,6 +306,14 @@ diff --git a/src/client/sources.js b/src/client/sources.js diff --git a/src/host/groups.js b/src/host/groups.js --- a/src/host/groups.js +++ b/src/host/groups.js +@@ -22,6 +22,7 @@ import { escapeRegExp, splitRef, textOf } from './text.js' + import { mentionSpans } from '../shared/mentions.js' + import { GROUP_REF, output } from './tools.js' + import { failureLine } from './turns.js' ++import { currentParticipants } from './room-membership.js' + + const ROOM_LOG_MAX = 60 + const ROOM_FIRST_LOOK = 12 @@ -41,9 +41,12 @@ const ensureRoomLog = (room) => { room.seen ??= {} } @@ -330,7 +338,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -113,6 +113,7 @@ const ROOM_READ_MAX = 30 +@@ -113,10 +113,28 @@ const ROOM_READ_MAX = 30 export function install(rt) { const { ctx } = rt @@ -338,6 +346,36 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the + // first message, so it stays fixed and the prefix stays cacheable. ++ // The brief is the member's owner's private chat with the Bot. In a room where ++ // another owner sits it stays out unless that owner shared it with this room (#53). ++ const briefAllowed = (room, member) => { ++ if (!rt.authzEnforced?.()) return true ++ const participants = currentParticipants(room) ++ const owners = new Set([rt.botOf(member.id)?.ownerId]) ++ for (const p of participants) { ++ if (p.kind === 'human') owners.add(p.id) ++ // A granted remote Bot speaks for another home; its handle stands for that owner. ++ else if (p.remote) owners.add(p.handle) ++ else owners.add(rt.botOf(p.id)?.ownerId) ++ } ++ owners.delete(undefined) ++ if (owners.size <= 1) return true ++ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) ++ } ++ + async function groupSessionFor(room, member) { + room.sessions ??= {} + const known = room.sessions[member.id] +@@ -137,7 +137,7 @@ export function install(rt) { + .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error)) + await rt.save() + await rt.ensureCapabilities(id) +- return { id, brief: await rt.ownChatBrief(member) } ++ return { id, brief: briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } + } + + // Lines from before the round's opening message are kept apart from this round, so @@ -292,8 +292,8 @@ export function install(rt) { const before = round.slots.length const members = room.members.map(rt.botOf).filter(Boolean) diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index c71212cac..fb4be93bb 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -322,6 +322,10 @@ export function install(rt) { if (ep === 'create-bot') return ALLOW + // Sharing a member Bot's brief is the Bot owner's decision, checked on the record + // in room-membership (#53); the endpoint itself only needs a signed-in actor. + if (ep === 'share-brief') return ALLOW + // Anything not in the table and not a known auth/read endpoint is denied. if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check' diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 3710ec177..578252e11 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -642,6 +642,22 @@ export function install(rt) { return linesVisibleTo(room, actor, state) } + // The owner of a member Bot lets its brief — their own chat with it — ride into + // this one room, or takes it back (#53). Bot ownership decides, not room admin; + // the flag lives on the participant, so leaving the room drops it. + rt.setBriefShare = function setBriefShare(roomId, botId, shared, actor) { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') + const bot = state.bots?.[String(botId ?? '')] + if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot') + if (rt.authzEnforced?.() && bot.ownerId !== actor?.memberId) throw forbidden("Only the Bot's owner may share its brief") + const entry = currentParticipants(room).find(p => p.kind === 'bot' && p.id === botId) + if (entry === undefined) throw forbidden('That Bot is not in this room') + if (shared) entry.briefShared = true + else delete entry.briefShared + return rt.save() + } + rt.applyParticipants = function applyParticipants(room, drafts, actor) { if (!Array.isArray(drafts) || drafts.length === 0) return room room.participants = [] diff --git a/patches/ds-bot/files/test/context-sharing.test.js b/patches/ds-bot/files/test/context-sharing.test.js new file mode 100644 index 000000000..1c3124926 --- /dev/null +++ b/patches/ds-bot/files/test/context-sharing.test.js @@ -0,0 +1,222 @@ +// Context sharing (#53): private context crosses owners only through an explicit, +// owner-granted share. A member's group brief is its owner's own chat; another +// owner's chat lines, folders and chat references never ride into a shared room +// without that share. Alice and Bob are two signed-in members on the #48/#49 +// foundation; team mode stays disabled. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'd' }, + }, + bots: { + a: { id: 'a', name: 'ChiefA', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1', workDir: '/tmp/alice-private', instructions: 'Alice secret instructions' }, + d: { id: 'd', name: 'ChiefB', role: 'Boss', sessionId: 'd', createdAt: 2, ownerId: 'owner-2', workDir: '/tmp/bob-private', instructions: 'Bob secret instructions' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 'd'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'human', id: 'owner-2', role: 'member', displayName: 'Bob', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'd', role: 'member', displayName: 'ChiefB', joinedSeq: 0, joinedAt: 1 }, + ], + }, + home: { + id: 'home', name: 'Home', members: ['a'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + ], + }, + }, + grants: { a: [{ id: 'g1', grantee: 'owner-2' }], d: [{ id: 'g2', grantee: 'owner-1' }] }, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function start(t, config = {}) { + const home = config.home ?? mkdtempSync(join(tmpdir(), 'ds-bot-context-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(config.state ?? team())) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const calls = [] + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls, status: 'idle', + followup(m) { calls.push(m) }, steer(m) { calls.push(m) }, + inject() {}, cancel() {}, async whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} }, + }) + } + return agents.get(sessionId) + } + 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: `s${(++created).toString(36)}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let created = 0 + 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 }) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + let seq = 0 + const emit = (id, type, data) => { + for (const listener of ctx.listeners.get('session/event') ?? []) { + if (listener.length >= 2) listener({ id }, { type, seq: ++seq, data }) + } + } + const tool = (name, args, sessionId, exec = {}) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId }, ...exec }) + const call = async (endpoint, payload, token) => { + const headers = token ? { 'x-flybot-member-session': token } : undefined + return (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) }))).json() + } + const signIn = async (displayName, inviterToken) => { + const invite = await call('auth-invite', {}, inviterToken) + assert.equal(invite.ok, true, JSON.stringify(invite)) + const redeemed = await call('auth-redeem', { token: invite.value.token, displayName }) + assert.equal(redeemed.ok, true, JSON.stringify(redeemed)) + return redeemed.value + } + // Drive the round's state machine: every member Session that has taken its first + // message replies at once, so the next member is called until nobody is waiting. + const roundTurns = new Map() + const memberIds = () => [...agents.keys()].filter(id => id.startsWith('s')) + const drive = (id) => { + const message = agents.get(id).calls.at(-1) + const turn = (roundTurns.get(id) ?? 0) + 1 + roundTurns.set(id, turn) + emit(id, 'turn/start', { turn }) + emit(id, 'user/message', { content: message.content, source: message.source }) + emit(id, 'assistant/message', { message: { content: [{ type: 'text', text: 'noted' }] } }) + emit(id, 'turn/end', { turn, reason: { kind: 'completed' } }) + } + const runRound = async (roomId, expected) => { + emit(roomId, 'user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + const stop = new AbortController() + t.after(() => stop.abort()) + void tool('group_relay', {}, roomId, { callId: 'c1', signal: stop.signal }).catch(() => {}) + const until = Date.now() + 3000 + while (Date.now() < until) { + const pending = memberIds().filter(id => agents.get(id).calls.length > 0 && !roundTurns.has(id)) + for (const id of pending) drive(id) + if (memberIds().length >= expected && memberIds().every(id => roundTurns.has(id))) break + await new Promise(resolve => setImmediate(resolve)) + } + return memberIds().map(id => textOf(agents.get(id).calls[0])) + } + return { ctx, call, emit, tool, agentOf, signIn, home, runRound } +} + +const textOf = message => (message?.content ?? []).map(block => block.text ?? '').join('\n') + +test('a member brings no private brief into a room where another owner sits', async (t) => { + const { runRound, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const turns = await runRound('war', 2) + assert.equal(turns.length, 2, 'both members took their first turn') + for (const first of turns) { + assert.ok(!first.includes('Your own chat with the user'), 'no own-chat brief rides into a cross-owner room') + assert.ok(!first.includes('ALICES-PRIVATE'), 'no private marker') + } + void alice +}) + +test('the owner can share the brief with one room, and take it back', async (t) => { + const { call, runRound, signIn, home } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const shared = await call('share-brief', { id: 'war', botId: 'a', share: true }, alice.sessionToken) + assert.equal(shared.ok, true, JSON.stringify(shared)) + const state = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(state.rooms.war.participants.find(p => p.id === 'a').briefShared, true) + + const turns = await runRound('war', 2) + const aliceBot = turns.find(first => first.includes('Members: the user, ChiefB')) + const bobBot = turns.find(first => first.includes('Members: the user, ChiefA')) + assert.ok(aliceBot, 'ChiefA took its turn') + assert.ok(bobBot, 'ChiefB took its turn') + assert.ok(aliceBot.includes('Your own chat with the user'), 'the owner shared the brief, so it rides along') + assert.ok(!bobBot.includes('Your own chat with the user'), "Bob's brief still stays home") + + const revoked = await call('share-brief', { id: 'war', botId: 'a', share: false }, alice.sessionToken) + assert.equal(revoked.ok, true, JSON.stringify(revoked)) + const after = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(after.rooms.war.participants.find(p => p.id === 'a').briefShared, undefined) +}) + +test('only the Bot\'s owner may share its brief', async (t) => { + const { call, signIn } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const foreign = await call('share-brief', { id: 'war', botId: 'a', share: true }, bob.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden', JSON.stringify(foreign)) + void alice +}) + +test('a single-owner room keeps the brief without any share', async (t) => { + const { runRound, signIn } = await start(t) + const alice = await signIn('Alice') + void alice + const turns = await runRound('home', 1) + assert.ok(turns[0]?.includes('Your own chat with the user'), 'own room keeps the shipped brief') +}) + +test('list_bots hides another owner\'s folder and instructions', async (t) => { + const { tool, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const seen = await tool('list_bots', {}, 'a') + assert.ok(seen.includes('ChiefB'), 'the other owner\'s Bot is still listed') + assert.ok(!seen.includes('/tmp/bob-private'), 'no folder disclosure') + assert.ok(!seen.includes('Bob secret instructions'), 'no instructions disclosure') + assert.ok(seen.includes('/tmp/alice-private'), 'own folder still shows') + assert.ok(seen.includes('Alice secret instructions'), 'own instructions still show') +}) + +test('a chat reference chip must name a chat the author may read', async (t) => { + const { call, signIn } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const foreign = await call('room-post', { id: 'war', text: 'look at @[that](dsh-session:d) please' }, alice.sessionToken) + assert.equal(foreign.ok, false, JSON.stringify(foreign)) + assert.equal(foreign.error.code, 'bot/forbidden') + const own = await call('room-post', { id: 'war', text: 'look at @[this](dsh-session:a) please' }, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) + void bob +}) + + + +test('a granted remote Bot in the room counts as another owner for the brief', async (t) => { + const state = team() + state.rooms.home.participants.push({ kind: 'bot', remote: true, handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', plugin: 'social', joinedSeq: 0, joinedAt: 2 }) + const { runRound, signIn } = await start(t, { state }) + const alice = await signIn('Alice') + void alice + const turns = await runRound('home', 1) + assert.ok(turns[0], 'the member took its turn') + assert.ok(!turns[0].includes('Your own chat with the user'), 'a remote participant is another owner: no brief without a share') +}) From 588fa9dc567fe30994220cdaa2b5d7922f3766a6 Mon Sep 17 00:00:00 2001 From: hsliu_ustc Date: Fri, 16 Oct 2026 00:51:53 +0800 Subject: [PATCH 3/3] Track tasks with a lifecycle and requester-only acceptance (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request was only chat text: nothing tracked whether work was claimed, running, blocked on an approval, finished, or accepted, and any member could not be refused from moving someone else's work along. state.tasks (additive, corrupt values reset) records each task with a stable id, the server-derived requester, the responsible Bot, per-status actor entries, and pointers to the originating conversation — a summary, never private transcripts. The state machine is open → claimed → working → blocked ⇄ working → result-ready → accepted, with cancelled and failed terminal and accepted reachable only from result-ready, so a failed or cancelled task can never display as accepted. A held send (#52 card) blocks the working task with the approval id; the owner's decision resumes or cancels it; deleting the responsible Bot fails its open tasks. Endpoints pass authz for any signed-in actor and are record-checked in tasks.js (requester-only moves). Task views in the chat are #57; team mode stays disabled. Fixes #54 --- CHANGELOG.md | 1 + core/index.js | 3 +- core/src/host/authz.js | 3 + core/src/host/channel.js | 9 + core/src/host/hooks.js | 6 + core/src/host/store.js | 8 +- core/src/host/tasks.js | 173 +++++++++++++++ core/test/tasks.test.js | 259 ++++++++++++++++++++++ docs/subsystems/core-host.md | 4 + docs/subsystems/team.md | 4 + patches/ds-bot/0002-flybot-api-host.patch | 15 +- patches/ds-bot/0005-social-seams.patch | 11 +- patches/ds-bot/files/src/host/authz.js | 3 + patches/ds-bot/files/src/host/hooks.js | 6 + patches/ds-bot/files/src/host/tasks.js | 173 +++++++++++++++ patches/ds-bot/files/test/tasks.test.js | 259 ++++++++++++++++++++++ 16 files changed, 928 insertions(+), 9 deletions(-) create mode 100644 core/src/host/tasks.js create mode 100644 core/test/tasks.test.js create mode 100644 patches/ds-bot/files/src/host/tasks.js create mode 100644 patches/ds-bot/files/test/tasks.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da79fcf0..5b29ce41a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ New entries are written as one fragment file per change in [changelog.d/](change ### Added +- **Tracked tasks.** A request can be created as a task with a stable id, acceptance criteria, and a visible state — claimed, worked, blocked while a send waits for your approval, returned, and accepted by you (or cancelled). Wrong-account moves are refused, a task cannot be accepted before its result exists, and deleting the responsible Bot fails its tasks instead of dropping them. Task views in the chat are next (#57). ([#54](https://github.com/ThinkFlowLab/FlyBot/issues/54)) - **Private context stays home in shared rooms.** A Bot only carries its owner's chat brief into a group where another owner sits when the owner shares it (a per-room, revocable share from the Bot's owner — host API `share-brief` now, room UI next); memory reviews in such a room keep entries in the Bot's own memory, never the team's, and read only their own owner as "the user"; `list_bots` no longer discloses another owner's Bot folder or instructions; and referencing a chat you cannot read is refused. Previously received messages are not retroactively unseen. ([#53](https://github.com/ThinkFlowLab/FlyBot/issues/53)) - **Held sends are bound to their owner.** An approval card shows who sent the request (and when it was relayed between Bots); only the sender Bot's owner can approve, reject, or retry it, the list shows you only your own holds, and a decision is voided safely (`stale`, never sent) when the request changed after it was held or the Bot / Chief authority behind it is gone. ([#52](https://github.com/ThinkFlowLab/FlyBot/issues/52)) - **Remove a friend's Bot** from Friends' Bots hides it on this install, takes it out of rooms, and does not unfriend. It reappears only if it is granted again. Held Bot replies show in the chat as **Waiting for approval** (owner: Approve / Reject / Auto-send; others: waiting for the owner). ([#90](https://github.com/ThinkFlowLab/FlyBot/issues/90)) diff --git a/core/index.js b/core/index.js index 6f275b131..35e18d2d9 100644 --- a/core/index.js +++ b/core/index.js @@ -8,6 +8,7 @@ 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 tasks from './src/host/tasks.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' @@ -46,7 +47,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, reach, 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, tasks, 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 fb4be93bb..4b754f05f 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -325,6 +325,9 @@ export function install(rt) { // Sharing a member Bot's brief is the Bot owner's decision, checked on the record // in room-membership (#53); the endpoint itself only needs a signed-in actor. if (ep === 'share-brief') return ALLOW + // Task transitions are requester-checked on the record in tasks.js (#54) — the + // endpoint only needs a signed-in actor, like held-send decisions. + if (ep === 'tasks' || ep.startsWith('task-')) return ALLOW // Anything not in the table and not a known auth/read endpoint is denied. if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 32da9948b..cb71a8a74 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -396,6 +396,15 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) + case 'tasks': + case 'task-create': + case 'task-accept': + case 'task-start': + case 'task-return': + case 'task-accept-result': + case 'task-cancel': + await rt.load() + return ok(await rt.taskEndpoint(endpoint, body, actor)) case 'share-brief': await rt.load() await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index b39af62fe..89a0fcd31 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -176,6 +176,8 @@ export function install(rt) { fire('bot.removed', { ...last, botId: id, bot: last }) // A hold naming this Bot as sender or target can no longer be spent (#52). await invalidateFor([id], 'the Bot was deleted') + // Its open tasks fail rather than wait forever (#54). + await rt.taskBotDeleted?.(id) // Deleting a friend's Bot's chat clears it; the friend's Bot is still there, so it gets a // fresh, empty chat at once. Otherwise its row waits ("opening its chat…") until the // plugin next publishes its roster, which may be hours or the next restart. @@ -378,6 +380,8 @@ export function install(rt) { for (const old of done.slice(0, Math.max(0, Object.keys(all).length - APPROVALS_KEPT))) delete all[old.id] await rt.save() approvalsChanged(approval) + // A working task's consequential send is held: the task blocks on this card (#54). + void rt.taskHeld?.(msg, approval).catch(error => warn('flybot: task hold failed: %s', error)) return approval } @@ -462,6 +466,8 @@ export function install(rt) { approval.decidedAt = Date.now() if (note) approval.note = String(note) if (decision === 'approve') await release(approval) + // The blocked task resumes on approve, or ends on reject (#54). + void rt.taskDecided?.(approval, decision).catch(error => warn('flybot: task decision failed: %s', error)) await rt.save() approvalsChanged(approval) return clone(approval) diff --git a/core/src/host/store.js b/core/src/host/store.js index 068f538f6..4592fa96a 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -20,8 +20,8 @@ const isRecord = value => value !== null && typeof value === 'object' && !Array. export function install(rt) { const { ctx, home, statePath } = rt - /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number, invitations:Record, authSessions:Record}} */ - const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, revision: 0 } + /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number, invitations:Record, authSessions:Record, tasks:Record}} */ + const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, tasks: {}, revision: 0 } let loaded = null let writing = Promise.resolve() const { warn } = rt @@ -305,6 +305,10 @@ export function install(rt) { state.authSessions = {} migrated = true } + if (!isRecord(state.tasks)) { + state.tasks = {} + migrated = true + } if (ensureOwner()) migrated = true if (legacy) { const ownerId = Object.keys(state.members)[0] diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js new file mode 100644 index 000000000..cd352bea3 --- /dev/null +++ b/core/src/host/tasks.js @@ -0,0 +1,173 @@ +// Tasks (#54): a tracked request with a stable id and a small state machine, so +// responsibilities and completion are visible beyond chat text. Team mode stays +// disabled: this is the task-record foundation the later team gates build on +// (docs/subsystems/team.md) — in the single-human product every transition is the +// requester's; with member sessions only the requester's own may move their task, +// and the record carries server-derived authority, never body claims (#52 rule). +// +// States: open → claimed → working → blocked ⇄ working → result-ready → accepted; +// cancelled and failed are terminal from any active state, and accepted is reachable +// only from result-ready — so a failed or cancelled task can never display as +// accepted. The record stores pointers and a summary, never private transcripts. +import { randomBytes } from 'node:crypto' + +const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked']) +const TRANSITIONS = { + open: ['claimed', 'cancelled', 'failed'], + claimed: ['working', 'cancelled', 'failed'], + working: ['blocked', 'result-ready', 'cancelled', 'failed'], + blocked: ['working', 'cancelled', 'failed'], + 'result-ready': ['accepted'], + accepted: [], + cancelled: [], + failed: [], +} +const KEPT = 200 + +const stale = message => Object.assign(new Error(message), { code: 'bot/stale' }) +const forbidden = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) + +export function install(rt) { + const { state } = rt + state.tasks ??= {} + + const store = () => { + state.tasks ??= {} + return state.tasks + } + // The requester is the human whose task this is: the signed-in actor, or the one + // owner of a host nobody has signed in on. Derived, never a body claim. + const requesterOf = actor => actor?.memberId ?? Object.keys(state.members ?? {})[0] ?? null + const mayAct = (task, actor) => { + if (!rt.authzEnforced?.()) return true + return actor !== null && actor !== undefined && task.requesterId === actor.memberId + } + const find = id => store()[String(id ?? '')] + // Only the record's own requesters may move their tasks; the responsible Bot is + // theirs too, checked against the team state at create time. + const requireRequester = (task, actor) => { + if (!mayAct(task, actor)) throw forbidden('Only the requester may move their task') + } + + function transition(task, to, actor, extra) { + if (!TRANSITIONS[task.status]?.includes(to)) throw stale(`a task that is ${task.status} cannot become ${to}`) + task.status = to + task.updatedAt = Date.now() + task.actors.push({ status: to, at: task.updatedAt, ...(actor?.memberId ? { memberId: actor.memberId } : {}), ...(extra ?? {}) }) + } + + const view = task => ({ ...task, actors: [...task.actors ?? []] }) + const prune = () => { + const all = Object.values(store()) + const terminal = all.filter(task => !ACTIVE.has(task.status)).sort((a, b) => a.updatedAt - b.updatedAt) + for (const task of terminal.slice(0, Math.max(0, all.length - KEPT))) delete store()[task.id] + } + const save = () => rt.save() + + // ---------------------------------------------------------------- host hooks + // A consequential send from the responsible Bot was held (#52 card): the task + // blocks on that approval until the owner decides. + rt.taskHeld = (msg, approval) => { + for (const task of Object.values(store())) { + if (task.status !== 'working' || task.ownerBotId !== msg.from) continue + task.approvalId = approval.id + transition(task, 'blocked', null, { approvalId: approval.id }) + } + return save() + } + // The owner's decision on that approval resumes the task — or, on reject, ends it. + rt.taskDecided = (approval, decision) => { + for (const task of Object.values(store())) { + if (task.status !== 'blocked' || task.approvalId !== approval.id) continue + delete task.approvalId + transition(task, decision === 'approve' ? 'working' : 'cancelled', { memberId: approval.ownerId }, { by: 'owner decision' }) + } + return save() + } + // The responsible Bot is gone: its open tasks fail (they are never silently dropped). + rt.taskBotDeleted = botId => { + for (const task of Object.values(store())) { + if (!ACTIVE.has(task.status) || (task.ownerBotId !== botId && task.workBotId !== botId)) continue + transition(task, 'failed', null, { why: 'the responsible Bot was deleted' }) + } + return save() + } + + // ---------------------------------------------------------------- channel API + rt.taskEndpoint = async function taskEndpoint(endpoint, body, actor) { + switch (endpoint) { + case 'tasks': + return Object.values(store()) + .filter(task => mayAct(task, actor)) + .sort((a, b) => a.createdAt - b.createdAt) + .map(view) + case 'task-create': { + const botId = String(body.botId ?? '') + const bot = state.bots?.[botId] + if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot') + const requesterId = requesterOf(actor) + if (rt.authzEnforced?.() && bot.ownerId !== requesterId) throw forbidden('You do not own that Bot') + const task = { + id: `task-${randomBytes(8).toString('hex')}`, + status: 'open', + createdAt: Date.now(), + updatedAt: Date.now(), + requesterId, + ownerBotId: botId, + title: String(body.title ?? '').slice(0, 300), + ...(typeof body.criteria === 'string' && body.criteria.trim() !== '' ? { criteria: body.criteria.slice(0, 2000) } : {}), + origin: { ...(body.roomId ? { roomId: String(body.roomId) } : {}), ...(body.sessionId ? { sessionId: String(body.sessionId) } : {}) }, + actors: [{ status: 'open', at: Date.now(), ...(actor?.memberId ? { memberId: actor.memberId } : {}) }], + } + store()[task.id] = task + prune() + await save() + return view(task) + } + case 'task-accept': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'claimed', actor) + await save() + return view(task) + } + case 'task-start': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'working', actor) + await save() + return view(task) + } + case 'task-return': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'result-ready', actor) + task.result = { kind: 'summary', summary: String(body.summary ?? '').slice(0, 2000) } + await save() + return view(task) + } + case 'task-accept-result': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'accepted', actor) + await save() + return view(task) + } + case 'task-cancel': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'cancelled', actor) + await save() + return view(task) + } + default: + throw Object.assign(new Error(`Unknown task endpoint ${endpoint}`), { code: 'bot/not-found' }) + } + } + return rt +} diff --git a/core/test/tasks.test.js b/core/test/tasks.test.js new file mode 100644 index 000000000..421941a4a --- /dev/null +++ b/core/test/tasks.test.js @@ -0,0 +1,259 @@ +// Task lifecycle (#54): a request becomes a tracked task with stable ids and a +// state machine — open → claimed → working → (blocked ⇄ working) → result-ready → +// accepted, with cancelled/failed terminal. Only the requester accepts or cancels; +// accepted is reachable only from result-ready, so a failed or cancelled task can +// never display as accepted. Authority is server-derived (#52 rule); team mode +// stays disabled — Alice and Bob are two signed-in members on the #48 foundation. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'd' }, + }, + bots: { + a: { id: 'a', name: 'ChiefA', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + d: { id: 'd', name: 'ChiefB', role: 'Boss', sessionId: 'd', createdAt: 2, ownerId: 'owner-2' }, + s: { id: 's', name: 'Writer', role: 'Write', sessionId: 's', createdAt: 3, ownerId: 'owner-1' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 's'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 's', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 }, + ], + }, + }, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function start(t, config = {}) { + const home = config.home ?? mkdtempSync(join(tmpdir(), 'ds-bot-tasks-')) + if (config.state !== undefined || !existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(config.state ?? team())) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const calls = [] + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls, status: 'idle', + followup(m) { calls.push(m) }, steer(m) { calls.push(m) }, + inject() {}, cancel() {}, async whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} }, + }) + } + return agents.get(sessionId) + } + 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: `s${++created}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let route + let created = 0 + 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, token) => { + const headers = token ? { 'x-flybot-member-session': token } : undefined + return (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) }))).json() + } + const stop = async ({ keepHome = false } = {}) => { + await ctx.dispose() + if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + const signIn = async (displayName, inviterToken) => { + const invite = await call('auth-invite', {}, inviterToken) + assert.equal(invite.ok, true, JSON.stringify(invite)) + const redeemed = await call('auth-redeem', { token: invite.value.token, displayName }) + assert.equal(redeemed.ok, true, JSON.stringify(redeemed)) + return redeemed.value + } + const createTask = async (token, spec = {}) => call('task-create', { botId: 'a', title: 'Prepare the launch note', criteria: 'note drafts exist', ...spec }, token) + return { ctx, fb: ctx.flybot, call, stop, signIn, createTask, agentOf, home, statePath: join(home, 'state.json') } +} + +const statusOf = async (call, token, id) => { + const list = await call('tasks', {}, token) + assert.equal(list.ok, true, JSON.stringify(list)) + return list.value.find(task => task.id === id) +} + +test('a task walks the happy path: created, claimed, worked, returned, accepted by the requester', async (t) => { + const { call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken) + assert.equal(created.ok, true, JSON.stringify(created)) + const task = created.value + assert.match(task.id, /^task-[0-9a-f]{16}$/) + assert.equal(task.status, 'open') + assert.equal(task.requesterId, alice.memberId) + + const claimed = await call('task-accept', { id: task.id }, alice.sessionToken) + assert.equal(claimed.ok, true, JSON.stringify(claimed)) + assert.equal(claimed.value.status, 'claimed') + + // Work starts when the room the task points at runs a round. + const worked = await call('task-start', { id: task.id }, alice.sessionToken) + assert.equal(worked.ok, true, JSON.stringify(worked)) + assert.equal(worked.value.status, 'working') + + const returned = await call('task-return', { id: task.id, summary: 'Draft is in the room' }, alice.sessionToken) + assert.equal(returned.ok, true, JSON.stringify(returned)) + assert.equal(returned.value.status, 'result-ready') + assert.equal(returned.value.result.summary, 'Draft is in the room') + + const accepted = await call('task-accept-result', { id: task.id }, alice.sessionToken) + assert.equal(accepted.ok, true, JSON.stringify(accepted)) + assert.equal(accepted.value.status, 'accepted') + // Terminal: accepting again, or any further move, is stale. + const again = await call('task-accept-result', { id: task.id }, alice.sessionToken) + assert.equal(again.ok, false) + assert.equal(again.error.code, 'bot/stale') +}) + +test('only the requester accepts the result or cancels; another member is forbidden', async (t) => { + const { call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const created = await createTask(alice.sessionToken) + const task = created.value + for (const [endpoint, status] of [['task-accept-result', 'result-ready'], ['task-cancel', 'open']]) { + if (status !== 'open') await call('task-accept', { id: task.id }, alice.sessionToken) + void status + const foreign = await call(endpoint, { id: task.id }, bob.sessionToken) + assert.equal(foreign.ok, false, JSON.stringify(foreign)) + assert.equal(foreign.error.code, 'bot/forbidden', `${endpoint}: ${JSON.stringify(foreign)}`) + } + // Bob cannot claim Alice's task either: the requester's Chief takes it. + const foreignClaim = await call('task-accept', { id: task.id }, bob.sessionToken) + assert.equal(foreignClaim.ok, false) + assert.equal(foreignClaim.error.code, 'bot/forbidden') + const own = await call('task-cancel', { id: task.id }, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) + assert.equal(own.value.status, 'cancelled') +}) + +test('invalid transitions are refused: accept-result before a result, cancel after terminal', async (t) => { + const { call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken) + const task = created.value + // No result yet: accepting is stale, not forbidden. + const early = await call('task-accept-result', { id: task.id }, alice.sessionToken) + assert.equal(early.ok, false) + assert.equal(early.error.code, 'bot/stale', JSON.stringify(early)) + await call('task-cancel', { id: task.id }, alice.sessionToken) + const afterTerminal = await call('task-accept', { id: task.id }, alice.sessionToken) + assert.equal(afterTerminal.ok, false) + assert.equal(afterTerminal.error.code, 'bot/stale', JSON.stringify(afterTerminal)) + const recancel = await call('task-cancel', { id: task.id }, alice.sessionToken) + assert.equal(recancel.ok, false) + assert.equal(recancel.error.code, 'bot/stale') +}) + +test('a held send blocks the task, the owner decision resumes or cancels it', async (t) => { + const { fb, call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken, { botId: 'a' }) + const task = created.value + await call('task-accept', { id: task.id }, alice.sessionToken) + await call('task-start', { id: task.id }, alice.sessionToken) + // A consequential send from the responsible Bot is held: the task blocks. + fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) + const held = await fb.bot.send({ from: 'a', to: 's', text: 'send this out', meta: { hold: true } }) + assert.equal(held.action, 'hold', JSON.stringify(held)) + const blocked = await statusOf(call, alice.sessionToken, task.id) + assert.equal(blocked.status, 'blocked', JSON.stringify(blocked)) + assert.equal(blocked.approvalId, held.approvalId) + // The owner approves in the existing card: the task resumes. + const decided = await call('flybot/decide', { id: held.approvalId, decision: 'approve' }, alice.sessionToken) + assert.equal(decided.ok, true, JSON.stringify(decided)) + const resumed = await statusOf(call, alice.sessionToken, task.id) + assert.equal(resumed.status, 'working', JSON.stringify(resumed)) +}) + +test('deleting the responsible Bot fails its open tasks', async (t) => { + const { call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken, { botId: 's' }) + const task = created.value + await call('task-accept', { id: task.id }, alice.sessionToken) + const removed = await call('delete-bot', { id: 's' }, alice.sessionToken) + assert.equal(removed.ok, true, JSON.stringify(removed)) + const after = await statusOf(call, alice.sessionToken, task.id) + assert.equal(after.status, 'failed', JSON.stringify(after)) +}) + +test('tasks survive a restart with their state and authority', async (t) => { + const { call, signIn, createTask, stop, home } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken) + const task = created.value + await call('task-accept', { id: task.id }, alice.sessionToken) + await stop({ keepHome: true }) + const again = await start(t, { home }) + t.after(() => again.stop()) + const list = await again.call('tasks', {}, alice.sessionToken) + assert.equal(list.ok, true, JSON.stringify(list)) + const restored = list.value.find(entry => entry.id === task.id) + assert.equal(restored.status, 'claimed', JSON.stringify(restored)) + assert.equal(restored.requesterId, task.requesterId) + const restartedWork = await again.call('task-start', { id: task.id }, alice.sessionToken) + assert.equal(restartedWork.ok, true, JSON.stringify(restartedWork)) + const returned = await again.call('task-return', { id: task.id, summary: 'back' }, alice.sessionToken) + assert.equal(returned.ok, true, JSON.stringify(returned)) + assert.equal(returned.value.status, 'result-ready') +}) + +test('a corrupt tasks map resets instead of poisoning the team', async (t) => { + const { signIn, createTask, stop, home } = await start(t) + const alice = await signIn('Alice') + await createTask(alice.sessionToken) + await stop({ keepHome: true }) + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + saved.tasks = 'garbage' + writeFileSync(join(home, 'state.json'), JSON.stringify(saved)) + const again = await start(t, { home }) + t.after(() => again.stop()) + const list = await again.call('tasks', {}, alice.sessionToken) + assert.equal(list.ok, true, JSON.stringify(list)) + assert.deepEqual(list.value, []) + const fresh = await again.createTask(alice.sessionToken) + assert.equal(fresh.ok, true, JSON.stringify(fresh)) +}) + +test('rejecting the blocked approval cancels the task', async (t) => { + const { fb, call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken, { botId: 'a' }) + const task = created.value + await call('task-accept', { id: task.id }, alice.sessionToken) + await call('task-start', { id: task.id }, alice.sessionToken) + fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) + const held = await fb.bot.send({ from: 'a', to: 's', text: 'send this out', meta: { hold: true } }) + assert.equal(held.action, 'hold') + const rejected = await call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken) + assert.equal(rejected.ok, true, JSON.stringify(rejected)) + const after = await statusOf(call, alice.sessionToken, task.id) + assert.equal(after.status, 'cancelled', JSON.stringify(after)) + // Terminal: no further moves. + const resume = await call('task-start', { id: task.id }, alice.sessionToken) + assert.equal(resume.ok, false) + assert.equal(resume.error.code, 'bot/stale') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 24b2d4e6e..82ba377a4 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -50,6 +50,10 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), the Bot changed owners, or its Chief was demoted (`setMain` wrapper, sender side). `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. +### Tasks + +A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. A working task's held send (#52 card) blocks it with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` pass authz for any signed-in actor and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Team mode stays disabled. + ### Diagnostics - 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). diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 21395d6bc..ee2f6fd16 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -107,6 +107,10 @@ Private context crosses owners only through an explicit, owner-granted share; ro - **Chat references stay owner-scoped.** A `dsh-session:` reference chip in `send` or `room-post` must name a Session the author may read (`sessionOwnedBy`); the snapshot rides to every recipient, so authorship is the gate. - **Not done here:** reference chips are author-checked on `send` and `room-post` only — whether DSH resolves chips in `answer-question`/`schedule-create` texts and in Bot-authored messages is an open DSH-side question (tracked follow-up); the secrets surface stays `SHARED_BLOCKED` for >1 member; previously received messages cannot be retroactively unseen (stated, not engineered); no per-owner file-sandbox changes — a room `workDir` remains readable by its member Bots under DSH's own policy. +## Task lifecycle foundation (landed, #54) + +`state.tasks` (`core/src/host/tasks.js`): a request becomes a tracked task — `open → claimed → working → blocked ⇄ working → result-ready → accepted`, `cancelled`/`failed` terminal, `accepted` only from `result-ready`. Only the requester's session moves their task (`bot/forbidden` otherwise); a held send (#52) blocks the task until the owner decides. Records carry per-status actor entries, pointers to the originating conversation, and a summary — never private transcripts. The in-room task view and audit trail are #57's; team mode stays disabled. + ## Identity model | Relation | Rule | diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index b7f44bca9..646e362fb 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -3,7 +3,7 @@ FlyBot patch 0002-flybot-api-host on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gen diff --git a/index.js b/index.js --- a/index.js +++ b/index.js -@@ -4,6 +4,10 @@ +@@ -4,6 +4,11 @@ import { join } from 'node:path' import * as diagnostics from './src/host/diagnostics.js' import * as store from './src/host/store.js' @@ -11,6 +11,7 @@ diff --git a/index.js b/index.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 tasks from './src/host/tasks.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' @@ -28,7 +29,7 @@ diff --git a/index.js b/index.js // 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, reach, 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, tasks, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) @@ -300,8 +301,8 @@ diff --git a/src/host/store.js b/src/host/store.js - /** @type {{version:1, workspaceId?:string, mainBotIds:string[], bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number}} */ - const state = { version: 1, mainBotIds: [], bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 } -+ /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number, invitations:Record, authSessions:Record}} */ -+ const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, revision: 0 } ++ /** @type {{version:2, workspaceId?:string, mainBotIds:string[], members:Record, bots:Record, rooms:Record, agents:Record, capabilities:{items:Record}, exchanges:any[], exchangeTurns:Record, revision:number, invitations:Record, authSessions:Record, tasks:Record}} */ ++ const state = { version: STATE_VERSION, mainBotIds: [], members: {}, bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, invitations: {}, authSessions: {}, tasks: {}, revision: 0 } let loaded = null let writing = Promise.resolve() const { warn } = rt @@ -441,7 +442,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,80 @@ export function install(rt) { +@@ -161,12 +161,84 @@ export function install(rt) { migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -473,6 +474,10 @@ diff --git a/src/host/store.js b/src/host/store.js + state.authSessions = {} + migrated = true + } ++ if (!isRecord(state.tasks)) { ++ state.tasks = {} ++ migrated = true ++ } + if (ensureOwner()) migrated = true + if (legacy) { + const ownerId = Object.keys(state.members)[0] diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 5a8b1a3c6..a524b70a2 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -255,10 +255,19 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,7 +319,42 @@ export function install(rt) { +@@ -319,7 +319,51 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) ++ case 'tasks': ++ case 'task-create': ++ case 'task-accept': ++ case 'task-start': ++ case 'task-return': ++ case 'task-accept-result': ++ case 'task-cancel': ++ await rt.load() ++ return ok(await rt.taskEndpoint(endpoint, body, actor)) + case 'share-brief': + await rt.load() + await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index fb4be93bb..4b754f05f 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -325,6 +325,9 @@ export function install(rt) { // Sharing a member Bot's brief is the Bot owner's decision, checked on the record // in room-membership (#53); the endpoint itself only needs a signed-in actor. if (ep === 'share-brief') return ALLOW + // Task transitions are requester-checked on the record in tasks.js (#54) — the + // endpoint only needs a signed-in actor, like held-send decisions. + if (ep === 'tasks' || ep.startsWith('task-')) return ALLOW // Anything not in the table and not a known auth/read endpoint is denied. if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index b39af62fe..89a0fcd31 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -176,6 +176,8 @@ export function install(rt) { fire('bot.removed', { ...last, botId: id, bot: last }) // A hold naming this Bot as sender or target can no longer be spent (#52). await invalidateFor([id], 'the Bot was deleted') + // Its open tasks fail rather than wait forever (#54). + await rt.taskBotDeleted?.(id) // Deleting a friend's Bot's chat clears it; the friend's Bot is still there, so it gets a // fresh, empty chat at once. Otherwise its row waits ("opening its chat…") until the // plugin next publishes its roster, which may be hours or the next restart. @@ -378,6 +380,8 @@ export function install(rt) { for (const old of done.slice(0, Math.max(0, Object.keys(all).length - APPROVALS_KEPT))) delete all[old.id] await rt.save() approvalsChanged(approval) + // A working task's consequential send is held: the task blocks on this card (#54). + void rt.taskHeld?.(msg, approval).catch(error => warn('flybot: task hold failed: %s', error)) return approval } @@ -462,6 +466,8 @@ export function install(rt) { approval.decidedAt = Date.now() if (note) approval.note = String(note) if (decision === 'approve') await release(approval) + // The blocked task resumes on approve, or ends on reject (#54). + void rt.taskDecided?.(approval, decision).catch(error => warn('flybot: task decision failed: %s', error)) await rt.save() approvalsChanged(approval) return clone(approval) diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js new file mode 100644 index 000000000..cd352bea3 --- /dev/null +++ b/patches/ds-bot/files/src/host/tasks.js @@ -0,0 +1,173 @@ +// Tasks (#54): a tracked request with a stable id and a small state machine, so +// responsibilities and completion are visible beyond chat text. Team mode stays +// disabled: this is the task-record foundation the later team gates build on +// (docs/subsystems/team.md) — in the single-human product every transition is the +// requester's; with member sessions only the requester's own may move their task, +// and the record carries server-derived authority, never body claims (#52 rule). +// +// States: open → claimed → working → blocked ⇄ working → result-ready → accepted; +// cancelled and failed are terminal from any active state, and accepted is reachable +// only from result-ready — so a failed or cancelled task can never display as +// accepted. The record stores pointers and a summary, never private transcripts. +import { randomBytes } from 'node:crypto' + +const ACTIVE = new Set(['open', 'claimed', 'working', 'blocked']) +const TRANSITIONS = { + open: ['claimed', 'cancelled', 'failed'], + claimed: ['working', 'cancelled', 'failed'], + working: ['blocked', 'result-ready', 'cancelled', 'failed'], + blocked: ['working', 'cancelled', 'failed'], + 'result-ready': ['accepted'], + accepted: [], + cancelled: [], + failed: [], +} +const KEPT = 200 + +const stale = message => Object.assign(new Error(message), { code: 'bot/stale' }) +const forbidden = message => Object.assign(new Error(message), { code: 'bot/forbidden' }) + +export function install(rt) { + const { state } = rt + state.tasks ??= {} + + const store = () => { + state.tasks ??= {} + return state.tasks + } + // The requester is the human whose task this is: the signed-in actor, or the one + // owner of a host nobody has signed in on. Derived, never a body claim. + const requesterOf = actor => actor?.memberId ?? Object.keys(state.members ?? {})[0] ?? null + const mayAct = (task, actor) => { + if (!rt.authzEnforced?.()) return true + return actor !== null && actor !== undefined && task.requesterId === actor.memberId + } + const find = id => store()[String(id ?? '')] + // Only the record's own requesters may move their tasks; the responsible Bot is + // theirs too, checked against the team state at create time. + const requireRequester = (task, actor) => { + if (!mayAct(task, actor)) throw forbidden('Only the requester may move their task') + } + + function transition(task, to, actor, extra) { + if (!TRANSITIONS[task.status]?.includes(to)) throw stale(`a task that is ${task.status} cannot become ${to}`) + task.status = to + task.updatedAt = Date.now() + task.actors.push({ status: to, at: task.updatedAt, ...(actor?.memberId ? { memberId: actor.memberId } : {}), ...(extra ?? {}) }) + } + + const view = task => ({ ...task, actors: [...task.actors ?? []] }) + const prune = () => { + const all = Object.values(store()) + const terminal = all.filter(task => !ACTIVE.has(task.status)).sort((a, b) => a.updatedAt - b.updatedAt) + for (const task of terminal.slice(0, Math.max(0, all.length - KEPT))) delete store()[task.id] + } + const save = () => rt.save() + + // ---------------------------------------------------------------- host hooks + // A consequential send from the responsible Bot was held (#52 card): the task + // blocks on that approval until the owner decides. + rt.taskHeld = (msg, approval) => { + for (const task of Object.values(store())) { + if (task.status !== 'working' || task.ownerBotId !== msg.from) continue + task.approvalId = approval.id + transition(task, 'blocked', null, { approvalId: approval.id }) + } + return save() + } + // The owner's decision on that approval resumes the task — or, on reject, ends it. + rt.taskDecided = (approval, decision) => { + for (const task of Object.values(store())) { + if (task.status !== 'blocked' || task.approvalId !== approval.id) continue + delete task.approvalId + transition(task, decision === 'approve' ? 'working' : 'cancelled', { memberId: approval.ownerId }, { by: 'owner decision' }) + } + return save() + } + // The responsible Bot is gone: its open tasks fail (they are never silently dropped). + rt.taskBotDeleted = botId => { + for (const task of Object.values(store())) { + if (!ACTIVE.has(task.status) || (task.ownerBotId !== botId && task.workBotId !== botId)) continue + transition(task, 'failed', null, { why: 'the responsible Bot was deleted' }) + } + return save() + } + + // ---------------------------------------------------------------- channel API + rt.taskEndpoint = async function taskEndpoint(endpoint, body, actor) { + switch (endpoint) { + case 'tasks': + return Object.values(store()) + .filter(task => mayAct(task, actor)) + .sort((a, b) => a.createdAt - b.createdAt) + .map(view) + case 'task-create': { + const botId = String(body.botId ?? '') + const bot = state.bots?.[botId] + if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot') + const requesterId = requesterOf(actor) + if (rt.authzEnforced?.() && bot.ownerId !== requesterId) throw forbidden('You do not own that Bot') + const task = { + id: `task-${randomBytes(8).toString('hex')}`, + status: 'open', + createdAt: Date.now(), + updatedAt: Date.now(), + requesterId, + ownerBotId: botId, + title: String(body.title ?? '').slice(0, 300), + ...(typeof body.criteria === 'string' && body.criteria.trim() !== '' ? { criteria: body.criteria.slice(0, 2000) } : {}), + origin: { ...(body.roomId ? { roomId: String(body.roomId) } : {}), ...(body.sessionId ? { sessionId: String(body.sessionId) } : {}) }, + actors: [{ status: 'open', at: Date.now(), ...(actor?.memberId ? { memberId: actor.memberId } : {}) }], + } + store()[task.id] = task + prune() + await save() + return view(task) + } + case 'task-accept': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'claimed', actor) + await save() + return view(task) + } + case 'task-start': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'working', actor) + await save() + return view(task) + } + case 'task-return': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'result-ready', actor) + task.result = { kind: 'summary', summary: String(body.summary ?? '').slice(0, 2000) } + await save() + return view(task) + } + case 'task-accept-result': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'accepted', actor) + await save() + return view(task) + } + case 'task-cancel': { + const task = find(body.id) + if (task === undefined) throw stale(`no task ${String(body.id ?? '')}`) + requireRequester(task, actor) + transition(task, 'cancelled', actor) + await save() + return view(task) + } + default: + throw Object.assign(new Error(`Unknown task endpoint ${endpoint}`), { code: 'bot/not-found' }) + } + } + return rt +} diff --git a/patches/ds-bot/files/test/tasks.test.js b/patches/ds-bot/files/test/tasks.test.js new file mode 100644 index 000000000..421941a4a --- /dev/null +++ b/patches/ds-bot/files/test/tasks.test.js @@ -0,0 +1,259 @@ +// Task lifecycle (#54): a request becomes a tracked task with stable ids and a +// state machine — open → claimed → working → (blocked ⇄ working) → result-ready → +// accepted, with cancelled/failed terminal. Only the requester accepts or cancels; +// accepted is reachable only from result-ready, so a failed or cancelled task can +// never display as accepted. Authority is server-derived (#52 rule); team mode +// stays disabled — Alice and Bob are two signed-in members on the #48 foundation. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'd' }, + }, + bots: { + a: { id: 'a', name: 'ChiefA', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + d: { id: 'd', name: 'ChiefB', role: 'Boss', sessionId: 'd', createdAt: 2, ownerId: 'owner-2' }, + s: { id: 's', name: 'Writer', role: 'Write', sessionId: 's', createdAt: 3, ownerId: 'owner-1' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 's'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 's', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 }, + ], + }, + }, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function start(t, config = {}) { + const home = config.home ?? mkdtempSync(join(tmpdir(), 'ds-bot-tasks-')) + if (config.state !== undefined || !existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(config.state ?? team())) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const calls = [] + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls, status: 'idle', + followup(m) { calls.push(m) }, steer(m) { calls.push(m) }, + inject() {}, cancel() {}, async whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} }, + }) + } + return agents.get(sessionId) + } + 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: `s${++created}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let route + let created = 0 + 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, token) => { + const headers = token ? { 'x-flybot-member-session': token } : undefined + return (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) }))).json() + } + const stop = async ({ keepHome = false } = {}) => { + await ctx.dispose() + if (!keepHome) rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + } + const signIn = async (displayName, inviterToken) => { + const invite = await call('auth-invite', {}, inviterToken) + assert.equal(invite.ok, true, JSON.stringify(invite)) + const redeemed = await call('auth-redeem', { token: invite.value.token, displayName }) + assert.equal(redeemed.ok, true, JSON.stringify(redeemed)) + return redeemed.value + } + const createTask = async (token, spec = {}) => call('task-create', { botId: 'a', title: 'Prepare the launch note', criteria: 'note drafts exist', ...spec }, token) + return { ctx, fb: ctx.flybot, call, stop, signIn, createTask, agentOf, home, statePath: join(home, 'state.json') } +} + +const statusOf = async (call, token, id) => { + const list = await call('tasks', {}, token) + assert.equal(list.ok, true, JSON.stringify(list)) + return list.value.find(task => task.id === id) +} + +test('a task walks the happy path: created, claimed, worked, returned, accepted by the requester', async (t) => { + const { call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken) + assert.equal(created.ok, true, JSON.stringify(created)) + const task = created.value + assert.match(task.id, /^task-[0-9a-f]{16}$/) + assert.equal(task.status, 'open') + assert.equal(task.requesterId, alice.memberId) + + const claimed = await call('task-accept', { id: task.id }, alice.sessionToken) + assert.equal(claimed.ok, true, JSON.stringify(claimed)) + assert.equal(claimed.value.status, 'claimed') + + // Work starts when the room the task points at runs a round. + const worked = await call('task-start', { id: task.id }, alice.sessionToken) + assert.equal(worked.ok, true, JSON.stringify(worked)) + assert.equal(worked.value.status, 'working') + + const returned = await call('task-return', { id: task.id, summary: 'Draft is in the room' }, alice.sessionToken) + assert.equal(returned.ok, true, JSON.stringify(returned)) + assert.equal(returned.value.status, 'result-ready') + assert.equal(returned.value.result.summary, 'Draft is in the room') + + const accepted = await call('task-accept-result', { id: task.id }, alice.sessionToken) + assert.equal(accepted.ok, true, JSON.stringify(accepted)) + assert.equal(accepted.value.status, 'accepted') + // Terminal: accepting again, or any further move, is stale. + const again = await call('task-accept-result', { id: task.id }, alice.sessionToken) + assert.equal(again.ok, false) + assert.equal(again.error.code, 'bot/stale') +}) + +test('only the requester accepts the result or cancels; another member is forbidden', async (t) => { + const { call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const created = await createTask(alice.sessionToken) + const task = created.value + for (const [endpoint, status] of [['task-accept-result', 'result-ready'], ['task-cancel', 'open']]) { + if (status !== 'open') await call('task-accept', { id: task.id }, alice.sessionToken) + void status + const foreign = await call(endpoint, { id: task.id }, bob.sessionToken) + assert.equal(foreign.ok, false, JSON.stringify(foreign)) + assert.equal(foreign.error.code, 'bot/forbidden', `${endpoint}: ${JSON.stringify(foreign)}`) + } + // Bob cannot claim Alice's task either: the requester's Chief takes it. + const foreignClaim = await call('task-accept', { id: task.id }, bob.sessionToken) + assert.equal(foreignClaim.ok, false) + assert.equal(foreignClaim.error.code, 'bot/forbidden') + const own = await call('task-cancel', { id: task.id }, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) + assert.equal(own.value.status, 'cancelled') +}) + +test('invalid transitions are refused: accept-result before a result, cancel after terminal', async (t) => { + const { call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken) + const task = created.value + // No result yet: accepting is stale, not forbidden. + const early = await call('task-accept-result', { id: task.id }, alice.sessionToken) + assert.equal(early.ok, false) + assert.equal(early.error.code, 'bot/stale', JSON.stringify(early)) + await call('task-cancel', { id: task.id }, alice.sessionToken) + const afterTerminal = await call('task-accept', { id: task.id }, alice.sessionToken) + assert.equal(afterTerminal.ok, false) + assert.equal(afterTerminal.error.code, 'bot/stale', JSON.stringify(afterTerminal)) + const recancel = await call('task-cancel', { id: task.id }, alice.sessionToken) + assert.equal(recancel.ok, false) + assert.equal(recancel.error.code, 'bot/stale') +}) + +test('a held send blocks the task, the owner decision resumes or cancels it', async (t) => { + const { fb, call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken, { botId: 'a' }) + const task = created.value + await call('task-accept', { id: task.id }, alice.sessionToken) + await call('task-start', { id: task.id }, alice.sessionToken) + // A consequential send from the responsible Bot is held: the task blocks. + fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) + const held = await fb.bot.send({ from: 'a', to: 's', text: 'send this out', meta: { hold: true } }) + assert.equal(held.action, 'hold', JSON.stringify(held)) + const blocked = await statusOf(call, alice.sessionToken, task.id) + assert.equal(blocked.status, 'blocked', JSON.stringify(blocked)) + assert.equal(blocked.approvalId, held.approvalId) + // The owner approves in the existing card: the task resumes. + const decided = await call('flybot/decide', { id: held.approvalId, decision: 'approve' }, alice.sessionToken) + assert.equal(decided.ok, true, JSON.stringify(decided)) + const resumed = await statusOf(call, alice.sessionToken, task.id) + assert.equal(resumed.status, 'working', JSON.stringify(resumed)) +}) + +test('deleting the responsible Bot fails its open tasks', async (t) => { + const { call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken, { botId: 's' }) + const task = created.value + await call('task-accept', { id: task.id }, alice.sessionToken) + const removed = await call('delete-bot', { id: 's' }, alice.sessionToken) + assert.equal(removed.ok, true, JSON.stringify(removed)) + const after = await statusOf(call, alice.sessionToken, task.id) + assert.equal(after.status, 'failed', JSON.stringify(after)) +}) + +test('tasks survive a restart with their state and authority', async (t) => { + const { call, signIn, createTask, stop, home } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken) + const task = created.value + await call('task-accept', { id: task.id }, alice.sessionToken) + await stop({ keepHome: true }) + const again = await start(t, { home }) + t.after(() => again.stop()) + const list = await again.call('tasks', {}, alice.sessionToken) + assert.equal(list.ok, true, JSON.stringify(list)) + const restored = list.value.find(entry => entry.id === task.id) + assert.equal(restored.status, 'claimed', JSON.stringify(restored)) + assert.equal(restored.requesterId, task.requesterId) + const restartedWork = await again.call('task-start', { id: task.id }, alice.sessionToken) + assert.equal(restartedWork.ok, true, JSON.stringify(restartedWork)) + const returned = await again.call('task-return', { id: task.id, summary: 'back' }, alice.sessionToken) + assert.equal(returned.ok, true, JSON.stringify(returned)) + assert.equal(returned.value.status, 'result-ready') +}) + +test('a corrupt tasks map resets instead of poisoning the team', async (t) => { + const { signIn, createTask, stop, home } = await start(t) + const alice = await signIn('Alice') + await createTask(alice.sessionToken) + await stop({ keepHome: true }) + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + saved.tasks = 'garbage' + writeFileSync(join(home, 'state.json'), JSON.stringify(saved)) + const again = await start(t, { home }) + t.after(() => again.stop()) + const list = await again.call('tasks', {}, alice.sessionToken) + assert.equal(list.ok, true, JSON.stringify(list)) + assert.deepEqual(list.value, []) + const fresh = await again.createTask(alice.sessionToken) + assert.equal(fresh.ok, true, JSON.stringify(fresh)) +}) + +test('rejecting the blocked approval cancels the task', async (t) => { + const { fb, call, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const created = await createTask(alice.sessionToken, { botId: 'a' }) + const task = created.value + await call('task-accept', { id: task.id }, alice.sessionToken) + await call('task-start', { id: task.id }, alice.sessionToken) + fb.bot.send.use({ name: 'gate', handle: async msg => (msg.meta?.hold ? { action: 'hold', reason: 'held by test' } : undefined) }) + const held = await fb.bot.send({ from: 'a', to: 's', text: 'send this out', meta: { hold: true } }) + assert.equal(held.action, 'hold') + const rejected = await call('flybot/decide', { id: held.approvalId, decision: 'reject' }, alice.sessionToken) + assert.equal(rejected.ok, true, JSON.stringify(rejected)) + const after = await statusOf(call, alice.sessionToken, task.id) + assert.equal(after.status, 'cancelled', JSON.stringify(after)) + // Terminal: no further moves. + const resume = await call('task-start', { id: task.id }, alice.sessionToken) + assert.equal(resume.ok, false) + assert.equal(resume.error.code, 'bot/stale') +})