From 6c6fe9f2a2c97c552287eeadf7cf37dbac112cc5 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Wed, 21 Oct 2026 20:07:56 +0800 Subject: [PATCH 1/4] Fail closed on a source kind turn-origin does not recognise (#620) `originOfSource` returned undefined, "nobody's request", from its default branch as well as from the two documented cases, so an unrecognised source kind in a Bot's own chat inherited the owner's standing approval after #616. The default now answers `other`; DSH's own notice kinds are listed explicitly so they still carry no verdict. One test also used a made-up `ds-bot` source kind to stand for a host notice; it now uses the real notice shape (kind `bot`, role `mention`). --- changelog.d/620-turn-origin-fail-closed.fixed.md | 1 + core/src/host/turn-origin.js | 12 ++++++++++-- core/test/group-status.test.js | 14 +++++++++++++- core/test/turn-origin.test.js | 5 +++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 16 ++++++++++++++-- patches/ds-bot/files/src/host/turn-origin.js | 12 ++++++++++-- patches/ds-bot/files/test/turn-origin.test.js | 5 +++++ 8 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 changelog.d/620-turn-origin-fail-closed.fixed.md diff --git a/changelog.d/620-turn-origin-fail-closed.fixed.md b/changelog.d/620-turn-origin-fail-closed.fixed.md new file mode 100644 index 000000000..da1da92a5 --- /dev/null +++ b/changelog.d/620-turn-origin-fail-closed.fixed.md @@ -0,0 +1 @@ +`turn-origin` now treats a message source kind it does not recognise as `other` instead of "nobody's request", so an unknown or future source arriving in a Bot's own chat no longer inherits the owner's standing approval: only DSH's own notice kinds and the documented mention/handoff cases carry no verdict ([#620](https://github.com/ThinkFlowLab/FlyBot/issues/620)). diff --git a/core/src/host/turn-origin.js b/core/src/host/turn-origin.js index 393466560..ae2dac2b3 100644 --- a/core/src/host/turn-origin.js +++ b/core/src/host/turn-origin.js @@ -11,7 +11,8 @@ // 'other' everything else that carries someone's words: a friend's message, a line in a room // with other people in it, a Bot of another owner, a relay nobody vouched for; // (nothing) a message that carries no one's request (DSH's own notices, a handoff note, a -// receipt): it neither grants nor takes away. +// receipt): it neither grants nor takes away. A source kind this version does not +// recognise is 'other', not nothing (#620). // // A turn's verdict is the strictest of its messages ('other' beats everything and sticks for the // rest of the turn), and a turn that took in no message with a verdict has none. The caller treats @@ -23,6 +24,9 @@ export const OTHER = 'other' export const SCHEDULED = 'scheduled' // The verdict of one message `source` (DSH's `user/message` event) to a Bot. +// DSH's own notices: a turn that took in only these carries nobody's request. +const NOTICE_KINDS = new Set(['tool', 'system-prompt', 'plan-mode', 'user-approval']) + export function originOfSource(source, bot) { if (source === undefined || source === null || typeof source !== 'object') return OTHER switch (source.kind) { @@ -41,7 +45,11 @@ export function originOfSource(source, bot) { if (source.handoff === true && source.resume !== true) return undefined return source.origin === OWNER || source.origin === SCHEDULED ? source.origin : OTHER default: - return undefined + if (NOTICE_KINDS.has(source.kind)) return undefined + // A kind this version does not recognise is not "nobody's request": the host cannot vouch for + // who is behind it, so it fails closed as 'other' (#620). Only the two documented no-request + // cases above may return undefined. + return OTHER } } diff --git a/core/test/group-status.test.js b/core/test/group-status.test.js index 8c898d6f6..c09d2cd33 100644 --- a/core/test/group-status.test.js +++ b/core/test/group-status.test.js @@ -363,7 +363,7 @@ test('#608: an own-chat turn that carries no verdict still honours the Bot grant assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'allowed-once') host.emit('b', 'turn/end', { reason: { kind: 'completed' } }) host.emit('b', 'turn/start', { turn: 3 }) - host.emit('b', 'user/message', { content: [{ type: 'text', text: 'note' }], source: { kind: 'ds-bot', role: 'mention' } }) + host.emit('b', 'user/message', { content: [{ type: 'text', text: 'note' }], source: { kind: 'bot', form: 'relay', role: 'mention' } }) assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'allowed-once') // Someone else's turn in that chat still asks, and says once why the grant was not used. host.emit('b', 'turn/end', { reason: { kind: 'completed' } }) @@ -372,6 +372,18 @@ test('#608: an own-chat turn that carries no verdict still honours the Bot grant assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'from-dsh') }) +// #620: the own-chat widening the own-chat widening in standingCovers is for a turn nobody's request is in, not for a +// source kind the host cannot read. An unrecognised kind fails closed, so the grant does not apply. +test('#620: an unrecognised source kind in the Bot’s own chat still asks', async (t) => { + const host = await start() + t.after(host.stop) + await openTurn(t, host) + await grantFromGroup(host, 'always-bot') + host.emit('b', 'turn/start', { turn: 2 }) + host.emit('b', 'user/message', { content: [{ type: 'text', text: 'from a future source' }], source: { kind: 'some-future-kind' } }) + assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'from-dsh') +}) + test('#608: a grant survives a restart and answers the same ask again', async (t) => { const first = await start() t.after(() => rmSync(first.home, { recursive: true, force: true, maxRetries: 5 })) diff --git a/core/test/turn-origin.test.js b/core/test/turn-origin.test.js index 40d793edf..1dafd9c21 100644 --- a/core/test/turn-origin.test.js +++ b/core/test/turn-origin.test.js @@ -29,6 +29,11 @@ test('each kind of message has one verdict, and a stamp the host did not write i for (const source of [{ kind: 'tool' }, { kind: 'system-prompt' }, { kind: 'plan-mode' }, { kind: 'user-approval' }, relay({ role: 'mention' }), relay({ role: 'handoff', handoff: true })]) { assert.equal(originOfSource(source, bot), undefined, JSON.stringify(source)) } + // #620: a source kind this version does not recognise is not a no-request message: the host + // cannot vouch for who is behind it, so it fails closed. + assert.equal(originOfSource({ kind: 'some-future-kind' }, bot), OTHER, 'an unrecognised kind is other') + assert.equal(originOfSource({ kind: 'some-future-kind', authorId: 'me' }, bot), OTHER, 'even one that claims an author') + assert.equal(originOfSource({ kind: '' }, bot), OTHER) // A handoff that goes on with a stopped turn is that turn's request, so it carries the turn's stamp. assert.equal(originOfSource(relay({ role: 'handoff', handoff: true, resume: true, origin: 'owner' }), bot), OWNER) assert.equal(originOfSource(relay({ role: 'handoff', handoff: true, resume: true }), bot), OTHER) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 33ebd16e9..02cd513ca 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -215,7 +215,7 @@ The connector tools are mounted once for the whole DSH profile, so who may use t **Decision (the owner, 2026-10-18, #369; it supersedes #346's "off by default, never on a shared Bot").** A connected connector is **on by default for the owner's own use** of every Bot (new, existing after the upgrade, and Bots that had no record). A Bot that is shared (grant, friend, face room, room code) is **still usable by its owner**. What is off by default is **other people's use**: when someone else drives the Bot, the tools are off unless the owner turned on that Bot's separate "also when others use it" switch, which is never disabled by sharing. - **Per-Bot records** (only what differs from the defaults is stored): `bot.connectors = { github?: false, composio?: false }` is an explicit off for the owner's own use (absent = on; a legacy `true` from #346 reads as on); `bot.connectorsShared = { github?: true, composio?: true }` is an explicit on for other people's use (absent = off), and it only counts while the owner's own use is on (others get what the owner has, never more). `bot-connectors { botId, connector, enabled, scope: 'owner' | 'shared' }` writes them: turning the owner's use off also drops the shared offer, and turning the shared offer on while the owner's use is off is refused. Sharing a Bot (a grant, a room) changes neither record. -- **Whose turn it is** (`src/host/turn-origin.js`) is read from the messages a Bot Session's turn took in (`user/message` events, tracked in `turns.js` on the Session's `live` entry), never from the Bot's own words. `owner`: the owner typed it (DSH's `source.kind: 'user'`, or a signed-in author equal to the Bot's `ownerId`), answered a question card, or a Bot of the **same owner** passed on a request that was itself the owner's (`deliver` stamps `source.origin`; `message_bot` passes the calling Session's own verdict, other callers the sender's running turns; a sender with no running turn, a Bot of another owner or a remote sender stamps `other`). `other`: a friend's message (any `source.remote`), a Bot of another owner, another signed-in member's line, a relay nobody stamped, and the end-of-turn replies, retries and deferred requests that carry their turn's verdict on. `scheduled`: a due scheduled task, which counts as the owner's only while the Bot is not shared (DSH gives the task no author and a Bot can create tasks). A relay keeps the sender's verdict, so a group round opened by a shared Bot's scheduled turn is `other` for every member, and an unshared member of such a round does not get the owner's verdict either (#581; each routine schedules its own Session instead of one shared Bot scheduling for the group). A message that carries no one's request (DSH's own notices, the handoff note of a part switch, a mention receipt) neither grants nor takes away. A turn's verdict is the strictest of its messages and **sticky**: a friend's line that lands in the owner's running turn makes the rest of that turn `other`, and the next turn starts clean. A turn with no message that has a verdict, no running turn at all, a Session whose Bot is gone, and a stamp the host did not write all count as **`other` (fail closed)**. +- **Whose turn it is** (`src/host/turn-origin.js`) is read from the messages a Bot Session's turn took in (`user/message` events, tracked in `turns.js` on the Session's `live` entry), never from the Bot's own words. `owner`: the owner typed it (DSH's `source.kind: 'user'`, or a signed-in author equal to the Bot's `ownerId`), answered a question card, or a Bot of the **same owner** passed on a request that was itself the owner's (`deliver` stamps `source.origin`; `message_bot` passes the calling Session's own verdict, other callers the sender's running turns; a sender with no running turn, a Bot of another owner or a remote sender stamps `other`). `other`: a friend's message (any `source.remote`), a Bot of another owner, another signed-in member's line, a relay nobody stamped, and the end-of-turn replies, retries and deferred requests that carry their turn's verdict on. `scheduled`: a due scheduled task, which counts as the owner's only while the Bot is not shared (DSH gives the task no author and a Bot can create tasks). A relay keeps the sender's verdict, so a group round opened by a shared Bot's scheduled turn is `other` for every member, and an unshared member of such a round does not get the owner's verdict either (#581; each routine schedules its own Session instead of one shared Bot scheduling for the group). A message that carries no one's request (DSH's own notices, the handoff note of a part switch, a mention receipt) neither grants nor takes away. A turn's verdict is the strictest of its messages and **sticky**: a friend's line that lands in the owner's running turn makes the rest of that turn `other`, and the next turn starts clean. A turn with no message that has a verdict, no running turn at all, a Session whose Bot is gone, and a stamp the host did not write all count as **`other` (fail closed)**. A source `kind` this version does not recognise is `other` too, never "nobody's request" (#620): only DSH's own notice kinds (`tool`, `system-prompt`, `plan-mode`, `user-approval`) and the documented mention/handoff cases carry no verdict, so a new source kind fails closed instead of inheriting the owner's standing approval. - **Rooms.** A Bot's turn in a room is read through the room as it is at the moment of the call (`othersInRoom` in `reach.js`): the owner's only while nobody but the owner and the owner's own Bots are in it; a friend's Bot, another person, another owner's Bot or a room joined by code makes every turn there `other`, because their lines are part of what the Bot reads. A room with other people therefore needs the Bot's "also when others use it" switch, even when the owner typed the line. A room of the owner's own Bots is the owner's only when what started the round was: a Bot post carries its calling turn's verdict (`post_to_group` stamps `source.origin`, a late reply stamps its turn's), the round takes the strictest verdict of the Bot posts that opened it and passes it to every member turn (`runTurn` → `deliver`), a post from a friend's Bot or one nobody stamped makes the round someone else's, and the `report_back` message to the poster is the owner's only when the round was and nobody else is in the room. Without this, a friend driving a Main Bot could post into an owner-only room and have the members use the owner's connectors (found in review of #370). This is the main place where the owner meets the shared-use switch on their own Bot; the owner's own chat with the Bot is never affected by sharing. - **Enforcement is two layers.** A `ctx.tools.guard` refuses every `mcp__github__*` / `mcp__composio__*` call from a Bot Session (its own chat, a group Session, an earlier part; a sub-agent Session is followed to its parent Bot Session and that Session's turn) that is not allowed. **It fails closed:** an Agent whose Bot cannot be determined (a parent Session that is gone, or a parent chain deeper than eight) gets no connector tools and every call is refused, so an unresolved sub-agent never reads as a plain owner Session; a friend's Bot (`bot.remote`) never gets them; only a chain that really ends at a Session of no Bot (the DSH Agent, a plain Session) keeps them. `agent.ctx.tools.restrict({ deny })` takes the same tools out of that Agent's tool list. The list is the courtesy and the guard the enforcement: an idle Session, or a turn whose first message is not in yet, is listed as for the owner's next message, and the guard still refuses any call whose turn is not the owner's. The mask is refreshed when the Agent is created, on every `resolveAgent` (delivery), when a switch changes, when a turn starts, takes in another message or ends (`connectorTurnChanged`, inside the Session's event, before the model sees the message), when who a Bot is shared with changes for any reason (a grant, a person, friend or other owner's Bot joining or leaving one of its rooms, a room joined by code: every save compares each Bot's sharing with the last time), and on `tools/change` (a connector that mounts or goes away later). DSH's hidden "Tool added/removed" notices for these tools are not drawn in the chat (`cells.js`). - **Turns that move or restart.** A turn that moves to a fresh part mid-way (`parts.js`) carries its verdict in the resume message's `origin`, and the resume record in `inflight.json` notes it, so a turn resumed after a restart keeps the owner's tools only when it was the owner's (a record with no verdict resumes as `other`). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index a767ad500..392bd7967 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -5059,7 +5059,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js } // Waits on real I/O (state writes) without timers, which some tests mock. -@@ -294,3 +294,143 @@ test('an approval asked inside a PTC program shows what that call does', async ( +@@ -294,3 +294,155 @@ test('an approval asked inside a PTC program shows what that call does', async ( assert.deepEqual(approval.detail, { text: 'Copy the doc', code: 'Copy-Item a.md E:/docs' }) assert.equal(value.work.bots.b.tool, 'pwsh') }) @@ -5132,7 +5132,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js + assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'allowed-once') + host.emit('b', 'turn/end', { reason: { kind: 'completed' } }) + host.emit('b', 'turn/start', { turn: 3 }) -+ host.emit('b', 'user/message', { content: [{ type: 'text', text: 'note' }], source: { kind: 'ds-bot', role: 'mention' } }) ++ host.emit('b', 'user/message', { content: [{ type: 'text', text: 'note' }], source: { kind: 'bot', form: 'relay', role: 'mention' } }) + assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'allowed-once') + // Someone else's turn in that chat still asks, and says once why the grant was not used. + host.emit('b', 'turn/end', { reason: { kind: 'completed' } }) @@ -5141,6 +5141,18 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js + assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'from-dsh') +}) + ++// #620: the own-chat widening the own-chat widening in standingCovers is for a turn nobody's request is in, not for a ++// source kind the host cannot read. An unrecognised kind fails closed, so the grant does not apply. ++test('#620: an unrecognised source kind in the Bot’s own chat still asks', async (t) => { ++ const host = await start() ++ t.after(host.stop) ++ await openTurn(t, host) ++ await grantFromGroup(host, 'always-bot') ++ host.emit('b', 'turn/start', { turn: 2 }) ++ host.emit('b', 'user/message', { content: [{ type: 'text', text: 'from a future source' }], source: { kind: 'some-future-kind' } }) ++ assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'from-dsh') ++}) ++ +test('#608: a grant survives a restart and answers the same ask again', async (t) => { + const first = await start() + t.after(() => rmSync(first.home, { recursive: true, force: true, maxRetries: 5 })) diff --git a/patches/ds-bot/files/src/host/turn-origin.js b/patches/ds-bot/files/src/host/turn-origin.js index 393466560..ae2dac2b3 100644 --- a/patches/ds-bot/files/src/host/turn-origin.js +++ b/patches/ds-bot/files/src/host/turn-origin.js @@ -11,7 +11,8 @@ // 'other' everything else that carries someone's words: a friend's message, a line in a room // with other people in it, a Bot of another owner, a relay nobody vouched for; // (nothing) a message that carries no one's request (DSH's own notices, a handoff note, a -// receipt): it neither grants nor takes away. +// receipt): it neither grants nor takes away. A source kind this version does not +// recognise is 'other', not nothing (#620). // // A turn's verdict is the strictest of its messages ('other' beats everything and sticks for the // rest of the turn), and a turn that took in no message with a verdict has none. The caller treats @@ -23,6 +24,9 @@ export const OTHER = 'other' export const SCHEDULED = 'scheduled' // The verdict of one message `source` (DSH's `user/message` event) to a Bot. +// DSH's own notices: a turn that took in only these carries nobody's request. +const NOTICE_KINDS = new Set(['tool', 'system-prompt', 'plan-mode', 'user-approval']) + export function originOfSource(source, bot) { if (source === undefined || source === null || typeof source !== 'object') return OTHER switch (source.kind) { @@ -41,7 +45,11 @@ export function originOfSource(source, bot) { if (source.handoff === true && source.resume !== true) return undefined return source.origin === OWNER || source.origin === SCHEDULED ? source.origin : OTHER default: - return undefined + if (NOTICE_KINDS.has(source.kind)) return undefined + // A kind this version does not recognise is not "nobody's request": the host cannot vouch for + // who is behind it, so it fails closed as 'other' (#620). Only the two documented no-request + // cases above may return undefined. + return OTHER } } diff --git a/patches/ds-bot/files/test/turn-origin.test.js b/patches/ds-bot/files/test/turn-origin.test.js index 40d793edf..1dafd9c21 100644 --- a/patches/ds-bot/files/test/turn-origin.test.js +++ b/patches/ds-bot/files/test/turn-origin.test.js @@ -29,6 +29,11 @@ test('each kind of message has one verdict, and a stamp the host did not write i for (const source of [{ kind: 'tool' }, { kind: 'system-prompt' }, { kind: 'plan-mode' }, { kind: 'user-approval' }, relay({ role: 'mention' }), relay({ role: 'handoff', handoff: true })]) { assert.equal(originOfSource(source, bot), undefined, JSON.stringify(source)) } + // #620: a source kind this version does not recognise is not a no-request message: the host + // cannot vouch for who is behind it, so it fails closed. + assert.equal(originOfSource({ kind: 'some-future-kind' }, bot), OTHER, 'an unrecognised kind is other') + assert.equal(originOfSource({ kind: 'some-future-kind', authorId: 'me' }, bot), OTHER, 'even one that claims an author') + assert.equal(originOfSource({ kind: '' }, bot), OTHER) // A handoff that goes on with a stopped turn is that turn's request, so it carries the turn's stamp. assert.equal(originOfSource(relay({ role: 'handoff', handoff: true, resume: true, origin: 'owner' }), bot), OWNER) assert.equal(originOfSource(relay({ role: 'handoff', handoff: true, resume: true }), bot), OTHER) From 00fa1859c2c301e67441269bcd5dba5fa3a8b346 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Wed, 21 Oct 2026 21:59:31 +0800 Subject: [PATCH 2/4] Cover the context kinds the host and DSH inject in ordinary owner turns The #620 allowlist only knew DSH's notice kinds, so a turn that took in this host's clock snapshot (ds-bot-time-context), DSH's clock (time-context), the snapshot of a cited conversation (session-reference) or the AGENTS.md baseline and changes (agent-instructions) became `other`: standingCovers skipped the owner's grant and the #608 card came back, and connectorTurnChanged masked the owner's tools for the rest of the turn. All four carry nobody's request, so they join the no-request allowlist; a kind the version does not know still fails closed (#626). --- core/src/host/turn-origin.js | 20 ++++++++++++++++--- core/test/group-status.test.js | 16 +++++++++++++++ core/test/turn-origin.test.js | 6 ++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 18 ++++++++++++++++- patches/ds-bot/files/src/host/turn-origin.js | 20 ++++++++++++++++--- patches/ds-bot/files/test/turn-origin.test.js | 6 ++++++ 7 files changed, 80 insertions(+), 8 deletions(-) diff --git a/core/src/host/turn-origin.js b/core/src/host/turn-origin.js index ae2dac2b3..3302ae56b 100644 --- a/core/src/host/turn-origin.js +++ b/core/src/host/turn-origin.js @@ -17,15 +17,29 @@ // A turn's verdict is the strictest of its messages ('other' beats everything and sticks for the // rest of the turn), and a turn that took in no message with a verdict has none. The caller treats // "none" as 'other' (fail closed). -import { REPLY_KIND, SOURCE_KIND } from './constants.js' +import { REFERENCE_KIND, REPLY_KIND, SOURCE_KIND } from './constants.js' export const OWNER = 'owner' export const OTHER = 'other' export const SCHEDULED = 'scheduled' // The verdict of one message `source` (DSH's `user/message` event) to a Bot. -// DSH's own notices: a turn that took in only these carries nobody's request. -const NOTICE_KINDS = new Set(['tool', 'system-prompt', 'plan-mode', 'user-approval']) +// The messages that carry nobody's request, by source kind: DSH's own notices; the clock snapshots +// this host injects (`ds-bot-time-context`, time-context.js `SOURCE`) and DSH injects +// (`time-context`); the snapshot of a cited conversation DSH logs after the message that cites it +// (`session-reference`, constants.js `REFERENCE_KIND`); and the AGENTS.md baseline and changes DSH +// injects as a user message (`agent-instructions`, @deepseek-ai/dsh-agent-instructions). Every +// other kind fails closed (#620, #626). +const NOTICE_KINDS = new Set([ + 'tool', + 'system-prompt', + 'plan-mode', + 'user-approval', + 'ds-bot-time-context', + 'time-context', + REFERENCE_KIND, + 'agent-instructions', +]) export function originOfSource(source, bot) { if (source === undefined || source === null || typeof source !== 'object') return OTHER diff --git a/core/test/group-status.test.js b/core/test/group-status.test.js index c09d2cd33..2f1d50fb5 100644 --- a/core/test/group-status.test.js +++ b/core/test/group-status.test.js @@ -446,3 +446,19 @@ test('a running Bot\'s work names its current step, in one short line, until the host.emit('gb', 'turn/end', { reason: { kind: 'error', error: new Error('boom') } }) assert.equal((await bots()).b, undefined) }) + +// #626: the host and DSH inject context into ordinary owner turns — this host's clock snapshot +// (`ds-bot-time-context`), DSH's own clock and cited-conversation snapshots, and the AGENTS.md +// baseline. None of them carries a request, so a turn that took one in still honours the owner's +// standing grant; treating one as `other` is what made the #608 card come back. +for (const kind of ['ds-bot-time-context', 'time-context', 'session-reference', 'agent-instructions']) { + test(`#626: a turn that took in a ${kind} message still honours the Bot grant`, async (t) => { + const host = await start() + t.after(host.stop) + await openTurn(t, host) + await grantFromGroup(host, 'always-bot') + host.emit('b', 'turn/start', { turn: 2 }) + host.emit('b', 'user/message', { content: [{ type: 'text', text: 'context' }], source: { kind, form: 'snapshot' } }) + assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'allowed-once') + }) +} diff --git a/core/test/turn-origin.test.js b/core/test/turn-origin.test.js index 1dafd9c21..8943abe91 100644 --- a/core/test/turn-origin.test.js +++ b/core/test/turn-origin.test.js @@ -29,6 +29,12 @@ test('each kind of message has one verdict, and a stamp the host did not write i for (const source of [{ kind: 'tool' }, { kind: 'system-prompt' }, { kind: 'plan-mode' }, { kind: 'user-approval' }, relay({ role: 'mention' }), relay({ role: 'handoff', handoff: true })]) { assert.equal(originOfSource(source, bot), undefined, JSON.stringify(source)) } + // #626: the contexts the host and DSH inject into ordinary owner turns carry nobody's request + // either, so they must not flip a turn to `other` (that dropped the standing grant of #608). + for (const kind of ['ds-bot-time-context', 'time-context', 'session-reference', 'agent-instructions']) { + assert.equal(originOfSource({ kind }, bot), undefined, kind) + assert.equal(originOfSource({ kind, form: 'snapshot' }, bot), undefined, `${kind} snapshot`) + } // #620: a source kind this version does not recognise is not a no-request message: the host // cannot vouch for who is behind it, so it fails closed. assert.equal(originOfSource({ kind: 'some-future-kind' }, bot), OTHER, 'an unrecognised kind is other') diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 02cd513ca..3aaf6f98c 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -215,7 +215,7 @@ The connector tools are mounted once for the whole DSH profile, so who may use t **Decision (the owner, 2026-10-18, #369; it supersedes #346's "off by default, never on a shared Bot").** A connected connector is **on by default for the owner's own use** of every Bot (new, existing after the upgrade, and Bots that had no record). A Bot that is shared (grant, friend, face room, room code) is **still usable by its owner**. What is off by default is **other people's use**: when someone else drives the Bot, the tools are off unless the owner turned on that Bot's separate "also when others use it" switch, which is never disabled by sharing. - **Per-Bot records** (only what differs from the defaults is stored): `bot.connectors = { github?: false, composio?: false }` is an explicit off for the owner's own use (absent = on; a legacy `true` from #346 reads as on); `bot.connectorsShared = { github?: true, composio?: true }` is an explicit on for other people's use (absent = off), and it only counts while the owner's own use is on (others get what the owner has, never more). `bot-connectors { botId, connector, enabled, scope: 'owner' | 'shared' }` writes them: turning the owner's use off also drops the shared offer, and turning the shared offer on while the owner's use is off is refused. Sharing a Bot (a grant, a room) changes neither record. -- **Whose turn it is** (`src/host/turn-origin.js`) is read from the messages a Bot Session's turn took in (`user/message` events, tracked in `turns.js` on the Session's `live` entry), never from the Bot's own words. `owner`: the owner typed it (DSH's `source.kind: 'user'`, or a signed-in author equal to the Bot's `ownerId`), answered a question card, or a Bot of the **same owner** passed on a request that was itself the owner's (`deliver` stamps `source.origin`; `message_bot` passes the calling Session's own verdict, other callers the sender's running turns; a sender with no running turn, a Bot of another owner or a remote sender stamps `other`). `other`: a friend's message (any `source.remote`), a Bot of another owner, another signed-in member's line, a relay nobody stamped, and the end-of-turn replies, retries and deferred requests that carry their turn's verdict on. `scheduled`: a due scheduled task, which counts as the owner's only while the Bot is not shared (DSH gives the task no author and a Bot can create tasks). A relay keeps the sender's verdict, so a group round opened by a shared Bot's scheduled turn is `other` for every member, and an unshared member of such a round does not get the owner's verdict either (#581; each routine schedules its own Session instead of one shared Bot scheduling for the group). A message that carries no one's request (DSH's own notices, the handoff note of a part switch, a mention receipt) neither grants nor takes away. A turn's verdict is the strictest of its messages and **sticky**: a friend's line that lands in the owner's running turn makes the rest of that turn `other`, and the next turn starts clean. A turn with no message that has a verdict, no running turn at all, a Session whose Bot is gone, and a stamp the host did not write all count as **`other` (fail closed)**. A source `kind` this version does not recognise is `other` too, never "nobody's request" (#620): only DSH's own notice kinds (`tool`, `system-prompt`, `plan-mode`, `user-approval`) and the documented mention/handoff cases carry no verdict, so a new source kind fails closed instead of inheriting the owner's standing approval. +- **Whose turn it is** (`src/host/turn-origin.js`) is read from the messages a Bot Session's turn took in (`user/message` events, tracked in `turns.js` on the Session's `live` entry), never from the Bot's own words. `owner`: the owner typed it (DSH's `source.kind: 'user'`, or a signed-in author equal to the Bot's `ownerId`), answered a question card, or a Bot of the **same owner** passed on a request that was itself the owner's (`deliver` stamps `source.origin`; `message_bot` passes the calling Session's own verdict, other callers the sender's running turns; a sender with no running turn, a Bot of another owner or a remote sender stamps `other`). `other`: a friend's message (any `source.remote`), a Bot of another owner, another signed-in member's line, a relay nobody stamped, and the end-of-turn replies, retries and deferred requests that carry their turn's verdict on. `scheduled`: a due scheduled task, which counts as the owner's only while the Bot is not shared (DSH gives the task no author and a Bot can create tasks). A relay keeps the sender's verdict, so a group round opened by a shared Bot's scheduled turn is `other` for every member, and an unshared member of such a round does not get the owner's verdict either (#581; each routine schedules its own Session instead of one shared Bot scheduling for the group). A message that carries no one's request (DSH's own notices, the handoff note of a part switch, a mention receipt) neither grants nor takes away. A turn's verdict is the strictest of its messages and **sticky**: a friend's line that lands in the owner's running turn makes the rest of that turn `other`, and the next turn starts clean. A turn with no message that has a verdict, no running turn at all, a Session whose Bot is gone, and a stamp the host did not write all count as **`other` (fail closed)**. A source `kind` this version does not recognise is `other` too, never "nobody's request" (#620): only DSH's own notice kinds (`tool`, `system-prompt`, `plan-mode`, `user-approval`) and the documented mention/handoff cases carry no verdict, so a new source kind fails closed instead of inheriting the owner's standing approval. The contexts the host and DSH inject into an ordinary owner turn carry no verdict either, or they would flip that turn to `other`: this host's clock snapshot (`ds-bot-time-context`, `time-context.js` `SOURCE`), DSH's clock (`time-context`), DSH's snapshot of a cited conversation (`session-reference`, `constants.js` `REFERENCE_KIND`), and the AGENTS.md baseline and changes DSH injects as a user message (`agent-instructions`); each has its own case in `core/test/turn-origin.test.js` and `core/test/group-status.test.js` (#626). - **Rooms.** A Bot's turn in a room is read through the room as it is at the moment of the call (`othersInRoom` in `reach.js`): the owner's only while nobody but the owner and the owner's own Bots are in it; a friend's Bot, another person, another owner's Bot or a room joined by code makes every turn there `other`, because their lines are part of what the Bot reads. A room with other people therefore needs the Bot's "also when others use it" switch, even when the owner typed the line. A room of the owner's own Bots is the owner's only when what started the round was: a Bot post carries its calling turn's verdict (`post_to_group` stamps `source.origin`, a late reply stamps its turn's), the round takes the strictest verdict of the Bot posts that opened it and passes it to every member turn (`runTurn` → `deliver`), a post from a friend's Bot or one nobody stamped makes the round someone else's, and the `report_back` message to the poster is the owner's only when the round was and nobody else is in the room. Without this, a friend driving a Main Bot could post into an owner-only room and have the members use the owner's connectors (found in review of #370). This is the main place where the owner meets the shared-use switch on their own Bot; the owner's own chat with the Bot is never affected by sharing. - **Enforcement is two layers.** A `ctx.tools.guard` refuses every `mcp__github__*` / `mcp__composio__*` call from a Bot Session (its own chat, a group Session, an earlier part; a sub-agent Session is followed to its parent Bot Session and that Session's turn) that is not allowed. **It fails closed:** an Agent whose Bot cannot be determined (a parent Session that is gone, or a parent chain deeper than eight) gets no connector tools and every call is refused, so an unresolved sub-agent never reads as a plain owner Session; a friend's Bot (`bot.remote`) never gets them; only a chain that really ends at a Session of no Bot (the DSH Agent, a plain Session) keeps them. `agent.ctx.tools.restrict({ deny })` takes the same tools out of that Agent's tool list. The list is the courtesy and the guard the enforcement: an idle Session, or a turn whose first message is not in yet, is listed as for the owner's next message, and the guard still refuses any call whose turn is not the owner's. The mask is refreshed when the Agent is created, on every `resolveAgent` (delivery), when a switch changes, when a turn starts, takes in another message or ends (`connectorTurnChanged`, inside the Session's event, before the model sees the message), when who a Bot is shared with changes for any reason (a grant, a person, friend or other owner's Bot joining or leaving one of its rooms, a room joined by code: every save compares each Bot's sharing with the last time), and on `tools/change` (a connector that mounts or goes away later). DSH's hidden "Tool added/removed" notices for these tools are not drawn in the chat (`cells.js`). - **Turns that move or restart.** A turn that moves to a fresh part mid-way (`parts.js`) carries its verdict in the resume message's `origin`, and the resume record in `inflight.json` notes it, so a turn resumed after a restart keeps the owner's tools only when it was the owner's (a record with no verdict resumes as `other`). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 392bd7967..25aaa9879 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -5059,7 +5059,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js } // Waits on real I/O (state writes) without timers, which some tests mock. -@@ -294,3 +294,155 @@ test('an approval asked inside a PTC program shows what that call does', async ( +@@ -294,3 +294,171 @@ assert.deepEqual(approval.detail, { text: 'Copy the doc', code: 'Copy-Item a.md E:/docs' }) assert.equal(value.work.bots.b.tool, 'pwsh') }) @@ -5215,6 +5215,22 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js + host.emit('gb', 'turn/end', { reason: { kind: 'error', error: new Error('boom') } }) + assert.equal((await bots()).b, undefined) +}) ++ ++// #626: the host and DSH inject context into ordinary owner turns — this host's clock snapshot ++// (`ds-bot-time-context`), DSH's own clock and cited-conversation snapshots, and the AGENTS.md ++// baseline. None of them carries a request, so a turn that took one in still honours the owner's ++// standing grant; treating one as `other` is what made the #608 card come back. ++for (const kind of ['ds-bot-time-context', 'time-context', 'session-reference', 'agent-instructions']) { ++ test(`#626: a turn that took in a ${kind} message still honours the Bot grant`, async (t) => { ++ const host = await start() ++ t.after(host.stop) ++ await openTurn(t, host) ++ await grantFromGroup(host, 'always-bot') ++ host.emit('b', 'turn/start', { turn: 2 }) ++ host.emit('b', 'user/message', { content: [{ type: 'text', text: 'context' }], source: { kind, form: 'snapshot' } }) ++ assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'allowed-once') ++ }) ++} diff --git a/test/group-work.test.js b/test/group-work.test.js --- a/test/group-work.test.js +++ b/test/group-work.test.js diff --git a/patches/ds-bot/files/src/host/turn-origin.js b/patches/ds-bot/files/src/host/turn-origin.js index ae2dac2b3..3302ae56b 100644 --- a/patches/ds-bot/files/src/host/turn-origin.js +++ b/patches/ds-bot/files/src/host/turn-origin.js @@ -17,15 +17,29 @@ // A turn's verdict is the strictest of its messages ('other' beats everything and sticks for the // rest of the turn), and a turn that took in no message with a verdict has none. The caller treats // "none" as 'other' (fail closed). -import { REPLY_KIND, SOURCE_KIND } from './constants.js' +import { REFERENCE_KIND, REPLY_KIND, SOURCE_KIND } from './constants.js' export const OWNER = 'owner' export const OTHER = 'other' export const SCHEDULED = 'scheduled' // The verdict of one message `source` (DSH's `user/message` event) to a Bot. -// DSH's own notices: a turn that took in only these carries nobody's request. -const NOTICE_KINDS = new Set(['tool', 'system-prompt', 'plan-mode', 'user-approval']) +// The messages that carry nobody's request, by source kind: DSH's own notices; the clock snapshots +// this host injects (`ds-bot-time-context`, time-context.js `SOURCE`) and DSH injects +// (`time-context`); the snapshot of a cited conversation DSH logs after the message that cites it +// (`session-reference`, constants.js `REFERENCE_KIND`); and the AGENTS.md baseline and changes DSH +// injects as a user message (`agent-instructions`, @deepseek-ai/dsh-agent-instructions). Every +// other kind fails closed (#620, #626). +const NOTICE_KINDS = new Set([ + 'tool', + 'system-prompt', + 'plan-mode', + 'user-approval', + 'ds-bot-time-context', + 'time-context', + REFERENCE_KIND, + 'agent-instructions', +]) export function originOfSource(source, bot) { if (source === undefined || source === null || typeof source !== 'object') return OTHER diff --git a/patches/ds-bot/files/test/turn-origin.test.js b/patches/ds-bot/files/test/turn-origin.test.js index 1dafd9c21..8943abe91 100644 --- a/patches/ds-bot/files/test/turn-origin.test.js +++ b/patches/ds-bot/files/test/turn-origin.test.js @@ -29,6 +29,12 @@ test('each kind of message has one verdict, and a stamp the host did not write i for (const source of [{ kind: 'tool' }, { kind: 'system-prompt' }, { kind: 'plan-mode' }, { kind: 'user-approval' }, relay({ role: 'mention' }), relay({ role: 'handoff', handoff: true })]) { assert.equal(originOfSource(source, bot), undefined, JSON.stringify(source)) } + // #626: the contexts the host and DSH inject into ordinary owner turns carry nobody's request + // either, so they must not flip a turn to `other` (that dropped the standing grant of #608). + for (const kind of ['ds-bot-time-context', 'time-context', 'session-reference', 'agent-instructions']) { + assert.equal(originOfSource({ kind }, bot), undefined, kind) + assert.equal(originOfSource({ kind, form: 'snapshot' }, bot), undefined, `${kind} snapshot`) + } // #620: a source kind this version does not recognise is not a no-request message: the host // cannot vouch for who is behind it, so it fails closed. assert.equal(originOfSource({ kind: 'some-future-kind' }, bot), OTHER, 'an unrecognised kind is other') From 030605609b8c4c57709441199a1b515b669e0556 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Wed, 21 Oct 2026 22:42:20 +0800 Subject: [PATCH 3/4] List every no-request source kind, and keep the list honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The INC on #626: the allowlist missed kinds that DSH injects into a turn that is already running — `subagent-settled` steers the owner's turn whenever the Bot uses `subagent`, and `compact-checkpoint`, `model-selection`, `working-directory`, `ptc-mode`, `skill-invocation` and `cordis-host-runner` arrive the same way. Each read as `other`, so the connector tools were taken away mid-turn: the symptom #626 exists to fix, through another door. The list now names every kind with the package that produces it, and says why `form` cannot stand in for it: `working-directory`, `ptc-mode` and `cordis-host-runner` declare no form, while `webhook` declares `form: 'notice'` and is an outside delivery, so `webhook`, `agent-message` and `team-message` stay `other`. A `goal` round continues a goal the Session already has, so it reads like a due task (the owner's while the Bot is not shared) instead of failing closed and losing the owner's tools. A test fails when the pinned DSH version moves, so the map is re-read on the bump rather than discovered months later as a lost tool. --- core/src/host/turn-origin.js | 50 +++++++++++++++---- core/test/group-status.test.js | 15 ++++++ core/test/turn-origin.test.js | 35 ++++++++++++- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 21 ++++++-- patches/ds-bot/files/src/host/turn-origin.js | 50 +++++++++++++++---- patches/ds-bot/files/test/turn-origin.test.js | 35 ++++++++++++- 7 files changed, 182 insertions(+), 26 deletions(-) diff --git a/core/src/host/turn-origin.js b/core/src/host/turn-origin.js index 3302ae56b..a99b3df02 100644 --- a/core/src/host/turn-origin.js +++ b/core/src/host/turn-origin.js @@ -23,22 +23,44 @@ export const OWNER = 'owner' export const OTHER = 'other' export const SCHEDULED = 'scheduled' -// The verdict of one message `source` (DSH's `user/message` event) to a Bot. -// The messages that carry nobody's request, by source kind: DSH's own notices; the clock snapshots -// this host injects (`ds-bot-time-context`, time-context.js `SOURCE`) and DSH injects -// (`time-context`); the snapshot of a cited conversation DSH logs after the message that cites it -// (`session-reference`, constants.js `REFERENCE_KIND`); and the AGENTS.md baseline and changes DSH -// injects as a user message (`agent-instructions`, @deepseek-ai/dsh-agent-instructions). Every -// other kind fails closed (#620, #626). +// The messages that carry nobody's request, by source kind — one list, because everything else +// fails closed as 'other' (#620): a kind missing here takes the connector tools away in the middle +// of a turn the owner started (#626), and a kind wrongly added here would hand them out. +// Derived from `MessageSourceMap` in @deepseek-ai/dsh-llm (0.2.0-rc.2, the version +// core/dev/harness/package.json pins). A kind is not recognisable by its `form` alone: the kinds +// below that declare no `form` at all (`working-directory`, `ptc-mode`, `cordis-host-runner`) would +// read as opaque content, and `webhook` declares `form: 'notice'` while being an outside delivery +// (see `originOfSource`). Bumping the DSH pin means re-reading that map: the test +// `#626: the no-request kind list is derived from the DSH version the tests run against` fails +// until this list has been looked at again. const NOTICE_KINDS = new Set([ + // Sources the host writes for itself, never a user turn. 'tool', 'system-prompt', - 'plan-mode', - 'user-approval', + 'plan-mode', // @deepseek-ai/dsh-plan-mode, ContextFormed + 'user-approval', // @deepseek-ai/dsh-user-approval, ContextFormed + // Context appended as role 'user' while a turn runs: the clock this host injects + // (`ds-bot-time-context`, time-context.js `SOURCE`) and DSH injects (`time-context`); the + // AGENTS.md baseline and changes (`agent-instructions`, @deepseek-ai/dsh-agent-instructions); a + // cited conversation's snapshot (`session-reference`, constants.js `REFERENCE_KIND`); the model + // and working directory a Session switched to (@deepseek-ai/dsh-agent `model-selection`, + // `working-directory`); the PTC toggle (`ptc-mode`); the plugin host runner + // (`cordis-host-runner`). 'ds-bot-time-context', 'time-context', REFERENCE_KIND, 'agent-instructions', + 'model-selection', + 'working-directory', + 'ptc-mode', + 'cordis-host-runner', + // One-off accounts of what just happened, `form: 'notice'`: a subagent settling, which steers + // the turn that is running (@deepseek-ai/dsh-subagent); a compaction checkpoint, the summary + // that replaces the log (@deepseek-ai/dsh-compaction); and a skill's own instructions, injected + // with `form: 'instructions'` when it is invoked (@deepseek-ai/dsh-skill). + 'subagent-settled', + 'compact-checkpoint', + 'skill-invocation', ]) export function originOfSource(source, bot) { @@ -52,6 +74,11 @@ export function originOfSource(source, bot) { return OWNER case 'schedule': return SCHEDULED + case 'goal': + // A goal round continues a goal that was created in this Session: by the owner, or by the Bot + // on a request that reached it. It is not a request arriving now, so it reads like a due task + // — the owner's while the Bot is not shared (`settled` turns it into 'other' when it is). + return SCHEDULED case SOURCE_KIND: if (source.remote !== undefined || source.form === 'remote-chat') return OTHER // A host notice about what the user did, and the note a Bot's own part switch hands on. @@ -62,7 +89,10 @@ export function originOfSource(source, bot) { if (NOTICE_KINDS.has(source.kind)) return undefined // A kind this version does not recognise is not "nobody's request": the host cannot vouch for // who is behind it, so it fails closed as 'other' (#620). Only the two documented no-request - // cases above may return undefined. + // cases above may return undefined. Three DSH kinds stay here on purpose: `webhook` carries + // an outside delivery (`form: 'notice'`, but a provider's event is somebody's request, so + // reading `form` alone would let it through); `agent-message` (`form: 'relay'`) and + // `team-message` carry another agent's words. return OTHER } } diff --git a/core/test/group-status.test.js b/core/test/group-status.test.js index 2f1d50fb5..b2742f673 100644 --- a/core/test/group-status.test.js +++ b/core/test/group-status.test.js @@ -447,6 +447,21 @@ test('a running Bot\'s work names its current step, in one short line, until the assert.equal((await bots()).b, undefined) }) +// #626: the shape a Bot actually hits — the owner asks for work, the turn calls `subagent`, and the +// child settles while that same turn is still running. The notice is an account of work the owner +// already asked for, not a new request, so the connector tools stay. +test('#626: a subagent settling mid-turn does not take the connection away', async (t) => { + const host = await start() + t.after(host.stop) + await openTurn(t, host) + await grantFromGroup(host, 'always-bot') + host.emit('b', 'turn/start', { turn: 2 }) + host.emit('b', 'user/message', { content: [{ type: 'text', text: 'read the repo and report' }], source: { kind: 'user' } }) + host.emit('b', 'tool/call', { callId: 's1', name: 'subagent', arguments: JSON.stringify({ prompt: 'summarise the README' }) }) + host.emit('b', 'user/message', { content: [{ type: 'text', text: 'Subagent finished: summarised the README.' }], source: { kind: 'subagent-settled', form: 'notice', summary: 'summarised the README', senderSessionId: 'child-1' } }) + assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'allowed-once') +}) + // #626: the host and DSH inject context into ordinary owner turns — this host's clock snapshot // (`ds-bot-time-context`), DSH's own clock and cited-conversation snapshots, and the AGENTS.md // baseline. None of them carries a request, so a turn that took one in still honours the owner's diff --git a/core/test/turn-origin.test.js b/core/test/turn-origin.test.js index 8943abe91..6dae72a14 100644 --- a/core/test/turn-origin.test.js +++ b/core/test/turn-origin.test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { test } from 'node:test' import { OTHER, OWNER, SCHEDULED, combine, originOfSource, settled } from '../src/host/turn-origin.js' import { othersInRoom, sharedWith } from '../src/host/reach.js' @@ -31,10 +32,33 @@ test('each kind of message has one verdict, and a stamp the host did not write i } // #626: the contexts the host and DSH inject into ordinary owner turns carry nobody's request // either, so they must not flip a turn to `other` (that dropped the standing grant of #608). - for (const kind of ['ds-bot-time-context', 'time-context', 'session-reference', 'agent-instructions']) { + for (const kind of [ + 'ds-bot-time-context', + 'time-context', + 'session-reference', + 'agent-instructions', + 'model-selection', + 'working-directory', + 'ptc-mode', + 'cordis-host-runner', + 'subagent-settled', + 'compact-checkpoint', + 'skill-invocation', + ]) { assert.equal(originOfSource({ kind }, bot), undefined, kind) assert.equal(originOfSource({ kind, form: 'snapshot' }, bot), undefined, `${kind} snapshot`) } + // #626: `form` alone cannot decide this. A webhook declares `form: 'notice'` but is an outside + // delivery, and an agent's relay or a team message carries somebody's words. + for (const source of [ + { kind: 'webhook', form: 'notice', summary: 'push to main' }, + { kind: 'agent-message', form: 'relay' }, + { kind: 'team-message' }, + ]) { + assert.equal(originOfSource(source, bot), OTHER, JSON.stringify(source)) + } + // #626: a goal round continues a goal this Session already has, like a due task. + assert.equal(originOfSource({ kind: 'goal', goalId: 'g1', revision: 1, round: 2 }, bot), SCHEDULED) // #620: a source kind this version does not recognise is not a no-request message: the host // cannot vouch for who is behind it, so it fails closed. assert.equal(originOfSource({ kind: 'some-future-kind' }, bot), OTHER, 'an unrecognised kind is other') @@ -104,3 +128,12 @@ test('settled: the verdict that holds now, from the team as it is now', () => { assert.equal(settled(rt, 'room-session'), OTHER) assert.equal(settled({ ...rt, roomOf: () => undefined }, 'room-session'), OTHER, 'a room that cannot be found') }) + +// #626: the no-request list is derived from DSH's `MessageSourceMap`. A new DSH version can add a +// kind, and a kind nobody listed silently takes the connector tools away mid-turn — so a bump fails +// here, where the fix is to re-read the map, instead of showing up months later as a lost tool. +test('#626: the no-request kind list is derived from the DSH version the tests run against', () => { + const harness = new URL('../dev/harness/package.json', import.meta.url) + const pinned = JSON.parse(readFileSync(harness, 'utf8')).dependencies['@deepseek-ai/dsh'] + assert.equal(pinned, '0.2.0-rc.2', 'DSH moved: re-read MessageSourceMap and update NOTICE_KINDS in src/host/turn-origin.js') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 3aaf6f98c..02e68b78e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -215,7 +215,7 @@ The connector tools are mounted once for the whole DSH profile, so who may use t **Decision (the owner, 2026-10-18, #369; it supersedes #346's "off by default, never on a shared Bot").** A connected connector is **on by default for the owner's own use** of every Bot (new, existing after the upgrade, and Bots that had no record). A Bot that is shared (grant, friend, face room, room code) is **still usable by its owner**. What is off by default is **other people's use**: when someone else drives the Bot, the tools are off unless the owner turned on that Bot's separate "also when others use it" switch, which is never disabled by sharing. - **Per-Bot records** (only what differs from the defaults is stored): `bot.connectors = { github?: false, composio?: false }` is an explicit off for the owner's own use (absent = on; a legacy `true` from #346 reads as on); `bot.connectorsShared = { github?: true, composio?: true }` is an explicit on for other people's use (absent = off), and it only counts while the owner's own use is on (others get what the owner has, never more). `bot-connectors { botId, connector, enabled, scope: 'owner' | 'shared' }` writes them: turning the owner's use off also drops the shared offer, and turning the shared offer on while the owner's use is off is refused. Sharing a Bot (a grant, a room) changes neither record. -- **Whose turn it is** (`src/host/turn-origin.js`) is read from the messages a Bot Session's turn took in (`user/message` events, tracked in `turns.js` on the Session's `live` entry), never from the Bot's own words. `owner`: the owner typed it (DSH's `source.kind: 'user'`, or a signed-in author equal to the Bot's `ownerId`), answered a question card, or a Bot of the **same owner** passed on a request that was itself the owner's (`deliver` stamps `source.origin`; `message_bot` passes the calling Session's own verdict, other callers the sender's running turns; a sender with no running turn, a Bot of another owner or a remote sender stamps `other`). `other`: a friend's message (any `source.remote`), a Bot of another owner, another signed-in member's line, a relay nobody stamped, and the end-of-turn replies, retries and deferred requests that carry their turn's verdict on. `scheduled`: a due scheduled task, which counts as the owner's only while the Bot is not shared (DSH gives the task no author and a Bot can create tasks). A relay keeps the sender's verdict, so a group round opened by a shared Bot's scheduled turn is `other` for every member, and an unshared member of such a round does not get the owner's verdict either (#581; each routine schedules its own Session instead of one shared Bot scheduling for the group). A message that carries no one's request (DSH's own notices, the handoff note of a part switch, a mention receipt) neither grants nor takes away. A turn's verdict is the strictest of its messages and **sticky**: a friend's line that lands in the owner's running turn makes the rest of that turn `other`, and the next turn starts clean. A turn with no message that has a verdict, no running turn at all, a Session whose Bot is gone, and a stamp the host did not write all count as **`other` (fail closed)**. A source `kind` this version does not recognise is `other` too, never "nobody's request" (#620): only DSH's own notice kinds (`tool`, `system-prompt`, `plan-mode`, `user-approval`) and the documented mention/handoff cases carry no verdict, so a new source kind fails closed instead of inheriting the owner's standing approval. The contexts the host and DSH inject into an ordinary owner turn carry no verdict either, or they would flip that turn to `other`: this host's clock snapshot (`ds-bot-time-context`, `time-context.js` `SOURCE`), DSH's clock (`time-context`), DSH's snapshot of a cited conversation (`session-reference`, `constants.js` `REFERENCE_KIND`), and the AGENTS.md baseline and changes DSH injects as a user message (`agent-instructions`); each has its own case in `core/test/turn-origin.test.js` and `core/test/group-status.test.js` (#626). +- **Whose turn it is** (`src/host/turn-origin.js`) is read from the messages a Bot Session's turn took in (`user/message` events, tracked in `turns.js` on the Session's `live` entry), never from the Bot's own words. `owner`: the owner typed it (DSH's `source.kind: 'user'`, or a signed-in author equal to the Bot's `ownerId`), answered a question card, or a Bot of the **same owner** passed on a request that was itself the owner's (`deliver` stamps `source.origin`; `message_bot` passes the calling Session's own verdict, other callers the sender's running turns; a sender with no running turn, a Bot of another owner or a remote sender stamps `other`). `other`: a friend's message (any `source.remote`), a Bot of another owner, another signed-in member's line, a relay nobody stamped, and the end-of-turn replies, retries and deferred requests that carry their turn's verdict on. `scheduled`: a due scheduled task, which counts as the owner's only while the Bot is not shared (DSH gives the task no author and a Bot can create tasks). A relay keeps the sender's verdict, so a group round opened by a shared Bot's scheduled turn is `other` for every member, and an unshared member of such a round does not get the owner's verdict either (#581; each routine schedules its own Session instead of one shared Bot scheduling for the group). A message that carries no one's request (DSH's own notices, the handoff note of a part switch, a mention receipt) neither grants nor takes away. A turn's verdict is the strictest of its messages and **sticky**: a friend's line that lands in the owner's running turn makes the rest of that turn `other`, and the next turn starts clean. A turn with no message that has a verdict, no running turn at all, a Session whose Bot is gone, and a stamp the host did not write all count as **`other` (fail closed)**. A source `kind` this version does not recognise is `other` too, never "nobody's request" (#620): a kind that is not on the no-request list fails closed instead of inheriting the owner's standing approval. That list is one constant in `src/host/turn-origin.js`, derived from DSH's `MessageSourceMap` (the version `core/dev/harness/package.json` pins) and annotated with the producer of each kind: DSH's own notices (`tool`, `system-prompt`, `plan-mode`, `user-approval`); the context DSH and this host append to an ordinary owner turn — this host's clock (`ds-bot-time-context`, `time-context.js` `SOURCE`), DSH's clock (`time-context`), a cited conversation (`session-reference`, `constants.js` `REFERENCE_KIND`), the AGENTS.md baseline (`agent-instructions`), the model and working directory a Session switched to (`model-selection`, `working-directory`), the PTC toggle (`ptc-mode`) and the plugin host runner (`cordis-host-runner`); and the one-off `notice`/`instructions` accounts — a subagent settling (which steers the turn that is running), a compaction checkpoint and a skill's own instructions (#626). `form` alone cannot decide it in either direction: `working-directory`, `ptc-mode` and `cordis-host-runner` declare no form, while `webhook` declares `form: \x27notice\x27` and is an outside delivery, so `webhook`, `agent-message` (`form: \x27relay\x27`) and `team-message` stay `other`. A `goal` round continues a goal the Session already has and is treated like a due task: the owner's while the Bot is not shared. The list is checked against the pinned DSH version by a test, so a DSH bump fails until the map is read again. Each context kind has its own case in `core/test/turn-origin.test.js` and `core/test/group-status.test.js` (#626). - **Rooms.** A Bot's turn in a room is read through the room as it is at the moment of the call (`othersInRoom` in `reach.js`): the owner's only while nobody but the owner and the owner's own Bots are in it; a friend's Bot, another person, another owner's Bot or a room joined by code makes every turn there `other`, because their lines are part of what the Bot reads. A room with other people therefore needs the Bot's "also when others use it" switch, even when the owner typed the line. A room of the owner's own Bots is the owner's only when what started the round was: a Bot post carries its calling turn's verdict (`post_to_group` stamps `source.origin`, a late reply stamps its turn's), the round takes the strictest verdict of the Bot posts that opened it and passes it to every member turn (`runTurn` → `deliver`), a post from a friend's Bot or one nobody stamped makes the round someone else's, and the `report_back` message to the poster is the owner's only when the round was and nobody else is in the room. Without this, a friend driving a Main Bot could post into an owner-only room and have the members use the owner's connectors (found in review of #370). This is the main place where the owner meets the shared-use switch on their own Bot; the owner's own chat with the Bot is never affected by sharing. - **Enforcement is two layers.** A `ctx.tools.guard` refuses every `mcp__github__*` / `mcp__composio__*` call from a Bot Session (its own chat, a group Session, an earlier part; a sub-agent Session is followed to its parent Bot Session and that Session's turn) that is not allowed. **It fails closed:** an Agent whose Bot cannot be determined (a parent Session that is gone, or a parent chain deeper than eight) gets no connector tools and every call is refused, so an unresolved sub-agent never reads as a plain owner Session; a friend's Bot (`bot.remote`) never gets them; only a chain that really ends at a Session of no Bot (the DSH Agent, a plain Session) keeps them. `agent.ctx.tools.restrict({ deny })` takes the same tools out of that Agent's tool list. The list is the courtesy and the guard the enforcement: an idle Session, or a turn whose first message is not in yet, is listed as for the owner's next message, and the guard still refuses any call whose turn is not the owner's. The mask is refreshed when the Agent is created, on every `resolveAgent` (delivery), when a switch changes, when a turn starts, takes in another message or ends (`connectorTurnChanged`, inside the Session's event, before the model sees the message), when who a Bot is shared with changes for any reason (a grant, a person, friend or other owner's Bot joining or leaving one of its rooms, a room joined by code: every save compares each Bot's sharing with the last time), and on `tools/change` (a connector that mounts or goes away later). DSH's hidden "Tool added/removed" notices for these tools are not drawn in the chat (`cells.js`). - **Turns that move or restart.** A turn that moves to a fresh part mid-way (`parts.js`) carries its verdict in the resume message's `origin`, and the resume record in `inflight.json` notes it, so a turn resumed after a restart keeps the owner's tools only when it was the owner's (a record with no verdict resumes as `other`). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 25aaa9879..6eeb8f351 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -5034,7 +5034,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -@@ -24,9 +24,8 @@ const seed = () => ({ +@@ -24,9 +24,8 @@ revision: 0, }) @@ -5046,7 +5046,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js const ctx = fakeContext() const agents = new Map() const agentOf = (sessionId) => { -@@ -71,9 +71,10 @@ async function start() { +@@ -71,9 +70,10 @@ const tool = (name, args, sessionId, exec = {}) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId }, ...exec }) const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() const askApproval = (sessionId, extra = {}, next = () => 'from-dsh') => ctx.listeners.get('approval/request')[0]({ agent: { session: { id: sessionId } }, toolName: 'pwsh', reason: 'escalate sandbox', ...extra }, next) @@ -5059,7 +5059,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js } // Waits on real I/O (state writes) without timers, which some tests mock. -@@ -294,3 +294,171 @@ +@@ -294,3 +294,186 @@ assert.deepEqual(approval.detail, { text: 'Copy the doc', code: 'Copy-Item a.md E:/docs' }) assert.equal(value.work.bots.b.tool, 'pwsh') }) @@ -5216,6 +5216,21 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js + assert.equal((await bots()).b, undefined) +}) + ++// #626: the shape a Bot actually hits — the owner asks for work, the turn calls `subagent`, and the ++// child settles while that same turn is still running. The notice is an account of work the owner ++// already asked for, not a new request, so the connector tools stay. ++test('#626: a subagent settling mid-turn does not take the connection away', async (t) => { ++ const host = await start() ++ t.after(host.stop) ++ await openTurn(t, host) ++ await grantFromGroup(host, 'always-bot') ++ host.emit('b', 'turn/start', { turn: 2 }) ++ host.emit('b', 'user/message', { content: [{ type: 'text', text: 'read the repo and report' }], source: { kind: 'user' } }) ++ host.emit('b', 'tool/call', { callId: 's1', name: 'subagent', arguments: JSON.stringify({ prompt: 'summarise the README' }) }) ++ host.emit('b', 'user/message', { content: [{ type: 'text', text: 'Subagent finished: summarised the README.' }], source: { kind: 'subagent-settled', form: 'notice', summary: 'summarised the README', senderSessionId: 'child-1' } }) ++ assert.equal(await host.askApproval('b', { toolName: 'bash', reason: ESCALATE }, () => 'from-dsh'), 'allowed-once') ++}) ++ +// #626: the host and DSH inject context into ordinary owner turns — this host's clock snapshot +// (`ds-bot-time-context`), DSH's own clock and cited-conversation snapshots, and the AGENTS.md +// baseline. None of them carries a request, so a turn that took one in still honours the owner's diff --git a/patches/ds-bot/files/src/host/turn-origin.js b/patches/ds-bot/files/src/host/turn-origin.js index 3302ae56b..a99b3df02 100644 --- a/patches/ds-bot/files/src/host/turn-origin.js +++ b/patches/ds-bot/files/src/host/turn-origin.js @@ -23,22 +23,44 @@ export const OWNER = 'owner' export const OTHER = 'other' export const SCHEDULED = 'scheduled' -// The verdict of one message `source` (DSH's `user/message` event) to a Bot. -// The messages that carry nobody's request, by source kind: DSH's own notices; the clock snapshots -// this host injects (`ds-bot-time-context`, time-context.js `SOURCE`) and DSH injects -// (`time-context`); the snapshot of a cited conversation DSH logs after the message that cites it -// (`session-reference`, constants.js `REFERENCE_KIND`); and the AGENTS.md baseline and changes DSH -// injects as a user message (`agent-instructions`, @deepseek-ai/dsh-agent-instructions). Every -// other kind fails closed (#620, #626). +// The messages that carry nobody's request, by source kind — one list, because everything else +// fails closed as 'other' (#620): a kind missing here takes the connector tools away in the middle +// of a turn the owner started (#626), and a kind wrongly added here would hand them out. +// Derived from `MessageSourceMap` in @deepseek-ai/dsh-llm (0.2.0-rc.2, the version +// core/dev/harness/package.json pins). A kind is not recognisable by its `form` alone: the kinds +// below that declare no `form` at all (`working-directory`, `ptc-mode`, `cordis-host-runner`) would +// read as opaque content, and `webhook` declares `form: 'notice'` while being an outside delivery +// (see `originOfSource`). Bumping the DSH pin means re-reading that map: the test +// `#626: the no-request kind list is derived from the DSH version the tests run against` fails +// until this list has been looked at again. const NOTICE_KINDS = new Set([ + // Sources the host writes for itself, never a user turn. 'tool', 'system-prompt', - 'plan-mode', - 'user-approval', + 'plan-mode', // @deepseek-ai/dsh-plan-mode, ContextFormed + 'user-approval', // @deepseek-ai/dsh-user-approval, ContextFormed + // Context appended as role 'user' while a turn runs: the clock this host injects + // (`ds-bot-time-context`, time-context.js `SOURCE`) and DSH injects (`time-context`); the + // AGENTS.md baseline and changes (`agent-instructions`, @deepseek-ai/dsh-agent-instructions); a + // cited conversation's snapshot (`session-reference`, constants.js `REFERENCE_KIND`); the model + // and working directory a Session switched to (@deepseek-ai/dsh-agent `model-selection`, + // `working-directory`); the PTC toggle (`ptc-mode`); the plugin host runner + // (`cordis-host-runner`). 'ds-bot-time-context', 'time-context', REFERENCE_KIND, 'agent-instructions', + 'model-selection', + 'working-directory', + 'ptc-mode', + 'cordis-host-runner', + // One-off accounts of what just happened, `form: 'notice'`: a subagent settling, which steers + // the turn that is running (@deepseek-ai/dsh-subagent); a compaction checkpoint, the summary + // that replaces the log (@deepseek-ai/dsh-compaction); and a skill's own instructions, injected + // with `form: 'instructions'` when it is invoked (@deepseek-ai/dsh-skill). + 'subagent-settled', + 'compact-checkpoint', + 'skill-invocation', ]) export function originOfSource(source, bot) { @@ -52,6 +74,11 @@ export function originOfSource(source, bot) { return OWNER case 'schedule': return SCHEDULED + case 'goal': + // A goal round continues a goal that was created in this Session: by the owner, or by the Bot + // on a request that reached it. It is not a request arriving now, so it reads like a due task + // — the owner's while the Bot is not shared (`settled` turns it into 'other' when it is). + return SCHEDULED case SOURCE_KIND: if (source.remote !== undefined || source.form === 'remote-chat') return OTHER // A host notice about what the user did, and the note a Bot's own part switch hands on. @@ -62,7 +89,10 @@ export function originOfSource(source, bot) { if (NOTICE_KINDS.has(source.kind)) return undefined // A kind this version does not recognise is not "nobody's request": the host cannot vouch for // who is behind it, so it fails closed as 'other' (#620). Only the two documented no-request - // cases above may return undefined. + // cases above may return undefined. Three DSH kinds stay here on purpose: `webhook` carries + // an outside delivery (`form: 'notice'`, but a provider's event is somebody's request, so + // reading `form` alone would let it through); `agent-message` (`form: 'relay'`) and + // `team-message` carry another agent's words. return OTHER } } diff --git a/patches/ds-bot/files/test/turn-origin.test.js b/patches/ds-bot/files/test/turn-origin.test.js index 8943abe91..6dae72a14 100644 --- a/patches/ds-bot/files/test/turn-origin.test.js +++ b/patches/ds-bot/files/test/turn-origin.test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { test } from 'node:test' import { OTHER, OWNER, SCHEDULED, combine, originOfSource, settled } from '../src/host/turn-origin.js' import { othersInRoom, sharedWith } from '../src/host/reach.js' @@ -31,10 +32,33 @@ test('each kind of message has one verdict, and a stamp the host did not write i } // #626: the contexts the host and DSH inject into ordinary owner turns carry nobody's request // either, so they must not flip a turn to `other` (that dropped the standing grant of #608). - for (const kind of ['ds-bot-time-context', 'time-context', 'session-reference', 'agent-instructions']) { + for (const kind of [ + 'ds-bot-time-context', + 'time-context', + 'session-reference', + 'agent-instructions', + 'model-selection', + 'working-directory', + 'ptc-mode', + 'cordis-host-runner', + 'subagent-settled', + 'compact-checkpoint', + 'skill-invocation', + ]) { assert.equal(originOfSource({ kind }, bot), undefined, kind) assert.equal(originOfSource({ kind, form: 'snapshot' }, bot), undefined, `${kind} snapshot`) } + // #626: `form` alone cannot decide this. A webhook declares `form: 'notice'` but is an outside + // delivery, and an agent's relay or a team message carries somebody's words. + for (const source of [ + { kind: 'webhook', form: 'notice', summary: 'push to main' }, + { kind: 'agent-message', form: 'relay' }, + { kind: 'team-message' }, + ]) { + assert.equal(originOfSource(source, bot), OTHER, JSON.stringify(source)) + } + // #626: a goal round continues a goal this Session already has, like a due task. + assert.equal(originOfSource({ kind: 'goal', goalId: 'g1', revision: 1, round: 2 }, bot), SCHEDULED) // #620: a source kind this version does not recognise is not a no-request message: the host // cannot vouch for who is behind it, so it fails closed. assert.equal(originOfSource({ kind: 'some-future-kind' }, bot), OTHER, 'an unrecognised kind is other') @@ -104,3 +128,12 @@ test('settled: the verdict that holds now, from the team as it is now', () => { assert.equal(settled(rt, 'room-session'), OTHER) assert.equal(settled({ ...rt, roomOf: () => undefined }, 'room-session'), OTHER, 'a room that cannot be found') }) + +// #626: the no-request list is derived from DSH's `MessageSourceMap`. A new DSH version can add a +// kind, and a kind nobody listed silently takes the connector tools away mid-turn — so a bump fails +// here, where the fix is to re-read the map, instead of showing up months later as a lost tool. +test('#626: the no-request kind list is derived from the DSH version the tests run against', () => { + const harness = new URL('../dev/harness/package.json', import.meta.url) + const pinned = JSON.parse(readFileSync(harness, 'utf8')).dependencies['@deepseek-ai/dsh'] + assert.equal(pinned, '0.2.0-rc.2', 'DSH moved: re-read MessageSourceMap and update NOTICE_KINDS in src/host/turn-origin.js') +}) From 5ed6851f4e5a25bc9063ed05ab9f0afba77a07e9 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Thu, 22 Oct 2026 02:41:52 +0800 Subject: [PATCH 4/4] fix(turn-origin): take the clock kinds from the module that produces them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer INC at 03060560 asked for one change: ds-bot-time-context and time-context were string literals inside turn-origin.js (and again in remote-chat.js), duplicated from core/src/host/time-context.js. Now time-context.js exports SOURCE and CLOCK_KINDS, turn-origin.js spreads CLOCK_KINDS into NOTICE_KINDS, remote-chat.js matches on CLOCK_KINDS, and the patches/ds-bot mirror is regenerated with the source. Authored by Dev, committed by Chief (owner proxy — Dev had no credentials). --- core/src/host/remote-chat.js | 3 ++- core/src/host/time-context.js | 6 ++++-- core/src/host/turn-origin.js | 4 ++-- patches/ds-bot/0001-core-fixes.patch | 18 +++++++++++++++--- patches/ds-bot/files/src/host/remote-chat.js | 3 ++- patches/ds-bot/files/src/host/turn-origin.js | 4 ++-- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 40aa529d5..c1239a868 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -10,6 +10,7 @@ // Remote chat Bots are not team members: findBot, the team tools, flybot.bot.list() and // memory skip them, and the tree never adopts them. import { randomUUID } from 'node:crypto' +import { CLOCK_KINDS } from './time-context.js' import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' import { cleanName, fenceUntrusted } from './delivery.js' @@ -73,7 +74,7 @@ export function notSentLine(error) { // time context), the AGENTS.md baseline (form 'instructions') and notices (plan mode, tool // reminders). Matched by form, not by a whitelist of senders, so a new notice kind is skipped too. const CONTEXT_FORMS = new Set(['snapshot', 'instructions', 'notice']) -const isContextSnapshot = source => CONTEXT_FORMS.has(source?.form) || source?.kind === 'agent-instructions' || source?.kind === 'ds-bot-time-context' || source?.kind === 'time-context' +const isContextSnapshot = source => CONTEXT_FORMS.has(source?.form) || source?.kind === 'agent-instructions' || CLOCK_KINDS.includes(source?.kind) export function remoteTurn(messages) { // Context the host appends as user messages (the time context, refreshed every few minutes; diff --git a/core/src/host/time-context.js b/core/src/host/time-context.js index 6da78f82e..4311d1cd9 100644 --- a/core/src/host/time-context.js +++ b/core/src/host/time-context.js @@ -1,7 +1,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' -const SOURCE = 'ds-bot-time-context' -const isClock = source => source?.kind === SOURCE || source?.kind === 'time-context' +export const SOURCE = 'ds-bot-time-context' +// The host injects `SOURCE`; DSH injects 'time-context'. One home for both (#626). +export const CLOCK_KINDS = [SOURCE, 'time-context'] +const isClock = source => CLOCK_KINDS.includes(source?.kind) export function install(rt) { const { ctx, config } = rt diff --git a/core/src/host/turn-origin.js b/core/src/host/turn-origin.js index a99b3df02..425b48981 100644 --- a/core/src/host/turn-origin.js +++ b/core/src/host/turn-origin.js @@ -18,6 +18,7 @@ // rest of the turn), and a turn that took in no message with a verdict has none. The caller treats // "none" as 'other' (fail closed). import { REFERENCE_KIND, REPLY_KIND, SOURCE_KIND } from './constants.js' +import { CLOCK_KINDS } from './time-context.js' export const OWNER = 'owner' export const OTHER = 'other' @@ -46,8 +47,7 @@ const NOTICE_KINDS = new Set([ // and working directory a Session switched to (@deepseek-ai/dsh-agent `model-selection`, // `working-directory`); the PTC toggle (`ptc-mode`); the plugin host runner // (`cordis-host-runner`). - 'ds-bot-time-context', - 'time-context', + ...CLOCK_KINDS, REFERENCE_KIND, 'agent-instructions', 'model-selection', diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 6eeb8f351..622fe866b 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -3622,6 +3622,18 @@ diff --git a/src/host/schedules.js b/src/host/schedules.js diff --git a/src/host/time-context.js b/src/host/time-context.js --- a/src/host/time-context.js +++ b/src/host/time-context.js +@@ -1,7 +1,9 @@ + import { createUserMessage } from '@deepseek-ai/dsh-llm' + +-const SOURCE = 'ds-bot-time-context' +-const isClock = source => source?.kind === SOURCE || source?.kind === 'time-context' ++export const SOURCE = 'ds-bot-time-context' ++// The host injects `SOURCE`; DSH injects 'time-context'. One home for both (#626). ++export const CLOCK_KINDS = [SOURCE, 'time-context'] ++const isClock = source => CLOCK_KINDS.includes(source?.kind) + + export function install(rt) { + const { ctx, config } = rt @@ -20,6 +20,8 @@ export function install(rt) { if (decision.kind === 'reject' || signal.aborted) return decision await rt.load() @@ -5034,7 +5046,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -@@ -24,9 +24,8 @@ +@@ -24,9 +24,8 @@ const seed = () => ({ revision: 0, }) @@ -5046,7 +5058,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js const ctx = fakeContext() const agents = new Map() const agentOf = (sessionId) => { -@@ -71,9 +70,10 @@ +@@ -71,9 +71,10 @@ async function start() { const tool = (name, args, sessionId, exec = {}) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId }, ...exec }) const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() const askApproval = (sessionId, extra = {}, next = () => 'from-dsh') => ctx.listeners.get('approval/request')[0]({ agent: { session: { id: sessionId } }, toolName: 'pwsh', reason: 'escalate sandbox', ...extra }, next) @@ -5059,7 +5071,7 @@ diff --git a/test/group-status.test.js b/test/group-status.test.js } // Waits on real I/O (state writes) without timers, which some tests mock. -@@ -294,3 +294,186 @@ +@@ -294,3 +294,186 @@ test('an approval asked inside a PTC program shows what that call does', async ( assert.deepEqual(approval.detail, { text: 'Copy the doc', code: 'Copy-Item a.md E:/docs' }) assert.equal(value.work.bots.b.tool, 'pwsh') }) diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index 40aa529d5..c1239a868 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -10,6 +10,7 @@ // Remote chat Bots are not team members: findBot, the team tools, flybot.bot.list() and // memory skip them, and the tree never adopts them. import { randomUUID } from 'node:crypto' +import { CLOCK_KINDS } from './time-context.js' import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' import { cleanName, fenceUntrusted } from './delivery.js' @@ -73,7 +74,7 @@ export function notSentLine(error) { // time context), the AGENTS.md baseline (form 'instructions') and notices (plan mode, tool // reminders). Matched by form, not by a whitelist of senders, so a new notice kind is skipped too. const CONTEXT_FORMS = new Set(['snapshot', 'instructions', 'notice']) -const isContextSnapshot = source => CONTEXT_FORMS.has(source?.form) || source?.kind === 'agent-instructions' || source?.kind === 'ds-bot-time-context' || source?.kind === 'time-context' +const isContextSnapshot = source => CONTEXT_FORMS.has(source?.form) || source?.kind === 'agent-instructions' || CLOCK_KINDS.includes(source?.kind) export function remoteTurn(messages) { // Context the host appends as user messages (the time context, refreshed every few minutes; diff --git a/patches/ds-bot/files/src/host/turn-origin.js b/patches/ds-bot/files/src/host/turn-origin.js index a99b3df02..425b48981 100644 --- a/patches/ds-bot/files/src/host/turn-origin.js +++ b/patches/ds-bot/files/src/host/turn-origin.js @@ -18,6 +18,7 @@ // rest of the turn), and a turn that took in no message with a verdict has none. The caller treats // "none" as 'other' (fail closed). import { REFERENCE_KIND, REPLY_KIND, SOURCE_KIND } from './constants.js' +import { CLOCK_KINDS } from './time-context.js' export const OWNER = 'owner' export const OTHER = 'other' @@ -46,8 +47,7 @@ const NOTICE_KINDS = new Set([ // and working directory a Session switched to (@deepseek-ai/dsh-agent `model-selection`, // `working-directory`); the PTC toggle (`ptc-mode`); the plugin host runner // (`cordis-host-runner`). - 'ds-bot-time-context', - 'time-context', + ...CLOCK_KINDS, REFERENCE_KIND, 'agent-instructions', 'model-selection',