diff --git a/CHANGELOG.md b/CHANGELOG.md index d056dac4b..3da79fcf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ New entries are written as one fragment file per change in [changelog.d/](change ### Added +- **Private context stays home in shared rooms.** A Bot only carries its owner's chat brief into a group where another owner sits when the owner shares it (a per-room, revocable share from the Bot's owner — host API `share-brief` now, room UI next); memory reviews in such a room keep entries in the Bot's own memory, never the team's, and read only their own owner as "the user"; `list_bots` no longer discloses another owner's Bot folder or instructions; and referencing a chat you cannot read is refused. Previously received messages are not retroactively unseen. ([#53](https://github.com/ThinkFlowLab/FlyBot/issues/53)) - **Held sends are bound to their owner.** An approval card shows who sent the request (and when it was relayed between Bots); only the sender Bot's owner can approve, reject, or retry it, the list shows you only your own holds, and a decision is voided safely (`stale`, never sent) when the request changed after it was held or the Bot / Chief authority behind it is gone. ([#52](https://github.com/ThinkFlowLab/FlyBot/issues/52)) - **Remove a friend's Bot** from Friends' Bots hides it on this install, takes it out of rooms, and does not unfriend. It reappears only if it is granted again. Held Bot replies show in the chat as **Waiting for approval** (owner: Approve / Reject / Auto-send; others: waiting for the owner). ([#90](https://github.com/ThinkFlowLab/FlyBot/issues/90)) - **Face-to-face join registers a Bot for that room only** when it has no identity yet. `@flybot/social` calls the same `add_home_member` path used for invites, then joins. The identity is not polled, not listed for friends, and is revoked with `remove_home_member` when you leave the last face room or the room closes. A failed join that created one is revoked too so retry is clean. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83), [#89](https://github.com/ThinkFlowLab/FlyBot/issues/89), [#90](https://github.com/ThinkFlowLab/FlyBot/issues/90)) diff --git a/core/src/host/authz.js b/core/src/host/authz.js index c71212cac..fb4be93bb 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -322,6 +322,10 @@ export function install(rt) { if (ep === 'create-bot') return ALLOW + // Sharing a member Bot's brief is the Bot owner's decision, checked on the record + // in room-membership (#53); the endpoint itself only needs a signed-in actor. + if (ep === 'share-brief') return ALLOW + // Anything not in the table and not a known auth/read endpoint is denied. if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check' diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 7ec77c782..32da9948b 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -106,6 +106,16 @@ export function install(rt) { // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami']) + // A chat reference chip names a Session whose snapshot rides to every recipient of + // the message, so the author must be able to read what they share (#53). + const unshareableReference = (actor, text) => { + if (!actor || !rt.authzEnforced?.() || typeof text !== 'string') return undefined + for (const match of text.matchAll(/dsh-session:([A-Za-z0-9_-]+)/g)) { + if (!rt.sessionOwnedBy?.(actor, match[1])) return match[1] + } + return undefined + } + function setPrefs(body) { if (body.theme !== undefined) { if (typeof body.theme !== 'string' || !/^[a-z][\w-]{0,31}$/.test(body.theme)) throw invalid('Unknown theme') @@ -221,6 +231,8 @@ export function install(rt) { } case 'room-post': { await rt.load() + const leaked = unshareableReference(actor, body.text) + if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') const line = await rt.postRoomMessage(String(body.roomId ?? body.id ?? ''), { text: body.text, authorId: body.authorId, @@ -329,6 +341,8 @@ export function install(rt) { const text = String(body.text ?? '').trim() if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid') await rt.load() + const leaked = unshareableReference(actor, text) + if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') // A Bot id or an earlier part of its own chat means the current part. const past = rt.pastOwners.get(body.sessionId) const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId @@ -382,6 +396,10 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) + case 'share-brief': + await rt.load() + await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) + return ok(true) case 'auth-whoami': return ok(actor) case 'auth-invite': { diff --git a/core/src/host/groups.js b/core/src/host/groups.js index 511b64132..8c191bba9 100644 --- a/core/src/host/groups.js +++ b/core/src/host/groups.js @@ -22,6 +22,7 @@ import { escapeRegExp, splitRef, textOf } from './text.js' import { mentionSpans } from '../shared/mentions.js' import { GROUP_REF, output } from './tools.js' import { failureLine } from './turns.js' +import { currentParticipants } from './room-membership.js' const ROOM_LOG_MAX = 60 const ROOM_FIRST_LOOK = 12 @@ -124,6 +125,23 @@ export function install(rt) { // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the // first message, so it stays fixed and the prefix stays cacheable. + // The brief is the member's owner's private chat with the Bot. In a room where + // another owner sits it stays out unless that owner shared it with this room (#53). + const briefAllowed = (room, member) => { + if (!rt.authzEnforced?.()) return true + const participants = currentParticipants(room) + const owners = new Set([rt.botOf(member.id)?.ownerId]) + for (const p of participants) { + if (p.kind === 'human') owners.add(p.id) + // A granted remote Bot speaks for another home; its handle stands for that owner. + else if (p.remote) owners.add(p.handle) + else owners.add(rt.botOf(p.id)?.ownerId) + } + owners.delete(undefined) + if (owners.size <= 1) return true + return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) + } + async function groupSessionFor(room, member) { room.sessions ??= {} const known = room.sessions[member.id] @@ -144,7 +162,7 @@ export function install(rt) { .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error)) await rt.save() await rt.ensureCapabilities(id) - return { id, brief: await rt.ownChatBrief(member) } + return { id, brief: briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } } // Lines from before the round's opening message are kept apart from this round, so diff --git a/core/src/host/memory.js b/core/src/host/memory.js index 5afa99190..9de245945 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import { currentParticipants } from './room-membership.js' import { defineTool } from '@deepseek-ai/dsh-tools' import { splitRef, unquote } from './text.js' import { output } from './tools.js' @@ -488,6 +489,22 @@ export function install(rt) { const writesTeam = botId => rt.isMain(botId) || Object.values(state.rooms).some(room => room.admin === botId) + // Team memory is one team's. A review that reads a room where another owner sits + // keeps entries in the Bot's own memory only (#53); ownership, not the admin role, + // decides whose memory "team" refers to there. + const teamAllowedFrom = (room, bot) => { + if (room === undefined) return true + const owners = new Set([bot.ownerId]) + for (const p of currentParticipants(room)) { + if (p.kind === 'human') owners.add(p.id) + // A granted remote Bot speaks for another home; its handle stands for that owner. + else if (p.remote) owners.add(p.handle) + else owners.add(state.bots?.[p.id]?.ownerId) + } + owners.delete(undefined) + return owners.size <= 1 + } + // `raw` names a scope: "own", "team", or a Bot. Undefined for the default. function namedScope(raw, botId) { const value = unquote(raw) @@ -1125,17 +1142,23 @@ export function install(rt) { // What is new in a group, from its log: the member's Session holds the same // messages wrapped in turn prompts. + // "The user" of a group review is the member Bot's owner; a line another human + // wrote is a peer's, however it is attributed (#53). + const isUserLine = (bot, entry) => (entry.authorKind === 'human' + ? entry.authorId !== undefined && entry.authorId === bot.ownerId + : entry.who === 'User') + function groupBatch(room, bot, since) { const log = (room.log ?? []).filter(line => line.kind === undefined && typeof line.text === 'string' && line.text.trim() !== '') // A first review reads back at least to the user's latest line, which a round of // replies from many members can push out of the last few. - const lastUser = log.findLastIndex(line => line.who === 'User') + const lastUser = log.findLastIndex(line => isUserLine(bot, line)) let start = since === undefined ? Math.max(0, Math.min(log.length - REVIEW_FIRST_LINES, lastUser === -1 ? log.length : lastUser)) : log.findIndex(line => line.seq > since) const oldest = room.log?.[0]?.seq if (since !== undefined && oldest !== undefined && oldest > since + 1) rt.warn('ds-bot: %d lines of group "%s" left its log before %s reviewed them', oldest - since - 1, room.name, bot.name) if (start === -1) start = log.length const line = (entry) => { - const who = entry.who === 'User' ? 'user' : entry.botId === bot.id ? 'you' : 'bot' + const who = isUserLine(bot, entry) ? 'user' : entry.botId === bot.id ? 'you' : 'bot' return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq } } return { @@ -1160,7 +1183,7 @@ export function install(rt) { return { texts: kept, count: kept.length } } - async function applyReview(bot, sessionId, numbered, changes, source) { + async function applyReview(bot, sessionId, numbered, changes, source, { teamAllowed = true } = {}) { const actor = { id: bot.id, name: bot.name, session: sessionId, auto: true } const done = [] let failed @@ -1168,7 +1191,7 @@ export function install(rt) { // A Bot deleted mid-review keeps no more changes, its team ones included. if (rt.botOf(bot.id) === undefined) return 0 // Its role may change mid-review too, so each change asks again. - const teamWrites = writesTeam(bot.id) + const teamWrites = teamAllowed && writesTeam(bot.id) const op = String(change?.op ?? '') const topic = op === 'add' && change?.topic !== undefined ? cleanTopic(String(change.topic)) : undefined const target = numbered[Number(change?.entry) - 1] @@ -1274,7 +1297,7 @@ export function install(rt) { const badTopic = applied.find(change => change.op === 'add' && typeof change.topic === 'string' && change.topic.trim() !== '' && cleanTopic(change.topic) === undefined) if (badTopic !== undefined) throw new Error(`the model's answer names the topic "${clip(badTopic.topic, 60)}", which cannot be a topic name`) if (rt.botOf(bot.id) === undefined) return - const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read)) + const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read), { teamAllowed: teamAllowedFrom(live, bot) }) if (rt.botOf(bot.id) === undefined) return // An answer at the limit may have left some out, so the same lines are read // again, a few times at most, while each pass still changes something. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index c75157c0d..61bc014d6 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -642,6 +642,22 @@ export function install(rt) { return linesVisibleTo(room, actor, state) } + // The owner of a member Bot lets its brief — their own chat with it — ride into + // this one room, or takes it back (#53). Bot ownership decides, not room admin; + // the flag lives on the participant, so leaving the room drops it. + rt.setBriefShare = function setBriefShare(roomId, botId, shared, actor) { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') + const bot = state.bots?.[String(botId ?? '')] + if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot') + if (rt.authzEnforced?.() && bot.ownerId !== actor?.memberId) throw forbidden("Only the Bot's owner may share its brief") + const entry = currentParticipants(room).find(p => p.kind === 'bot' && p.id === botId) + if (entry === undefined) throw forbidden('That Bot is not in this room') + if (shared) entry.briefShared = true + else delete entry.briefShared + return rt.save() + } + rt.applyParticipants = function applyParticipants(room, drafts, actor) { if (!Array.isArray(drafts) || drafts.length === 0) return room room.participants = [] diff --git a/core/src/host/tools.js b/core/src/host/tools.js index e0ee8567d..4f851f5f7 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -58,10 +58,14 @@ export function install(rt) { async execute(_args, exec) { await rt.load() const self = rt.selfOf(exec.agent?.id) + const selfOwner = rt.botOf(self)?.ownerId + // Another owner's Bot lists by name and role only: its folder and instructions + // are its owner's private setup (#53). + const foreign = bot => rt.authzEnforced?.() && selfOwner !== undefined && bot.ownerId !== undefined && bot.ownerId !== selfOwner const lines = Object.values(state.bots).filter(bot => bot.remote === undefined).map(bot => { const model = bot.appliedModel ?? bot.model - const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') - const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' + const tags = [bot.role, model ? `model ${model}` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') + const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}` }) if (lines.length === 0) return 'No Bots yet.' diff --git a/core/test/context-sharing.test.js b/core/test/context-sharing.test.js new file mode 100644 index 000000000..1c3124926 --- /dev/null +++ b/core/test/context-sharing.test.js @@ -0,0 +1,222 @@ +// Context sharing (#53): private context crosses owners only through an explicit, +// owner-granted share. A member's group brief is its owner's own chat; another +// owner's chat lines, folders and chat references never ride into a shared room +// without that share. Alice and Bob are two signed-in members on the #48/#49 +// foundation; team mode stays disabled. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'd' }, + }, + bots: { + a: { id: 'a', name: 'ChiefA', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1', workDir: '/tmp/alice-private', instructions: 'Alice secret instructions' }, + d: { id: 'd', name: 'ChiefB', role: 'Boss', sessionId: 'd', createdAt: 2, ownerId: 'owner-2', workDir: '/tmp/bob-private', instructions: 'Bob secret instructions' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 'd'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'human', id: 'owner-2', role: 'member', displayName: 'Bob', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'd', role: 'member', displayName: 'ChiefB', joinedSeq: 0, joinedAt: 1 }, + ], + }, + home: { + id: 'home', name: 'Home', members: ['a'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + ], + }, + }, + grants: { a: [{ id: 'g1', grantee: 'owner-2' }], d: [{ id: 'g2', grantee: 'owner-1' }] }, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function start(t, config = {}) { + const home = config.home ?? mkdtempSync(join(tmpdir(), 'ds-bot-context-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(config.state ?? team())) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const calls = [] + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls, status: 'idle', + followup(m) { calls.push(m) }, steer(m) { calls.push(m) }, + inject() {}, cancel() {}, async whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} }, + }) + } + return agents.get(sessionId) + } + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: `s${(++created).toString(36)}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let created = 0 + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (options) => { + if (options.path.endsWith('bot')) route = options + return register(options) + } + apply(ctx, { home }) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + let seq = 0 + const emit = (id, type, data) => { + for (const listener of ctx.listeners.get('session/event') ?? []) { + if (listener.length >= 2) listener({ id }, { type, seq: ++seq, data }) + } + } + const tool = (name, args, sessionId, exec = {}) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId }, ...exec }) + const call = async (endpoint, payload, token) => { + const headers = token ? { 'x-flybot-member-session': token } : undefined + return (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) }))).json() + } + const signIn = async (displayName, inviterToken) => { + const invite = await call('auth-invite', {}, inviterToken) + assert.equal(invite.ok, true, JSON.stringify(invite)) + const redeemed = await call('auth-redeem', { token: invite.value.token, displayName }) + assert.equal(redeemed.ok, true, JSON.stringify(redeemed)) + return redeemed.value + } + // Drive the round's state machine: every member Session that has taken its first + // message replies at once, so the next member is called until nobody is waiting. + const roundTurns = new Map() + const memberIds = () => [...agents.keys()].filter(id => id.startsWith('s')) + const drive = (id) => { + const message = agents.get(id).calls.at(-1) + const turn = (roundTurns.get(id) ?? 0) + 1 + roundTurns.set(id, turn) + emit(id, 'turn/start', { turn }) + emit(id, 'user/message', { content: message.content, source: message.source }) + emit(id, 'assistant/message', { message: { content: [{ type: 'text', text: 'noted' }] } }) + emit(id, 'turn/end', { turn, reason: { kind: 'completed' } }) + } + const runRound = async (roomId, expected) => { + emit(roomId, 'user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + const stop = new AbortController() + t.after(() => stop.abort()) + void tool('group_relay', {}, roomId, { callId: 'c1', signal: stop.signal }).catch(() => {}) + const until = Date.now() + 3000 + while (Date.now() < until) { + const pending = memberIds().filter(id => agents.get(id).calls.length > 0 && !roundTurns.has(id)) + for (const id of pending) drive(id) + if (memberIds().length >= expected && memberIds().every(id => roundTurns.has(id))) break + await new Promise(resolve => setImmediate(resolve)) + } + return memberIds().map(id => textOf(agents.get(id).calls[0])) + } + return { ctx, call, emit, tool, agentOf, signIn, home, runRound } +} + +const textOf = message => (message?.content ?? []).map(block => block.text ?? '').join('\n') + +test('a member brings no private brief into a room where another owner sits', async (t) => { + const { runRound, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const turns = await runRound('war', 2) + assert.equal(turns.length, 2, 'both members took their first turn') + for (const first of turns) { + assert.ok(!first.includes('Your own chat with the user'), 'no own-chat brief rides into a cross-owner room') + assert.ok(!first.includes('ALICES-PRIVATE'), 'no private marker') + } + void alice +}) + +test('the owner can share the brief with one room, and take it back', async (t) => { + const { call, runRound, signIn, home } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const shared = await call('share-brief', { id: 'war', botId: 'a', share: true }, alice.sessionToken) + assert.equal(shared.ok, true, JSON.stringify(shared)) + const state = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(state.rooms.war.participants.find(p => p.id === 'a').briefShared, true) + + const turns = await runRound('war', 2) + const aliceBot = turns.find(first => first.includes('Members: the user, ChiefB')) + const bobBot = turns.find(first => first.includes('Members: the user, ChiefA')) + assert.ok(aliceBot, 'ChiefA took its turn') + assert.ok(bobBot, 'ChiefB took its turn') + assert.ok(aliceBot.includes('Your own chat with the user'), 'the owner shared the brief, so it rides along') + assert.ok(!bobBot.includes('Your own chat with the user'), "Bob's brief still stays home") + + const revoked = await call('share-brief', { id: 'war', botId: 'a', share: false }, alice.sessionToken) + assert.equal(revoked.ok, true, JSON.stringify(revoked)) + const after = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(after.rooms.war.participants.find(p => p.id === 'a').briefShared, undefined) +}) + +test('only the Bot\'s owner may share its brief', async (t) => { + const { call, signIn } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const foreign = await call('share-brief', { id: 'war', botId: 'a', share: true }, bob.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden', JSON.stringify(foreign)) + void alice +}) + +test('a single-owner room keeps the brief without any share', async (t) => { + const { runRound, signIn } = await start(t) + const alice = await signIn('Alice') + void alice + const turns = await runRound('home', 1) + assert.ok(turns[0]?.includes('Your own chat with the user'), 'own room keeps the shipped brief') +}) + +test('list_bots hides another owner\'s folder and instructions', async (t) => { + const { tool, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const seen = await tool('list_bots', {}, 'a') + assert.ok(seen.includes('ChiefB'), 'the other owner\'s Bot is still listed') + assert.ok(!seen.includes('/tmp/bob-private'), 'no folder disclosure') + assert.ok(!seen.includes('Bob secret instructions'), 'no instructions disclosure') + assert.ok(seen.includes('/tmp/alice-private'), 'own folder still shows') + assert.ok(seen.includes('Alice secret instructions'), 'own instructions still show') +}) + +test('a chat reference chip must name a chat the author may read', async (t) => { + const { call, signIn } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const foreign = await call('room-post', { id: 'war', text: 'look at @[that](dsh-session:d) please' }, alice.sessionToken) + assert.equal(foreign.ok, false, JSON.stringify(foreign)) + assert.equal(foreign.error.code, 'bot/forbidden') + const own = await call('room-post', { id: 'war', text: 'look at @[this](dsh-session:a) please' }, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) + void bob +}) + + + +test('a granted remote Bot in the room counts as another owner for the brief', async (t) => { + const state = team() + state.rooms.home.participants.push({ kind: 'bot', remote: true, handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', plugin: 'social', joinedSeq: 0, joinedAt: 2 }) + const { runRound, signIn } = await start(t, { state }) + const alice = await signIn('Alice') + void alice + const turns = await runRound('home', 1) + assert.ok(turns[0], 'the member took its turn') + assert.ok(!turns[0].includes('Your own chat with the user'), 'a remote participant is another owner: no brief without a share') +}) diff --git a/core/test/memory.test.js b/core/test/memory.test.js index fd18d6bcc..f4cd8ea88 100644 --- a/core/test/memory.test.js +++ b/core/test/memory.test.js @@ -1085,3 +1085,70 @@ test('a memory call names the current part of the Bot chat', async (t) => { assert.equal(answer.ok, true) assert.equal(requests[0].sessionId, 'b2-part2') }) + +// A cross-owner shared room (#53): "the user" of a member Bot's review is its own +// owner, and another owner's room is not the place to publish team memory. +const SHARED_ROOM_TEAM = { + version: 2, + revision: 1, + mainBotIds: ['m1'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'm1' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'x1' }, + }, + bots: { + m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', instructions: '', createdAt: 1, ownerId: 'owner-1' }, + b3: { id: 'b3', sessionId: 'b3', name: 'Coder', role: 'Code', instructions: '', createdAt: 3, ownerId: 'owner-1' }, + x1: { id: 'x1', sessionId: 'x1', name: 'Guest', role: 'Visit', instructions: '', createdAt: 4, ownerId: 'owner-2' }, + }, + rooms: { + r2: { + id: 'r2', name: 'Shared', named: true, members: ['b3', 'x1'], admin: 'b3', mode: 'everyone', notice: '', sessions: { b3: 'g3' }, + seq: 3, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'human', id: 'owner-2', role: 'member', displayName: 'Bob', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'b3', role: 'admin', displayName: 'Coder', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'x1', role: 'member', displayName: 'Guest', joinedSeq: 0, joinedAt: 1 }, + ], + log: [ + { who: 'Alice', authorKind: 'human', authorId: 'owner-1', text: 'Keep the launch on Friday', seq: 1 }, + { who: 'Bob', authorKind: 'human', authorId: 'owner-2', text: 'Bob prefers Sundays', seq: 2 }, + { who: 'Coder', botId: 'b3', text: 'Will do.', seq: 3 }, + ], + }, + }, + exchanges: [], exchangeTurns: {}, questions: {}, +} + +test('in a cross-owner room, the review reads its own owner as the user and the other owner as a peer', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(SHARED_ROOM_TEAM)) + const replies = [JSON.stringify({ changes: [{ op: 'add', text: 'The launch stays on Friday' }] })] + const { ctx, call, requests, stop } = start({ home, replies, config: { memoryReviewDelayMs: 0 } }) + t.after(() => stop()) + await call('state', {}) + ctx.emit('agent/status', { agent: { id: 'g3' }, status: 'idle' }) + for (let tries = 0; tries < 200 && requests.length === 0; tries += 1) await new Promise(resolve => setTimeout(resolve, 20)) + assert.equal(requests.length, 1) + const batch = requests[0].messages[0].content[0].text + assert.match(batch, /User: Keep the launch on Friday/, 'the owner speaks as the user') + assert.match(batch, /Bob: Bob prefers Sundays/, 'the other owner is a named peer') + assert.doesNotMatch(batch, /User: Bob prefers Sundays/) +}) + +test('a review out of a cross-owner room keeps entries out of the team memory', async (t) => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(SHARED_ROOM_TEAM)) + const replies = [JSON.stringify({ changes: [{ op: 'add', text: 'The launch stays on Friday', team: true }] })] + const { ctx, call, stop } = start({ home, replies, config: { memoryReviewDelayMs: 0 } }) + t.after(() => stop()) + await call('state', {}) + ctx.emit('agent/status', { agent: { id: 'g3' }, status: 'idle' }) + for (let tries = 0; tries < 200; tries += 1) { + if (existsSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'))) break + await new Promise(resolve => setTimeout(resolve, 20)) + } + assert.equal(existsSync(join(home, 'memory', 'team', 'MEMORY.md')), false, 'no team write from another owner\'s room') + assert.match(readFileSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'), 'utf8'), /The launch stays on Friday/) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index e93dbba87..a23278ab9 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -50,6 +50,8 @@ 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. + ### Face-to-face participant look - `syncFaceParticipants` / `faceRemoteRecord` keep optional `color` / `avatar` from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see [core-client](core-client.md)). Face-room REST still does not require a person-grant. diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index dea372b84..21395d6bc 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -97,6 +97,16 @@ The held-send approval (`state.flybot.approvals` in `core/src/host/hooks.js`) no - **Legacy records keep working.** Holds saved before this change have no `version`; they skip only the tamper check, and their owner is derived at decide time. - **Not done here:** the social plugin's server-side approval holds (`social/decide-approval`) stay a deployment-wide plugin write (blocked while more than one member exists); the in-chat approval cards and task-level approvals are #90/#54 work. Gate 3 is satisfied for held sends only. +## Context sharing foundation (landed, #53) + +Private context crosses owners only through an explicit, owner-granted share; room membership never widens what a Bot carries or reads. Only the #48/#49/#50 foundations are assumed; team mode stays disabled. + +- **The own-chat brief is share-gated.** A member's group Session starts with its owner's own-chat brief only when the room has a single owner, or that owner called `share-brief` for this room (the `briefShared` flag lives on the room participant, so leaving drops it; `rt.setBriefShare` in `core/src/host/room-membership.js` owner-checks, the endpoint only needs a signed-in actor). A group Session created after the share is taken back carries no brief. A granted remote Bot in the room counts as another owner (its handle stands for its home), so a friend's Bot's presence also suppresses the brief. A brief already delivered is not retroactively unseen — document that to owners. +- **Memory review is owner-aware.** In `core/src/host/memory.js`, "the user" of a group review is the member Bot's owner (`isUserLine`); another owner's human is a named peer and does not get user-line retention. A review that reads a room with more than one owner cannot write the team scope (`teamAllowedFrom`) — entries land in the Bot's own memory. Entries already written are not retroactively unseen. +- **Roster disclosure narrows.** `list_bots` shows another owner's Bot by name, role and model; its folder and instructions are its owner's private setup. +- **Chat references stay owner-scoped.** A `dsh-session:` reference chip in `send` or `room-post` must name a Session the author may read (`sessionOwnedBy`); the snapshot rides to every recipient, so authorship is the gate. +- **Not done here:** reference chips are author-checked on `send` and `room-post` only — whether DSH resolves chips in `answer-question`/`schedule-create` texts and in Bot-authored messages is an open DSH-side question (tracked follow-up); the secrets surface stays `SHARED_BLOCKED` for >1 member; previously received messages cannot be retroactively unseen (stated, not engineered); no per-owner file-sandbox changes — a room `workDir` remains readable by its member Bots under DSH's own policy. + ## Identity model | Relation | Rule | @@ -220,4 +230,6 @@ Owner-scoped access (#49) tests in `core/test/authz.test.js`: cross-owner denial **Approval binding (#52)** has tests in `core/test/approval-binding.test.js`: the hold stamps server-derived provenance and a request-content hash while forged `meta` claims stay data; only the sender Bot's owner's session may decide (another member gets `bot/forbidden`, no session gets `bot/unauthorized`, a body `actorId` grants nothing); the pending list is per-actor; a request that changed after it was held goes `stale` and never dispatches; same-owner deciding survives a restart; deleting a sender or target Bot or replacing the Chief invalidates pending holds; and a record whose sender Bot and owner are both gone stays in state with its attribution but in no member's list. +**Context sharing (#53)** has tests in `core/test/context-sharing.test.js` (brief gate per room ownership, the owner-only `share-brief` round trip, the single-owner guard, `list_bots` narrowing, reference-chip authorization) and in `core/test/memory.test.js` (owner-as-user classification, the team-write gate out of a cross-owner room). + The routing-matrix rows above remain untested because the behavior is not shipped. When enforcement work starts (#48+), each row becomes a test first, per [fail-without-change](../code-standards.md): same-owner delegation allowed (a), Chief-to-Chief allowed (b), cross-owner specialist denied (c), team-admin vs private-owner denied (d), forwarded approval rejected (e). Each must fail on the unbroken tip before implementation lands. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 14cf0b9f4..89a83dad9 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -363,6 +363,14 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js diff --git a/src/host/memory.js b/src/host/memory.js --- a/src/host/memory.js +++ b/src/host/memory.js +@@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSyn + import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' + import { dirname, join } from 'node:path' + import { BlockAssembler } from '@deepseek-ai/dsh-llm' ++import { currentParticipants } from './room-membership.js' + import { defineTool } from '@deepseek-ai/dsh-tools' + import { splitRef, unquote } from './text.js' + import { output } from './tools.js' @@ -312,7 +312,7 @@ const teamRule = (main, writer) => (main ? '- The team memory is shared by every Bot. As a group admin you write it with remember scope "team", for what the whole team needs.' : '- The team memory is shared by every Bot. Main Bots and group admins write it; when the whole team should know something, tell a Main Bot with message_bot.') @@ -390,6 +398,29 @@ diff --git a/src/host/memory.js b/src/host/memory.js 'Reply with one JSON object and nothing else: {"changes": […]}, with an empty list when nothing should change.', `At most ${REVIEW_CHANGES_MAX} changes in one answer, the most lasting first; the rest can wait for the next review.`, 'Keep, as a new entry:', +@@ -488,6 +488,22 @@ export function install(rt) { + + const writesTeam = botId => rt.isMain(botId) || Object.values(state.rooms).some(room => room.admin === botId) + ++ // Team memory is one team's. A review that reads a room where another owner sits ++ // keeps entries in the Bot's own memory only (#53); ownership, not the admin role, ++ // decides whose memory "team" refers to there. ++ const teamAllowedFrom = (room, bot) => { ++ if (room === undefined) return true ++ const owners = new Set([bot.ownerId]) ++ for (const p of currentParticipants(room)) { ++ if (p.kind === 'human') owners.add(p.id) ++ // A granted remote Bot speaks for another home; its handle stands for that owner. ++ else if (p.remote) owners.add(p.handle) ++ else owners.add(state.bots?.[p.id]?.ownerId) ++ } ++ owners.delete(undefined) ++ return owners.size <= 1 ++ } ++ + // `raw` names a scope: "own", "team", or a Bot. Undefined for the default. + function namedScope(raw, botId) { + const value = unquote(raw) @@ -941,6 +941,8 @@ export function install(rt) { // desiredModel only falls back for models that are not served; so a failed call // is tried once more on the profile default. @@ -408,6 +439,59 @@ diff --git a/src/host/memory.js b/src/host/memory.js return { botId: bot.id, entries, +@@ -1123,17 +1123,23 @@ export function install(rt) { + + // What is new in a group, from its log: the member's Session holds the same + // messages wrapped in turn prompts. ++ // "The user" of a group review is the member Bot's owner; a line another human ++ // wrote is a peer's, however it is attributed (#53). ++ const isUserLine = (bot, entry) => (entry.authorKind === 'human' ++ ? entry.authorId !== undefined && entry.authorId === bot.ownerId ++ : entry.who === 'User') ++ + function groupBatch(room, bot, since) { + const log = (room.log ?? []).filter(line => line.kind === undefined && typeof line.text === 'string' && line.text.trim() !== '') + // A first review reads back at least to the user's latest line, which a round of + // replies from many members can push out of the last few. +- const lastUser = log.findLastIndex(line => line.who === 'User') ++ const lastUser = log.findLastIndex(line => isUserLine(bot, line)) + let start = since === undefined ? Math.max(0, Math.min(log.length - REVIEW_FIRST_LINES, lastUser === -1 ? log.length : lastUser)) : log.findIndex(line => line.seq > since) + const oldest = room.log?.[0]?.seq + if (since !== undefined && oldest !== undefined && oldest > since + 1) rt.warn('ds-bot: %d lines of group "%s" left its log before %s reviewed them', oldest - since - 1, room.name, bot.name) + if (start === -1) start = log.length + const line = (entry) => { +- const who = entry.who === 'User' ? 'user' : entry.botId === bot.id ? 'you' : 'bot' ++ const who = isUserLine(bot, entry) ? 'user' : entry.botId === bot.id ? 'you' : 'bot' + return { who, text: `${who === 'user' ? 'User' : who === 'you' ? 'You' : entry.who}: ${entry.text}`, seq: entry.seq } + } + return { +@@ -1158,7 +1158,7 @@ export function install(rt) { + return { texts: kept, count: kept.length } + } + +- async function applyReview(bot, sessionId, numbered, changes, source) { ++ async function applyReview(bot, sessionId, numbered, changes, source, { teamAllowed = true } = {}) { + const actor = { id: bot.id, name: bot.name, session: sessionId, auto: true } + const done = [] + let failed +@@ -1166,7 +1166,7 @@ export function install(rt) { + // A Bot deleted mid-review keeps no more changes, its team ones included. + if (rt.botOf(bot.id) === undefined) return 0 + // Its role may change mid-review too, so each change asks again. +- const teamWrites = writesTeam(bot.id) ++ const teamWrites = teamAllowed && writesTeam(bot.id) + const op = String(change?.op ?? '') + const topic = op === 'add' && change?.topic !== undefined ? cleanTopic(String(change.topic)) : undefined + const target = numbered[Number(change?.entry) - 1] +@@ -1272,7 +1272,7 @@ export function install(rt) { + const badTopic = applied.find(change => change.op === 'add' && typeof change.topic === 'string' && change.topic.trim() !== '' && cleanTopic(change.topic) === undefined) + if (badTopic !== undefined) throw new Error(`the model's answer names the topic "${clip(badTopic.topic, 60)}", which cannot be a topic name`) + if (rt.botOf(bot.id) === undefined) return +- const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read)) ++ const changed = await applyReview(bot, sessionId, numbered, applied, batch.source(read), { teamAllowed: teamAllowedFrom(live, bot) }) + if (rt.botOf(bot.id) === undefined) return + // An answer at the limit may have left some out, so the same lines are read + // again, a few times at most, while each pass still changes something. diff --git a/src/host/models.js b/src/host/models.js --- a/src/host/models.js +++ b/src/host/models.js @@ -705,6 +789,77 @@ diff --git a/test/memory.test.js b/test/memory.test.js const view = (await call('memory-view', { botId: 'b2' })).value assert.equal(view.error, null) assert.equal(view.summary?.text, 'You like tea.') +@@ -1076,3 +1076,70 @@ test('a memory call names the current part of the Bot chat', async (t) => { + assert.equal(answer.ok, true) + assert.equal(requests[0].sessionId, 'b2-part2') + }) ++ ++// A cross-owner shared room (#53): "the user" of a member Bot's review is its own ++// owner, and another owner's room is not the place to publish team memory. ++const SHARED_ROOM_TEAM = { ++ version: 2, ++ revision: 1, ++ mainBotIds: ['m1'], ++ members: { ++ 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'm1' }, ++ 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'x1' }, ++ }, ++ bots: { ++ m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', instructions: '', createdAt: 1, ownerId: 'owner-1' }, ++ b3: { id: 'b3', sessionId: 'b3', name: 'Coder', role: 'Code', instructions: '', createdAt: 3, ownerId: 'owner-1' }, ++ x1: { id: 'x1', sessionId: 'x1', name: 'Guest', role: 'Visit', instructions: '', createdAt: 4, ownerId: 'owner-2' }, ++ }, ++ rooms: { ++ r2: { ++ id: 'r2', name: 'Shared', named: true, members: ['b3', 'x1'], admin: 'b3', mode: 'everyone', notice: '', sessions: { b3: 'g3' }, ++ seq: 3, seen: {}, ++ participants: [ ++ { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, ++ { kind: 'human', id: 'owner-2', role: 'member', displayName: 'Bob', joinedSeq: 0, joinedAt: 1 }, ++ { kind: 'bot', id: 'b3', role: 'admin', displayName: 'Coder', joinedSeq: 0, joinedAt: 1 }, ++ { kind: 'bot', id: 'x1', role: 'member', displayName: 'Guest', joinedSeq: 0, joinedAt: 1 }, ++ ], ++ log: [ ++ { who: 'Alice', authorKind: 'human', authorId: 'owner-1', text: 'Keep the launch on Friday', seq: 1 }, ++ { who: 'Bob', authorKind: 'human', authorId: 'owner-2', text: 'Bob prefers Sundays', seq: 2 }, ++ { who: 'Coder', botId: 'b3', text: 'Will do.', seq: 3 }, ++ ], ++ }, ++ }, ++ exchanges: [], exchangeTurns: {}, questions: {}, ++} ++ ++test('in a cross-owner room, the review reads its own owner as the user and the other owner as a peer', async (t) => { ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) ++ writeFileSync(join(home, 'state.json'), JSON.stringify(SHARED_ROOM_TEAM)) ++ const replies = [JSON.stringify({ changes: [{ op: 'add', text: 'The launch stays on Friday' }] })] ++ const { ctx, call, requests, stop } = start({ home, replies, config: { memoryReviewDelayMs: 0 } }) ++ t.after(() => stop()) ++ await call('state', {}) ++ ctx.emit('agent/status', { agent: { id: 'g3' }, status: 'idle' }) ++ for (let tries = 0; tries < 200 && requests.length === 0; tries += 1) await new Promise(resolve => setTimeout(resolve, 20)) ++ assert.equal(requests.length, 1) ++ const batch = requests[0].messages[0].content[0].text ++ assert.match(batch, /User: Keep the launch on Friday/, 'the owner speaks as the user') ++ assert.match(batch, /Bob: Bob prefers Sundays/, 'the other owner is a named peer') ++ assert.doesNotMatch(batch, /User: Bob prefers Sundays/) ++}) ++ ++test('a review out of a cross-owner room keeps entries out of the team memory', async (t) => { ++ const home = mkdtempSync(join(tmpdir(), 'ds-bot-memory-')) ++ writeFileSync(join(home, 'state.json'), JSON.stringify(SHARED_ROOM_TEAM)) ++ const replies = [JSON.stringify({ changes: [{ op: 'add', text: 'The launch stays on Friday', team: true }] })] ++ const { ctx, call, stop } = start({ home, replies, config: { memoryReviewDelayMs: 0 } }) ++ t.after(() => stop()) ++ await call('state', {}) ++ ctx.emit('agent/status', { agent: { id: 'g3' }, status: 'idle' }) ++ for (let tries = 0; tries < 200; tries += 1) { ++ if (existsSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'))) break ++ await new Promise(resolve => setTimeout(resolve, 20)) ++ } ++ assert.equal(existsSync(join(home, 'memory', 'team', 'MEMORY.md')), false, 'no team write from another owner\'s room') ++ assert.match(readFileSync(join(home, 'memory', 'bots', 'b3', 'MEMORY.md'), 'utf8'), /The launch stays on Friday/) ++}) diff --git a/test/model-menu.test.js b/test/model-menu.test.js --- a/test/model-menu.test.js +++ b/test/model-menu.test.js diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 7b623c9cd..6f599f6ac 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -606,16 +606,22 @@ diff --git a/src/host/tools.js b/src/host/tools.js ctx.tools.register(defineTool({ name: 'list_bots', -@@ -46,7 +46,7 @@ export function install(rt) { +@@ -46,20 +46,26 @@ export function install(rt) { async execute(_args, exec) { await rt.load() const self = rt.selfOf(exec.agent?.id) - const lines = Object.values(state.bots).map(bot => { ++ const selfOwner = rt.botOf(self)?.ownerId ++ // Another owner's Bot lists by name and role only: its folder and instructions ++ // are its owner's private setup (#53). ++ const foreign = bot => rt.authzEnforced?.() && selfOwner !== undefined && bot.ownerId !== undefined && bot.ownerId !== selfOwner + const lines = Object.values(state.bots).filter(bot => bot.remote === undefined).map(bot => { const model = bot.appliedModel ?? bot.model - const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') - const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' -@@ -54,12 +54,14 @@ export function install(rt) { +- const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') +- const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' ++ const tags = [bot.role, model ? `model ${model}` : '', !foreign(bot) && bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ') ++ const job = !foreign(bot) && bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : '' + return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}` }) if (lines.length === 0) return 'No Bots yet.' const rooms = visibleRooms(self) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index ddc53c281..dbc33ebb8 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -111,7 +111,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +65,30 @@ export function install(rt) { +@@ -65,22 +65,40 @@ export function install(rt) { memory: rt.memoryEnabled !== false, // Set while another dsh host holds the team (store.js): why this one cannot change it. readOnly: rt.readOnly() ?? null, @@ -149,6 +149,16 @@ diff --git a/src/host/channel.js b/src/host/channel.js - const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer']) + // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. + const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami']) ++ ++ // A chat reference chip names a Session whose snapshot rides to every recipient of ++ // the message, so the author must be able to read what they share (#53). ++ const unshareableReference = (actor, text) => { ++ if (!actor || !rt.authzEnforced?.() || typeof text !== 'string') return undefined ++ for (const match of text.matchAll(/dsh-session:([A-Za-z0-9_-]+)/g)) { ++ if (!rt.sessionOwnedBy?.(actor, match[1])) return match[1] ++ } ++ return undefined ++ } function setPrefs(body) { if (body.theme !== undefined) { @@ -206,7 +216,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js if (body.greet === true) await rt.greetRoom(room.id).catch(error => rt.warn('ds-bot: greeting in %s failed: %s', room.name, error)) return ok(room) } -@@ -175,8 +175,24 @@ export function install(rt) { +@@ -175,8 +175,26 @@ export function install(rt) { add: Array.isArray(body.add) ? body.add : undefined, remove: Array.isArray(body.remove) ? body.remove : undefined, workDir: body.workDir, @@ -223,6 +233,8 @@ diff --git a/src/host/channel.js b/src/host/channel.js + } + case 'room-post': { + await rt.load() ++ const leaked = unshareableReference(actor, body.text) ++ if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') + const line = await rt.postRoomMessage(String(body.roomId ?? body.id ?? ''), { + text: body.text, + authorId: body.authorId, @@ -233,7 +245,12 @@ diff --git a/src/host/channel.js b/src/host/channel.js } case 'folders': case 'folder-pick': -@@ -281,6 +281,18 @@ export function install(rt) { +@@ -278,9 +278,23 @@ export function install(rt) { + const text = String(body.text ?? '').trim() + if (text === '' || typeof body.sessionId !== 'string') return fail('Nothing to send', 'bot/invalid') + await rt.load() ++ const leaked = unshareableReference(actor, text) ++ if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') // A Bot id or an earlier part of its own chat means the current part. const past = rt.pastOwners.get(body.sessionId) const sessionId = rt.botOf(body.sessionId) ? rt.chatOf(body.sessionId) : past && past.roomId === undefined ? rt.chatOf(past.botId) : body.sessionId @@ -252,10 +269,14 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,7 +319,38 @@ export function install(rt) { +@@ -319,7 +319,42 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) ++ case 'share-brief': ++ await rt.load() ++ await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) ++ return ok(true) + case 'auth-whoami': + return ok(actor) + case 'auth-invite': { diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index dcac8b387..d76d4d7c5 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -325,6 +325,14 @@ diff --git a/src/client/sources.js b/src/client/sources.js diff --git a/src/host/groups.js b/src/host/groups.js --- a/src/host/groups.js +++ b/src/host/groups.js +@@ -22,6 +22,7 @@ import { escapeRegExp, splitRef, textOf } from './text.js' + import { mentionSpans } from '../shared/mentions.js' + import { GROUP_REF, output } from './tools.js' + import { failureLine } from './turns.js' ++import { currentParticipants } from './room-membership.js' + + const ROOM_LOG_MAX = 60 + const ROOM_FIRST_LOOK = 12 @@ -41,9 +41,12 @@ const ensureRoomLog = (room) => { room.seen ??= {} } @@ -349,7 +357,7 @@ diff --git a/src/host/groups.js b/src/host/groups.js return `${line.who}: ${line.text}` } const transcript = lines => lines.map(lineText).join('\n') -@@ -113,6 +113,7 @@ const ROOM_READ_MAX = 30 +@@ -113,10 +113,28 @@ const ROOM_READ_MAX = 30 export function install(rt) { const { ctx } = rt @@ -357,6 +365,36 @@ diff --git a/src/host/groups.js b/src/host/groups.js // A member's group Session, created on its first turn there. A new one starts with // a fresh look at the log and a brief of the member's own chat; the brief is the + // first message, so it stays fixed and the prefix stays cacheable. ++ // The brief is the member's owner's private chat with the Bot. In a room where ++ // another owner sits it stays out unless that owner shared it with this room (#53). ++ const briefAllowed = (room, member) => { ++ if (!rt.authzEnforced?.()) return true ++ const participants = currentParticipants(room) ++ const owners = new Set([rt.botOf(member.id)?.ownerId]) ++ for (const p of participants) { ++ if (p.kind === 'human') owners.add(p.id) ++ // A granted remote Bot speaks for another home; its handle stands for that owner. ++ else if (p.remote) owners.add(p.handle) ++ else owners.add(rt.botOf(p.id)?.ownerId) ++ } ++ owners.delete(undefined) ++ if (owners.size <= 1) return true ++ return participants.some(p => p.kind === 'bot' && p.id === member.id && p.briefShared === true) ++ } ++ + async function groupSessionFor(room, member) { + room.sessions ??= {} + const known = room.sessions[member.id] +@@ -137,7 +137,7 @@ export function install(rt) { + .catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error)) + await rt.save() + await rt.ensureCapabilities(id) +- return { id, brief: await rt.ownChatBrief(member) } ++ return { id, brief: briefAllowed(room, member) ? await rt.ownChatBrief(member) : undefined } + } + + // Lines from before the round's opening message are kept apart from this round, so @@ -292,8 +292,8 @@ export function install(rt) { const before = round.slots.length const members = room.members.map(rt.botOf).filter(Boolean) diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index c71212cac..fb4be93bb 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -322,6 +322,10 @@ export function install(rt) { if (ep === 'create-bot') return ALLOW + // Sharing a member Bot's brief is the Bot owner's decision, checked on the record + // in room-membership (#53); the endpoint itself only needs a signed-in actor. + if (ep === 'share-brief') return ALLOW + // Anything not in the table and not a known auth/read endpoint is denied. if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check' diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index c75157c0d..61bc014d6 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -642,6 +642,22 @@ export function install(rt) { return linesVisibleTo(room, actor, state) } + // The owner of a member Bot lets its brief — their own chat with it — ride into + // this one room, or takes it back (#53). Bot ownership decides, not room admin; + // the flag lives on the participant, so leaving the room drops it. + rt.setBriefShare = function setBriefShare(roomId, botId, shared, actor) { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') + const bot = state.bots?.[String(botId ?? '')] + if (bot === undefined || bot.remote !== undefined) throw forbidden('No such Bot') + if (rt.authzEnforced?.() && bot.ownerId !== actor?.memberId) throw forbidden("Only the Bot's owner may share its brief") + const entry = currentParticipants(room).find(p => p.kind === 'bot' && p.id === botId) + if (entry === undefined) throw forbidden('That Bot is not in this room') + if (shared) entry.briefShared = true + else delete entry.briefShared + return rt.save() + } + rt.applyParticipants = function applyParticipants(room, drafts, actor) { if (!Array.isArray(drafts) || drafts.length === 0) return room room.participants = [] diff --git a/patches/ds-bot/files/test/context-sharing.test.js b/patches/ds-bot/files/test/context-sharing.test.js new file mode 100644 index 000000000..1c3124926 --- /dev/null +++ b/patches/ds-bot/files/test/context-sharing.test.js @@ -0,0 +1,222 @@ +// Context sharing (#53): private context crosses owners only through an explicit, +// owner-granted share. A member's group brief is its owner's own chat; another +// owner's chat lines, folders and chat references never ride into a shared room +// without that share. Alice and Bob are two signed-in members on the #48/#49 +// foundation; team mode stays disabled. +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' }, + 'owner-2': { id: 'owner-2', createdAt: 2, designatedChiefId: 'd' }, + }, + bots: { + a: { id: 'a', name: 'ChiefA', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1', workDir: '/tmp/alice-private', instructions: 'Alice secret instructions' }, + d: { id: 'd', name: 'ChiefB', role: 'Boss', sessionId: 'd', createdAt: 2, ownerId: 'owner-2', workDir: '/tmp/bob-private', instructions: 'Bob secret instructions' }, + }, + rooms: { + war: { + id: 'war', name: 'War', members: ['a', 'd'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'human', id: 'owner-2', role: 'member', displayName: 'Bob', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'd', role: 'member', displayName: 'ChiefB', joinedSeq: 0, joinedAt: 1 }, + ], + }, + home: { + id: 'home', name: 'Home', members: ['a'], admin: 'a', mode: 'everyone', named: true, sessions: {}, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'a', role: 'admin', displayName: 'ChiefA', joinedSeq: 0, joinedAt: 1 }, + ], + }, + }, + grants: { a: [{ id: 'g1', grantee: 'owner-2' }], d: [{ id: 'g2', grantee: 'owner-1' }] }, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function start(t, config = {}) { + const home = config.home ?? mkdtempSync(join(tmpdir(), 'ds-bot-context-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(config.state ?? team())) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + const calls = [] + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls, status: 'idle', + followup(m) { calls.push(m) }, steer(m) { calls.push(m) }, + inject() {}, cancel() {}, async whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} }, + }) + } + return agents.get(sessionId) + } + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionController: { create: async () => ({ sessionId: `s${(++created).toString(36)}` }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + let created = 0 + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = (options) => { + if (options.path.endsWith('bot')) route = options + return register(options) + } + apply(ctx, { home }) + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + let seq = 0 + const emit = (id, type, data) => { + for (const listener of ctx.listeners.get('session/event') ?? []) { + if (listener.length >= 2) listener({ id }, { type, seq: ++seq, data }) + } + } + const tool = (name, args, sessionId, exec = {}) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId }, ...exec }) + const call = async (endpoint, payload, token) => { + const headers = token ? { 'x-flybot-member-session': token } : undefined + return (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) }))).json() + } + const signIn = async (displayName, inviterToken) => { + const invite = await call('auth-invite', {}, inviterToken) + assert.equal(invite.ok, true, JSON.stringify(invite)) + const redeemed = await call('auth-redeem', { token: invite.value.token, displayName }) + assert.equal(redeemed.ok, true, JSON.stringify(redeemed)) + return redeemed.value + } + // Drive the round's state machine: every member Session that has taken its first + // message replies at once, so the next member is called until nobody is waiting. + const roundTurns = new Map() + const memberIds = () => [...agents.keys()].filter(id => id.startsWith('s')) + const drive = (id) => { + const message = agents.get(id).calls.at(-1) + const turn = (roundTurns.get(id) ?? 0) + 1 + roundTurns.set(id, turn) + emit(id, 'turn/start', { turn }) + emit(id, 'user/message', { content: message.content, source: message.source }) + emit(id, 'assistant/message', { message: { content: [{ type: 'text', text: 'noted' }] } }) + emit(id, 'turn/end', { turn, reason: { kind: 'completed' } }) + } + const runRound = async (roomId, expected) => { + emit(roomId, 'user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) + const stop = new AbortController() + t.after(() => stop.abort()) + void tool('group_relay', {}, roomId, { callId: 'c1', signal: stop.signal }).catch(() => {}) + const until = Date.now() + 3000 + while (Date.now() < until) { + const pending = memberIds().filter(id => agents.get(id).calls.length > 0 && !roundTurns.has(id)) + for (const id of pending) drive(id) + if (memberIds().length >= expected && memberIds().every(id => roundTurns.has(id))) break + await new Promise(resolve => setImmediate(resolve)) + } + return memberIds().map(id => textOf(agents.get(id).calls[0])) + } + return { ctx, call, emit, tool, agentOf, signIn, home, runRound } +} + +const textOf = message => (message?.content ?? []).map(block => block.text ?? '').join('\n') + +test('a member brings no private brief into a room where another owner sits', async (t) => { + const { runRound, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const turns = await runRound('war', 2) + assert.equal(turns.length, 2, 'both members took their first turn') + for (const first of turns) { + assert.ok(!first.includes('Your own chat with the user'), 'no own-chat brief rides into a cross-owner room') + assert.ok(!first.includes('ALICES-PRIVATE'), 'no private marker') + } + void alice +}) + +test('the owner can share the brief with one room, and take it back', async (t) => { + const { call, runRound, signIn, home } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const shared = await call('share-brief', { id: 'war', botId: 'a', share: true }, alice.sessionToken) + assert.equal(shared.ok, true, JSON.stringify(shared)) + const state = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(state.rooms.war.participants.find(p => p.id === 'a').briefShared, true) + + const turns = await runRound('war', 2) + const aliceBot = turns.find(first => first.includes('Members: the user, ChiefB')) + const bobBot = turns.find(first => first.includes('Members: the user, ChiefA')) + assert.ok(aliceBot, 'ChiefA took its turn') + assert.ok(bobBot, 'ChiefB took its turn') + assert.ok(aliceBot.includes('Your own chat with the user'), 'the owner shared the brief, so it rides along') + assert.ok(!bobBot.includes('Your own chat with the user'), "Bob's brief still stays home") + + const revoked = await call('share-brief', { id: 'war', botId: 'a', share: false }, alice.sessionToken) + assert.equal(revoked.ok, true, JSON.stringify(revoked)) + const after = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + assert.equal(after.rooms.war.participants.find(p => p.id === 'a').briefShared, undefined) +}) + +test('only the Bot\'s owner may share its brief', async (t) => { + const { call, signIn } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const foreign = await call('share-brief', { id: 'war', botId: 'a', share: true }, bob.sessionToken) + assert.equal(foreign.ok, false) + assert.equal(foreign.error.code, 'bot/forbidden', JSON.stringify(foreign)) + void alice +}) + +test('a single-owner room keeps the brief without any share', async (t) => { + const { runRound, signIn } = await start(t) + const alice = await signIn('Alice') + void alice + const turns = await runRound('home', 1) + assert.ok(turns[0]?.includes('Your own chat with the user'), 'own room keeps the shipped brief') +}) + +test('list_bots hides another owner\'s folder and instructions', async (t) => { + const { tool, signIn } = await start(t) + const alice = await signIn('Alice') + await signIn('Bob', alice.sessionToken) + const seen = await tool('list_bots', {}, 'a') + assert.ok(seen.includes('ChiefB'), 'the other owner\'s Bot is still listed') + assert.ok(!seen.includes('/tmp/bob-private'), 'no folder disclosure') + assert.ok(!seen.includes('Bob secret instructions'), 'no instructions disclosure') + assert.ok(seen.includes('/tmp/alice-private'), 'own folder still shows') + assert.ok(seen.includes('Alice secret instructions'), 'own instructions still show') +}) + +test('a chat reference chip must name a chat the author may read', async (t) => { + const { call, signIn } = await start(t) + const alice = await signIn('Alice') + const bob = await signIn('Bob', alice.sessionToken) + const foreign = await call('room-post', { id: 'war', text: 'look at @[that](dsh-session:d) please' }, alice.sessionToken) + assert.equal(foreign.ok, false, JSON.stringify(foreign)) + assert.equal(foreign.error.code, 'bot/forbidden') + const own = await call('room-post', { id: 'war', text: 'look at @[this](dsh-session:a) please' }, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(own)) + void bob +}) + + + +test('a granted remote Bot in the room counts as another owner for the brief', async (t) => { + const state = team() + state.rooms.home.participants.push({ kind: 'bot', remote: true, handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', plugin: 'social', joinedSeq: 0, joinedAt: 2 }) + const { runRound, signIn } = await start(t, { state }) + const alice = await signIn('Alice') + void alice + const turns = await runRound('home', 1) + assert.ok(turns[0], 'the member took its turn') + assert.ok(!turns[0].includes('Your own chat with the user'), 'a remote participant is another owner: no brief without a share') +})