From b46de8728387a48fe7e0e561f9160bf9b3ac920a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 09:42:04 +0000 Subject: [PATCH 1/2] Track tasks with a lifecycle and grant-aware create (#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 records each task with a stable id, the server-derived requester, the responsible Bot, per-status actor entries, and pointers to the originating conversation. The state machine is open → claimed → working → blocked ⇄ working → result-ready → accepted. Create does not require owning the Bot: a matching grant or a shared room is enough for a granted child to take a handoff; outgoing holds stay the owner's. list_bots no longer dumps other owners' Bots or a granted child's owner roster. Memory review labels a grantee's human lines as peer, not bot. Credit: hsliuustc0106 / ThinkFlowLab#110. Changelog fragments only. Task views in the chat are #57; grant-tied brief share is a follow-up. Fixes #54 Supersedes ThinkFlowLab/FlyBot#110 Co-authored-by: Danyang Chen --- .../53-list-bots-grant-scope.changed.md | 1 + changelog.d/54-task-lifecycle.added.md | 1 + core/index.js | 3 +- core/src/host/authz.js | 4 + core/src/host/channel.js | 9 + core/src/host/hooks.js | 6 + core/src/host/memory.js | 7 +- core/src/host/store.js | 8 +- core/src/host/tasks.js | 194 +++++++++++++ core/src/host/tools.js | 57 +++- core/test/context-sharing.test.js | 26 +- core/test/tasks.test.js | 274 ++++++++++++++++++ docs/subsystems/core-host.md | 6 +- docs/subsystems/team.md | 14 +- patches/ds-bot/0001-core-fixes.patch | 9 +- patches/ds-bot/0002-flybot-api-host.patch | 79 ++++- patches/ds-bot/0005-social-seams.patch | 11 +- patches/ds-bot/files/src/host/authz.js | 4 + patches/ds-bot/files/src/host/hooks.js | 6 + patches/ds-bot/files/src/host/tasks.js | 194 +++++++++++++ .../ds-bot/files/test/context-sharing.test.js | 26 +- patches/ds-bot/files/test/tasks.test.js | 274 ++++++++++++++++++ 22 files changed, 1180 insertions(+), 33 deletions(-) create mode 100644 changelog.d/53-list-bots-grant-scope.changed.md create mode 100644 changelog.d/54-task-lifecycle.added.md 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.d/53-list-bots-grant-scope.changed.md b/changelog.d/53-list-bots-grant-scope.changed.md new file mode 100644 index 000000000..d835a6d61 --- /dev/null +++ b/changelog.d/53-list-bots-grant-scope.changed.md @@ -0,0 +1 @@ +**list_bots stays inside the grant.** It lists your owner's Bots, Bots granted to that owner, and Bots in the current room — not every other owner's Bot by name. A granted child only sees its own subtree, not the owner's whole roster with folders and instructions. ([#53](https://github.com/ThinkFlowLab/FlyBot/issues/53) follow-up) diff --git a/changelog.d/54-task-lifecycle.added.md b/changelog.d/54-task-lifecycle.added.md new file mode 100644 index 000000000..606fdb39c --- /dev/null +++ b/changelog.d/54-task-lifecycle.added.md @@ -0,0 +1 @@ +**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). You do not have to own the Bot: a grant to you, or sharing a room with it, is enough to hand work to a granted child. 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](https://github.com/ThinkFlowLab/FlyBot/issues/57)). ([#54](https://github.com/ThinkFlowLab/FlyBot/issues/54)) 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..b16f959ea 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -325,6 +325,10 @@ 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. Create is + // grant-aware there (owner, matching grant, or the named room). + 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 726394ed3..f63af717a 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -405,6 +405,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 67f4091de..0910aee15 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). + try { await 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). + try { await 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/memory.js b/core/src/host/memory.js index 9de245945..0d564eeb5 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -1158,7 +1158,12 @@ export function install(rt) { 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 = isUserLine(bot, entry) ? 'user' : entry.botId === bot.id ? 'you' : 'bot' + // A grantee's human lines are a peer's, never another Bot's (#53 follow-up). + const who = isUserLine(bot, entry) + ? 'user' + : (entry.botId === bot.id || entry.authorId === bot.id) + ? 'you' + : (entry.authorKind === 'human' ? 'peer' : 'bot') return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq } } return { diff --git a/core/src/host/store.js b/core/src/host/store.js index 9b16bd50c..be5c11e32 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..cfadfca09 --- /dev/null +++ b/core/src/host/tasks.js @@ -0,0 +1,194 @@ +// 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). +// +// Create is grant-aware: the requester need not own the Bot. A granted child (or a +// Bot in the named room the requester currently sits in) may take a task within +// its authority. Outgoing holds stay the owner's (#52 card). +// +// 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' +import { currentParticipants } from './room-membership.js' + +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 + // checked at create time (owner, grant, or shared-room membership). + const requireRequester = (task, actor) => { + if (!mayAct(task, actor)) throw forbidden('Only the requester may move their task') + } + + // Owner, a matching grant, or a current human in the named room with that Bot. + const mayCreateOn = (bot, actor, roomId) => { + if (!rt.authzEnforced?.()) return true + const requesterId = actor?.memberId + if (!requesterId) return false + if (bot.ownerId === requesterId) return true + if (rt.hasGrant?.(bot.id, requesterId, roomId) || rt.hasGrant?.(bot.id, requesterId)) return true + if (!roomId) return false + const room = state.rooms?.[String(roomId)] + if (room === undefined) return false + const live = currentParticipants(room) + return live.some(p => p.kind === 'human' && p.id === requesterId) + && live.some(p => p.kind === 'bot' && !p.remote && p.id === bot.id) + } + + 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 roomId = body.roomId ? String(body.roomId) : undefined + if (!mayCreateOn(bot, actor, roomId)) throw forbidden('You cannot assign a task to that Bot') + const requesterId = requesterOf(actor) + 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: { ...(roomId ? { 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/src/host/tools.js b/core/src/host/tools.js index 4f851f5f7..46874cc1b 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -5,6 +5,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js' import { unquote } from './text.js' import { isListedRemoteChat } from '../shared/remote-label.js' +import { currentParticipants } from './room-membership.js' export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } @@ -52,17 +53,63 @@ export function install(rt) { ctx.tools.register(defineTool({ name: 'list_bots', - description: 'List every Bot on the user\'s team with its role and what it does, and the group chats you can see.', + description: 'List the Bots you may see — your owner\'s, those granted to your owner, and those in this room — with role and what they do, and the group chats you can see.', parameters: {}, output, 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 fromRoomId = rt.groupOwners.get(exec.agent?.id)?.roomId + // Walk createdBy toward the root: a granted child only manages its own downstream. + const inSubtree = (rootId, botId) => { + if (botId === rootId) return true + const seen = new Set() + let id = botId + while (id && !seen.has(id)) { + seen.add(id) + const parent = state.bots?.[id]?.createdBy + if (parent === rootId) return true + id = parent + } + return false + } + const grantedToNonOwner = (botId, ownerId) => { + if (!ownerId) return false + return (rt.grantsOf?.(botId) ?? []).some(grant => { + const grantee = String(grant?.grantee ?? '') + return grantee !== '' && grantee !== String(ownerId) + }) + } + const subtreeOnly = rt.authzEnforced?.() && grantedToNonOwner(self, selfOwner) + const inRoom = new Set() + if (fromRoomId) { + const room = state.rooms?.[fromRoomId] + if (room) { + for (const p of currentParticipants(room)) { + if (p.kind === 'bot' && !p.remote && p.id) inRoom.add(p.id) + } + } + } + // Own Bots (or this child's subtree when it is granted), Bots granted to the + // same owner, and Bots in the current room. Nobody browses others' Bots (#53). + const listed = bot => { + if (bot.remote !== undefined) return false + if (!rt.authzEnforced?.()) return true + if (inRoom.has(bot.id)) return true + if (selfOwner && (rt.hasGrant?.(bot.id, selfOwner, fromRoomId) || rt.hasGrant?.(bot.id, selfOwner))) return true + if (selfOwner && bot.ownerId === selfOwner) return subtreeOnly ? inSubtree(self, bot.id) : true + return false + } + // Another owner's setup stays private. A granted child also withholds siblings' + // folder and instructions — those are not this child's to dump. + const foreign = bot => { + if (!rt.authzEnforced?.()) return false + if (selfOwner !== undefined && bot.ownerId !== undefined && bot.ownerId !== selfOwner) return true + if (subtreeOnly && !inSubtree(self, bot.id)) return true + return false + } + const lines = Object.values(state.bots).filter(listed).map(bot => { const model = bot.appliedModel ?? bot.model const tags = [bot.role, model ? `model ${model}` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' diff --git a/core/test/context-sharing.test.js b/core/test/context-sharing.test.js index 1c3124926..fbc08ca44 100644 --- a/core/test/context-sharing.test.js +++ b/core/test/context-sharing.test.js @@ -22,6 +22,9 @@ const team = () => ({ 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' }, + s: { id: 's', name: 'Writer', role: 'Write', sessionId: 's', createdAt: 3, ownerId: 'owner-1', createdBy: 'a', workDir: '/tmp/writer-private', instructions: 'Writer secret instructions' }, + r: { id: 'r', name: 'Researcher', role: 'Research', sessionId: 'r', createdAt: 4, ownerId: 'owner-1', createdBy: 'a', workDir: '/tmp/research-private', instructions: 'Research secret instructions' }, + e: { id: 'e', name: 'PrivateB', role: 'Hide', sessionId: 'e', createdAt: 5, ownerId: 'owner-2', workDir: '/tmp/bob-hidden', instructions: 'Bob hidden instructions' }, }, rooms: { war: { @@ -41,7 +44,7 @@ const team = () => ({ ], }, }, - grants: { a: [{ id: 'g1', grantee: 'owner-2' }], d: [{ id: 'g2', grantee: 'owner-1' }] }, + grants: { a: [{ id: 'g1', grantee: 'owner-2' }], d: [{ id: 'g2', grantee: 'owner-1' }], s: [{ id: 'g3', grantee: 'owner-2' }] }, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, }) @@ -189,13 +192,32 @@ test('list_bots hides another owner\'s folder and instructions', async (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('ChiefB'), 'a Bot granted to this owner is still listed') + assert.ok(!seen.includes('PrivateB'), 'an ungranted Bot of another owner is hidden') assert.ok(!seen.includes('/tmp/bob-private'), 'no folder disclosure') + assert.ok(!seen.includes('/tmp/bob-hidden'), 'no hidden-bot folder disclosure') assert.ok(!seen.includes('Bob secret instructions'), 'no instructions disclosure') + assert.ok(!seen.includes('Bob hidden instructions'), 'no hidden-bot 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 granted child does not dump its owner\'s roster or siblings\' setup', async (t) => { + const { tool, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const seen = await tool('list_bots', {}, 's') + assert.ok(seen.includes('Writer'), 'the granted child lists itself') + assert.ok(!seen.includes('Researcher'), 'a sibling outside the child\'s subtree stays off the list') + assert.ok(!seen.includes('ChiefA'), 'the owner\'s Chief is not this child\'s to list') + assert.ok(!seen.includes('/tmp/research-private'), 'sibling folder stays private') + assert.ok(!seen.includes('Research secret instructions'), 'sibling instructions stay private') + assert.ok(!seen.includes('/tmp/alice-private'), 'the Chief\'s folder stays private') + assert.ok(seen.includes('/tmp/writer-private'), 'the child\'s own folder still shows') + assert.ok(seen.includes('Writer secret instructions'), 'the child\'s own instructions still show') + void alice +}) + 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') diff --git a/core/test/tasks.test.js b/core/test/tasks.test.js new file mode 100644 index 000000000..650bc9e5d --- /dev/null +++ b/core/test/tasks.test.js @@ -0,0 +1,274 @@ +// 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', createdBy: 'a' }, + }, + 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 }, + ], + }, + }, + grants: {}, + 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') +}) + +test('a grantee may create a task on a granted child, not on an ungranted sibling', async (t) => { + const { fb, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + await fb.grants.set('s', [{ grantee: bob.memberId }]) + const onGranted = await createTask(bob.sessionToken, { botId: 's', title: 'Draft Bob\'s note' }) + assert.equal(onGranted.ok, true, JSON.stringify(onGranted)) + assert.equal(onGranted.value.requesterId, bob.memberId) + assert.equal(onGranted.value.ownerBotId, 's') + const onUngranted = await createTask(bob.sessionToken, { botId: 'a', title: 'Use the Chief' }) + assert.equal(onUngranted.ok, false, JSON.stringify(onUngranted)) + assert.equal(onUngranted.error.code, 'bot/forbidden') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index ed4ee6c31..0714b2917 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -50,7 +50,11 @@ 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. -- Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` narrows another owner's Bot to name, role and model, and a `dsh-session:` chip in `send` / `room-post` must resolve for the author (`sessionOwnedBy`). Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled. +- Context sharing (#53) is specified in [team](team.md), **Context sharing foundation**: the own-chat brief is share-gated (`share-brief` / `briefShared`), a memory review reads only its owner as the user (`isUserLine`) and labels another human as `peer` (not `bot`), and keeps cross-owner entries out of the team scope (`teamAllowedFrom`), `list_bots` lists only the caller's owner's Bots (a granted child: that child's `createdBy` subtree), Bots granted to the same owner, and Bots in the current room — another owner's folder and instructions stay private — and a `dsh-session:` chip in `send` / `room-post` must resolve for the author (`sessionOwnedBy`). Host modules: `src/host/groups.js`, `src/host/memory.js`, `src/host/tools.js`, `src/host/channel.js`, `src/host/room-membership.js`, `src/host/authz.js`. Team mode stays disabled. + +### 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 after create 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. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send 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. ### Inbound shared rooms diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 16c5bf6fa..54dee3a4f 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -102,10 +102,14 @@ The held-send approval (`state.flybot.approvals` in `core/src/host/hooks.js`) no 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. +- **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` (not `bot`) 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` lists the caller's owner's Bots, Bots granted to that owner, and Bots in the current room — never an ungranted Bot of another owner. A granted child triggered by a grantee is scoped to its own `createdBy` subtree and does not dump the owner's roster with `workDir` / instructions. Another owner's folder and instructions stay private when that Bot is listed at all. - **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. +- **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. **Follow-up (brief share):** `briefAllowed` still withholds the owner's own-chat brief in any multi-owner room, including face-to-face rooms where someone brings their own Bot; the intended model is a grant-tied share (a flag on `state.grants` or the room-scoped grant) rather than a participant `briefShared` flag lost on leave. `setBriefShare` already requires the Bot to be a current room participant. That redesign is out of this #54 PR. + +## 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). Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough — a granted child can take a handoff within its authority. Outgoing holds stay the owner's: a held send (#52) blocks the task until that 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; per-task hold linkage, prune window, authz prefix, clarification and `workBotId` are #111's. Team mode stays disabled. ## Identity model @@ -225,6 +229,8 @@ 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). +**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 to own / granted / current-room Bots and granted-child subtree scoping, reference-chip authorization) and in `core/test/memory.test.js` (owner-as-user classification, the team-write gate out of a cross-owner room). + +**Task lifecycle (#54)** has tests in `core/test/tasks.test.js`: the happy path (open → claimed → working → result-ready → accepted, then stale), wrong-member accept/claim/cancel forbidden, accept-before-result and post-terminal moves stale, held-send block → owner approve resumes, owner reject cancels, responsible-Bot deletion fails the task, restart persistence, corrupt `state.tasks` resets, and a grantee may create a task on a granted child but not on an ungranted sibling. 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 d6ecebb64..1b0b76b29 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -443,7 +443,7 @@ 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) { +@@ -1123,17 +1123,28 @@ 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. @@ -465,7 +465,12 @@ diff --git a/src/host/memory.js b/src/host/memory.js 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' ++ // A grantee's human lines are a peer's, never another Bot's (#53 follow-up). ++ const who = isUserLine(bot, entry) ++ ? 'user' ++ : (entry.botId === bot.id || entry.authorId === bot.id) ++ ? 'you' ++ : (entry.authorKind === 'human' ? 'peer' : 'bot') return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq } } return { diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index e9df62492..2cff00518 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) @@ -326,8 +327,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 @@ -479,7 +480,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. @@ -511,6 +512,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] @@ -584,15 +589,16 @@ diff --git a/src/host/store.js b/src/host/store.js diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js -@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' +@@ -4,6 +4,8 @@ import { randomUUID } from 'node:crypto' import { defineTool } from '@deepseek-ai/dsh-tools' import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js' import { unquote } from './text.js' +import { isListedRemoteChat } from '../shared/remote-label.js' ++import { currentParticipants } from './room-membership.js' export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] } export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' } -@@ -27,16 +27,27 @@ export function install(rt) { +@@ -27,39 +27,102 @@ export function install(rt) { const roomLine = (room, self) => { const members = room.members.map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`) @@ -623,16 +629,65 @@ diff --git a/src/host/tools.js b/src/host/tools.js ctx.tools.register(defineTool({ name: 'list_bots', -@@ -46,20 +46,26 @@ export function install(rt) { +- description: 'List every Bot on the user\'s team with its role and what it does, and the group chats you can see.', ++ description: 'List the Bots you may see — your owner\'s, those granted to your owner, and those in this room — with role and what they do, and the group chats you can see.', + parameters: {}, + output, 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 fromRoomId = rt.groupOwners.get(exec.agent?.id)?.roomId ++ // Walk createdBy toward the root: a granted child only manages its own downstream. ++ const inSubtree = (rootId, botId) => { ++ if (botId === rootId) return true ++ const seen = new Set() ++ let id = botId ++ while (id && !seen.has(id)) { ++ seen.add(id) ++ const parent = state.bots?.[id]?.createdBy ++ if (parent === rootId) return true ++ id = parent ++ } ++ return false ++ } ++ const grantedToNonOwner = (botId, ownerId) => { ++ if (!ownerId) return false ++ return (rt.grantsOf?.(botId) ?? []).some(grant => { ++ const grantee = String(grant?.grantee ?? '') ++ return grantee !== '' && grantee !== String(ownerId) ++ }) ++ } ++ const subtreeOnly = rt.authzEnforced?.() && grantedToNonOwner(self, selfOwner) ++ const inRoom = new Set() ++ if (fromRoomId) { ++ const room = state.rooms?.[fromRoomId] ++ if (room) { ++ for (const p of currentParticipants(room)) { ++ if (p.kind === 'bot' && !p.remote && p.id) inRoom.add(p.id) ++ } ++ } ++ } ++ // Own Bots (or this child's subtree when it is granted), Bots granted to the ++ // same owner, and Bots in the current room. Nobody browses others' Bots (#53). ++ const listed = bot => { ++ if (bot.remote !== undefined) return false ++ if (!rt.authzEnforced?.()) return true ++ if (inRoom.has(bot.id)) return true ++ if (selfOwner && (rt.hasGrant?.(bot.id, selfOwner, fromRoomId) || rt.hasGrant?.(bot.id, selfOwner))) return true ++ if (selfOwner && bot.ownerId === selfOwner) return subtreeOnly ? inSubtree(self, bot.id) : true ++ return false ++ } ++ // Another owner's setup stays private. A granted child also withholds siblings' ++ // folder and instructions — those are not this child's to dump. ++ const foreign = bot => { ++ if (!rt.authzEnforced?.()) return false ++ if (selfOwner !== undefined && bot.ownerId !== undefined && bot.ownerId !== selfOwner) return true ++ if (subtreeOnly && !inSubtree(self, bot.id)) return true ++ return false ++ } ++ const lines = Object.values(state.bots).filter(listed).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)}` : '' diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 0a706ea8d..4ed350d0b 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -329,10 +329,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..b16f959ea 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -325,6 +325,10 @@ 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. Create is + // grant-aware there (owner, matching grant, or the named room). + 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 67f4091de..0910aee15 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). + try { await 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). + try { await 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..cfadfca09 --- /dev/null +++ b/patches/ds-bot/files/src/host/tasks.js @@ -0,0 +1,194 @@ +// 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). +// +// Create is grant-aware: the requester need not own the Bot. A granted child (or a +// Bot in the named room the requester currently sits in) may take a task within +// its authority. Outgoing holds stay the owner's (#52 card). +// +// 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' +import { currentParticipants } from './room-membership.js' + +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 + // checked at create time (owner, grant, or shared-room membership). + const requireRequester = (task, actor) => { + if (!mayAct(task, actor)) throw forbidden('Only the requester may move their task') + } + + // Owner, a matching grant, or a current human in the named room with that Bot. + const mayCreateOn = (bot, actor, roomId) => { + if (!rt.authzEnforced?.()) return true + const requesterId = actor?.memberId + if (!requesterId) return false + if (bot.ownerId === requesterId) return true + if (rt.hasGrant?.(bot.id, requesterId, roomId) || rt.hasGrant?.(bot.id, requesterId)) return true + if (!roomId) return false + const room = state.rooms?.[String(roomId)] + if (room === undefined) return false + const live = currentParticipants(room) + return live.some(p => p.kind === 'human' && p.id === requesterId) + && live.some(p => p.kind === 'bot' && !p.remote && p.id === bot.id) + } + + 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 roomId = body.roomId ? String(body.roomId) : undefined + if (!mayCreateOn(bot, actor, roomId)) throw forbidden('You cannot assign a task to that Bot') + const requesterId = requesterOf(actor) + 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: { ...(roomId ? { 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/context-sharing.test.js b/patches/ds-bot/files/test/context-sharing.test.js index 1c3124926..fbc08ca44 100644 --- a/patches/ds-bot/files/test/context-sharing.test.js +++ b/patches/ds-bot/files/test/context-sharing.test.js @@ -22,6 +22,9 @@ const team = () => ({ 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' }, + s: { id: 's', name: 'Writer', role: 'Write', sessionId: 's', createdAt: 3, ownerId: 'owner-1', createdBy: 'a', workDir: '/tmp/writer-private', instructions: 'Writer secret instructions' }, + r: { id: 'r', name: 'Researcher', role: 'Research', sessionId: 'r', createdAt: 4, ownerId: 'owner-1', createdBy: 'a', workDir: '/tmp/research-private', instructions: 'Research secret instructions' }, + e: { id: 'e', name: 'PrivateB', role: 'Hide', sessionId: 'e', createdAt: 5, ownerId: 'owner-2', workDir: '/tmp/bob-hidden', instructions: 'Bob hidden instructions' }, }, rooms: { war: { @@ -41,7 +44,7 @@ const team = () => ({ ], }, }, - grants: { a: [{ id: 'g1', grantee: 'owner-2' }], d: [{ id: 'g2', grantee: 'owner-1' }] }, + grants: { a: [{ id: 'g1', grantee: 'owner-2' }], d: [{ id: 'g2', grantee: 'owner-1' }], s: [{ id: 'g3', grantee: 'owner-2' }] }, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, }) @@ -189,13 +192,32 @@ test('list_bots hides another owner\'s folder and instructions', async (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('ChiefB'), 'a Bot granted to this owner is still listed') + assert.ok(!seen.includes('PrivateB'), 'an ungranted Bot of another owner is hidden') assert.ok(!seen.includes('/tmp/bob-private'), 'no folder disclosure') + assert.ok(!seen.includes('/tmp/bob-hidden'), 'no hidden-bot folder disclosure') assert.ok(!seen.includes('Bob secret instructions'), 'no instructions disclosure') + assert.ok(!seen.includes('Bob hidden instructions'), 'no hidden-bot 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 granted child does not dump its owner\'s roster or siblings\' setup', async (t) => { + const { tool, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const seen = await tool('list_bots', {}, 's') + assert.ok(seen.includes('Writer'), 'the granted child lists itself') + assert.ok(!seen.includes('Researcher'), 'a sibling outside the child\'s subtree stays off the list') + assert.ok(!seen.includes('ChiefA'), 'the owner\'s Chief is not this child\'s to list') + assert.ok(!seen.includes('/tmp/research-private'), 'sibling folder stays private') + assert.ok(!seen.includes('Research secret instructions'), 'sibling instructions stay private') + assert.ok(!seen.includes('/tmp/alice-private'), 'the Chief\'s folder stays private') + assert.ok(seen.includes('/tmp/writer-private'), 'the child\'s own folder still shows') + assert.ok(seen.includes('Writer secret instructions'), 'the child\'s own instructions still show') + void alice +}) + 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') 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..650bc9e5d --- /dev/null +++ b/patches/ds-bot/files/test/tasks.test.js @@ -0,0 +1,274 @@ +// 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', createdBy: 'a' }, + }, + 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 }, + ], + }, + }, + grants: {}, + 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') +}) + +test('a grantee may create a task on a granted child, not on an ungranted sibling', async (t) => { + const { fb, signIn, createTask } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + await fb.grants.set('s', [{ grantee: bob.memberId }]) + const onGranted = await createTask(bob.sessionToken, { botId: 's', title: 'Draft Bob\'s note' }) + assert.equal(onGranted.ok, true, JSON.stringify(onGranted)) + assert.equal(onGranted.value.requesterId, bob.memberId) + assert.equal(onGranted.value.ownerBotId, 's') + const onUngranted = await createTask(bob.sessionToken, { botId: 'a', title: 'Use the Chief' }) + assert.equal(onUngranted.ok, false, JSON.stringify(onUngranted)) + assert.equal(onUngranted.error.code, 'bot/forbidden') +}) From 04e90192535cd72fe9434f96ab0cf5a9e244b9ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 09:42:27 +0000 Subject: [PATCH 2/2] Document task hold seams on the plugin-runtime hooks page hooks.js is owned by plugin-runtime (longest match), so the #54 taskHeld / taskDecided / taskBotDeleted wiring needs an invariant there as well as on core-host. Co-authored-by: Danyang Chen --- docs/subsystems/plugin-runtime.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 91e6b2d19..fd1a5b121 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -30,7 +30,7 @@ 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: send middleware, transports, grants, and the owner-bound held-send approvals (#52). | +| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), and task hold/decide/delete seams (#54). | | `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. | @@ -52,6 +52,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 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`). +11. Task lifecycle (#54): a working task's held send calls `rt.taskHeld` (blocks on that approval); the owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. ### Chat-row pending badge