Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/1759-project-assistant-owner.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
7 changes: 5 additions & 2 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
32 changes: 32 additions & 0 deletions core/test/projects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
})
11 changes: 8 additions & 3 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
3 changes: 2 additions & 1 deletion patches/ds-bot/0005-social-seams.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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':
Expand Down
7 changes: 5 additions & 2 deletions patches/ds-bot/files/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
32 changes: 32 additions & 0 deletions patches/ds-bot/files/test/projects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
})
Loading