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/2267-readonly-window-reads.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a read-only window the panels that only read now draw instead of reporting `bot/read-only`: the Projects panel, a Bot's task list, the folder picker's list and the connector status line answer again, while every write keeps its refusal ([#2267](https://github.com/ThinkFlowLab/FlyBot/issues/2267)).
11 changes: 10 additions & 1 deletion core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,16 @@ export function install(rt) {
// What a host that only reads the team may still answer.
// Updates change the dsh profile, not the team, so a reading host may run them too.
// 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', 'workflows'])
// The screens a second window still draws are reads of the same team: every Projects view
// (`projects`, `project-read`, `project-board`, `project-team`, `project-of`, `project-decision`,
// and the assistant memory `project-memory`), the task boards (`tasks`), the folder rows and
// browser (`folders`, `folder-list`) and the connector row's app sign-in state
// (`connector-app-status`). Each handler only reads; the write beside it (`project-create`,
// `project-bind`, `task-create`, `folder-pick`, `connector-connect`, ...) stays out, so a missing
// read here is an empty screen, never a change made through the reading window.
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', 'workflows',
'tasks', 'projects', 'project-read', 'project-board', 'project-team', 'project-of', 'project-decision', 'project-memory',
'folders', 'folder-list', 'connector-app-status'])

// 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, #106).
Expand Down
159 changes: 159 additions & 0 deletions core/test/readonly-window-reads.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// P-466 and P-468: a second window on the same team only reads (another dsh holds the team), and
// `channel.js` refuses everything outside its own `READS` name whitelist before `authorize()` runs.
// That whitelist was missing every read the window's own screens ask for besides `state`: the
// Projects list and panel (`projects`, `project-read`, `project-board`, `project-team`, `project-of`,
// `project-decision`, `project-memory`), the task boards (`tasks`), the folder rows and browser
// (`folders`, `folder-list`) and the connector row's app sign-in state (`connector-app-status`).
// The window drew those screens empty with no way to fill them, while the same names answer on a
// writable window. The fix adds exactly those reads; the writes stay refused.
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { hostname, tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { createRuntime, provideFlyBot } from '../index.js'
import { fakeContext } from './fake-ctx.js'

const seed = () => ({
version: 2,
revision: 1,
mainBotIds: ['a'],
members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } },
bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' } },
rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {},
tasks: {
'task-1': { id: 'task-1', title: 'Read the board', ownerBotId: 'a', column: 'waiting', createdAt: 1, updatedAt: 1, origin: { kind: 'bot' } },
},
projects: {
'project-p1': {
id: 'project-p1',
name: 'P1',
repository: 'ThinkFlowLab/FlyBot',
defaultBranch: 'main',
ownerUserId: 'owner-1',
createdAt: 1,
updatedAt: 1,
status: 'active',
roles: { maintainers: [], members: [] },
},
},
})

// The reads a read-only window draws: the Projects list and panel, the task boards, the folder
// rows and browser, and the connector row's app state. Each is a read in its handler.
const READS = [
['tasks', {}],
['projects', {}],
['project-read', { id: 'project-p1' }],
['project-of', { kind: 'bot', id: 'a' }],
['project-board', { id: 'project-p1' }],
['project-team', { id: 'project-p1' }],
['project-decision', { id: 'project-p1', path: 'decisions/2026-01-01-x.md' }],
['project-memory', { id: 'project-p1' }],
['folders', {}],
['folder-list', {}],
['connector-app-status', { id: 'composio' }],
]

// Writes the same screens offer; a read-only window must still be refused every one of them.
const WRITES = [
['task-create', { botId: 'a', title: 'x' }],
['project-create', { name: 'x' }],
['project-bind', { kind: 'bot', id: 'a', projectId: 'project-p1' }],
['project-memory-write', { id: 'project-p1', kind: 'note', text: 'x' }],
['connector-connect', { id: 'composio', token: 'x' }],
['set-prefs', { theme: 'deepseek' }],
['create-bot', { name: 'Writer' }],
['workflow-save', { id: 'x', doc: {} }],
]

async function start() {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-readonly-reads-'))
writeFileSync(join(home, 'state.json'), JSON.stringify(seed()))
const ctx = fakeContext()
let route
const register = ctx.connection.fetch.register
ctx.connection.fetch.register = (options) => { route = options; return register(options) }
Object.assign(ctx, {
llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) },
workspaceRegistry: { get: () => undefined, list: () => [], resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} },
sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async () => ({ agent: { session: { id: 's1' }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {} } }), selectModel: async () => {} },
sessionTitle: { rename: async () => {} },
sessions: { flush: async () => {} },
})
const rt = createRuntime(ctx, { home })
provideFlyBot(ctx, rt.flybot)
await rt.load()
const call = async (endpoint, payload = {}) => (await route.fetch(new Request('http://localhost/api/bot', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ endpoint, payload }),
}))).json()
const stop = async () => {
await ctx.dispose()
rmSync(home, { recursive: true, force: true, maxRetries: 5 })
}
return { rt, call, home, stop }
}

// Another dsh on this host holds the team: its pid answers, so `store.js` keeps this window
// read-only until the file is gone. `state.json` is left as this process wrote it, so adopting the
// other host's copy changes nothing the test reads.
const holdElsewhere = home => writeFileSync(join(home, 'state.lock'), JSON.stringify({ pid: process.ppid, host: hostname(), token: 'the-other-window', since: Date.now() }))

test('P-466/P-468: a read-only window answers the reads its own screens ask for', async () => {
const { call, home, stop } = await start()
try {
holdElsewhere(home)
// The setup itself: the state answer names the other window, so the gate below is the
// read-only one and not an anonymous-actor refusal.
const state = await call('state')
assert.equal(state.ok, true, JSON.stringify(state))
assert.match(String(state.value.readOnly), /open in another dsh/)
const refused = []
for (const [endpoint, payload] of READS) {
const answer = await call(endpoint, payload)
if (answer.error?.code === 'bot/read-only') refused.push(endpoint)
}
assert.deepEqual(refused, [], `a read-only window still answers these reads: ${refused.join(', ')}`)
// And they answer with the team's own data, not only past the gate.
const tasks = await call('tasks')
assert.equal(tasks.value.some(task => task.id === 'task-1'), true, JSON.stringify(tasks.value))
const projects = await call('projects')
assert.equal(projects.value.projects.some(project => project.id === 'project-p1'), true, JSON.stringify(projects.value))
const read = await call('project-read', { id: 'project-p1' })
assert.equal(read.value.project.id, 'project-p1')
} finally { await stop() }
})

test('P-466/P-468: the same window still has every write refused', async () => {
const { call, home, stop } = await start()
try {
holdElsewhere(home)
await call('state')
const allowed = []
for (const [endpoint, payload] of WRITES) {
const answer = await call(endpoint, payload)
if (answer.error?.code !== 'bot/read-only') allowed.push(endpoint)
}
assert.deepEqual(allowed, [], `writes stay refused: ${allowed.join(', ')}`)
} finally { await stop() }
})

test('P-466/P-468: the reads and the writes are unchanged once this window may write again', async () => {
const { call, home, stop } = await start()
try {
holdElsewhere(home)
await call('state')
// The other window closes; the next request takes the lock over.
unlinkSync(join(home, 'state.lock'))
const refused = []
for (const [endpoint, payload] of READS) {
const answer = await call(endpoint, payload)
if (answer.error?.code === 'bot/read-only') refused.push(endpoint)
}
assert.deepEqual(refused, [], `a writable window answers them as before: ${refused.join(', ')}`)
const created = await call('create-bot', { name: 'Writer' })
assert.equal(created.ok, true, JSON.stringify(created))
} finally { await stop() }
})
Loading
Loading