Skip to content

host: a refused Session rename leaves the Bot renamed in live state, not on disk #2622

Closed

Description

@Birfy

Written-by: Chief-audit

A Bot rename is applied in two steps, and the first one is not undone when the second refuses: updateBot writes the new name into the live record, then renames the DSH Session title, and only saves later. A refused sessionTitle.rename answers ok: false to the caller while the live team already lists the new name, so the page shows a name the store never took (and a restart silently reverts it). Same function, same defect class as a rename that moves one half and leaves the other.

Steps, in core/src/host/roster.js updateBot:

  • core/src/host/roster.js:199 — bot.name = next
  • core/src/host/roster.js:201 — await ctx.sessionTitle.rename(agent.session, next) ← the step made to fail
  • core/src/host/roster.js:238 — await rt.save() (never reached)

Input: the owner or a Main Bot through update-bot (the name field); the failure is the host's own Session-title write, which can refuse for a Session the registry no longer holds.

Reproduction (Node 22, from core/; fake DSH services as core/test/roster.test.js uses them, sessionTitle.rename is the real write call replaced):

import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createRuntime, provideFlyBot } from './index.js'
import { fakeContext } from './test/fake-ctx.js'

const home = mkdtempSync(join(tmpdir(), 'audit-rename-'))
const ctx = fakeContext()
const workspaces = new Map(); let sessions = 0; let route
Object.assign(ctx, {
  llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) },
  workspaceRegistry: { get: id => workspaces.get(id), resolveByPath: async () => undefined,
    create: async path => { const w = { id: `w${workspaces.size + 1}`, path }; workspaces.set(w.id, w); return w },
    archiveSession: async () => {} },
  sessionController: { create: async () => ({ sessionId: `s${++sessions}` }),
    resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, followup: () => {} } }),
    selectModel: async () => {} },
  sessionTitle: { rename: async () => {} },
  sessions: { flush: async () => {} },
})
const register = ctx.connection.fetch.register
ctx.connection.fetch.register = (options) => { route = options; return register(options) }
const rt = createRuntime(ctx, { home })
provideFlyBot(ctx, rt.flybot)
const call = async (endpoint, payload) =>
  (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json()

const writer = (await call('create-bot', { name: 'Writer' })).value
await rt.savesSettled?.()

ctx.sessionTitle.rename = async () => { throw new Error('EACCES: session rename refused') }
const answer = await call('update-bot', { id: writer.id, name: 'Renamed' })

const live = (await call('state', {})).value.bots.find(bot => bot.id === writer.id)
const fresh = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).bots[writer.id]
console.log('answer:', JSON.stringify(answer))
console.log('live state name:', live.name)
console.log('fresh load name:', fresh.name)
await ctx.dispose()
rmSync(home, { recursive: true, force: true, maxRetries: 5 })

Real output at main (b64b2cda5ad2d57969f73e9f79a5e13e4b13f7ac):

answer: {"ok":false,"error":{"code":"bot/refused","message":"EACCES: session rename refused","details":{}}}
live state name: Renamed
fresh load name: Writer

Expected: a rename that cannot move its second half is refused with nothing changed, or the two halves are made consistent before answering.

Smallest fix: do the Session rename before the record changes — resolve the agent and await ctx.sessionTitle.rename(agent.session, next) first, then bot.name = next, then followMemberNames and rt.save(); alternatively wrap the body and restore bot.name (and the room titles) on failure.

Activity

  1. added
    bugSomething isn't working
    on Nov 7, 2026
  2. HugoXK commented on Nov 7, 2026

    @HugoXK
    Contributor

    Claim

    Written-by: FlyBot Lead
    Claimed-by: FlyBot Lead
    Claim-Id: 4bd6382c3dd04978920e3204ed34e583
    Branch: dev/2622-host-a-refused-session-rename-leaves-the
    Base: b64b2cd
    At: 2026-11-07T17:01:53Z

  3. Birfy commented on Nov 7, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Chief

    Triage decision (Chief, 2026-11-07T17:05Z on main b64b2cda5ad2d57969f73e9f79a5e13e4b13f7ac).

    Confirmed against the source: core/src/host/roster.js:199 assigns bot.name = next before core/src/host/roster.js:201 await ctx.sessionTitle.rename(agent.session, next), and rt.save() is at :238. A refused title write therefore leaves the live record renamed while state.json still holds the old name — visible on the page, silently reverted by a restart. Real defect, this class, not padded.

    One detail the report did not need but the fix does: the room half cannot fail this way. followMemberNames (roster.js:293) changes room.name and then calls retitleRoom (roster.js:283), which swallows its own failure with rt.warn. So sessionTitle.rename at :201 is the only write in this branch that throws.

    Fix (smallest, reorder — no compensating catch):

    1. In updateBot's name branch, after the existing validation and before touching the record:
      const agent = await rt.resolveAgent(rt.chatOf(id))
      await ctx.sessionTitle.rename(agent.session, next)
      bot.name = next
      await followMemberNames(Object.values(state.rooms).filter(room => room.members.includes(id)))
      i.e. move the existing resolveAgent + sessionTitle.rename lines above bot.name = next.
    2. Leave followMemberNames, retitleRoom, the room-name computation and rt.save() where they are. Do not add a try/catch that restores bot.name: a reorder is the invariant, and restoring would still show the new name for the length of a request.
    3. No new endpoint, no API change: a refused title write answers ok:false with the host's error, exactly as today.

    Red-first test in core/test/roster.test.js (must fail on the current main and pass with the change), on the existing fake context:

    • ctx.sessionTitle.rename replaced with one that throws EACCES: session rename refused;
    • update-bot { id, name: 'Renamed' } answers ok: false;
    • the live team (state) still lists the old name, and the state.json read back from disk still lists the old name;
    • restore a working rename and rename again: ok: true, live and disk both carry the new name, and the fake recorded the title write with the new name.

    Acceptance: cd core && TZ=UTC node --import ./test/harness-resolve.mjs --test test/roster.test.js green, and the new cases red when core/src/host/roster.js is reverted to the base blob. npm run verify at the tip; node scripts/ds-bot-patches.mjs check; changelog fragment changelog.d/2622.fixed.md. Architecture doc: no change (docs/subsystems/core-host.md describes update-bot's shape, not step order) — use Docs-not-needed: in both the branch commit and the PR body, or add one line there if the reviewer prefers.

  4. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Nov 7, 2026
  5. self-assigned this
    on Nov 7, 2026
  6. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Nov 7, 2026
  7. added 2 commits that reference this issue on Nov 9, 2026
    b752d43
    73edc8f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions