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/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 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') +})