diff --git a/changelog.d/1847-memory-write-bounds.fixed.md b/changelog.d/1847-memory-write-bounds.fixed.md new file mode 100644 index 000000000..554244543 --- /dev/null +++ b/changelog.d/1847-memory-write-bounds.fixed.md @@ -0,0 +1 @@ +The Project memory box now refuses what the Project Assistant's own writer always refused: an empty entry is answered with *A Project memory entry needs text* and one over 600 characters with *A Project memory entry is at most 600 characters*, in the interface language in the panel, and neither writes a row or touches the Project. Before this the endpoint stored a blank entry, or cut a long one to 600 characters, and still reported success ([#1847](https://github.com/ThinkFlowLab/FlyBot/issues/1847)). diff --git a/core/client.js b/core/client.js index 2db1fef70..9f8f5f276 100644 --- a/core/client.js +++ b/core/client.js @@ -1488,10 +1488,13 @@ var HOST_ERRORS = [ [/^Memory cannot change now: [\s\S]*$/, "现在不能修改记忆:另一个 dsh 正在使用这个 Bot 团队"], // The Project settings panel quotes the host's refusal as it is, so a zh reader reads it in // Chinese. Only refusals that panel's three write paths can reach are here (#1820): the owner - // gate on all three, an empty member id on Bind/Unbind, and a stale Project id. The id rule is - // last and takes one token, so it can never claim a longer "Unknown Project …" sentence. + // gate on all three, an empty member id on Bind/Unbind, a stale Project id, and — since #1847 — + // the memory box's two bounds. The id rule is last and takes one token, so it can never claim a + // longer "Unknown Project …" sentence. [/^Only the owner of this Project may change it$/, "只有这个项目的主人可以修改"], [/^A binding needs the member id$/, "绑定需要 member id"], + [/^A Project memory entry needs text$/, "项目记忆条目需要文字"], + [/^A Project memory entry is at most (\d+) characters$/, "项目记忆条目最多 $1 个字符"], [/^Unknown Project (\S+)$/, "找不到项目 $1"] ]; var HOST_ZH = { diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 310498111..20f2ad882 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1444,10 +1444,13 @@ const HOST_ERRORS = [ [/^Memory cannot change now: [\s\S]*$/, '现在不能修改记忆:另一个 dsh 正在使用这个 Bot 团队'], // The Project settings panel quotes the host's refusal as it is, so a zh reader reads it in // Chinese. Only refusals that panel's three write paths can reach are here (#1820): the owner - // gate on all three, an empty member id on Bind/Unbind, and a stale Project id. The id rule is - // last and takes one token, so it can never claim a longer "Unknown Project …" sentence. + // gate on all three, an empty member id on Bind/Unbind, a stale Project id, and — since #1847 — + // the memory box's two bounds. The id rule is last and takes one token, so it can never claim a + // longer "Unknown Project …" sentence. [/^Only the owner of this Project may change it$/, '只有这个项目的主人可以修改'], [/^A binding needs the member id$/, '绑定需要 member id'], + [/^A Project memory entry needs text$/, '项目记忆条目需要文字'], + [/^A Project memory entry is at most (\d+) characters$/, '项目记忆条目最多 $1 个字符'], [/^Unknown Project (\S+)$/, '找不到项目 $1'], ] const HOST_ZH = { diff --git a/core/src/host/constants.js b/core/src/host/constants.js index c9ed9bfec..245ca7231 100644 --- a/core/src/host/constants.js +++ b/core/src/host/constants.js @@ -21,6 +21,9 @@ export const OCR_CACHE_MAX = 500 export const MAX_AVATAR_IMAGE = 160_000 export const ROUTE = '/api/bot' export const MAX_HOPS = 6 +// The bound on one Project memory entry (#1847): one value, shared by the assistant writer and the +// store's `appendProjectMemory`, so no writer can store a blank entry or silently cut a long one. +export const MAX_ENTRY = 600 // Bot requests a Bot's own chat may hold unread; past this the Bot is most likely // stuck, and more messages only pile up behind it. export const MAX_UNREAD_REQUESTS = 10 diff --git a/core/src/host/project-assistant.js b/core/src/host/project-assistant.js index e82592191..7790e1e07 100644 --- a/core/src/host/project-assistant.js +++ b/core/src/host/project-assistant.js @@ -15,6 +15,7 @@ // on the assistant Session through the host's schedule path, and `project-delete` removes it with // the Project. A missed beat is DSH's, and it posts nothing. import { defineTool } from './tool-schema.js' +import { MAX_ENTRY } from './constants.js' import { listProjectState } from './project-state.js' import { output } from './tools.js' import { inQuietHours, clockIn, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js' @@ -24,7 +25,9 @@ export const DIGEST_ZONE = 'Asia/Shanghai' export const DIGEST_TIME = '09:00' export const DIGEST_TITLE = '09:00 Project digest' export const DIGEST_PROMPT = 'Build today\'s Project digest: call project_digest once. It answers whether today already has one.' -export const MAX_ENTRY = 600 +// The bound itself is in `constants.js` (#1847), beside the other limits host modules share, +// because the store's write door enforces it too; this re-export keeps the assistant's own name. +export { MAX_ENTRY } export const MEMORY_KINDS = ['decision', 'question'] // At most this many cards that need one owner's decision in one digest (#1733): a digest is a // short list of things to decide, not the board. One constant, not a setting. diff --git a/core/src/host/store.js b/core/src/host/store.js index 8977c2f94..bda6d26a0 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto' import { constants, mkdirSync, statSync } from 'node:fs' import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { LEGACY_THEMES, MAX_EXCHANGES, ROOM_MODES } from './constants.js' +import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants.js' import { homeLock } from './home-lock.js' import { openJournal } from './journal.js' import { currentColor, normalize, unquote } from './text.js' @@ -512,11 +512,18 @@ export function install(rt) { const appendProjectMemory = (id, kind, entry = {}) => { const project = requireProject(id) if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`) + // #1847: this is the one door into a Project's memory, so the bound the assistant writer states + // is enforced here too. Before this, an endpoint caller could store an empty entry or have a + // long one cut to `MAX_ENTRY` without being told; a refusal leaves the table and `updatedAt` as + // they were. + const entryText = projectText(entry.text) + if (entryText === '') throw projectRefusal('A Project memory entry needs text') + if (entryText.length > MAX_ENTRY) throw projectRefusal(`A Project memory entry is at most ${MAX_ENTRY} characters`) const table = memoryTable(project) const ownerId = projectText(entry.ownerId, 120) || primaryOwner(project) const record = ownerRow(table, ownerId, project) const list = kind === 'decision' ? record.decisions : record.questions - list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) + list.push({ text: entryText, at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) mirrorOwner(table, project, ownerId, record) project.updatedAt = Math.max(project.updatedAt, Date.now()) return ownerView(record) diff --git a/core/test/project-board-panels.test.js b/core/test/project-board-panels.test.js index 63bb66df2..aa9273391 100644 --- a/core/test/project-board-panels.test.js +++ b/core/test/project-board-panels.test.js @@ -318,6 +318,68 @@ test('a memory kind the host does not know writes nothing, and the panel quotes } }) +// #1847: the panel is not the only writer of a Project's memory. The endpoint used to store a +// blank entry and silently cut one over 600 characters, while the assistant writer refused both. +// The bound now lives at `appendProjectMemory`, the one door every writer goes through. +test('#1847: the memory write door refuses empty and over-long text and stores 600 exactly', async (t) => { + const rt = await startHost(t) + const call = (endpoint, payload) => rt.projectEndpoint(endpoint, payload) + const before = (await call('project-read', { id: 'project-1' })).project + + await assert.rejects(() => call('project-memory-write', { id: 'project-1', kind: 'decision', text: '' }), + error => error.code === 'bot/invalid' && error.message === 'A Project memory entry needs text') + await assert.rejects(() => call('project-memory-write', { id: 'project-1', kind: 'decision', text: 'x'.repeat(601) }), + error => error.code === 'bot/invalid' && error.message === 'A Project memory entry is at most 600 characters') + + let memory = (await call('project-memory', { id: 'project-1' })).memory + assert.deepEqual(memory.decisions, [], 'a refused write leaves no entry behind') + assert.deepEqual(memory.questions, [], 'and it does not land in the other list either') + const after = (await call('project-read', { id: 'project-1' })).project + assert.equal(after.updatedAt, before.updatedAt, 'a refused write does not touch the Project') + + const exact = 'x'.repeat(600) + await call('project-memory-write', { id: 'project-1', kind: 'decision', text: exact }) + memory = (await call('project-memory', { id: 'project-1' })).memory + assert.equal(memory.decisions.length, 1, 'the entry at the cap is accepted') + assert.equal(memory.decisions[0].text.length, 600, 'and stored whole, not cut') + assert.equal(memory.decisions[0].text, exact, 'the text is what was sent') +}) + +// The other half of #1847: the panel quotes the host's new refusals in Chinese, so the reader sees +// why Save did nothing instead of a success line, and no row is added. +test('#1847: the panel shows the Chinese refusal for an empty or over-long memory box', async (t) => { + const rt = await startHost(t) + const call = (endpoint, payload) => rt.projectEndpoint(endpoint, payload) + const off = installLocale(fakeLocale('zh')) + try { + // The hook slots outlive a render, so the box starts where the previous test left it; empty it + // explicitly before pressing Save. + let tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + all(tree).find(node => node.props?.id === 'bt-project-note').props.onChange({ target: { value: '' } }) + tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + byAction(tree, 'project-memory-save').props.onClick() + await settle() + tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + let status = all(tree).find(node => node.props?.role === 'status') + assert.equal(text(status), '项目记忆条目需要文字') + assert.ok(!text(status).includes('Memory saved.'), 'a refusal is never painted as a success') + + const note = all(tree).find(node => node.props?.id === 'bt-project-note') + note.props.onChange({ target: { value: 'x'.repeat(601) } }) + tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + byAction(tree, 'project-memory-save').props.onClick() + await settle() + tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + status = all(tree).find(node => node.props?.role === 'status') + assert.equal(text(status), '项目记忆条目最多 600 个字符') + + const memory = (await call('project-memory', { id: 'project-1' })).memory + assert.deepEqual(memory.decisions, [], 'neither refusal adds a row') + } finally { + off() + } +}) + test("the settings panel shows the host's refusal instead of pretending the save landed", async () => { resetSlots() const refused = Object.assign(new Error('Only the owner of this Project may change it'), { code: 'bot/forbidden' }) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index bfbff3cf4..4cc53a7c9 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -288,8 +288,12 @@ box reads the caller's own `assistantMine.off` from `project-read` (#1845), neve and a Save that changes nothing leaves the stored value and the 09:00 task alone. Its memory box sends one of the two kinds the host knows: the free text is recorded as a `decision`, never as a third kind `project-memory-write` would refuse, so the success line (`Memory saved.`) is reachable. The status line -quotes a refusal through `hostText`, so a zh reader reads the host's own sentence in Chinese. Tests: -`core/test/project-board-panels.test.js`. +quotes a refusal through `hostText`, so a zh reader reads the host's own sentence in Chinese: since +#1847 the host refuses an empty entry and one over 600 characters at that write door, and `i18n.js` +carries both sentences — the exact key for `A Project memory entry needs text` and the number-carrying +`[/^A Project memory entry is at most (\d+) characters$/, …]` — so a zh box that was empty or over the +cap reads 项目记忆条目需要文字 / 项目记忆条目最多 600 个字符 instead of `Memory saved.`, with no row +added. Tests: `core/test/project-board-panels.test.js`. The **Members** panel lists every owner and every member the board names, and since #1859 also the Project's own Chief as its own row (`data-member-role="chief"`), read from `chief` on the `project-read` diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 27a8ea49c..66b030cf6 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -50,7 +50,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/host/usage.js` | Usage ledger at `$DSH_HOME/bot/usage.sqlite` (`calls`). Ledger v3 (#903): every row carries `harness`, `issue_pr`, `tool_calls`, `wall_ms`, plus existing token columns (`withUsageFields` / `missingUsageFields`). Session turns are metered via `foldCalls` over turn events (`tool/call` count, wall time from attempt start, `issuePr` from turn text or `rt.noteUsageIssuePr(sessionId, ref)`), not from llm/stream alone. A triage tier is stamped with `rt.noteUsageHarness(sessionId, idOrLabels)` and lands on the row's `harness` field; the resume cursor (`marks.fold`) stays provider / model / retry. Sample row fixture: `core/test/fixtures/usage-sample.sqlite`. Tests: `core/test/usage-fields.test.js`, `core/test/usage.test.js`. | | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | | `core/src/host/project-state.js` | The `project_state` tool (#1702, #1550 Stage 2.4): the Project State files of the Session working folder (`PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md`), listed with byte size, sha256 content hash and last-modified time, or one file handed over whole up to 200,000 bytes. A caller compares the hash with the copy it already had, so a stale copy is visible instead of assumed. Answers `absent`, `too large` (with the size) or `not a repository` rather than an empty document, refuses any path outside that list, and reads nothing at install time: no prompt, system message or Session context is written by a call. Tests: `core/test/project-state.test.js`. | -| `core/src/host/project-assistant.js` | The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by `project-create` through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own `schedule-create` path and removed with the Project by `project-delete` — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in `state.projectMemory[id]` (`{decisions, questions, digest}`, behind `rt.projectMemoryOf` / `appendProjectMemory` / `setProjectDigest` and the `project-memory` / `project-memory-write` endpoints) keyed by Project id, so one Project cannot read another's entries. `buildDigest` answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (`source`/`column`/`assignee`/`updatedAt`) and the Project State file list; a day with no change is one line, one day has one note and one `deliver()`, and the second build answers `skipped`. Identity is the install's GitHub connector account (the owner's own on a single-person install): with no connector signed in, or on a multi-member host whose `projects.ownerUserId` is not this host's own user, `digest` refuses and escalates instead of posting as the shared app, and every write carries `Written-by: Project Assistant ()` and `Posted-for: `. Since #1733 the memory and the digest are per (Project, owner) — `state.projectMemory[projectId].owners[ownerId]` with the primary owner mirrored on the row, behind `rt.projectMemoryFor(id, ownerId)`, `appendProjectMemory(id, kind, entry)`, `setProjectDigest(id, note, ownerId)` — and one `projectBoard` read plus one `listProjectState` read serve one render per owner, so the facts are shared and the personalisation is not; a read outside `ownersOf(project)` is refused naming that boundary. Since #1846 the digest reads the Project's own board, not one member's page: `rt.projectBoard(id, null, { internal: true })` is the one opt-in that skips the per-reader `taskVisible` rule, and the channel builds its options from the zone alone, so no request body can ask for it. Tests: `core/test/project-assistant.test.js`, `core/test/project-digest-authz.test.js`. | +| `core/src/host/project-assistant.js` | The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by `project-create` through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own `schedule-create` path and removed with the Project by `project-delete` — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in `state.projectMemory[id]` (`{decisions, questions, digest}`, behind `rt.projectMemoryOf` / `appendProjectMemory` / `setProjectDigest` and the `project-memory` / `project-memory-write` endpoints) keyed by Project id, so one Project cannot read another's entries. Since #1847 that bound is enforced twice, from one value: `MAX_ENTRY` (600, in `constants.js`) is checked by the assistant writer and again by `appendProjectMemory`, the one write door, so the endpoint refuses an empty entry (`A Project memory entry needs text`) and one over the cap (`A Project memory entry is at most 600 characters`) instead of storing a blank row or cutting the text, and a refusal leaves the table and `project.updatedAt` untouched; the panel quotes either sentence, in Chinese under a zh locale (`core/src/client/i18n.js`). `buildDigest` answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (`source`/`column`/`assignee`/`updatedAt`) and the Project State file list; a day with no change is one line, one day has one note and one `deliver()`, and the second build answers `skipped`. Identity is the install's GitHub connector account (the owner's own on a single-person install): with no connector signed in, or on a multi-member host whose `projects.ownerUserId` is not this host's own user, `digest` refuses and escalates instead of posting as the shared app, and every write carries `Written-by: Project Assistant ()` and `Posted-for: `. Since #1733 the memory and the digest are per (Project, owner) — `state.projectMemory[projectId].owners[ownerId]` with the primary owner mirrored on the row, behind `rt.projectMemoryFor(id, ownerId)`, `appendProjectMemory(id, kind, entry)`, `setProjectDigest(id, note, ownerId)` — and one `projectBoard` read plus one `listProjectState` read serve one render per owner, so the facts are shared and the personalisation is not; a read outside `ownersOf(project)` is refused naming that boundary. Since #1846 the digest reads the Project's own board, not one member's page: `rt.projectBoard(id, null, { internal: true })` is the one opt-in that skips the per-reader `taskVisible` rule, and the channel builds its options from the zone alone, so no request body can ask for it. Tests: `core/test/project-assistant.test.js`, `core/test/project-digest-authz.test.js`. | | `core/src/host/project-chief.js` | The Project Chief (#1859): every Project gets its own Chief, created with the Project by `project-create` — never the user's main Chief acting part-time. `rt.projectChief` has the same shape as `rt.projectAssistant` (`create(projectId)`, `chiefOf(projectId)`, `remove(projectId)`), the record carries `chief: { botId, sessionId, createdAt }` beside `assistant`, and the spawned Bot is named `Project Chief ()`, bound with `bindProject` so it carries `projectId`, and given instructions that name that one Project and forbid answering for any other (another Project's name is never quoted). A host without the seam logs once and answers `undefined`, so `project-create` still creates the Project with `chief: { error }`; `project-delete` retires the Chief through `deleteBot` and clears its binding. Tests: `core/test/projects.test.js`. | | `core/src/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. | | `core/src/shared/preview.js` | What a stored line may show a person (#130, #1347): `visibleText(text)` unwraps a complete `` wrap, then removes leftover marker fragments until neither changes the text, then folds to one line. `stripFence` is the marker half alone. The sidebar rows read it (`previewOf` in `src/client/text.js`), and so does the `preview` this channel puts on a room in the `state` payload, which used to be a plain `unwrapUntrusted` and so leaked `from="User"> … >` on a line whose opener had already been cut. Tests: `core/test/ui-clickthrough.test.js`. | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 7dd77da78..12ea4618d 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1537,16 +1537,19 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js [/^HTTP (\d+)$/, '请求失败(HTTP $1)'], [/^GitHub answered (\d+)$/, 'GitHub 返回了 $1'], [/^Keep the name under (\d+) characters$/, '名字不能超过 $1 个字'], -@@ -872,6 +872,13 @@ const HOST_ERRORS = [ +@@ -872,6 +872,16 @@ const HOST_ERRORS = [ [/^No entry in [\s\S]* matches "([\s\S]*)"\.[\s\S]*$/, '找不到「$1」这条记忆'], [/^"([\s\S]*)" matches (\d+) entries in [\s\S]*$/, '「$1」对应了 $2 条记忆,请说得更具体'], [/^Memory cannot change now: [\s\S]*$/, '现在不能修改记忆:另一个 dsh 正在使用这个 Bot 团队'], + // The Project settings panel quotes the host's refusal as it is, so a zh reader reads it in + // Chinese. Only refusals that panel's three write paths can reach are here (#1820): the owner -+ // gate on all three, an empty member id on Bind/Unbind, and a stale Project id. The id rule is -+ // last and takes one token, so it can never claim a longer "Unknown Project …" sentence. ++ // gate on all three, an empty member id on Bind/Unbind, a stale Project id, and — since #1847 — ++ // the memory box's two bounds. The id rule is last and takes one token, so it can never claim a ++ // longer "Unknown Project …" sentence. + [/^Only the owner of this Project may change it$/, '只有这个项目的主人可以修改'], + [/^A binding needs the member id$/, '绑定需要 member id'], ++ [/^A Project memory entry needs text$/, '项目记忆条目需要文字'], ++ [/^A Project memory entry is at most (\d+) characters$/, '项目记忆条目最多 $1 个字符'], + [/^Unknown Project (\S+)$/, '找不到项目 $1'], ] const HOST_ZH = { diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 17151495a..3f3c9d22a 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -88,6 +88,16 @@ diff --git a/index.js b/index.js diff --git a/src/host/constants.js b/src/host/constants.js --- a/src/host/constants.js +++ b/src/host/constants.js +@@ -21,6 +21,9 @@ export const OCR_CACHE_MAX = 500 + export const MAX_AVATAR_IMAGE = 160_000 + export const ROUTE = '/api/bot' + export const MAX_HOPS = 6 ++// The bound on one Project memory entry (#1847): one value, shared by the assistant writer and the ++// store's `appendProjectMemory`, so no writer can store a blank entry or silently cut a long one. ++export const MAX_ENTRY = 600 + // Bot requests a Bot's own chat may hold unread; past this the Bot is most likely + // stuck, and more messages only pile up behind it. + export const MAX_UNREAD_REQUESTS = 10 @@ -48,15 +48,14 @@ export const AGENT_SESSION_TITLE_MAX = 80 // Every tool this plugin registers for Bots. `read_browser` only exists in the // Desktop profile, so a restriction filters this list down to registered names. @@ -670,7 +680,8 @@ diff --git a/src/host/store.js b/src/host/store.js +import { constants, mkdirSync, statSync } from 'node:fs' import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' - import { LEGACY_THEMES, MAX_EXCHANGES, ROOM_MODES } from './constants.js' +-import { LEGACY_THEMES, MAX_EXCHANGES, ROOM_MODES } from './constants.js' ++import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants.js' import { homeLock } from './home-lock.js' +import { openJournal } from './journal.js' import { currentColor, normalize, unquote } from './text.js' @@ -885,7 +896,7 @@ diff --git a/src/host/store.js b/src/host/store.js // LEGACY: Bots saved with a lab ran on that lab's default model; the model they // last ran on becomes their own, so dropping the lab changes nothing for them. if ('lab' in bot) { -@@ -161,12 +161,465 @@ export function install(rt) { +@@ -161,12 +161,472 @@ export function install(rt) { migrated = true } // Unnamed groups from before names followed their members may still list old names. @@ -1090,11 +1101,18 @@ diff --git a/src/host/store.js b/src/host/store.js + const appendProjectMemory = (id, kind, entry = {}) => { + const project = requireProject(id) + if (kind !== 'decision' && kind !== 'question') throw projectRefusal(`Unknown Project memory kind ${String(kind ?? '')}`) ++ // #1847: this is the one door into a Project's memory, so the bound the assistant writer states ++ // is enforced here too. Before this, an endpoint caller could store an empty entry or have a ++ // long one cut to `MAX_ENTRY` without being told; a refusal leaves the table and `updatedAt` as ++ // they were. ++ const entryText = projectText(entry.text) ++ if (entryText === '') throw projectRefusal('A Project memory entry needs text') ++ if (entryText.length > MAX_ENTRY) throw projectRefusal(`A Project memory entry is at most ${MAX_ENTRY} characters`) + const table = memoryTable(project) + const ownerId = projectText(entry.ownerId, 120) || primaryOwner(project) + const record = ownerRow(table, ownerId, project) + const list = kind === 'decision' ? record.decisions : record.questions -+ list.push({ text: projectText(entry.text, 600), at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) ++ list.push({ text: entryText, at: Number(entry.at) || Date.now(), ...(projectText(entry.by, 120) === '' ? {} : { by: projectText(entry.by, 120) }) }) + mirrorOwner(table, project, ownerId, record) + project.updatedAt = Math.max(project.updatedAt, Date.now()) + return ownerView(record) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ae3bf6570..b55caa8f6 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -958,7 +958,7 @@ diff --git a/client.js b/client.js [/^HTTP (\d+)$/, "请求失败(HTTP $1)"], [/^GitHub answered (\d+)$/, "GitHub 返回了 $1"], [/^Keep the name under (\d+) characters$/, "名字不能超过 $1 个字"], -@@ -918,7 +918,14 @@ var HOST_ERRORS = [ +@@ -918,7 +918,17 @@ var HOST_ERRORS = [ [/^There is no entry (.+)\.$/, "没有第 $1 条"], [/^No entry in [\s\S]* matches "([\s\S]*)"\.[\s\S]*$/, "找不到「$1」这条记忆"], [/^"([\s\S]*)" matches (\d+) entries in [\s\S]*$/, "「$1」对应了 $2 条记忆,请说得更具体"], @@ -966,10 +966,13 @@ diff --git a/client.js b/client.js + [/^Memory cannot change now: [\s\S]*$/, "现在不能修改记忆:另一个 dsh 正在使用这个 Bot 团队"], + // The Project settings panel quotes the host's refusal as it is, so a zh reader reads it in + // Chinese. Only refusals that panel's three write paths can reach are here (#1820): the owner -+ // gate on all three, an empty member id on Bind/Unbind, and a stale Project id. The id rule is -+ // last and takes one token, so it can never claim a longer "Unknown Project …" sentence. ++ // gate on all three, an empty member id on Bind/Unbind, a stale Project id, and — since #1847 — ++ // the memory box's two bounds. The id rule is last and takes one token, so it can never claim a ++ // longer "Unknown Project …" sentence. + [/^Only the owner of this Project may change it$/, "只有这个项目的主人可以修改"], + [/^A binding needs the member id$/, "绑定需要 member id"], ++ [/^A Project memory entry needs text$/, "项目记忆条目需要文字"], ++ [/^A Project memory entry is at most (\d+) characters$/, "项目记忆条目最多 $1 个字符"], + [/^Unknown Project (\S+)$/, "找不到项目 $1"] ]; var HOST_ZH = { diff --git a/patches/ds-bot/files/src/host/project-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js index e82592191..7790e1e07 100644 --- a/patches/ds-bot/files/src/host/project-assistant.js +++ b/patches/ds-bot/files/src/host/project-assistant.js @@ -15,6 +15,7 @@ // on the assistant Session through the host's schedule path, and `project-delete` removes it with // the Project. A missed beat is DSH's, and it posts nothing. import { defineTool } from './tool-schema.js' +import { MAX_ENTRY } from './constants.js' import { listProjectState } from './project-state.js' import { output } from './tools.js' import { inQuietHours, clockIn, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js' @@ -24,7 +25,9 @@ export const DIGEST_ZONE = 'Asia/Shanghai' export const DIGEST_TIME = '09:00' export const DIGEST_TITLE = '09:00 Project digest' export const DIGEST_PROMPT = 'Build today\'s Project digest: call project_digest once. It answers whether today already has one.' -export const MAX_ENTRY = 600 +// The bound itself is in `constants.js` (#1847), beside the other limits host modules share, +// because the store's write door enforces it too; this re-export keeps the assistant's own name. +export { MAX_ENTRY } export const MEMORY_KINDS = ['decision', 'question'] // At most this many cards that need one owner's decision in one digest (#1733): a digest is a // short list of things to decide, not the board. One constant, not a setting. diff --git a/patches/ds-bot/files/test/project-board-panels.test.js b/patches/ds-bot/files/test/project-board-panels.test.js index 63bb66df2..aa9273391 100644 --- a/patches/ds-bot/files/test/project-board-panels.test.js +++ b/patches/ds-bot/files/test/project-board-panels.test.js @@ -318,6 +318,68 @@ test('a memory kind the host does not know writes nothing, and the panel quotes } }) +// #1847: the panel is not the only writer of a Project's memory. The endpoint used to store a +// blank entry and silently cut one over 600 characters, while the assistant writer refused both. +// The bound now lives at `appendProjectMemory`, the one door every writer goes through. +test('#1847: the memory write door refuses empty and over-long text and stores 600 exactly', async (t) => { + const rt = await startHost(t) + const call = (endpoint, payload) => rt.projectEndpoint(endpoint, payload) + const before = (await call('project-read', { id: 'project-1' })).project + + await assert.rejects(() => call('project-memory-write', { id: 'project-1', kind: 'decision', text: '' }), + error => error.code === 'bot/invalid' && error.message === 'A Project memory entry needs text') + await assert.rejects(() => call('project-memory-write', { id: 'project-1', kind: 'decision', text: 'x'.repeat(601) }), + error => error.code === 'bot/invalid' && error.message === 'A Project memory entry is at most 600 characters') + + let memory = (await call('project-memory', { id: 'project-1' })).memory + assert.deepEqual(memory.decisions, [], 'a refused write leaves no entry behind') + assert.deepEqual(memory.questions, [], 'and it does not land in the other list either') + const after = (await call('project-read', { id: 'project-1' })).project + assert.equal(after.updatedAt, before.updatedAt, 'a refused write does not touch the Project') + + const exact = 'x'.repeat(600) + await call('project-memory-write', { id: 'project-1', kind: 'decision', text: exact }) + memory = (await call('project-memory', { id: 'project-1' })).memory + assert.equal(memory.decisions.length, 1, 'the entry at the cap is accepted') + assert.equal(memory.decisions[0].text.length, 600, 'and stored whole, not cut') + assert.equal(memory.decisions[0].text, exact, 'the text is what was sent') +}) + +// The other half of #1847: the panel quotes the host's new refusals in Chinese, so the reader sees +// why Save did nothing instead of a success line, and no row is added. +test('#1847: the panel shows the Chinese refusal for an empty or over-long memory box', async (t) => { + const rt = await startHost(t) + const call = (endpoint, payload) => rt.projectEndpoint(endpoint, payload) + const off = installLocale(fakeLocale('zh')) + try { + // The hook slots outlive a render, so the box starts where the previous test left it; empty it + // explicitly before pressing Save. + let tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + all(tree).find(node => node.props?.id === 'bt-project-note').props.onChange({ target: { value: '' } }) + tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + byAction(tree, 'project-memory-save').props.onClick() + await settle() + tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + let status = all(tree).find(node => node.props?.role === 'status') + assert.equal(text(status), '项目记忆条目需要文字') + assert.ok(!text(status).includes('Memory saved.'), 'a refusal is never painted as a success') + + const note = all(tree).find(node => node.props?.id === 'bt-project-note') + note.props.onChange({ target: { value: 'x'.repeat(601) } }) + tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + byAction(tree, 'project-memory-save').props.onClick() + await settle() + tree = render(ProjectSettingsPanel, { project: project(), call, onClose() {} }) + status = all(tree).find(node => node.props?.role === 'status') + assert.equal(text(status), '项目记忆条目最多 600 个字符') + + const memory = (await call('project-memory', { id: 'project-1' })).memory + assert.deepEqual(memory.decisions, [], 'neither refusal adds a row') + } finally { + off() + } +}) + test("the settings panel shows the host's refusal instead of pretending the save landed", async () => { resetSlots() const refused = Object.assign(new Error('Only the owner of this Project may change it'), { code: 'bot/forbidden' })