From b4ae460f30f61342b604fae6737a87be9a275fe0 Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 27 Oct 2026 04:26:48 +0000 Subject: [PATCH 1/3] fix(schedules): a copy and an edit read only the caller's own task (#1321) copies: core/ and tests only, patches/ds-bot/ left at main for a generate commit --- .../1321-schedules-owner-scope.fixed.md | 1 + core/src/host/channel.js | 4 +- core/src/host/schedules.js | 36 ++++- core/test/schedules.test.js | 146 ++++++++++++++++++ docs/subsystems/core-host.md | 5 +- 5 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 changelog.d/1321-schedules-owner-scope.fixed.md diff --git a/changelog.d/1321-schedules-owner-scope.fixed.md b/changelog.d/1321-schedules-owner-scope.fixed.md new file mode 100644 index 000000000..87a9c3973 --- /dev/null +++ b/changelog.d/1321-schedules-owner-scope.fixed.md @@ -0,0 +1 @@ +Copying a scheduled task, and editing one, now reads only a task of your own user: a copy from another member's Bot or from a shared room's task is refused instead of carrying that task's title and prompt into a new one, and the same holds for Edit, whose source the room-membership rule used to let a seated member through. The refusal is the existing `No such task` sentence, so a peer's task reads exactly like an unknown one. A deployment with nobody signed in keeps the single-human behavior it had. ([#1321](https://github.com/ThinkFlowLab/FlyBot/issues/1321)) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 7167b0c2e..3e6255d98 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -516,7 +516,9 @@ export function install(rt) { await rt.load() const leaked = unshareableReference(actor, [body.title, body.prompt].filter(part => typeof part === 'string').join('\n')) if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') - return ok(await rt.scheduleEndpoint(endpoint, body)) + // The actor goes down with the request: a copy's source and an edit's own Session + // are owner-scoped in the data layer, where authz cannot see them (#1321). + return ok(await rt.scheduleEndpoint(endpoint, body, actor)) } case 'memory-view': case 'memory-ask': diff --git a/core/src/host/schedules.js b/core/src/host/schedules.js index 57c07e512..5833317b8 100644 --- a/core/src/host/schedules.js +++ b/core/src/host/schedules.js @@ -167,6 +167,26 @@ export function install(rt) { return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId) } const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined) + // A source the caller names — a copy's `copyFrom`, an edit's own Session — is the caller's + // to read only while it belongs to the caller's own user (#1321). `rt.sessionOwnedBy` is not + // this rule: it also lets a room's current member through, and being able to speak in a room + // is not being allowed to read a task's text. A room's task is the user's only while every + // Bot in it is (`rt.roomOwnedBy`). Enforced only where someone has signed in; alone on the + // deployment the legacy single-human path is untouched. + const ownedByActor = (owner, actor) => { + if (owner === undefined) return false + if (owner.roomId !== undefined) return rt.roomOwnedBy?.(actor, owner.roomId) === true + return state.bots[owner.botId]?.ownerId === actor.memberId + } + // An unreadable source answers exactly as an unknown one does: one sentence ("No such + // task"), no second wording, and nothing about the other owner's task leaks. Where owner + // scoping is on, a missing actor fails closed: no actor is not a licence to read. + const sourceOf = (sessionId, actor) => { + const owner = ownerOf(sessionId) + if (owner === undefined) return undefined + if (rt.authzEnforced?.() && (!actor || !ownedByActor(owner, actor))) return undefined + return owner + } // Paused tasks, by their DSH id: { id, sessionId, record, pausedAt }. DSH ids are // unique across the host, so a paused task keeps the id it had. const paused = () => { @@ -317,11 +337,11 @@ export function install(rt) { // under it, and no delivery history. Nothing needs re-arming: the copy's instant is the // source's, and the source's own row already keeps it in the driver's global minimum. A // paused source has no next run to keep, so its copy goes through `create`, as Resume does. - async function copySchedule(body) { + async function copySchedule(body, actor) { const from = body.copyFrom ?? {} const sourceSession = String(from.sessionId ?? '') const sourceId = String(from.id ?? '') - const owner = ownerOf(sourceSession) + const owner = sourceOf(sourceSession, actor) if (owner === undefined) throw invalid('No such task') const held = pausedEntry(sourceSession, sourceId) const source = held !== undefined ? held.record : await findRecord(sourceSession, sourceId) @@ -392,11 +412,11 @@ export function install(rt) { // Edit: a paused task stays paused with its new content; an active one is updated in // place on the same Session, otherwise created anew before the old one is deleted. // `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). - async function updateSchedule(body) { + async function updateSchedule(body, actor) { ensureService() const sessionId = String(body.sessionId ?? '') const id = String(body.id ?? '') - if (ownerOf(sessionId) === undefined) throw invalid('No such task') + if (sourceOf(sessionId, actor) === undefined) throw invalid('No such task') const content = contentOf(body) const keep = body.when?.kind === 'keep' const held = pausedEntry(sessionId, id) @@ -556,16 +576,16 @@ export function install(rt) { const endpoints = { schedules: () => schedulesView(), - async 'schedule-create'(body) { + async 'schedule-create'(body, actor) { ensureService() - if (body.copyFrom !== undefined) return copySchedule(body) + if (body.copyFrom !== undefined) return copySchedule(body, actor) const { sessionId, title, prompt } = contentOf(body) const timing = selectorOf(body.when) refuseDuringQuietHours(timing, body.when?.timeZone) await service.create(sessionId, { title, prompt, ...timing }) return schedulesView() }, - 'schedule-update': body => updateSchedule(body), + 'schedule-update': (body, actor) => updateSchedule(body, actor), 'schedule-pause': body => pauseSchedule(body), 'schedule-resume': body => resumeSchedule(body), 'schedule-retry': body => retrySchedule(body), @@ -638,7 +658,7 @@ export function install(rt) { } Object.assign(rt, { - scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), + scheduleEndpoint: (endpoint, body, actor) => endpoints[endpoint](body ?? {}, actor), moveSchedules, scheduleRunStarted: runStarted, scheduleRunEnded: runEnded, diff --git a/core/test/schedules.test.js b/core/test/schedules.test.js index 71c58984e..5316953f9 100644 --- a/core/test/schedules.test.js +++ b/core/test/schedules.test.js @@ -680,3 +680,149 @@ test('#628: a Bot with no task in any Session gets an answer that is about the B const answer = await ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: 'w2' } }) assert.match(answer, /none — no scheduled task in any of its Sessions, so this is the whole list for the Bot/) }) + +// #1321: two owners on one host. `copyFrom` and `schedule-update` resolve a source Session the +// way the page does, and the host knowing that Session is not the caller being allowed to read +// its task: a copy or an edit may only touch a task of the caller's own user. +const TWO_OWNERS = { + version: 2, + mainBotIds: ['m1'], + revision: 1, + members: { + 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'm1' }, + 'owner-2': { id: 'owner-2', createdAt: 2 }, + }, + bots: { + m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', instructions: '', createdAt: 1, ownerId: 'owner-1' }, + a1: { id: 'a1', sessionId: 'a1', name: 'A Bot', role: '', instructions: '', createdAt: 2, ownerId: 'owner-1' }, + b1: { id: 'b1', sessionId: 'b1', name: 'B Bot', role: '', instructions: '', createdAt: 3, ownerId: 'owner-2' }, + }, + rooms: { + // One owner's room: every member Bot belongs to owner-1. + g3: { id: 'g3', name: 'Own room', named: true, members: ['a1'], admin: 'a1', mode: 'admin', notice: '', createdAt: 5, + participants: [{ kind: 'bot', id: 'a1', joinedSeq: 1 }, { kind: 'human', id: 'owner-1', joinedSeq: 1 }] }, + // A shared room: a Bot of each owner, and both humans seated. Membership is not ownership. + g2: { id: 'g2', name: 'Shared room', named: true, members: ['a1', 'b1'], admin: 'a1', mode: 'admin', notice: '', createdAt: 6, + participants: [{ kind: 'bot', id: 'a1', joinedSeq: 1 }, { kind: 'bot', id: 'b1', joinedSeq: 2 }, + { kind: 'human', id: 'owner-1', joinedSeq: 1 }, { kind: 'human', id: 'owner-2', joinedSeq: 2 }] }, + }, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +// The channel route with a member signed in: the cookie the browser sends is the token below. +function signedIn(t, team = TWO_OWNERS) { + const schedule = scheduleService() + const { ctx, home, rt } = setup({ schedule }, team) + rt.authenticate = async (token) => { + if (token === 'tok-a') return { memberId: 'owner-1', displayName: 'A' } + if (token === 'tok-b') return { memberId: 'owner-2', displayName: 'B' } + return null + } + const callAs = async (endpoint, payload, token) => { + const headers = { 'content-type': 'application/json' } + if (token) headers['x-flybot-member-session'] = token + return (await ctx.route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) }))).json() + } + return { ctx, home, schedule, rt, callAs } +} + +// One member signed in is what turns owner scoping on; the state file itself stays the team's. +const signedInNow = async (rt, callAs) => { + await callAs('schedules', {}, 'tok-a') + rt.state.authSessions = { 's-a': { memberId: 'owner-1', tokenHash: 'a' }, 's-b': { memberId: 'owner-2', tokenHash: 'b' } } +} + +test('#1321: a copy reads only the caller’s own task; a peer’s task is refused as unknown', async (t) => { + const { ctx, home, schedule, rt, callAs } = signedIn(t) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await signedInNow(rt, callAs) + const made = await callAs('schedule-create', { target: 'a1', title: 'Private', prompt: 'Read my mail', when: DAILY }, 'tok-a') + assert.equal(made.ok, true, JSON.stringify(made)) + const [id] = schedule.tasks.keys() + + // Another owner is refused, in the sentence an unknown source already gets: one sentence, + // never a second wording that would tell the caller the task is real. + const foreign = await callAs('schedule-create', { target: 'b1', copyFrom: { sessionId: 'a1', id } }, 'tok-b') + assert.equal(foreign.ok, false, 'a foreign task is not copyable') + assert.equal(foreign.error.message, 'No such task', 'the refusal is the existing sentence, verbatim') + + // Fail closed in the data layer: with owner scoping on, a request that reaches the endpoint + // with no actor is refused, not let through. (The route refuses it earlier, "Sign in first".) + await assert.rejects(() => rt.scheduleEndpoint('schedule-create', { target: 'm1', copyFrom: { sessionId: 'a1', id } }), /No such task/) +}) + +test('#1321: a refused copy writes nothing, and its refusal carries nothing of the source', async (t) => { + const { ctx, home, schedule, rt, callAs } = signedIn(t) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await signedInNow(rt, callAs) + const title = 'Board notes only the owner may read' + const made = await callAs('schedule-create', { target: 'a1', title, prompt: 'Summarise the private board', when: DAILY }, 'tok-a') + assert.equal(made.ok, true, JSON.stringify(made)) + const [id] = schedule.tasks.keys() + const before = schedule.tasks.size + + const foreign = await callAs('schedule-create', { target: 'b1', copyFrom: { sessionId: 'a1', id } }, 'tok-b') + assert.equal(foreign.ok, false, 'a foreign task is not copyable') + // Nothing was written: no new row, and the source is untouched. + assert.equal(schedule.tasks.size, before, 'the refused copy created nothing') + assert.equal(schedule.tasks.get(id).record.title, title) + assert.deepEqual([...schedule.tasks.values()].map(task => task.sessionId), ['a1']) + // Nothing of the source came back in the refusal: not the title, not a word of the prompt. + assert.equal(foreign.error.message, 'No such task') + const wire = JSON.stringify(foreign) + assert.ok(!wire.includes(title), 'the refusal does not echo the source title') + assert.ok(!wire.includes('private board'), 'the refusal does not echo the source prompt') + assert.ok(!wire.includes('a1'), 'the refusal does not name the source Session either') +}) + +test('#1321: with owner scoping on, the same user still copies their own task', async (t) => { + const { ctx, home, schedule, rt, callAs } = signedIn(t) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await signedInNow(rt, callAs) + assert.equal(rt.authzEnforced(), true, 'the guard is on in this case, or the assertion proves nothing') + const made = await callAs('schedule-create', { target: 'a1', title: 'Private', prompt: 'Read my mail', when: DAILY }, 'tok-a') + assert.equal(made.ok, true, JSON.stringify(made)) + const [id] = schedule.tasks.keys() + + const own = await callAs('schedule-create', { target: 'm1', copyFrom: { sessionId: 'a1', id } }, 'tok-a') + assert.equal(own.ok, true, JSON.stringify(own)) + assert.deepEqual([...schedule.tasks.values()].map(task => [task.sessionId, task.record.title]), [['a1', 'Private'], ['m1', 'Private']], 'the copy landed and carries the source text') +}) + +test('#1321: an edit reads only the caller’s own user — being seated in the room is not owning it', async (t) => { + const { ctx, home, schedule, rt, callAs } = signedIn(t) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await signedInNow(rt, callAs) + + // owner-1's own room: the task's Session is that room, and its member Bot is owner-1's. + const ownMade = await callAs('schedule-create', { target: 'g3', title: 'Brief', prompt: 'Sum up', when: DAILY }, 'tok-a') + assert.equal(ownMade.ok, true, JSON.stringify(ownMade)) + const ownId = [...schedule.tasks.values()].find(task => task.sessionId === 'g3').record.id + const own = await callAs('schedule-update', { sessionId: 'g3', id: ownId, target: 'g3', title: 'Renamed', prompt: 'x', when: DAILY }, 'tok-a') + assert.equal(own.ok, true, JSON.stringify(own)) + assert.equal(schedule.tasks.get(ownId).record.title, 'Renamed') + + // A shared room: owner-2 is a current human participant, so authz lets the request past. + // The data layer still refuses, because the room's member Bots are not all owner-2's. + const sharedMade = await callAs('schedule-create', { target: 'g2', title: 'Shared brief', prompt: 'Sum up the room', when: DAILY }, 'tok-a') + assert.equal(sharedMade.ok, true, JSON.stringify(sharedMade)) + const sharedId = [...schedule.tasks.values()].find(task => task.sessionId === 'g2').record.id + const foreign = await callAs('schedule-update', { sessionId: 'g2', id: sharedId, target: 'g2', title: 'Mine now', prompt: 'x', when: DAILY }, 'tok-b') + assert.equal(foreign.ok, false, 'membership is not ownership') + assert.match(foreign.error.message, /No such task/) + assert.equal(schedule.tasks.get(sharedId).record.title, 'Shared brief', 'the task was not rewritten') +}) + +test('#1321: with nobody signed in the single-human path still copies across Bots', async (t) => { + const { ctx, home, schedule, callAs } = signedIn(t) + t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) + await callAs('schedule-create', { target: 'a1', title: 'Private', prompt: 'Read my mail', when: DAILY }, 'tok-a') + const [id] = schedule.tasks.keys() + // Not signed in: `authzEnforced()` is false, so the legacy path is untouched — the same + // behavior a one-person deployment had before #1321. + const copied = await callAs('schedule-create', { target: 'b1', copyFrom: { sessionId: 'a1', id } }) + assert.equal(copied.ok, true, JSON.stringify(copied)) + assert.equal([...schedule.tasks.values()].at(-1).sessionId, 'b1') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 2fba3d2c8..8491b374e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -463,7 +463,7 @@ DSH's own `schedule_list` answers for the Session the call runs in, and its `[]` ### Scheduled tasks: edit, pause, missed runs (#354) -- **Edit** (`schedule-update`). On the same Session an active task goes through DSH's compare-and-update with the exact record `list` returned, so its id, next run and delivery records stay; the timing is sent only when it changed, so renaming a task does not move its next run. Moved to another Bot or group chat, or a finished one-time task, the new task is created first and the old one deleted after, so a failure keeps the old task (a part move needs no such create — it rewrites the stored row, below). `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). The edit needs the task's Session **and** its new target (`authz.js`), and its text gets the same chip check as `schedule-create`. +- **Edit** (`schedule-update`). On the same Session an active task goes through DSH's compare-and-update with the exact record `list` returned, so its id, next run and delivery records stay; the timing is sent only when it changed, so renaming a task does not move its next run. Moved to another Bot or group chat, or a finished one-time task, the new task is created first and the old one deleted after, so a failure keeps the old task (a part move needs no such create — it rewrites the stored row, below). `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). The edit needs the task's Session **and** its new target (`authz.js`), its text gets the same chip check as `schedule-create`, and its source must belong to the caller's own user (#1321, the rule in **Scheduled tasks: copy**) — authz admits a room's current member, so that last check lives in the data layer. - **Part move** (`moveSchedules`, #1245). A handoff is not an edit, so the earlier part's active tasks are **rewritten in place** in DSH's storage domain, inside `service.serialize`: the row keeps its id, record and delivery history and only `sessionId` changes to the fresh Session. The next run therefore stays the instant it was already due — creating the task again would set `now + interval` (the slip and reset history #1245 documents) — and a one-shot whose time has passed stays where it is. The due set is unchanged, so the runtime needs no re-arm: it re-reads the table before every delivery. A failed write leaves the task on the earlier part, with nothing half-deleted, which is the property #901 relies on. The write reaches DSH's storage domain directly — `getDomain()`, `serialize()`, `table('tasks')`, `put` and the `tasks` row shape are **private** in `@deepseek-ai/dsh-schedule@0.2.0-rc.2`, so re-check them on every DSH upgrade. A missing or renamed `getDomain()`, `serialize()` or `table('tasks')` throws before the loop: nothing moves and `moveSchedules` warns `ds-bot: tasks of %s not moved`. A missing or renamed `record` throws inside the loop, on the first row that passes the filter, so rows already written in that call stay moved — a uniform drift moves nothing, a partial one leaves it half done (the same `ds-bot: tasks of %s not moved` warn fires). A missing or renamed `put` throws per row: the per-row catch warns `ds-bot: task %s stayed with the earlier part`, and only that row stays behind while the loop continues. A renamed row field the filter reads (`sessionId`, `status`) throws nothing and warns nothing — every row is skipped silently, so a fake row carrying `state` instead of `status` is skipped and grepping either warning finds nothing, and those tasks silently stop firing rather than crash. A present `record` with a renamed `kind` reads as `kind: undefined`, so the one-shot test is false and an expired one-shot is moved to the fresh part; a renamed `scheduledAt` reads as undefined, so `Date.parse` gives NaN and the expiry check passes — those rows do not stop firing, they move and can fire there, an expired one-shot late. The tests model the same private shape, so they will not catch any of it. - **Pause / Resume** (`schedule-pause`, `schedule-resume`). DSH has no pause (its README: "Pause ... not supported"). Pause writes the task's record to `state.pausedSchedules[]` (`{ id, sessionId, record, pausedAt }`) and saves the team before DSH deletes the task; a failed delete takes the entry back out, so the task keeps running. Resume creates the task again on the Bot's current part (or the group's Session) before the entry goes, so a failed create leaves it paused. A paused task lists with `status: 'paused'` and its id, can be edited (it stays paused) or deleted, and a one-time task whose time passed while paused is refused until it is edited. A recurring task's next run is computed afresh on Resume. - **Missed runs** (DSH's behavior, checked against the pinned `@deepseek-ai/dsh-schedule`). Tasks are stored by the host and survive a restart, but run only while the app is running. After the app was closed, a recurring task (every N minutes, daily, weekly, cron) runs **once**, for its latest missed time, and then waits for its next future time: it never runs once per missed time. A one-time task whose time passed while the app was closed runs once when the app starts again. A paused task runs no missed time at all. DSH notes one exception it does not rule out: a crash or shutdown between delivering a reminder and saving that it did can deliver that one reminder again. Test: `a recurring task missed while the app was closed runs once, for its latest time` in `core/test/schedules.test.js`. @@ -476,7 +476,8 @@ A task can be copied to another Bot or group chat: the same title, prompt and ti - **No re-arm.** The copy's due instant is the source's, and the source's own row keeps it in the driver's global minimum, so the row is covered before it exists. - **A paused source** has no next run to keep, so its copy goes through `create` with the stored record's timing, as Resume does. A one-time task whose time has passed is refused with `Pick a time in the future`, the same sentence Edit uses, because such a copy could never run. - **Refusals.** An unknown source Session, an unknown id, the same Bot or group chat as the source, and an unknown target each get their own sentence; a copy to the same chat never falls through to `No such task`. Quiet hours are checked as on create (a repeat or cron rule is let through, as the view says). -- **Tests:** three in `core/test/schedules.test.js` — the copy's id, next run, empty history and untouched source; the four refusals; a daily rule's time and zone plus an earlier part's task going to the target's current part. +- **Same user only (#1321).** The source is the caller's to read only while it belongs to the caller's own user. `copySchedule` and `updateSchedule` resolve it through one `sourceOf(sessionId, actor)`, and where `authzEnforced()` is true an owner that is not the actor's answers exactly as an unknown source does — the existing `No such task`, no second sentence and nothing about the other member's task. Ownership here is the Bot's `ownerId`, or `rt.roomOwnedBy` for a room's own Session (every member Bot belongs to that user); it is deliberately **not** `rt.sessionOwnedBy`, which also admits a room's current human member, so being seated in a room is not owning its task and a shared room's task is nobody's single user's. The actor reaches the data layer as `scheduleEndpoint(endpoint, body, actor)` from the channel, because authz cannot see a `copyFrom` or an edit's source. A deployment with nobody signed in is untouched: the legacy single-human path still copies across Bots. +- **Tests:** four in `core/test/schedules.test.js` — the copy's id, next run, empty history and untouched source; the four refusals; a daily rule's time and zone plus an earlier part's task going to the target's current part; and the three owner-scope cases of #1321 (a peer's task refused by copy and by edit while the owner's own copy and edit stay green, and the unsigned single-human copy still allowed). ### Scheduled tasks: team quiet hours (#1166) From 99de30f59f704e672f0dc58d13c7346f332445fc Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Tue, 27 Oct 2026 12:32:39 +0800 Subject: [PATCH 2/3] chore(patches): regenerate the ds-bot mirrors for the owner-scope copy fix (#1321) `node scripts/ds-bot-patches.mjs generate` on top of b4ae460f, whose core changes (`src/host/schedules.js`, `src/host/channel.js`) live in numbered groups: `0001-core-fixes.patch` and `0005-social-seams.patch` carry them now. `patches/ds-bot/files/` is byte-identical, no group moved, and no core file or test is touched by this commit. --- patches/ds-bot/0001-core-fixes.patch | 201 ++++++++++++++++++++++--- patches/ds-bot/0005-social-seams.patch | 6 +- 2 files changed, 188 insertions(+), 19 deletions(-) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index af94f17ff..2b5c594b9 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -4934,10 +4934,30 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js // The settings form's timing as a create request selector. function selectorOf(when) { const zone = typeof when?.timeZone === 'string' && when.timeZone !== '' ? when.timeZone : 'UTC' -@@ -89,63 +89,482 @@ export function install(rt) { +@@ -89,63 +89,502 @@ export function install(rt) { return rt.groupOwners.get(sessionId) ?? rt.pastOwners.get(sessionId) } const known = owner => owner !== undefined && (owner.botId === undefined ? state.rooms[owner.roomId] !== undefined : state.bots[owner.botId] !== undefined) ++ // A source the caller names — a copy's `copyFrom`, an edit's own Session — is the caller's ++ // to read only while it belongs to the caller's own user (#1321). `rt.sessionOwnedBy` is not ++ // this rule: it also lets a room's current member through, and being able to speak in a room ++ // is not being allowed to read a task's text. A room's task is the user's only while every ++ // Bot in it is (`rt.roomOwnedBy`). Enforced only where someone has signed in; alone on the ++ // deployment the legacy single-human path is untouched. ++ const ownedByActor = (owner, actor) => { ++ if (owner === undefined) return false ++ if (owner.roomId !== undefined) return rt.roomOwnedBy?.(actor, owner.roomId) === true ++ return state.bots[owner.botId]?.ownerId === actor.memberId ++ } ++ // An unreadable source answers exactly as an unknown one does: one sentence ("No such ++ // task"), no second wording, and nothing about the other owner's task leaks. Where owner ++ // scoping is on, a missing actor fails closed: no actor is not a licence to read. ++ const sourceOf = (sessionId, actor) => { ++ const owner = ownerOf(sessionId) ++ if (owner === undefined) return undefined ++ if (rt.authzEnforced?.() && (!actor || !ownedByActor(owner, actor))) return undefined ++ return owner ++ } + // Paused tasks, by their DSH id: { id, sessionId, record, pausedAt }. DSH ids are + // unique across the host, so a paused task keeps the id it had. + const paused = () => { @@ -5088,11 +5108,11 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + // under it, and no delivery history. Nothing needs re-arming: the copy's instant is the + // source's, and the source's own row already keeps it in the driver's global minimum. A + // paused source has no next run to keep, so its copy goes through `create`, as Resume does. -+ async function copySchedule(body) { ++ async function copySchedule(body, actor) { + const from = body.copyFrom ?? {} + const sourceSession = String(from.sessionId ?? '') + const sourceId = String(from.id ?? '') -+ const owner = ownerOf(sourceSession) ++ const owner = sourceOf(sourceSession, actor) + if (owner === undefined) throw invalid('No such task') + const held = pausedEntry(sourceSession, sourceId) + const source = held !== undefined ? held.record : await findRecord(sourceSession, sourceId) @@ -5149,8 +5169,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js - tasks.push({ ...entry, ...owner }) + const lastRun = lastRunOf(entry) + tasks.push({ ...entry, ...owner, ...(lastRun ? { lastRun } : {}) }) - } -- return { available: true, tasks } ++ } + for (const entry of Object.values(paused())) { + const owner = ownerOf(entry.sessionId) + if (!known(owner)) continue @@ -5161,16 +5180,16 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + // claiming a silence FlyBot cannot keep. + const unchecked = quietHours.on === true && tasks.some(task => task.kind === 'every' || task.kind === 'cron') + return { available: true, tasks, quietHours, ...(unchecked ? { quietUnchecked: true } : {}) } - } - ++ } ++ + // Edit: a paused task stays paused with its new content; an active one is updated in + // place on the same Session, otherwise created anew before the old one is deleted. + // `when: { kind: 'keep' }` keeps the timing (a cron rule the form cannot show). -+ async function updateSchedule(body) { ++ async function updateSchedule(body, actor) { + ensureService() + const sessionId = String(body.sessionId ?? '') + const id = String(body.id ?? '') -+ if (ownerOf(sessionId) === undefined) throw invalid('No such task') ++ if (sourceOf(sessionId, actor) === undefined) throw invalid('No such task') + const content = contentOf(body) + const keep = body.when?.kind === 'keep' + const held = pausedEntry(sessionId, id) @@ -5271,10 +5290,11 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + void rt.save().catch(() => {}) + } + throw error -+ } + } +- return { available: true, tasks } + return schedulesView() -+ } -+ + } + + // Which Session a task lives in, named as the Bot reads it: its own chat (now, or an + // earlier part), or a group chat it speaks in (now, or an earlier part). + const placeOf = (sessionId, botId) => { @@ -5330,7 +5350,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js + const endpoints = { schedules: () => schedulesView(), - async 'schedule-create'(body) { +- async 'schedule-create'(body) { - if (service === null) throw invalid('Automation tasks are off in DSH') - const bot = state.bots[body.target] - const room = state.rooms[body.target] @@ -5341,15 +5361,16 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js - if (title.length > MAX_TITLE) throw invalid(`Keep the name under ${MAX_TITLE} characters`) - if (prompt === '') throw invalid('Say what the Bot should do') - await service.create(bot ? rt.chatOf(bot.id) : room.id, { title, prompt, ...selectorOf(body.when) }) ++ async 'schedule-create'(body, actor) { + ensureService() -+ if (body.copyFrom !== undefined) return copySchedule(body) ++ if (body.copyFrom !== undefined) return copySchedule(body, actor) + const { sessionId, title, prompt } = contentOf(body) + const timing = selectorOf(body.when) + refuseDuringQuietHours(timing, body.when?.timeZone) + await service.create(sessionId, { title, prompt, ...timing }) return schedulesView() }, -+ 'schedule-update': body => updateSchedule(body), ++ 'schedule-update': (body, actor) => updateSchedule(body, actor), + 'schedule-pause': body => pauseSchedule(body), + 'schedule-resume': body => resumeSchedule(body), + 'schedule-retry': body => retrySchedule(body), @@ -5439,7 +5460,7 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js - Object.assign(rt, { scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), moveSchedules }) + Object.assign(rt, { -+ scheduleEndpoint: (endpoint, body) => endpoints[endpoint](body ?? {}), ++ scheduleEndpoint: (endpoint, body, actor) => endpoints[endpoint](body ?? {}, actor), + moveSchedules, + scheduleRunStarted: runStarted, + scheduleRunEnded: runEnded, @@ -9639,7 +9660,7 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js const refused = await call('schedule-create', { target: 'm1', title: 'Briefing', prompt: 'Sum up', when: { kind: 'daily', time: '09:00', timeZone: 'Asia/Shanghai' } }) assert.equal(refused.ok, false) assert.match(refused.error.message, /Automation tasks are off/) -@@ -121,3 +121,508 @@ test('a due reminder reaches a group as its prompts', () => { +@@ -121,3 +121,654 @@ test('a due reminder reaches a group as its prompts', () => { assert.equal(reminderText(batch), '[Scheduled task] One\n[Scheduled task] Two') assert.equal(reminderText('plain text'), 'plain text') }) @@ -10148,6 +10169,152 @@ diff --git a/test/schedules.test.js b/test/schedules.test.js + const answer = await ctx.registeredTools.get('bot_schedules').execute({}, { agent: { id: 'w2' } }) + assert.match(answer, /none — no scheduled task in any of its Sessions, so this is the whole list for the Bot/) +}) ++ ++// #1321: two owners on one host. `copyFrom` and `schedule-update` resolve a source Session the ++// way the page does, and the host knowing that Session is not the caller being allowed to read ++// its task: a copy or an edit may only touch a task of the caller's own user. ++const TWO_OWNERS = { ++ version: 2, ++ mainBotIds: ['m1'], ++ revision: 1, ++ members: { ++ 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'm1' }, ++ 'owner-2': { id: 'owner-2', createdAt: 2 }, ++ }, ++ bots: { ++ m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', instructions: '', createdAt: 1, ownerId: 'owner-1' }, ++ a1: { id: 'a1', sessionId: 'a1', name: 'A Bot', role: '', instructions: '', createdAt: 2, ownerId: 'owner-1' }, ++ b1: { id: 'b1', sessionId: 'b1', name: 'B Bot', role: '', instructions: '', createdAt: 3, ownerId: 'owner-2' }, ++ }, ++ rooms: { ++ // One owner's room: every member Bot belongs to owner-1. ++ g3: { id: 'g3', name: 'Own room', named: true, members: ['a1'], admin: 'a1', mode: 'admin', notice: '', createdAt: 5, ++ participants: [{ kind: 'bot', id: 'a1', joinedSeq: 1 }, { kind: 'human', id: 'owner-1', joinedSeq: 1 }] }, ++ // A shared room: a Bot of each owner, and both humans seated. Membership is not ownership. ++ g2: { id: 'g2', name: 'Shared room', named: true, members: ['a1', 'b1'], admin: 'a1', mode: 'admin', notice: '', createdAt: 6, ++ participants: [{ kind: 'bot', id: 'a1', joinedSeq: 1 }, { kind: 'bot', id: 'b1', joinedSeq: 2 }, ++ { kind: 'human', id: 'owner-1', joinedSeq: 1 }, { kind: 'human', id: 'owner-2', joinedSeq: 2 }] }, ++ }, ++ exchanges: [], ++ exchangeTurns: {}, ++ questions: {}, ++} ++ ++// The channel route with a member signed in: the cookie the browser sends is the token below. ++function signedIn(t, team = TWO_OWNERS) { ++ const schedule = scheduleService() ++ const { ctx, home, rt } = setup({ schedule }, team) ++ rt.authenticate = async (token) => { ++ if (token === 'tok-a') return { memberId: 'owner-1', displayName: 'A' } ++ if (token === 'tok-b') return { memberId: 'owner-2', displayName: 'B' } ++ return null ++ } ++ const callAs = async (endpoint, payload, token) => { ++ const headers = { 'content-type': 'application/json' } ++ if (token) headers['x-flybot-member-session'] = token ++ return (await ctx.route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify({ endpoint, payload }) }))).json() ++ } ++ return { ctx, home, schedule, rt, callAs } ++} ++ ++// One member signed in is what turns owner scoping on; the state file itself stays the team's. ++const signedInNow = async (rt, callAs) => { ++ await callAs('schedules', {}, 'tok-a') ++ rt.state.authSessions = { 's-a': { memberId: 'owner-1', tokenHash: 'a' }, 's-b': { memberId: 'owner-2', tokenHash: 'b' } } ++} ++ ++test('#1321: a copy reads only the caller’s own task; a peer’s task is refused as unknown', async (t) => { ++ const { ctx, home, schedule, rt, callAs } = signedIn(t) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await signedInNow(rt, callAs) ++ const made = await callAs('schedule-create', { target: 'a1', title: 'Private', prompt: 'Read my mail', when: DAILY }, 'tok-a') ++ assert.equal(made.ok, true, JSON.stringify(made)) ++ const [id] = schedule.tasks.keys() ++ ++ // Another owner is refused, in the sentence an unknown source already gets: one sentence, ++ // never a second wording that would tell the caller the task is real. ++ const foreign = await callAs('schedule-create', { target: 'b1', copyFrom: { sessionId: 'a1', id } }, 'tok-b') ++ assert.equal(foreign.ok, false, 'a foreign task is not copyable') ++ assert.equal(foreign.error.message, 'No such task', 'the refusal is the existing sentence, verbatim') ++ ++ // Fail closed in the data layer: with owner scoping on, a request that reaches the endpoint ++ // with no actor is refused, not let through. (The route refuses it earlier, "Sign in first".) ++ await assert.rejects(() => rt.scheduleEndpoint('schedule-create', { target: 'm1', copyFrom: { sessionId: 'a1', id } }), /No such task/) ++}) ++ ++test('#1321: a refused copy writes nothing, and its refusal carries nothing of the source', async (t) => { ++ const { ctx, home, schedule, rt, callAs } = signedIn(t) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await signedInNow(rt, callAs) ++ const title = 'Board notes only the owner may read' ++ const made = await callAs('schedule-create', { target: 'a1', title, prompt: 'Summarise the private board', when: DAILY }, 'tok-a') ++ assert.equal(made.ok, true, JSON.stringify(made)) ++ const [id] = schedule.tasks.keys() ++ const before = schedule.tasks.size ++ ++ const foreign = await callAs('schedule-create', { target: 'b1', copyFrom: { sessionId: 'a1', id } }, 'tok-b') ++ assert.equal(foreign.ok, false, 'a foreign task is not copyable') ++ // Nothing was written: no new row, and the source is untouched. ++ assert.equal(schedule.tasks.size, before, 'the refused copy created nothing') ++ assert.equal(schedule.tasks.get(id).record.title, title) ++ assert.deepEqual([...schedule.tasks.values()].map(task => task.sessionId), ['a1']) ++ // Nothing of the source came back in the refusal: not the title, not a word of the prompt. ++ assert.equal(foreign.error.message, 'No such task') ++ const wire = JSON.stringify(foreign) ++ assert.ok(!wire.includes(title), 'the refusal does not echo the source title') ++ assert.ok(!wire.includes('private board'), 'the refusal does not echo the source prompt') ++ assert.ok(!wire.includes('a1'), 'the refusal does not name the source Session either') ++}) ++ ++test('#1321: with owner scoping on, the same user still copies their own task', async (t) => { ++ const { ctx, home, schedule, rt, callAs } = signedIn(t) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await signedInNow(rt, callAs) ++ assert.equal(rt.authzEnforced(), true, 'the guard is on in this case, or the assertion proves nothing') ++ const made = await callAs('schedule-create', { target: 'a1', title: 'Private', prompt: 'Read my mail', when: DAILY }, 'tok-a') ++ assert.equal(made.ok, true, JSON.stringify(made)) ++ const [id] = schedule.tasks.keys() ++ ++ const own = await callAs('schedule-create', { target: 'm1', copyFrom: { sessionId: 'a1', id } }, 'tok-a') ++ assert.equal(own.ok, true, JSON.stringify(own)) ++ assert.deepEqual([...schedule.tasks.values()].map(task => [task.sessionId, task.record.title]), [['a1', 'Private'], ['m1', 'Private']], 'the copy landed and carries the source text') ++}) ++ ++test('#1321: an edit reads only the caller’s own user — being seated in the room is not owning it', async (t) => { ++ const { ctx, home, schedule, rt, callAs } = signedIn(t) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await signedInNow(rt, callAs) ++ ++ // owner-1's own room: the task's Session is that room, and its member Bot is owner-1's. ++ const ownMade = await callAs('schedule-create', { target: 'g3', title: 'Brief', prompt: 'Sum up', when: DAILY }, 'tok-a') ++ assert.equal(ownMade.ok, true, JSON.stringify(ownMade)) ++ const ownId = [...schedule.tasks.values()].find(task => task.sessionId === 'g3').record.id ++ const own = await callAs('schedule-update', { sessionId: 'g3', id: ownId, target: 'g3', title: 'Renamed', prompt: 'x', when: DAILY }, 'tok-a') ++ assert.equal(own.ok, true, JSON.stringify(own)) ++ assert.equal(schedule.tasks.get(ownId).record.title, 'Renamed') ++ ++ // A shared room: owner-2 is a current human participant, so authz lets the request past. ++ // The data layer still refuses, because the room's member Bots are not all owner-2's. ++ const sharedMade = await callAs('schedule-create', { target: 'g2', title: 'Shared brief', prompt: 'Sum up the room', when: DAILY }, 'tok-a') ++ assert.equal(sharedMade.ok, true, JSON.stringify(sharedMade)) ++ const sharedId = [...schedule.tasks.values()].find(task => task.sessionId === 'g2').record.id ++ const foreign = await callAs('schedule-update', { sessionId: 'g2', id: sharedId, target: 'g2', title: 'Mine now', prompt: 'x', when: DAILY }, 'tok-b') ++ assert.equal(foreign.ok, false, 'membership is not ownership') ++ assert.match(foreign.error.message, /No such task/) ++ assert.equal(schedule.tasks.get(sharedId).record.title, 'Shared brief', 'the task was not rewritten') ++}) ++ ++test('#1321: with nobody signed in the single-human path still copies across Bots', async (t) => { ++ const { ctx, home, schedule, callAs } = signedIn(t) ++ t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) }) ++ await callAs('schedule-create', { target: 'a1', title: 'Private', prompt: 'Read my mail', when: DAILY }, 'tok-a') ++ const [id] = schedule.tasks.keys() ++ // Not signed in: `authzEnforced()` is false, so the legacy path is untouched — the same ++ // behavior a one-person deployment had before #1321. ++ const copied = await callAs('schedule-create', { target: 'b1', copyFrom: { sessionId: 'a1', id } }) ++ assert.equal(copied.ok, true, JSON.stringify(copied)) ++ assert.equal([...schedule.tasks.values()].at(-1).sessionId, 'b1') ++}) diff --git a/test/session-identity.test.js b/test/session-identity.test.js --- a/test/session-identity.test.js +++ b/test/session-identity.test.js diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 2a2ed9938..204e1d754 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -734,7 +734,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.load() return ok(await rt.connectorEndpoint(endpoint, body)) case 'capability-save': -@@ -241,11 +241,22 @@ export function install(rt) { +@@ -241,11 +241,24 @@ export function install(rt) { case 'bot-capability': await rt.load() return ok(await rt.capabilityEndpoint(endpoint, body)) @@ -753,7 +753,9 @@ diff --git a/src/host/channel.js b/src/host/channel.js + await rt.load() + const leaked = unshareableReference(actor, [body.title, body.prompt].filter(part => typeof part === 'string').join('\n')) + if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden') -+ return ok(await rt.scheduleEndpoint(endpoint, body)) ++ // The actor goes down with the request: a copy's source and an edit's own Session ++ // are owner-scoped in the data layer, where authz cannot see them (#1321). ++ return ok(await rt.scheduleEndpoint(endpoint, body, actor)) + } case 'memory-view': case 'memory-ask': From ddc9907e4257fb3fcfa709d8498c1f5a6f237421 Mon Sep 17 00:00:00 2001 From: Birfy Date: Tue, 27 Oct 2026 04:33:20 +0000 Subject: [PATCH 3/3] docs(core-host): the owner-scope fix adds five cases, not three (#1321) docs only: patches unaffected, no generate needed. --- docs/subsystems/core-host.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 8491b374e..91334fb2c 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -477,7 +477,7 @@ A task can be copied to another Bot or group chat: the same title, prompt and ti - **A paused source** has no next run to keep, so its copy goes through `create` with the stored record's timing, as Resume does. A one-time task whose time has passed is refused with `Pick a time in the future`, the same sentence Edit uses, because such a copy could never run. - **Refusals.** An unknown source Session, an unknown id, the same Bot or group chat as the source, and an unknown target each get their own sentence; a copy to the same chat never falls through to `No such task`. Quiet hours are checked as on create (a repeat or cron rule is let through, as the view says). - **Same user only (#1321).** The source is the caller's to read only while it belongs to the caller's own user. `copySchedule` and `updateSchedule` resolve it through one `sourceOf(sessionId, actor)`, and where `authzEnforced()` is true an owner that is not the actor's answers exactly as an unknown source does — the existing `No such task`, no second sentence and nothing about the other member's task. Ownership here is the Bot's `ownerId`, or `rt.roomOwnedBy` for a room's own Session (every member Bot belongs to that user); it is deliberately **not** `rt.sessionOwnedBy`, which also admits a room's current human member, so being seated in a room is not owning its task and a shared room's task is nobody's single user's. The actor reaches the data layer as `scheduleEndpoint(endpoint, body, actor)` from the channel, because authz cannot see a `copyFrom` or an edit's source. A deployment with nobody signed in is untouched: the legacy single-human path still copies across Bots. -- **Tests:** four in `core/test/schedules.test.js` — the copy's id, next run, empty history and untouched source; the four refusals; a daily rule's time and zone plus an earlier part's task going to the target's current part; and the three owner-scope cases of #1321 (a peer's task refused by copy and by edit while the owner's own copy and edit stay green, and the unsigned single-human copy still allowed). +- **Tests:** four in `core/test/schedules.test.js` — the copy's id, next run, empty history and untouched source; the four refusals; a daily rule's time and zone plus an earlier part's task going to the target's current part; and the five owner-scope cases of #1321 (a peer's task refused by copy and by edit; a refusal that writes nothing and carries nothing of the source; the owner's own copy still landing; and the unsigned single-human copy still allowed). ### Scheduled tasks: team quiet hours (#1166)