diff --git a/changelog.d/1759-project-assistant-owner.fixed.md b/changelog.d/1759-project-assistant-owner.fixed.md new file mode 100644 index 000000000..d799bbfcb --- /dev/null +++ b/changelog.d/1759-project-assistant-owner.fixed.md @@ -0,0 +1 @@ +The Project Assistant settings — the digest opt-out, the quiet window and the zone — can now be written by the Project's owner from the product: `project-assistant-owner` is dispatched by the host channel and authorization-checked like the other Project writes, so a non-owner gets `bot/forbidden` instead of `Unknown endpoint` ([#1759](https://github.com/ThinkFlowLab/FlyBot/issues/1759)). diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 5884e93ad..c0458de89 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -69,8 +69,11 @@ const TASK_ENDPOINTS = new Set([ // other wiring point was present, and a signed-in host answered `bot/not-found: Unknown // endpoint project-board` while `project-read` and `project-of` answered ALLOW. 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board', - // The assistant (#1712): its memory is per Project, and deleting a Project takes it along. - 'project-memory', 'project-memory-write', 'project-delete', + // The assistant (#1712): its memory is per Project, and deleting a Project takes it along. The + // owner controls (#1759) are a Project-wide write — the digest opt-out silences every member's + // 09:00 digest — so `project-assistant-owner` belongs here with them: dispatched but missing + // from this list would reach `store.js` with no owner check in front of it. + 'project-memory', 'project-memory-write', 'project-delete', 'project-assistant-owner', ]) const deny = (code, message) => ({ ok: false, code, message }) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index eee23cf1e..dd4a826e1 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -675,6 +675,7 @@ export function install(rt) { case 'project-delete': case 'project-memory': case 'project-memory-write': + case 'project-assistant-owner': await rt.load() return ok(await rt.projectEndpoint(endpoint, body, actor)) case 'share-brief': diff --git a/core/test/projects.test.js b/core/test/projects.test.js index 2a78a8ccc..ba2ad0d18 100644 --- a/core/test/projects.test.js +++ b/core/test/projects.test.js @@ -290,3 +290,35 @@ test('a Project carries an optional working folder and refuses a relative one', } } finally { await stop() } }) + +// #1759: `project-assistant-owner` had a store case and an owner gate (#1740) but no channel case +// and no `TASK_ENDPOINTS` entry, so every call failed `bot/not-found` and the digest opt-out, the +// quiet window and the zone were writable only in-process. There is no read endpoint: the second +// write's answer echoes the record the first one stored, so it proves the write went through the +// Project's own assistant rather than being accepted and dropped. +test('the Project owner writes the assistant settings through the host channel (#1759)', async () => { + const { call, stop } = await start() + try { + const project = created(await call('project-create', { name: 'Assistant', repository: 'acme/assistant' }, 'tok-a')) + assert.equal(typeof project.assistant?.botId, 'string', `the Project got an assistant: ${JSON.stringify(project.assistant)}`) + + // Before #1759 this answered `Unknown endpoint project-assistant-owner` / `bot/not-found` for + // both callers: the endpoint was not dispatched at all. + const refused = await call('project-assistant-owner', { id: project.id, settings: { off: true } }, 'tok-b') + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.equal(refused.error.code, 'bot/forbidden', JSON.stringify(refused)) + + const written = value(await call('project-assistant-owner', { + id: project.id, + settings: { off: true, quiet: { on: true, from: '09:00', to: '11:00' }, zone: 'Asia/Shanghai' }, + }, 'tok-a')) + assert.equal(written.owner, 'owner-1') + assert.equal(written.off, true) + assert.deepEqual(written.quiet, { on: true, from: '09:00', to: '11:00', zone: 'Asia/Shanghai' }) + assert.equal(written.changed, true) + + const reread = value(await call('project-assistant-owner', { id: project.id, settings: { off: false } }, 'tok-a')) + assert.equal(reread.off, false) + assert.deepEqual(reread.quiet, { on: true, from: '09:00', to: '11:00', zone: 'Asia/Shanghai' }, 'the window and the zone are still stored') + } finally { await stop() } +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 0245eae90..39dd9df8b 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -589,8 +589,11 @@ gate. The same check covers the record itself since #1740: `project-delete`, `pr non-owner gets `bot/forbidden` and the Project, its memory and the other owner's digest settings are unchanged afterwards — a refusal, never a silent no-op. `project-assistant-owner` also refuses a body whose `ownerId` is not the caller's own record, so even a Project's owner cannot rewrite -another owner's quiet window or opt-out before that endpoint is wired; an omitted `ownerId` means -the caller's own. The gate returns early on a missing id, so `project-memory` keeps the not-found +another owner's quiet window or opt-out; an omitted `ownerId` means the caller's own. Since #1759 +`project-assistant-owner` is dispatched by `channel.js` alongside the other `project-*` endpoints +and listed in `authz.js`'s `TASK_ENDPOINTS`, so the owner reaches the store case through the host +channel instead of only in-process; the two landed together because a dispatch without the +allow-list entry would reach `store.js` with the owner check bypassed. The gate returns early on a missing id, so `project-memory` keeps the not-found refusal it answered before rather than turning an unknown id into a 403. Tests: `core/test/project-owner-gate.test.js`. Reading the board (`project-board`) stays open to a non-owner: the same rows, read-only. @@ -706,4 +709,6 @@ the host side only, so the tier stays T2 and `core/src/shared/` is untouched. delivered to each owner's own Session through `deliver()` with no connector signed in, and the refusal is recorded on that digest's note (`GitHub write refused: …`) instead of suppressing the message. `Written-by:`/`Posted-for:` appear only when an identity was resolved. -- Endpoint `project-assistant-owner` (`{ id, ownerId, settings }`) exposes the controls. +- Endpoint `project-assistant-owner` (`{ id, ownerId, settings }`) exposes the controls: dispatched + by `channel.js` and in `authz.js`'s `TASK_ENDPOINTS` since #1759, with `requireProjectOwner` + refusing a non-owner with `bot/forbidden` before any record is written. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index aab3e019d..cf491f0ed 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -832,7 +832,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,32 +319,132 @@ export function install(rt) { +@@ -319,32 +319,133 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -865,6 +865,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js + case 'project-delete': + case 'project-memory': + case 'project-memory-write': ++ case 'project-assistant-owner': + await rt.load() + return ok(await rt.projectEndpoint(endpoint, body, actor)) + case 'share-brief': diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 5884e93ad..c0458de89 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -69,8 +69,11 @@ const TASK_ENDPOINTS = new Set([ // other wiring point was present, and a signed-in host answered `bot/not-found: Unknown // endpoint project-board` while `project-read` and `project-of` answered ALLOW. 'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board', - // The assistant (#1712): its memory is per Project, and deleting a Project takes it along. - 'project-memory', 'project-memory-write', 'project-delete', + // The assistant (#1712): its memory is per Project, and deleting a Project takes it along. The + // owner controls (#1759) are a Project-wide write — the digest opt-out silences every member's + // 09:00 digest — so `project-assistant-owner` belongs here with them: dispatched but missing + // from this list would reach `store.js` with no owner check in front of it. + 'project-memory', 'project-memory-write', 'project-delete', 'project-assistant-owner', ]) const deny = (code, message) => ({ ok: false, code, message }) diff --git a/patches/ds-bot/files/test/projects.test.js b/patches/ds-bot/files/test/projects.test.js index 2a78a8ccc..ba2ad0d18 100644 --- a/patches/ds-bot/files/test/projects.test.js +++ b/patches/ds-bot/files/test/projects.test.js @@ -290,3 +290,35 @@ test('a Project carries an optional working folder and refuses a relative one', } } finally { await stop() } }) + +// #1759: `project-assistant-owner` had a store case and an owner gate (#1740) but no channel case +// and no `TASK_ENDPOINTS` entry, so every call failed `bot/not-found` and the digest opt-out, the +// quiet window and the zone were writable only in-process. There is no read endpoint: the second +// write's answer echoes the record the first one stored, so it proves the write went through the +// Project's own assistant rather than being accepted and dropped. +test('the Project owner writes the assistant settings through the host channel (#1759)', async () => { + const { call, stop } = await start() + try { + const project = created(await call('project-create', { name: 'Assistant', repository: 'acme/assistant' }, 'tok-a')) + assert.equal(typeof project.assistant?.botId, 'string', `the Project got an assistant: ${JSON.stringify(project.assistant)}`) + + // Before #1759 this answered `Unknown endpoint project-assistant-owner` / `bot/not-found` for + // both callers: the endpoint was not dispatched at all. + const refused = await call('project-assistant-owner', { id: project.id, settings: { off: true } }, 'tok-b') + assert.equal(refused.ok, false, JSON.stringify(refused)) + assert.equal(refused.error.code, 'bot/forbidden', JSON.stringify(refused)) + + const written = value(await call('project-assistant-owner', { + id: project.id, + settings: { off: true, quiet: { on: true, from: '09:00', to: '11:00' }, zone: 'Asia/Shanghai' }, + }, 'tok-a')) + assert.equal(written.owner, 'owner-1') + assert.equal(written.off, true) + assert.deepEqual(written.quiet, { on: true, from: '09:00', to: '11:00', zone: 'Asia/Shanghai' }) + assert.equal(written.changed, true) + + const reread = value(await call('project-assistant-owner', { id: project.id, settings: { off: false } }, 'tok-a')) + assert.equal(reread.off, false) + assert.deepEqual(reread.quiet, { on: true, from: '09:00', to: '11:00', zone: 'Asia/Shanghai' }, 'the window and the zone are still stored') + } finally { await stop() } +})