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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ New entries are written as one fragment file per change in [changelog.d/](change

### Added

- **Tracked tasks.** A request can be created as a task with a stable id, acceptance criteria, and a visible state — claimed, worked, blocked while a send waits for your approval, returned, and accepted by you (or cancelled). Wrong-account moves are refused, a task cannot be accepted before its result exists, and deleting the responsible Bot fails its tasks instead of dropping them. Task views in the chat are next (#57). ([#54](https://github.com/ThinkFlowLab/FlyBot/issues/54))
- **Private context stays home in shared rooms.** A Bot only carries its owner's chat brief into a group where another owner sits when the owner shares it (a per-room, revocable share from the Bot's owner — host API `share-brief` now, room UI next); memory reviews in such a room keep entries in the Bot's own memory, never the team's, and read only their own owner as "the user"; `list_bots` no longer discloses another owner's Bot folder or instructions; and referencing a chat you cannot read is refused. Previously received messages are not retroactively unseen. ([#53](https://github.com/ThinkFlowLab/FlyBot/issues/53))
- **Held sends are bound to their owner.** An approval card shows who sent the request (and when it was relayed between Bots); only the sender Bot's owner can approve, reject, or retry it, the list shows you only your own holds, and a decision is voided safely (`stale`, never sent) when the request changed after it was held or the Bot / Chief authority behind it is gone. ([#52](https://github.com/ThinkFlowLab/FlyBot/issues/52))
- **Remove a friend's Bot** from Friends' Bots hides it on this install, takes it out of rooms, and does not unfriend. It reappears only if it is granted again. Held Bot replies show in the chat as **Waiting for approval** (owner: Approve / Reject / Auto-send; others: waiting for the owner). ([#90](https://github.com/ThinkFlowLab/FlyBot/issues/90))
- **Face-to-face join registers a Bot for that room only** when it has no identity yet. `@flybot/social` calls the same `add_home_member` path used for invites, then joins. The identity is not polled, not listed for friends, and is revoked with `remove_home_member` when you leave the last face room or the room closes. A failed join that created one is revoked too so retry is clean. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83), [#89](https://github.com/ThinkFlowLab/FlyBot/issues/89), [#90](https://github.com/ThinkFlowLab/FlyBot/issues/90))
- **Face-to-face group.** People next to you join one group by typing the same 6-digit number. Joining is direct — no preview. The room is a normal group with the full member list as `<Bot> · <owner>'s Bot` (owner badges from #82), one-click **Leave**, and **Start chat** / **Enter chat** (voids the number; a 10-minute countdown while it is still a candidate). Joins, leaves, start, and peer messages render as system / attributed lines; Bot replies go through Bot Social (`pending_approval` uses the existing Approve / Reject queue). **Auto-send my Bot's room messages** in the room header (and **Approve and always auto-send in this room** / **Turn on auto-send** on a waiting draft) is one click onto `auto_approve_face_rooms`; default off, easy to turn off again. Peer text is fenced. Entry and waiting-room UI: #92. ([#83](https://github.com/ThinkFlowLab/FlyBot/issues/83))
Expand Down
11 changes: 10 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ var ZH = {
"Bot tree": "Bot 树",
"no longer shared": "已不再共享",
"Messages waiting for your approval": "等你批准的消息",
"Sent by {sender}": "由 {sender} 发送",
"Sent by {sender} · delegated": "由 {sender} 发送 · 委托转达",
"Not sent to {to} · {reason}": "未发给 {to} · {reason}",
"Message to {to} · {reason}": "发给 {to} 的消息 · {reason}",
failed: "失败",
Expand Down Expand Up @@ -1831,11 +1833,17 @@ function FlyBotApprovals() {
pending.map((approval) => {
const custom = entries2.find((entry) => entry.policy === void 0 || entry.policy === approval.by);
if (custom) return (0, import_react2.createElement)("div", { key: approval.id }, (0, import_react2.createElement)(Safe, { entry: custom, props: { approval, decide: (decision) => decide(approval.id, decision), call: call2 } }));
const to = typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to;
const to = approval.targetName ?? (typeof approval.request.to === "object" ? approval.request.to.handle : approval.request.to);
return (0, import_react2.createElement)(
"div",
{ key: approval.id, className: "bt-flybot-approval" },
(0, import_react2.createElement)("div", { className: "bt-flybot-approval-head" }, approval.status === "failed" ? t("Not sent to {to} · {reason}", { to, reason: approval.error ?? t("failed") }) : t("Message to {to} · {reason}", { to, reason: approval.reason })),
// Where the request came from (#52): the sending Bot, and when it was relayed.
approval.senderName !== void 0 ? (0, import_react2.createElement)(
"div",
{ className: "bt-flybot-approval-origin" },
approval.request.hop > 0 ? t("Sent by {sender} · delegated", { sender: approval.senderName }) : t("Sent by {sender}", { sender: approval.senderName })
) : null,
(0, import_react2.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text),
approval.request.attachments?.length ? (0, import_react2.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react2.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react2.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null,
(0, import_react2.createElement)(
Expand All @@ -1861,6 +1869,7 @@ var FLYBOT_CSS = `
.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; }
.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; }
.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; }
.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; }
.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; }
.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; }
.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; }
Expand Down
3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion core/src/client/flybot-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,12 @@ export function FlyBotApprovals() {
pending.map((approval) => {
const custom = entries.find(entry => entry.policy === undefined || entry.policy === approval.by)
if (custom) return h('div', { key: approval.id }, h(Safe, { entry: custom, props: { approval, decide: decision => decide(approval.id, decision), call } }))
const to = typeof approval.request.to === 'object' ? approval.request.to.handle : approval.request.to
const to = approval.targetName ?? (typeof approval.request.to === 'object' ? approval.request.to.handle : approval.request.to)
return h('div', { key: approval.id, className: 'bt-flybot-approval' },
h('div', { className: 'bt-flybot-approval-head' }, approval.status === 'failed' ? t('Not sent to {to} · {reason}', { to, reason: approval.error ?? t('failed') }) : t('Message to {to} · {reason}', { to, reason: approval.reason })),
// Where the request came from (#52): the sending Bot, and when it was relayed.
approval.senderName !== undefined ? h('div', { className: 'bt-flybot-approval-origin' },
approval.request.hop > 0 ? t('Sent by {sender} · delegated', { sender: approval.senderName }) : t('Sent by {sender}', { sender: approval.senderName })) : null,
h('div', { className: 'bt-flybot-approval-text' }, approval.request.text),
approval.request.attachments?.length
? h('ul', { className: 'bt-flybot-approval-files', 'aria-label': t('Attached files') }, approval.request.attachments.map((file, index) =>
Expand All @@ -172,6 +175,7 @@ export const FLYBOT_CSS = `
.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; }
.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; }
.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; }
.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; }
.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; }
.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; }
.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; }
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ const ZH = {
'Bot tree': 'Bot 树',
'no longer shared': '已不再共享',
'Messages waiting for your approval': '等你批准的消息',
'Sent by {sender}': '由 {sender} 发送',
'Sent by {sender} · delegated': '由 {sender} 发送 · 委托转达',
'Not sent to {to} · {reason}': '未发给 {to} · {reason}',
'Message to {to} · {reason}': '发给 {to} 的消息 · {reason}',
failed: '失败',
Expand Down
10 changes: 10 additions & 0 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ export function install(rt) {
// owners, so they follow the shared rule.
if (ep.startsWith('flybot/')) {
if (rt.flybotIsRead?.(ep.slice('flybot/'.length)) === true) return ALLOW
// Held-send decisions are per-owner enforced in hooks.js — the approval belongs
// to the sender Bot's owner — so deciding does not follow the shared rule (#52).
if (ep === 'flybot/decide') return ALLOW
if (!alone()) return deny('bot/blocked-shared', 'This plugin endpoint cannot be scoped per owner yet, so it is unavailable while the team has more than one member')
return ALLOW
}
Expand Down Expand Up @@ -319,6 +322,13 @@ 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
// Task transitions are requester-checked on the record in tasks.js (#54) — the
// endpoint only needs a signed-in actor, like held-send decisions.
if (ep === 'tasks' || ep.startsWith('task-')) return ALLOW

// Anything not in the table and not a known auth/read endpoint is denied.
if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW
if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check'
Expand Down
32 changes: 30 additions & 2 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -382,6 +396,19 @@ 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)
return ok(true)
case 'auth-whoami':
return ok(actor)
case 'auth-invite': {
Expand Down Expand Up @@ -411,8 +438,9 @@ export function install(rt) {
return ok(removed)
}
default:
// FlyBot plugins' endpoints (src/host/hooks.js).
if (endpoint.startsWith('flybot/')) return ok(await rt.flybotEndpoint(endpoint.slice('flybot/'.length), body))
// FlyBot plugins' endpoints (src/host/hooks.js). The actor rides along so
// owner-checked endpoints (held-send decisions) can bind it (#52).
if (endpoint.startsWith('flybot/')) return ok(await rt.flybotEndpoint(endpoint.slice('flybot/'.length), body, { actor }))
return fail(`Unknown endpoint ${endpoint}`, 'bot/not-found')
}
} catch (error) {
Expand Down
20 changes: 19 additions & 1 deletion core/src/host/groups.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand Down
Loading
Loading