From fa7da8269e177d28847096f6629112f2a010783f Mon Sep 17 00:00:00 2001 From: Birfy Date: Sun, 8 Nov 2026 16:13:01 +0000 Subject: [PATCH] Make the host's own files private The host's own files carry the user's content, and a write that names no mode takes the process umask: on a stock 022 the saved team, a Bot's memory, the chat index, the decision log, the OCR cache and inflight.json land 0644 in a 0755 home, and an atomic rewrite widens a file the owner narrowed to 0600. core/src/host/private-files.js is the one place those modes live: a file is 0600, the folder holding it 0700, and an atomic rewrite chmods the temp name to the mode of the file it replaces before the rename, so a chmod the owner made by hand survives the next remember. The chat index is tightened after SQLite creates it, and the files that stay ambient are the ones that are public or computed. Fixes #2697 --- changelog.d/2697-private-files.fixed.md | 1 + core/src/host/chat-index.js | 24 +- core/src/host/decisions.js | 14 +- core/src/host/images.js | 11 +- core/src/host/memory.js | 26 +- core/src/host/private-files.js | 97 +++++++ core/src/host/resume.js | 10 +- core/src/host/store.js | 15 +- core/test/private-files.test.js | 257 ++++++++++++++++++ docs/subsystems/core-host.md | 4 + patches/ds-bot/0001-core-fixes.patch | 152 ++++++++--- patches/ds-bot/0002-flybot-api-host.patch | 34 ++- patches/ds-bot/files/src/host/decisions.js | 14 +- .../ds-bot/files/src/host/private-files.js | 97 +++++++ .../ds-bot/files/test/private-files.test.js | 257 ++++++++++++++++++ 15 files changed, 934 insertions(+), 79 deletions(-) create mode 100644 changelog.d/2697-private-files.fixed.md create mode 100644 core/src/host/private-files.js create mode 100644 core/test/private-files.test.js create mode 100644 patches/ds-bot/files/src/host/private-files.js create mode 100644 patches/ds-bot/files/test/private-files.test.js diff --git a/changelog.d/2697-private-files.fixed.md b/changelog.d/2697-private-files.fixed.md new file mode 100644 index 000000000..b85248e28 --- /dev/null +++ b/changelog.d/2697-private-files.fixed.md @@ -0,0 +1 @@ +The host now creates its own files private: a Bot's memory and its snapshot, `state.json`, the chat index, the decision log, the OCR cache and `inflight.json` are `0600` in a `0700` folder whatever `umask` the process inherited, and a rewrite keeps the mode the file already has, so a `chmod 0600` the owner made by hand is no longer undone by the next `remember` ([#2697](https://github.com/ThinkFlowLab/FlyBot/issues/2697)). diff --git a/core/src/host/chat-index.js b/core/src/host/chat-index.js index 4d8db9498..034069003 100644 --- a/core/src/host/chat-index.js +++ b/core/src/host/chat-index.js @@ -7,9 +7,10 @@ // The index is derived from the Session logs: a part catches up when it is read, an // ended part is sealed once it is complete, and deleting the file only costs a // rebuild. Without SQLite, read_own_chat scans the logs as before. -import { mkdir, unlink } from 'node:fs/promises' +import { unlink } from 'node:fs/promises' import { join } from 'node:path' import { ANCHOR_LINE_MAX, HISTORY_PAGE, HISTORY_PAGES_MAX, HISTORY_TIMEOUT_MS, OWN_CHAT_BRIEF, OWN_CHAT_LINE_MAX, OWN_CHAT_READ, OWN_CHAT_SEARCH } from './constants.js' +import { ensurePrivateDir, tightenFile } from './private-files.js' import { excerpt } from './text.js' const INDEX_VERSION = 3 @@ -90,13 +91,22 @@ export function install(rt) { } } + // The index is a full-text copy of every part of a Bot's own chat, so SQLite's files are private + // like the rest of the home (#2697). SQLite creates them itself, with the ambient mode, so what + // exists is tightened right after it is made; `-wal` and `-shm` appear at the first write, which + // is why the home is made private as well. + async function hardenIndex() { + await ensurePrivateDir(home) + for (const file of [indexPath, `${indexPath}-wal`, `${indexPath}-shm`]) await tightenFile(file) + } + function openIndex() { if (chatIndex !== undefined) return Promise.resolve(chatIndex) const opening = indexOpening ??= (async () => { let DatabaseSync try { ({ DatabaseSync } = await import('node:sqlite')) - await mkdir(home, { recursive: true }) + await ensurePrivateDir(home) } catch (error) { rt.warn('ds-bot: no chat index, read_own_chat scans the logs: %s', error) return null @@ -105,7 +115,9 @@ export function install(rt) { for (const fresh of [false, true]) { try { if (fresh) await unlink(indexPath).catch(() => { /* the index file may not exist yet */ }) - return createIndex(new DatabaseSync(indexPath)) + const index = createIndex(new DatabaseSync(indexPath)) + await hardenIndex().catch(error => rt.warn('ds-bot: the chat index could not be made private: %s', error)) + return index } catch (error) { if (fresh) rt.warn('ds-bot: no chat index, read_own_chat scans the logs: %s', error) } @@ -135,6 +147,7 @@ export function install(rt) { } return { db, + hardened: false, mark: db.prepare('SELECT through, keep, sealed, turn FROM marks WHERE session = ?'), setMark: db.prepare('INSERT OR REPLACE INTO marks (session, through, keep, sealed, turn) VALUES (?, ?, ?, ?, ?)'), insert: db.prepare('INSERT INTO lines (text, session, seq, time, who) VALUES (?, ?, ?, ?, ?)'), @@ -192,6 +205,11 @@ export function install(rt) { } index.setMark.run(part.sessionId, last, keep ? 1 : 0, seal ? 1 : 0, turn) index.db.exec('COMMIT') + // The first commit is what makes `-wal`/`-shm`, so the modes are put right once it exists. + if (!index.hardened) { + index.hardened = true + await hardenIndex().catch(error => rt.warn('ds-bot: the chat index could not be made private: %s', error)) + } } catch (error) { index.db.exec('ROLLBACK') throw error diff --git a/core/src/host/decisions.js b/core/src/host/decisions.js index 5bbd3e2f0..6b6b94bb5 100644 --- a/core/src/host/decisions.js +++ b/core/src/host/decisions.js @@ -8,11 +8,12 @@ // default install behaves exactly as before. The log is local text, one JSON line per entry; the // same entry is fired on the activity feed as `decision` so a consumer (Timeline, an audit view) // can show it without reading the file. -import { appendFile, mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises' +import { appendFile, open, readFile, stat } from 'node:fs/promises' import { dirname, join } from 'node:path' import { cut } from './text.js' import { defineTool } from './tool-schema.js' import { looksSecret } from './memory.js' +import { PRIVATE_FILE_MODE, ensurePrivateDir, replacePrivateFile } from './private-files.js' import { fileKind, tempWritable } from './real-dir.js' import { output } from './tools.js' @@ -86,8 +87,9 @@ export function install(rt) { } return kept.length } - await writeFile(tmp, next, 'utf8') - await rename(tmp, path) + // A trim replaces the log through the temp name, so the mode the log has now is the one the + // replacement lands with: a log the owner narrowed by hand stays narrowed (#2697). + await replacePrivateFile(tmp, path, next) } return kept.length } @@ -95,8 +97,10 @@ export function install(rt) { const line = `${JSON.stringify(entry)}\n` writing = writing.then(async () => { if (!await leafWritable()) return 'refused' - await mkdir(dirname(path), { recursive: true }) - await appendFile(path, line, 'utf8') + // What a Bot considered and rejected is its own reasoning, so the log and its folder are + // private (#2697). `appendFile`'s mode applies when the append creates the file. + await ensurePrivateDir(dirname(path)) + await appendFile(path, line, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }) linesOnDisk = linesOnDisk === null ? await countLines() : linesOnDisk + 1 const { size } = await stat(path) if (linesOnDisk > KEPT || size > MAX_BYTES) { diff --git a/core/src/host/images.js b/core/src/host/images.js index f0ef287c1..60ba3c4e6 100644 --- a/core/src/host/images.js +++ b/core/src/host/images.js @@ -2,10 +2,11 @@ // reaches a model that cannot read them, each image becomes text from a vision model. // Texts are kept per attachment, so later requests repeat the same words and the // prompt cache still hits. -import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises' +import { readFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import { OCR_CACHE_MAX, OCR_PROMPT, OCR_RETRY_MS, OCR_TIMEOUT_MS } from './constants.js' +import { ensurePrivateDir, replacePrivateFile } from './private-files.js' import { tempWritable } from './real-dir.js' import { splitRef } from './text.js' @@ -130,13 +131,13 @@ export function install(rt) { while (ocrCache.size > OCR_CACHE_MAX) ocrCache.delete(ocrCache.keys().next().value) const snapshot = JSON.stringify(Object.fromEntries(ocrCache)) ocrWriting = ocrWriting.then(async () => { - await mkdir(home, { recursive: true }) + await ensurePrivateDir(home) const temp = `${ocrPath}.${process.pid}.tmp` // The temp name is a leaf like the cache: a link there would take the readings outside - // the home and the rename would put the link over ocr.json (temp-name sweep). + // the home and the rename would put the link over ocr.json (temp-name sweep). The readings + // are the user's own images, so the cache is private like the team file (#2697). if (!tempWritable(temp)) throw new Error(`Not saved: ${temp} is not a plain file of its own, so the OCR cache was not written there.`) - await writeFile(temp, snapshot, 'utf8') - await rename(temp, ocrPath) + await replacePrivateFile(temp, ocrPath, snapshot) }).catch(error => rt.warn('ds-bot: OCR cache write failed: %s', error)) } diff --git a/core/src/host/memory.js b/core/src/host/memory.js index 7854acb6e..3d8a1c269 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -21,10 +21,11 @@ // provider's prefix cache holds; a fresh part, or a checkpoint (which breaks the cache // anyway), freezes it anew. import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' -import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' +import { existsSync, readFileSync, readdirSync, unlinkSync } from 'node:fs' +import { appendFile, rename, unlink } from 'node:fs/promises' import { dirname, join, relative, sep } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import { PRIVATE_FILE_MODE, ensurePrivateDir, ensurePrivateDirSync, replacePrivateFile, replacePrivateFileSync } from './private-files.js' import { currentParticipants } from './room-membership.js' import { defineTool } from './tool-schema.js' import { clip, codePoints, splitRef, unquote } from './text.js' @@ -557,16 +558,16 @@ export function install(rt) { // too, asked like the journal's file — absent is ours to create, anything else refuses the write // before it starts, says so once, and leaves the memory file as it was. Asking and then writing // still leaves a window in which the name can change: this closes a link that is already there, - // not the race. + // not the race. The bytes are the user's own notes, so the folder is the host's private one and + // the temp file takes the mode of the file it replaces (#2697). async function writeText(path, text) { - await mkdir(dirname(path), { recursive: true }) + await ensurePrivateDir(dirname(path)) const temp = `${path}.${process.pid}.tmp` if (!tempWritable(temp)) { warnFile(temp, 'the memory write', 'nothing is written there') throw new Error(`Not saved: ${temp} is not a plain file of its own, so nothing is written there.`) } - await writeFile(temp, text, 'utf8') - await rename(temp, path) + await replacePrivateFile(temp, path, text) } async function journal(record) { if (!writeFolder(root)) return @@ -574,8 +575,8 @@ export function install(rt) { warnFile(journalPath) return } - await mkdir(root, { recursive: true }) - await appendFile(journalPath, `${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`, { encoding: 'utf8', mode: 0o600 }) + await ensurePrivateDir(root) + await appendFile(journalPath, `${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }) } let writing = Promise.resolve() const queued = (job) => { @@ -782,7 +783,7 @@ export function install(rt) { if (!readFolder(dir)) return if (!writeFolder(archiveDir)) return const name = `${safeId(botId)}-${new Date().toISOString().replace(/[:.]/g, '-')}` - await mkdir(archiveDir, { recursive: true }) + await ensurePrivateDir(archiveDir) await rename(dir, join(archiveDir, name)) await journal({ op: 'archive', scope: scopeKey(ownScope(botId)), actor: 'user', actorName: 'user', botName: bot?.name, to: `archive/${name}` }) }).catch(error => rt.warn('ds-bot: memory of %s not archived: %s', bot?.name ?? botId, error)) @@ -838,15 +839,12 @@ export function install(rt) { text = memorySection(bot) if (rt.readOnly() === undefined && writeFolder(snapshotsDir)) { try { - mkdirSync(dirname(path), { recursive: true }) + ensurePrivateDirSync(dirname(path)) // The snapshot's temp name is a leaf like the entry's (#2097): a link left there would // take the frozen memory outside and then replace the snapshot with the link. const temp = `${path}.tmp` if (!tempWritable(temp)) warnFile(temp, 'the memory snapshot', 'nothing is written there') - else { - writeFileSync(temp, text, 'utf8') - renameSync(temp, path) - } + else replacePrivateFileSync(temp, path, text) } catch (error) { rt.warn('ds-bot: memory snapshot for %s not saved: %s', bot.name, error) } diff --git a/core/src/host/private-files.js b/core/src/host/private-files.js new file mode 100644 index 000000000..b6adc190b --- /dev/null +++ b/core/src/host/private-files.js @@ -0,0 +1,97 @@ +// The host's own files carry what the user told it: a Bot's memory, the saved team, the index of +// every conversation part, the decision log, the OCR cache, the notes of a running turn. They are +// written by a process that inherits its umask from whoever started it, so a write that names no +// mode lands `0644` on a stock `022` and another local account reads all of it. These helpers name +// the mode instead — a file `0600`, the folder holding it `0700` — so the file's mode is a +// decision in the code and not a property of the shell it was started from (#2697). +// +// A rewrite must not widen what is already there. The writers put a temp file next to the old one +// and rename it over, and that new name would be created with the ambient mode; so the mode of the +// file being replaced is read first and given to the temp file before the rename. A `chmod 0400` a +// person made by hand survives the next `remember`; a name that does not exist yet is created +// private. +import { chmodSync, lstatSync, mkdirSync, renameSync, writeFileSync } from 'node:fs' +import { chmod, lstat, mkdir, rename, writeFile } from 'node:fs/promises' + +export const PRIVATE_FILE_MODE = 0o600 +export const PRIVATE_DIR_MODE = 0o700 + +// `mkdir`'s mode is filtered by the umask and it leaves a folder that already exists alone, so the +// `chmod` is what makes an existing folder private too. A folder this process may not chmod — a +// read-only mount, a volume owned by another account — is not a reason to refuse to write: the +// file modes below are what the writers promise, and a login that cannot chmod cannot read the +// folder through a narrower mode either way. +export async function ensurePrivateDir(dir) { + await mkdir(dir, { recursive: true, mode: PRIVATE_DIR_MODE }) + await chmod(dir, PRIVATE_DIR_MODE).catch(() => { /* the file modes still hold */ }) +} + +export function ensurePrivateDirSync(dir) { + mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE }) + try { + chmodSync(dir, PRIVATE_DIR_MODE) + } catch { + /* the file modes still hold */ + } +} + +// The mode a rewrite lands with. `lstat` and not `stat`: a link is not this file, and its target's +// mode is not ours to copy. A missing or unreadable name is the creation case. +const modeOf = info => (info.isFile() && !info.isSymbolicLink() ? info.mode & 0o777 : PRIVATE_FILE_MODE) + +export async function keptFileMode(path) { + try { + return modeOf(await lstat(path)) + } catch { + return PRIVATE_FILE_MODE + } +} + +export function keptFileModeSync(path) { + try { + return modeOf(lstatSync(path)) + } catch { + return PRIVATE_FILE_MODE + } +} + +// Writes the temp name a caller will `rename` over the target, at the mode the target has now. +// The caller keeps its own temp-name check and its own ordering around the rename: the lock it +// asks for, and whether the target was a link, are read between these two steps. +export async function stagePrivateFile(temp, target, text) { + // `writeFile`'s `mode` is used only when it creates the name: a leftover temp file keeps its own + // mode, so the chmod is what the rename lands with. + const mode = await keptFileMode(target) + await writeFile(temp, text, { encoding: 'utf8', mode }) + await chmod(temp, mode) + return mode +} + +export function stagePrivateFileSync(temp, target, text) { + const mode = keptFileModeSync(target) + writeFileSync(temp, text, { encoding: 'utf8', mode }) + chmodSync(temp, mode) + return mode +} + +export async function replacePrivateFile(temp, target, text) { + await stagePrivateFile(temp, target, text) + await rename(temp, target) +} + +export function replacePrivateFileSync(temp, target, text) { + stagePrivateFileSync(temp, target, text) + renameSync(temp, target) +} + +// A file written in place, not replaced: `writeFile` keeps the mode of a name that already exists, +// which is what a narrowed file keeps, and creates a missing one private. +export async function writePrivateFile(file, text) { + await writeFile(file, text, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }) +} + +// SQLite creates its own files, so their mode is not ours to pass. Tighten what exists now; the +// folder is checked as well, because the `-wal`/`-shm` pair appears on SQLite's own time. +export async function tightenFile(file, mode = PRIVATE_FILE_MODE) { + await chmod(file, mode).catch(() => { /* not created yet, or no modes on this file system */ }) +} diff --git a/core/src/host/resume.js b/core/src/host/resume.js index d010bf247..85397a19f 100644 --- a/core/src/host/resume.js +++ b/core/src/host/resume.js @@ -6,9 +6,9 @@ // inflight.json; what is still noted at the next start is picked up once. import { randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { SOURCE_KIND } from './constants.js' +import { ensurePrivateDir, writePrivateFile } from './private-files.js' import { OTHER, settled } from './turn-origin.js' import { retiredBot } from './store.js' @@ -70,12 +70,14 @@ export function install(rt) { if (due) return due = true // Written in place: a rename over the file fails on Windows while anything reads - // it, and a torn write only loses what one restart would pick up. + // it, and a torn write only loses what one restart would pick up. The notes name the + // Session, the room and the message a turn was handed, so the file is private like the + // team file: created 0600 in a 0700 home, and an existing file keeps the mode it has (#2697). writing = writing.then(async () => { due = false const snapshot = latest - await mkdir(home, { recursive: true }) - await writeFile(file, snapshot, 'utf8') + await ensurePrivateDir(home) + await writePrivateFile(file, snapshot) }).catch(error => rt.warn('ds-bot: noting running turns failed: %s', error)) } const originNote = (id) => { diff --git a/core/src/host/store.js b/core/src/host/store.js index 6e8f9abbd..d39ae92ac 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -2,12 +2,13 @@ // host on the same data from writing it, and the indexes that map a Session to the Bot // or group chat it belongs to. import { randomUUID } from 'node:crypto' -import { constants, mkdirSync, statSync } from 'node:fs' -import { copyFile, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { constants, statSync } from 'node:fs' +import { copyFile, lstat, readFile, rename, unlink } from 'node:fs/promises' import { join } from 'node:path' import { LEGACY_THEMES, MAX_ENTRY, MAX_EXCHANGES, ROOM_MODES } from './constants.js' import { homeLock } from './home-lock.js' import { openJournal } from './journal.js' +import { ensurePrivateDir, ensurePrivateDirSync, stagePrivateFile } from './private-files.js' import { tempWritable } from './real-dir.js' import { TEAM_TURNS_PER_DAY } from './turn-budget.js' import { codePoints, currentColor, cut, normalize, unquote } from './text.js' @@ -95,7 +96,9 @@ export function install(rt) { const claimLock = () => { if (lockBroken) return try { - mkdirSync(home, { recursive: true }) + // The home holds the team file, the chat index and the OCR cache: the host's own folder, so + // it is created private and an existing one is narrowed to the same mode (#2697). + ensurePrivateDirSync(home) holder = lock.claim() } catch (error) { // An unusable lock (a read-only or odd file system) must not stop the team; a @@ -978,7 +981,7 @@ export function install(rt) { // outside the home and the rename would put the link over state.json (temp-name sweep). if (!tempWritable(temp)) throw new Error(`Not saved: ${temp} is not a plain file of its own, so the Bot team was not written there.`) try { - await mkdir(home, { recursive: true }) + await ensurePrivateDir(home) const ensureLock = () => { if (lockBroken) return if (!lock.owns()) { @@ -988,7 +991,9 @@ export function install(rt) { } } ensureLock() - await writeFile(temp, snapshot, 'utf8') + // The team file holds the messages and the saved keys, so the temp name is written at the + // mode the file it replaces already has — the owner's `chmod 0600` survives the save (#2697). + await stagePrivateFile(temp, statePath, snapshot) ensureLock() // The rename replaces the name, link included, so a linked team file is never written // through: its target keeps every byte and its last write time. Ask the name here, before diff --git a/core/test/private-files.test.js b/core/test/private-files.test.js new file mode 100644 index 000000000..100cd0150 --- /dev/null +++ b/core/test/private-files.test.js @@ -0,0 +1,257 @@ +// The host's own files carry the user's content: a Bot's memory, the saved team, the index of +// every conversation part, the decision log, the OCR cache and the notes of a running turn. They +// are written by a process whose umask the host does not control, so without an explicit mode a +// stock `022` leaves them world-readable, and an atomic rewrite replaces a `chmod 0600` the owner +// made with the ambient `0644` (#2697). +// +// Every test here sets `umask 022` in-process and puts it back afterwards: this box runs `077`, +// which hides the defect. A scratch home is created `0755` first, the mode a stock shell gives it. +import assert from 'node:assert/strict' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { install as installImages } from '../src/host/images.js' +import { install as installResume } from '../src/host/resume.js' +import { fakeContext } from './fake-ctx.js' + +const TEAM = { + version: 1, + mainBotIds: ['m1'], + revision: 1, + bots: { + m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', model: 'deepseek/v4', instructions: '', createdAt: 1 }, + b2: { id: 'b2', sessionId: 'b2', name: 'Writer', role: '写手', model: 'deepseek/v4', instructions: '', createdAt: 2 }, + }, + rooms: { + r1: { + id: 'r1', name: 'Launch', named: true, members: ['b2', 'm1'], admin: 'b2', mode: 'everyone', notice: '', sessions: {}, + seq: 2, + log: [{ who: 'User', text: '私聊内容:帮我把这封离职信改短一点', seq: 1 }, { who: 'Writer', botId: 'b2', text: '好的,已改短。', seq: 2 }], + }, + }, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +const mode = path => (statSync(path).mode & 0o777).toString(8).padStart(4, '0') +const tick = (ms = 40) => new Promise(resolve => setTimeout(resolve, ms)) + +// A scratch root widened to 0755, then a home under it, created the way `mkdir(..., { recursive: +// true })` leaves it under `umask 022`. `apply` gets a home that already exists at 0755. +function scratch(t) { + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-private-')) + chmodSync(dir, 0o755) + t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 5 })) + const home = join(dir, 'dsh', 'bot') + mkdirSync(home, { recursive: true }) + assert.equal(mode(home), '0755', 'the scratch home must start world-readable, or this test proves nothing') + return { dir, home } +} + +function start(home, config = {}) { + if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM), { mode: 0o600 }) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => ['v4'], resolveModelInfo: async () => ({}) }, + sessionController: { + create: async () => ({ sessionId: 's1' }), + resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, ctx: { tools: { restrict: () => () => {} }, effect: fn => fn() }, followup() {} } }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {}, messageProjections: [] }, + }) + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = options => { route = options; return register(options) } + apply(ctx, { home, ...config }) + const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) + return { ctx, call, tool, stop: async () => { await ctx.dispose() } } +} + +test('Bot memory, its topics and its snapshot are private, and a narrowed file stays narrowed', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const host = start(home) + t.after(host.stop) + + await host.tool('remember', { text: '- 用户的家庭住址是杭州西湖区文三路 100 号 [source: part 1 #1]' }, 'b2') + await tick() + + const entry = join(home, 'memory', 'bots', 'b2', 'MEMORY.md') + assert.match(readFileSync(entry, 'utf8'), /杭州西湖区/) + assert.equal(mode(entry), '0600') + for (const dir of [home, join(home, 'memory'), join(home, 'memory', 'bots'), join(home, 'memory', 'bots', 'b2')]) { + assert.equal(mode(dir), '0700', dir) + } + + // The frozen per-Session snapshot holds the same text under the same mode. + host.ctx.sections.get('bot-memory').text({ agent: { id: 'b2' } }) + const snapshot = join(home, 'memory', 'snapshots', 'b2.md') + assert.match(readFileSync(snapshot, 'utf8'), /杭州西湖区/) + assert.equal(mode(snapshot), '0600') + + // The owner narrows the file by hand; the next rewrite must keep that, not put 0600 back. + chmodSync(entry, 0o400) + await host.tool('remember', { text: '- 用户偏好 DeepSeek 模型 [source: part 1 #2]' }, 'b2') + await tick() + assert.match(readFileSync(entry, 'utf8'), /DeepSeek/) + assert.equal(mode(entry), '0400') +}) + +test('the saved team is private, also when it was seeded 0600', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const state = join(home, 'state.json') + const host = start(home) + t.after(host.stop) + assert.equal(mode(state), '0600') + + await host.call('state', {}) + await host.call('add-agent') + await tick(120) + + // The file carries a group message, and the team keeps the mode the owner gave it. + assert.match(readFileSync(state, 'utf8'), /私聊内容/) + assert.equal(mode(state), '0600') + assert.equal(mode(home), '0700') +}) + +test('the decision log is private', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const host = start(home, { decisions: true }) + t.after(host.stop) + await host.ctx.flybot.ready() + + const out = await host.tool('record_decision', { option: '把客户的报价单直接发到公开群', choice: 'rejected', why: '泄露报价' }, 'b2') + await tick(80) + + const log = join(home, 'decisions.jsonl') + assert.match(out, /Recorded/) + assert.match(readFileSync(log, 'utf8'), /泄露报价/) + assert.equal(mode(log), '0600') + assert.equal(mode(home), '0700') +}) + +test('the OCR cache of the user\'s images is private', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { + listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }], + listModels: async () => [{ id: 'vision', input: ['text', 'image'] }], + resolveModelInfo: async () => ({ inputModalities: ['text', 'image'] }), + stream: async function* () { + yield { type: 'text-delta', index: 0, text: 'A red triangle on a bank statement dated 2026-11-01.' } + yield { type: 'finish', reason: { kind: 'stop' } } + }, + }, + }) + const rt = { + ctx, home, config: {}, warn: () => {}, load: async () => {}, readOnly: () => undefined, + roomOf: () => 'r1', isBotSession: () => true, ownRequest: () => false, + catalog: async () => [{ ref: 'deepseek/vision', name: 'Vision', input: ['image'] }], + defaultRef: () => 'deepseek/vision', tagUsage: messages => messages, + } + installImages(rt) + await rt.imagesForGroup([{ attachmentId: `sha256:${'b'.repeat(64)}`, name: 'statement.png' }], 'r1') + + const cache = join(home, 'ocr.json') + for (let i = 0; i < 100 && !existsSync(cache); i += 1) await tick(10) + assert.match(readFileSync(cache, 'utf8'), /bank statement/) + assert.equal(mode(cache), '0600') + assert.equal(mode(home), '0700') +}) + +test('the chat index of every part is private, SQLite or not', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const host = start(home) + t.after(host.stop) + + try { await host.tool('read_own_chat', { query: '银行卡' }, 'b2') } catch { /* no SQLite: nothing to check */ } + await tick(150) + + const indexPath = join(home, 'chat-index.sqlite') + // Without `node:sqlite` read_own_chat scans the logs and no file is made; the mode is then + // nothing this test can fail on. + if (!existsSync(indexPath)) return + assert.equal(mode(indexPath), '0600') + for (const suffix of ['-wal', '-shm']) { + if (existsSync(`${indexPath}${suffix}`)) assert.equal(mode(`${indexPath}${suffix}`), '0600') + } + // SQLite creates the WAL files itself, on its own time; the folder is what covers them. + assert.equal(mode(home), '0700') +}) + +test('the notes of a running turn are private', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const ctx = fakeContext() + const rt = { + ctx, home, readOnly: () => undefined, warn: () => {}, + deliver: async () => {}, chatOf: id => id, + } + installResume(rt) + t.after(() => ctx.dispose()) + + await rt.deliver({ toId: 'b2', text: '用户说:帮我把这封离职信改短一点' }) + const notes = join(home, 'inflight.json') + for (let i = 0; i < 100 && !existsSync(notes); i += 1) await tick(10) + assert.match(readFileSync(notes, 'utf8'), /离职信/) + assert.equal(mode(notes), '0600') + assert.equal(mode(home), '0700') +}) + +test('the helpers: a private folder is 0700, a new file 0600, a rewrite keeps the mode', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-private-helpers-')) + t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 5 })) + // The module is loaded here rather than at the top of the file, so the other tests show the + // mode the writers put on disk even before this helper exists. + const { ensurePrivateDir, ensurePrivateDirSync, replacePrivateFile, replacePrivateFileSync, PRIVATE_DIR_MODE, PRIVATE_FILE_MODE } = await import('../src/host/private-files.js') + assert.equal(PRIVATE_DIR_MODE, 0o700) + assert.equal(PRIVATE_FILE_MODE, 0o600) + + const created = join(dir, 'made', 'by', 'the', 'helper') + await ensurePrivateDir(created) + assert.equal(mode(created), '0700') + // An existing folder at 0755 is narrowed too: `mkdir` leaves it alone. + chmodSync(created, 0o755) + ensurePrivateDirSync(created) + assert.equal(mode(created), '0700') + + const fresh = join(created, 'fresh.md') + await replacePrivateFile(`${fresh}.tmp`, fresh, 'hello') + assert.equal(mode(fresh), '0600') + assert.equal(readFileSync(fresh, 'utf8'), 'hello') + + // A mode the owner chose — narrower or wider — is the one the rewrite lands with. + writeFileSync(fresh, 'old', { mode: 0o640 }) + chmodSync(fresh, 0o640) + replacePrivateFileSync(`${fresh}.tmp`, fresh, 'new') + assert.equal(mode(fresh), '0640') + assert.equal(readFileSync(fresh, 'utf8'), 'new') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 93cb6e75c..afe266d1f 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -228,6 +228,10 @@ A member's own line calls the members it names in two shapes where the call used An admin's `@` used to be the room's control plane: every `@Dev #1234 rebase and push` opened a group round for that member, so dispatch stayed chat and the board only recorded what chat had already said. Now the line is the assignment. `work-dispatch.js` `workDispatch(text, { callees })` answers only the shape — exactly one named member, an issue/PR id (`#1234` or a `github.com///(issues|pull)/` link), an imperative within 40 characters of that id (English verbs, or Chinese ones such as 修 / 改 / 推 / 补), and no `?` — and `groups.js` `dispatchOf` adds the room's rules: a Bot's own post, no `@all`, not quiet or an event, a room with a board, and the poster is `rt.taskLeadOf(room)` (the picked lead, else the admin). `rt.assignFromDispatch` in `tasks.js` then updates the open card that member already holds for that issue (`cardNamesIssue` in `src/shared/issue-ref.js`, re-exported from `work-dispatch.js`: matched on `title` or `target`; a card whose own fields name no issue is matched on its `note`, so a note that merely mentions another issue no longer reclaims it — #1331) or opens one owned by it, still `open` — the host assigns, and the member claims on its own turn, where #1253's claim line is posted in its own voice. The save runs the drive, so the member is woken in its own chat (#1205). A card that is not driving — `result-ready` or `blocked` — is restarted through an edge the state machine already has (`result-ready → working`, `blocked → working`), because `task-drive` wakes only `open`/`claimed`/`working` and rewriting the note alone would leave the member unwoken; a card blocked on an input is reused rather than duplicated, so one issue stays one card, and a stored card with no `actors` list has one started instead of throwing after the note moved. The relay's `speakers` is empty for that opener and the round records no part or pass; the same dispatch posted while a round runs is written from `showInRound` and adds no `round.calls`, so the member is not called twice — but a card write that answers `undefined` is never a silent drop: the opener's `speakers` fall back to the ordinary ones and a mid-round dispatch falls back to `round.calls`, so the member is called in the round instead of not at all. A question, a remark with no imperative, two named members, `@all`, a poster that does not divide the board, and a face-to-face room (no board) keep the group turn they had. The line is still in the room log, and the card shows on the board. Tests: `core/test/work-dispatch.test.js`. +### Host files are private (#2697) + +The host's own files carry the user's content — a Bot's memory and the memory section each Session froze, the saved team (group messages included), the full-text index of every conversation part, the decision log, the OCR cache of the user's images and `inflight.json` (the Session, room and message a running turn was handed) — so each is created `0600` inside a `0700` folder, independent of the process umask. `src/host/private-files.js` is the one place those numbers live: `ensurePrivateDir` (`mkdir` with the mode, then `chmod`, because `mkdir` leaves an existing folder alone and the umask filters the mode it does set) and `stagePrivateFile`/`replacePrivateFile` (write the temp name, then `chmod` it to the mode the file being replaced already has, then `rename`). A rewrite therefore never widens: an owner's `chmod 0400` survives the next `remember`, and a file that does not exist yet is created private; `writePrivateFile` is the in-place variant for `inflight.json`, whose mode is set when the name is created and kept after that. SQLite creates the chat index itself with the ambient mode, so `chat-index.js` chmods the index and its `-wal`/`-shm` names right after SQLite makes them, and the home's own mode covers a WAL file that appears between two checks. Content that is public or computed (a build output, an exported static file) may keep the ambient mode, but the choice is written where it is made, never left to the default. Covered by `core/test/private-files.test.js`, which sets `umask 022` inside the test process and restores it. + ### Team state and access - The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, a durable **Project** record (#1691): `state.projects` holds `{id, name, repository, defaultBranch, ownerUserId, createdAt, updatedAt, status}`, `bot.projectId` / `room.projectId` and `state.projectRuns[runId]` bind a member (a group, a Bot or a workflow run) to at most one Project, and the `api/bot` endpoints `projects`, `project-create`, `project-read`, `project-bind`, `project-of` and `project-board` sit in the task allow list. An unbound member resolves `null`, so an install with zero Projects behaves exactly as before. Since #2638 every text field a Project stores goes through `cut()` (`core/src/shared/text-boundary.js`) instead of `String#slice`: the bound counts UTF-16 units as before, but the kept prefix stops on a code-point boundary, so a name whose last character is an emoji at the 120 bound is stored whole or dropped whole rather than as half a surrogate pair that every UTF-8 write reads as U+FFFD. The read-only board over those bindings is `project-board` (#1696): Project id in, `{ tasks: [...] }` out, one row per core task id with `source` (`bot`/`group`/`workflow`), `column`, `assignee`, `updatedAt` and `runId`/`nodeId` when a run holds it, plus the card's own `title` (`null` when the record has none), `priority` (`priorityOf`, `'normal'` when the record stored none) and `board` (the `{ kind, id }` the row opens, `null` on a `workflow` row) so a row draws a card and opens the board it lives on: the open `tasks` read a page joins by id keeps open cards only, so a finished card in the `done` half has no record there and would otherwise draw as `#` with no priority and nowhere to go. `core/src/host/project-board.js` reads `state.tasks` and the bindings and asks the registered providers through `rt.projectRunRefs` (`flybot.tasks.onProjectProvider`); it never reads `workflows.json` and writes nothing. Since #2021 the board's record halves say what their caps left out and cut the right end of the list: the decision read answers `total` — the files left after the one-row-per-path dedup, before the cut — and takes `.slice(-DECISIONS_MAX)` over the still-ascending sort, so a Project past 200 decisions keeps the newest instead of dropping the ones just written and the order on screen does not change, while the meetings read answers `decidedTotal`/`pendingTotal` per list beside the 100 rows it already kept newest; a list at or under its cap answers the same rows as before, plus the count ([#2021](https://github.com/ThinkFlowLab/FlyBot/issues/2021)). Since #2033 the lookup a decided meeting uses for its own file falls back to the un-truncated dedupe set of that same read when the name is not among the kept rows, so a file that fell off the oldest end of the cut is still named by the meeting (`path` set, and no `sha256`, because only the kept rows carry one) while a name the folder no longer holds still answers `path: ''`; the fallback reads names from that dedupe set and never asks the filesystem, so a crafted `../outside.md` yields no path either ([#2033](https://github.com/ThinkFlowLab/FlyBot/issues/2033)). Since #2037 the name is also checked where the listing is produced, by the same `isDecisionPath` the reader uses: a name with a `..` segment, an absolute path, a symlink standing in for the file, or a name this read never listed yields no row and no meeting `path` (and no `sha256`), so the fallback past the cut cannot bring one back — `archivedOf` reads through `describeFile`, the reader's own `lstat`, never `stat` ([#2037](https://github.com/ThinkFlowLab/FlyBot/issues/2037)). Since #2041 a decision row can be opened: `rt.projectDecision(projectId, name)` reads one name through `readListedFile` and answers the reader's own three states (`text`, `absent`, `too large`), but only for a name the last `projectBoard` read of that Project produced — that read's un-truncated dedupe set is kept per Project, each name with the folder it was read from, so a name past the 200-row cut still opens while `decisions/../outside.md`, an absolute path, a name one level down or a symlink answers `not listed` without a `stat`, an open or a byte of content; the endpoint is `project-decision`, dispatched in `channel.js` beside `project-board` and listed in `authz.js`'s `TASK_ENDPOINTS` ([#2041](https://github.com/ThinkFlowLab/FlyBot/issues/2041)). A decided meeting that saved no folder keeps the cards it decided: its file is one of the Project folder's own rows, and binding those cards to that row is what keeps the name behind the same door as the cards, while a meeting that decided no card still answers through the role list ([#2052](https://github.com/ThinkFlowLab/FlyBot/issues/2052)). A record that named a folder whose file this read could not open is not that folder-less shape: it binds no cards, so the same relative name in the Project's own folder answers through the role list instead of inheriting the cards of a file in another folder. Since [#2029](https://github.com/ThinkFlowLab/FlyBot/issues/2029) a blocked card also carries the age of its own question: `blockedOf(task, now)` answers `since` (the `inputRequest.requestedAt` stamp `tasks.js` wrote, preserved across a re-ask) and `waitedMs` (`now - since`, the same shape `waitingOf` answers), both `null` when the ask wrote no stamp — never derived from `createdAt`/`updatedAt` — and `buildQueues` sorts `blocked` oldest-first with the comparator `waiting.sort` already uses, so an undated card sorts last; the Waiting group's own output is unchanged. Tests: `core/test/project-board.test.js`. On top of the record and the board sits the **Project Assistant** (#1712, Stage 2.5): `project-delete` removes the Project, its bindings and its memory; the assistant's digest is one stored note plus one `deliver()` to the owner, idempotent per Asia/Shanghai day, and its identity is the connector account and never the shared app — no connector signed in, or a multi-member host whose Project owner is not this host's own user, refuses and escalates with nothing written. Per-owner memory (#1733) keys those three fields by `(Project, owner)` under `projectMemory[id].owners`, mirrors the primary owner onto the row itself so a single-owner Project reads as before, and migrates a pre-#1733 record onto its primary owner on the first write instead of replacing it; the boundary a memory read is checked against is the Project's own owner list, so an owner asking for another owner's entries is refused naming that list, while the assistant's own render walks every owner. A card needs an owner's decision when the Bot that owns the card belongs to that owner, never from the board's `assignee` (a Bot name or null); the ask names the card's own title, at most `MAX_ACTIONABLE` (5) per owner. A quiet window on the assistant record holds a due digest and releases it once, labelled, when the window ends; a window covering 09:00 delivers nothing that day; an owner's opt-out stops generation and pauses the shared 09:00 task once every owner is off, and a re-enable resumes it; delivery goes to each owner's own Session and falls back to the main Bot only for the primary owner. A held digest is released by the next `digest()` call, not by the 09:00 task, which does not make one after a window ends. On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads — except a Bot Social read naming a `body.botId` the actor does not own, which `bot/forbidden` refuses before the plugin handler registers that Bot's identity or syncs its grants (#1788, an owner-decided tightening); plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). Approval binding (#52) completes the held-send half of this: `state.flybot.approvals` records carry `requestId`, a sha256 `version` over the request content, server-derived `ownerId` and `origin` (`via`/`hop`/audience) — claims in the message (`meta`, text) never confer authority. `flybot/decide` is allowed through authz for any signed-in actor and the per-record check happens in hooks (`decide` requires the sender Bot's owner's session, `bot/forbidden` otherwise, `bot/unauthorized` without a session); `release()` revalidates before dispatch and marks the record `stale` when the request changed, the sender or target Bot is gone (also invalidated eagerly on `deleteBot`), or the Bot changed owners; a demoted Chief's sender-side holds are invalidated eagerly on `setMain` (not inside `staleReason`), and `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. `flybot/approvals` lists only the actor's own holds while enforcement is on; decided stale records keep their attribution in state but appear in no member's list. Which statuses still wait for that decision is one rule in `src/shared/approval-waiting.js` (#754): `decide()`, `heldOf()` in the chat view, the in-flight filter that invalidates holds, `PENDING_MAX` and the `APPROVALS_KEPT` cleanup all ask `isApprovalWaiting()`, and `channel.js` counts a row's waiting replies with it, so a `pending_approval` hold (Bot Social's own spelling) is decidable, drawn as waiting and never dropped mid-wait. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 06f865501..23245c27a 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -3806,6 +3806,18 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.js diff --git a/src/host/chat-index.js b/src/host/chat-index.js --- a/src/host/chat-index.js +++ b/src/host/chat-index.js +@@ -7,9 +7,10 @@ + // The index is derived from the Session logs: a part catches up when it is read, an + // ended part is sealed once it is complete, and deleting the file only costs a + // rebuild. Without SQLite, read_own_chat scans the logs as before. +-import { mkdir, unlink } from 'node:fs/promises' ++import { unlink } from 'node:fs/promises' + import { join } from 'node:path' + import { ANCHOR_LINE_MAX, HISTORY_PAGE, HISTORY_PAGES_MAX, HISTORY_TIMEOUT_MS, OWN_CHAT_BRIEF, OWN_CHAT_LINE_MAX, OWN_CHAT_READ, OWN_CHAT_SEARCH } from './constants.js' ++import { ensurePrivateDir, tightenFile } from './private-files.js' + import { excerpt } from './text.js' + + const INDEX_VERSION = 3 @@ -21,8 +21,9 @@ const INDEX_TIMEOUT_MS = 120_000 const SEAL_AFTER_MS = 60_000 @@ -3848,16 +3860,50 @@ diff --git a/src/host/chat-index.js b/src/host/chat-index.js } export function install(rt) { -@@ -82,7 +82,7 @@ export function install(rt) { +@@ -68,13 +68,22 @@ export function install(rt) { + } + } + ++ // The index is a full-text copy of every part of a Bot's own chat, so SQLite's files are private ++ // like the rest of the home (#2697). SQLite creates them itself, with the ambient mode, so what ++ // exists is tightened right after it is made; `-wal` and `-shm` appear at the first write, which ++ // is why the home is made private as well. ++ async function hardenIndex() { ++ await ensurePrivateDir(home) ++ for (const file of [indexPath, `${indexPath}-wal`, `${indexPath}-shm`]) await tightenFile(file) ++ } ++ + function openIndex() { + if (chatIndex !== undefined) return Promise.resolve(chatIndex) + const opening = indexOpening ??= (async () => { + let DatabaseSync + try { + ({ DatabaseSync } = await import('node:sqlite')) +- await mkdir(home, { recursive: true }) ++ await ensurePrivateDir(home) + } catch (error) { + rt.warn('ds-bot: no chat index, read_own_chat scans the logs: %s', error) + return null +@@ -82,8 +82,10 @@ export function install(rt) { // A damaged file is rebuilt from the logs once. for (const fresh of [false, true]) { try { - if (fresh) await unlink(indexPath).catch(() => {}) +- return createIndex(new DatabaseSync(indexPath)) + if (fresh) await unlink(indexPath).catch(() => { /* the index file may not exist yet */ }) - return createIndex(new DatabaseSync(indexPath)) ++ const index = createIndex(new DatabaseSync(indexPath)) ++ await hardenIndex().catch(error => rt.warn('ds-bot: the chat index could not be made private: %s', error)) ++ return index } catch (error) { if (fresh) rt.warn('ds-bot: no chat index, read_own_chat scans the logs: %s', error) -@@ -118,6 +118,8 @@ export function install(rt) { + } +@@ -113,11 +113,14 @@ export function install(rt) { + } + return { + db, ++ hardened: false, + mark: db.prepare('SELECT through, keep, sealed, turn FROM marks WHERE session = ?'), + setMark: db.prepare('INSERT OR REPLACE INTO marks (session, through, keep, sealed, turn) VALUES (?, ?, ?, ?, ?)'), insert: db.prepare('INSERT INTO lines (text, session, seq, time, who) VALUES (?, ?, ?, ?, ?)'), before: db.prepare('SELECT text, session, seq, time, who FROM lines WHERE session = ? AND seq < ? ORDER BY seq DESC LIMIT ?'), from: db.prepare('SELECT text, session, seq, time, who FROM lines WHERE session = ? AND seq >= ? ORDER BY seq LIMIT ?'), @@ -3866,6 +3912,18 @@ diff --git a/src/host/chat-index.js b/src/host/chat-index.js } } catch (error) { db.close() +@@ -168,6 +168,11 @@ export function install(rt) { + } + index.setMark.run(part.sessionId, last, keep ? 1 : 0, seal ? 1 : 0, turn) + index.db.exec('COMMIT') ++ // The first commit is what makes `-wal`/`-shm`, so the modes are put right once it exists. ++ if (!index.hardened) { ++ index.hardened = true ++ await hardenIndex().catch(error => rt.warn('ds-bot: the chat index could not be made private: %s', error)) ++ } + } catch (error) { + index.db.exec('ROLLBACK') + throw error @@ -202,19 +202,21 @@ export function install(rt) { } @@ -5634,15 +5692,16 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js diff --git a/src/host/images.js b/src/host/images.js --- a/src/host/images.js +++ b/src/host/images.js -@@ -2,10 +2,11 @@ +@@ -2,10 +2,12 @@ // reaches a model that cannot read them, each image becomes text from a vision model. // Texts are kept per attachment, so later requests repeat the same words and the // prompt cache still hits. -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' -+import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises' ++import { readFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import { OCR_CACHE_MAX, OCR_PROMPT, OCR_RETRY_MS, OCR_TIMEOUT_MS } from './constants.js' ++import { ensurePrivateDir, replacePrivateFile } from './private-files.js' +import { tempWritable } from './real-dir.js' import { splitRef } from './text.js' @@ -5704,16 +5763,23 @@ diff --git a/src/host/images.js b/src/host/images.js export function install(rt) { const { ctx, config, home } = rt -@@ -81,6 +81,9 @@ export function install(rt) { +@@ -79,10 +79,13 @@ export function install(rt) { + while (ocrCache.size > OCR_CACHE_MAX) ocrCache.delete(ocrCache.keys().next().value) + const snapshot = JSON.stringify(Object.fromEntries(ocrCache)) ocrWriting = ocrWriting.then(async () => { - await mkdir(home, { recursive: true }) +- await mkdir(home, { recursive: true }) ++ await ensurePrivateDir(home) const temp = `${ocrPath}.${process.pid}.tmp` +- await writeFile(temp, snapshot, 'utf8') +- await rename(temp, ocrPath) + // The temp name is a leaf like the cache: a link there would take the readings outside -+ // the home and the rename would put the link over ocr.json (temp-name sweep). ++ // the home and the rename would put the link over ocr.json (temp-name sweep). The readings ++ // are the user's own images, so the cache is private like the team file (#2697). + if (!tempWritable(temp)) throw new Error(`Not saved: ${temp} is not a plain file of its own, so the OCR cache was not written there.`) - await writeFile(temp, snapshot, 'utf8') - await rename(temp, ocrPath) ++ await replacePrivateFile(temp, ocrPath, snapshot) }).catch(error => rt.warn('ds-bot: OCR cache write failed: %s', error)) + } + @@ -128,16 +128,18 @@ export function install(rt) { return ocrPending.get(id) } @@ -5775,15 +5841,20 @@ diff --git a/src/host/images.js b/src/host/images.js diff --git a/src/host/memory.js b/src/host/memory.js --- a/src/host/memory.js +++ b/src/host/memory.js -@@ -23,11 +23,17 @@ +@@ -21,13 +21,20 @@ + // provider's prefix cache holds; a fresh part, or a checkpoint (which breaks the cache + // anyway), freezes it anew. import { createHash } from 'node:crypto' - import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' - import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' +-import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +-import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' ++import { existsSync, readFileSync, readdirSync, unlinkSync } from 'node:fs' ++import { appendFile, rename, unlink } from 'node:fs/promises' +import { dirname, join, relative, sep } from 'node:path' import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { splitRef, unquote } from './text.js' ++import { PRIVATE_FILE_MODE, ensurePrivateDir, ensurePrivateDirSync, replacePrivateFile, replacePrivateFileSync } from './private-files.js' +import { currentParticipants } from './room-membership.js' +import { defineTool } from './tool-schema.js' +import { clip, codePoints, splitRef, unquote } from './text.js' @@ -5989,26 +6060,30 @@ diff --git a/src/host/memory.js b/src/host/memory.js + // too, asked like the journal's file — absent is ours to create, anything else refuses the write + // before it starts, says so once, and leaves the memory file as it was. Asking and then writing + // still leaves a window in which the name can change: this closes a link that is already there, -+ // not the race. ++ // not the race. The bytes are the user's own notes, so the folder is the host's private one and ++ // the temp file takes the mode of the file it replaces (#2697). async function writeText(path, text) { - await mkdir(dirname(path), { recursive: true }) +- await mkdir(dirname(path), { recursive: true }) ++ await ensurePrivateDir(dirname(path)) const temp = `${path}.${process.pid}.tmp` +- await writeFile(temp, text, 'utf8') +- await rename(temp, path) + if (!tempWritable(temp)) { + warnFile(temp, 'the memory write', 'nothing is written there') + throw new Error(`Not saved: ${temp} is not a plain file of its own, so nothing is written there.`) + } - await writeFile(temp, text, 'utf8') - await rename(temp, path) ++ await replacePrivateFile(temp, path, text) } async function journal(record) { +- await mkdir(root, { recursive: true }) +- await appendFile(journalPath, `${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`, 'utf8') + if (!writeFolder(root)) return + if (fileKindSync(journalPath) === 'other') { + warnFile(journalPath) + return + } - await mkdir(root, { recursive: true }) -- await appendFile(journalPath, `${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`, 'utf8') -+ await appendFile(journalPath, `${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`, { encoding: 'utf8', mode: 0o600 }) ++ await ensurePrivateDir(root) ++ await appendFile(journalPath, `${JSON.stringify({ time: new Date().toISOString(), ...record })}\n`, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }) } let writing = Promise.resolve() const queued = (job) => { @@ -6144,7 +6219,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js const name = `${safeId(botId)}-${new Date().toISOString().replace(/[:.]/g, '-')}` - await mkdir(join(root, 'archive'), { recursive: true }) - await rename(dir, join(root, 'archive', name)) -+ await mkdir(archiveDir, { recursive: true }) ++ await ensurePrivateDir(archiveDir) + await rename(dir, join(archiveDir, name)) await journal({ op: 'archive', scope: scopeKey(ownScope(botId)), actor: 'user', actorName: 'user', botName: bot?.name, to: `archive/${name}` }) }).catch(error => rt.warn('ds-bot: memory of %s not archived: %s', bot?.name ?? botId, error)) @@ -6160,7 +6235,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js return [ '# Your memory', memoryRules(reviewOn), -@@ -685,14 +685,22 @@ export function install(rt) { +@@ -685,14 +685,19 @@ export function install(rt) { const kept = frozen.get(sessionId) if (kept !== undefined && kept.includes(rule)) return kept const path = snapshotPath(sessionId) @@ -6173,17 +6248,15 @@ diff --git a/src/host/memory.js b/src/host/memory.js - if (rt.readOnly() === undefined) { + if (rt.readOnly() === undefined && writeFolder(snapshotsDir)) { try { - mkdirSync(dirname(path), { recursive: true }) +- mkdirSync(dirname(path), { recursive: true }) - writeFileSync(`${path}.tmp`, text, 'utf8') - renameSync(`${path}.tmp`, path) ++ ensurePrivateDirSync(dirname(path)) + // The snapshot's temp name is a leaf like the entry's (#2097): a link left there would + // take the frozen memory outside and then replace the snapshot with the link. + const temp = `${path}.tmp` + if (!tempWritable(temp)) warnFile(temp, 'the memory snapshot', 'nothing is written there') -+ else { -+ writeFileSync(temp, text, 'utf8') -+ renameSync(temp, path) -+ } ++ else replacePrivateFileSync(temp, path, text) } catch (error) { rt.warn('ds-bot: memory snapshot for %s not saved: %s', bot.name, error) } @@ -6906,10 +6979,14 @@ diff --git a/src/host/prompt.js b/src/host/prompt.js diff --git a/src/host/resume.js b/src/host/resume.js --- a/src/host/resume.js +++ b/src/host/resume.js -@@ -9,6 +9,8 @@ import { readFileSync } from 'node:fs' - import { mkdir, writeFile } from 'node:fs/promises' +@@ -6,9 +6,11 @@ + // inflight.json; what is still noted at the next start is picked up once. + import { randomUUID } from 'node:crypto' + import { readFileSync } from 'node:fs' +-import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { SOURCE_KIND } from './constants.js' ++import { ensurePrivateDir, writePrivateFile } from './private-files.js' +import { OTHER, settled } from './turn-origin.js' +import { retiredBot } from './store.js' @@ -6924,8 +7001,21 @@ diff --git a/src/host/resume.js b/src/host/resume.js let turns = {} /** @type {Record} deliveries no turn has taken up, by Session */ let queued = {} -@@ -76,6 +76,10 @@ export function install(rt) { - await writeFile(file, snapshot, 'utf8') +@@ -68,14 +68,20 @@ export function install(rt) { + if (due) return + due = true + // Written in place: a rename over the file fails on Windows while anything reads +- // it, and a torn write only loses what one restart would pick up. ++ // it, and a torn write only loses what one restart would pick up. The notes name the ++ // Session, the room and the message a turn was handed, so the file is private like the ++ // team file: created 0600 in a 0700 home, and an existing file keeps the mode it has (#2697). + writing = writing.then(async () => { + due = false + const snapshot = latest +- await mkdir(home, { recursive: true }) +- await writeFile(file, snapshot, 'utf8') ++ await ensurePrivateDir(home) ++ await writePrivateFile(file, snapshot) }).catch(error => rt.warn('ds-bot: noting running turns failed: %s', error)) } + const originNote = (id) => { diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index a09b0d86e..611e5fc7c 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -824,21 +824,22 @@ diff --git a/src/host/roster.js b/src/host/roster.js diff --git a/src/host/store.js b/src/host/store.js --- a/src/host/store.js +++ b/src/host/store.js -@@ -1,21 +1,77 @@ +@@ -1,21 +1,78 @@ // Store: the saved team (state.json), its migrations, the lock that keeps a second dsh // host on the same data from writing it, and the indexes that map a Session to the Bot // or group chat it belongs to. -import { mkdirSync, statSync } from 'node:fs' -import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' -+import { constants, mkdirSync, statSync } from 'node:fs' -+import { copyFile, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' ++import { constants, statSync } from 'node:fs' ++import { copyFile, lstat, readFile, rename, unlink } 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 { currentColor, normalize, unquote } from './text.js' +import { openJournal } from './journal.js' ++import { ensurePrivateDir, ensurePrivateDirSync, stagePrivateFile } from './private-files.js' +import { tempWritable } from './real-dir.js' +import { TEAM_TURNS_PER_DAY } from './turn-budget.js' +import { codePoints, currentColor, cut, normalize, unquote } from './text.js' @@ -909,6 +910,17 @@ diff --git a/src/host/store.js b/src/host/store.js const EMPTY = JSON.stringify(state) // The other dsh host that holds state.lock while this one only reads (see home-lock.js); +@@ -39,7 +39,9 @@ export function install(rt) { + const claimLock = () => { + if (lockBroken) return + try { +- mkdirSync(home, { recursive: true }) ++ // The home holds the team file, the chat index and the OCR cache: the host's own folder, so ++ // it is created private and an existing one is narrowed to the same mode (#2697). ++ ensurePrivateDirSync(home) + holder = lock.claim() + } catch (error) { + // An unusable lock (a read-only or odd file system) must not stop the team; a @@ -50,12 +50,88 @@ export function install(rt) { } } @@ -1625,7 +1637,7 @@ diff --git a/src/host/store.js b/src/host/store.js warnedReadOnly = false adopt(await readFileState()) return -@@ -235,16 +235,58 @@ export function install(rt) { +@@ -235,18 +235,60 @@ export function install(rt) { return warnReadOnly() } @@ -1682,11 +1694,19 @@ diff --git a/src/host/store.js b/src/host/store.js + // outside the home and the rename would put the link over state.json (temp-name sweep). + if (!tempWritable(temp)) throw new Error(`Not saved: ${temp} is not a plain file of its own, so the Bot team was not written there.`) try { - await mkdir(home, { recursive: true }) +- await mkdir(home, { recursive: true }) ++ await ensurePrivateDir(home) const ensureLock = () => { -@@ -258,11 +258,17 @@ export function install(rt) { + if (lockBroken) return + if (!lock.owns()) { +@@ -256,13 +256,21 @@ export function install(rt) { + } + } ensureLock() - await writeFile(temp, snapshot, 'utf8') +- await writeFile(temp, snapshot, 'utf8') ++ // The team file holds the messages and the saved keys, so the temp name is written at the ++ // mode the file it replaces already has — the owner's `chmod 0600` survives the save (#2697). ++ await stagePrivateFile(temp, statePath, snapshot) ensureLock() + // The rename replaces the name, link included, so a linked team file is never written + // through: its target keeps every byte and its last write time. Ask the name here, before diff --git a/patches/ds-bot/files/src/host/decisions.js b/patches/ds-bot/files/src/host/decisions.js index 5bbd3e2f0..6b6b94bb5 100644 --- a/patches/ds-bot/files/src/host/decisions.js +++ b/patches/ds-bot/files/src/host/decisions.js @@ -8,11 +8,12 @@ // default install behaves exactly as before. The log is local text, one JSON line per entry; the // same entry is fired on the activity feed as `decision` so a consumer (Timeline, an audit view) // can show it without reading the file. -import { appendFile, mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises' +import { appendFile, open, readFile, stat } from 'node:fs/promises' import { dirname, join } from 'node:path' import { cut } from './text.js' import { defineTool } from './tool-schema.js' import { looksSecret } from './memory.js' +import { PRIVATE_FILE_MODE, ensurePrivateDir, replacePrivateFile } from './private-files.js' import { fileKind, tempWritable } from './real-dir.js' import { output } from './tools.js' @@ -86,8 +87,9 @@ export function install(rt) { } return kept.length } - await writeFile(tmp, next, 'utf8') - await rename(tmp, path) + // A trim replaces the log through the temp name, so the mode the log has now is the one the + // replacement lands with: a log the owner narrowed by hand stays narrowed (#2697). + await replacePrivateFile(tmp, path, next) } return kept.length } @@ -95,8 +97,10 @@ export function install(rt) { const line = `${JSON.stringify(entry)}\n` writing = writing.then(async () => { if (!await leafWritable()) return 'refused' - await mkdir(dirname(path), { recursive: true }) - await appendFile(path, line, 'utf8') + // What a Bot considered and rejected is its own reasoning, so the log and its folder are + // private (#2697). `appendFile`'s mode applies when the append creates the file. + await ensurePrivateDir(dirname(path)) + await appendFile(path, line, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }) linesOnDisk = linesOnDisk === null ? await countLines() : linesOnDisk + 1 const { size } = await stat(path) if (linesOnDisk > KEPT || size > MAX_BYTES) { diff --git a/patches/ds-bot/files/src/host/private-files.js b/patches/ds-bot/files/src/host/private-files.js new file mode 100644 index 000000000..b6adc190b --- /dev/null +++ b/patches/ds-bot/files/src/host/private-files.js @@ -0,0 +1,97 @@ +// The host's own files carry what the user told it: a Bot's memory, the saved team, the index of +// every conversation part, the decision log, the OCR cache, the notes of a running turn. They are +// written by a process that inherits its umask from whoever started it, so a write that names no +// mode lands `0644` on a stock `022` and another local account reads all of it. These helpers name +// the mode instead — a file `0600`, the folder holding it `0700` — so the file's mode is a +// decision in the code and not a property of the shell it was started from (#2697). +// +// A rewrite must not widen what is already there. The writers put a temp file next to the old one +// and rename it over, and that new name would be created with the ambient mode; so the mode of the +// file being replaced is read first and given to the temp file before the rename. A `chmod 0400` a +// person made by hand survives the next `remember`; a name that does not exist yet is created +// private. +import { chmodSync, lstatSync, mkdirSync, renameSync, writeFileSync } from 'node:fs' +import { chmod, lstat, mkdir, rename, writeFile } from 'node:fs/promises' + +export const PRIVATE_FILE_MODE = 0o600 +export const PRIVATE_DIR_MODE = 0o700 + +// `mkdir`'s mode is filtered by the umask and it leaves a folder that already exists alone, so the +// `chmod` is what makes an existing folder private too. A folder this process may not chmod — a +// read-only mount, a volume owned by another account — is not a reason to refuse to write: the +// file modes below are what the writers promise, and a login that cannot chmod cannot read the +// folder through a narrower mode either way. +export async function ensurePrivateDir(dir) { + await mkdir(dir, { recursive: true, mode: PRIVATE_DIR_MODE }) + await chmod(dir, PRIVATE_DIR_MODE).catch(() => { /* the file modes still hold */ }) +} + +export function ensurePrivateDirSync(dir) { + mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE }) + try { + chmodSync(dir, PRIVATE_DIR_MODE) + } catch { + /* the file modes still hold */ + } +} + +// The mode a rewrite lands with. `lstat` and not `stat`: a link is not this file, and its target's +// mode is not ours to copy. A missing or unreadable name is the creation case. +const modeOf = info => (info.isFile() && !info.isSymbolicLink() ? info.mode & 0o777 : PRIVATE_FILE_MODE) + +export async function keptFileMode(path) { + try { + return modeOf(await lstat(path)) + } catch { + return PRIVATE_FILE_MODE + } +} + +export function keptFileModeSync(path) { + try { + return modeOf(lstatSync(path)) + } catch { + return PRIVATE_FILE_MODE + } +} + +// Writes the temp name a caller will `rename` over the target, at the mode the target has now. +// The caller keeps its own temp-name check and its own ordering around the rename: the lock it +// asks for, and whether the target was a link, are read between these two steps. +export async function stagePrivateFile(temp, target, text) { + // `writeFile`'s `mode` is used only when it creates the name: a leftover temp file keeps its own + // mode, so the chmod is what the rename lands with. + const mode = await keptFileMode(target) + await writeFile(temp, text, { encoding: 'utf8', mode }) + await chmod(temp, mode) + return mode +} + +export function stagePrivateFileSync(temp, target, text) { + const mode = keptFileModeSync(target) + writeFileSync(temp, text, { encoding: 'utf8', mode }) + chmodSync(temp, mode) + return mode +} + +export async function replacePrivateFile(temp, target, text) { + await stagePrivateFile(temp, target, text) + await rename(temp, target) +} + +export function replacePrivateFileSync(temp, target, text) { + stagePrivateFileSync(temp, target, text) + renameSync(temp, target) +} + +// A file written in place, not replaced: `writeFile` keeps the mode of a name that already exists, +// which is what a narrowed file keeps, and creates a missing one private. +export async function writePrivateFile(file, text) { + await writeFile(file, text, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }) +} + +// SQLite creates its own files, so their mode is not ours to pass. Tighten what exists now; the +// folder is checked as well, because the `-wal`/`-shm` pair appears on SQLite's own time. +export async function tightenFile(file, mode = PRIVATE_FILE_MODE) { + await chmod(file, mode).catch(() => { /* not created yet, or no modes on this file system */ }) +} diff --git a/patches/ds-bot/files/test/private-files.test.js b/patches/ds-bot/files/test/private-files.test.js new file mode 100644 index 000000000..100cd0150 --- /dev/null +++ b/patches/ds-bot/files/test/private-files.test.js @@ -0,0 +1,257 @@ +// The host's own files carry the user's content: a Bot's memory, the saved team, the index of +// every conversation part, the decision log, the OCR cache and the notes of a running turn. They +// are written by a process whose umask the host does not control, so without an explicit mode a +// stock `022` leaves them world-readable, and an atomic rewrite replaces a `chmod 0600` the owner +// made with the ambient `0644` (#2697). +// +// Every test here sets `umask 022` in-process and puts it back afterwards: this box runs `077`, +// which hides the defect. A scratch home is created `0755` first, the mode a stock shell gives it. +import assert from 'node:assert/strict' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { install as installImages } from '../src/host/images.js' +import { install as installResume } from '../src/host/resume.js' +import { fakeContext } from './fake-ctx.js' + +const TEAM = { + version: 1, + mainBotIds: ['m1'], + revision: 1, + bots: { + m1: { id: 'm1', sessionId: 'm1', name: 'Chief', role: 'Chief of Staff', model: 'deepseek/v4', instructions: '', createdAt: 1 }, + b2: { id: 'b2', sessionId: 'b2', name: 'Writer', role: '写手', model: 'deepseek/v4', instructions: '', createdAt: 2 }, + }, + rooms: { + r1: { + id: 'r1', name: 'Launch', named: true, members: ['b2', 'm1'], admin: 'b2', mode: 'everyone', notice: '', sessions: {}, + seq: 2, + log: [{ who: 'User', text: '私聊内容:帮我把这封离职信改短一点', seq: 1 }, { who: 'Writer', botId: 'b2', text: '好的,已改短。', seq: 2 }], + }, + }, + exchanges: [], + exchangeTurns: {}, + questions: {}, +} + +const mode = path => (statSync(path).mode & 0o777).toString(8).padStart(4, '0') +const tick = (ms = 40) => new Promise(resolve => setTimeout(resolve, ms)) + +// A scratch root widened to 0755, then a home under it, created the way `mkdir(..., { recursive: +// true })` leaves it under `umask 022`. `apply` gets a home that already exists at 0755. +function scratch(t) { + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-private-')) + chmodSync(dir, 0o755) + t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 5 })) + const home = join(dir, 'dsh', 'bot') + mkdirSync(home, { recursive: true }) + assert.equal(mode(home), '0755', 'the scratch home must start world-readable, or this test proves nothing') + return { dir, home } +} + +function start(home, config = {}) { + if (!existsSync(join(home, 'state.json'))) writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM), { mode: 0o600 }) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => ['v4'], resolveModelInfo: async () => ({}) }, + sessionController: { + create: async () => ({ sessionId: 's1' }), + resolveAgent: async sessionId => ({ agent: { session: { id: sessionId }, ctx: { tools: { restrict: () => () => {} }, effect: fn => fn() }, followup() {} } }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {}, messageProjections: [] }, + }) + let route + const register = ctx.connection.fetch.register + ctx.connection.fetch.register = options => { route = options; return register(options) } + apply(ctx, { home, ...config }) + const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() + const tool = (name, args, sessionId) => ctx.registeredTools.get(name).execute(args, { agent: { id: sessionId } }) + return { ctx, call, tool, stop: async () => { await ctx.dispose() } } +} + +test('Bot memory, its topics and its snapshot are private, and a narrowed file stays narrowed', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const host = start(home) + t.after(host.stop) + + await host.tool('remember', { text: '- 用户的家庭住址是杭州西湖区文三路 100 号 [source: part 1 #1]' }, 'b2') + await tick() + + const entry = join(home, 'memory', 'bots', 'b2', 'MEMORY.md') + assert.match(readFileSync(entry, 'utf8'), /杭州西湖区/) + assert.equal(mode(entry), '0600') + for (const dir of [home, join(home, 'memory'), join(home, 'memory', 'bots'), join(home, 'memory', 'bots', 'b2')]) { + assert.equal(mode(dir), '0700', dir) + } + + // The frozen per-Session snapshot holds the same text under the same mode. + host.ctx.sections.get('bot-memory').text({ agent: { id: 'b2' } }) + const snapshot = join(home, 'memory', 'snapshots', 'b2.md') + assert.match(readFileSync(snapshot, 'utf8'), /杭州西湖区/) + assert.equal(mode(snapshot), '0600') + + // The owner narrows the file by hand; the next rewrite must keep that, not put 0600 back. + chmodSync(entry, 0o400) + await host.tool('remember', { text: '- 用户偏好 DeepSeek 模型 [source: part 1 #2]' }, 'b2') + await tick() + assert.match(readFileSync(entry, 'utf8'), /DeepSeek/) + assert.equal(mode(entry), '0400') +}) + +test('the saved team is private, also when it was seeded 0600', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const state = join(home, 'state.json') + const host = start(home) + t.after(host.stop) + assert.equal(mode(state), '0600') + + await host.call('state', {}) + await host.call('add-agent') + await tick(120) + + // The file carries a group message, and the team keeps the mode the owner gave it. + assert.match(readFileSync(state, 'utf8'), /私聊内容/) + assert.equal(mode(state), '0600') + assert.equal(mode(home), '0700') +}) + +test('the decision log is private', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const host = start(home, { decisions: true }) + t.after(host.stop) + await host.ctx.flybot.ready() + + const out = await host.tool('record_decision', { option: '把客户的报价单直接发到公开群', choice: 'rejected', why: '泄露报价' }, 'b2') + await tick(80) + + const log = join(home, 'decisions.jsonl') + assert.match(out, /Recorded/) + assert.match(readFileSync(log, 'utf8'), /泄露报价/) + assert.equal(mode(log), '0600') + assert.equal(mode(home), '0700') +}) + +test('the OCR cache of the user\'s images is private', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const ctx = fakeContext() + Object.assign(ctx, { + llm: { + listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }], + listModels: async () => [{ id: 'vision', input: ['text', 'image'] }], + resolveModelInfo: async () => ({ inputModalities: ['text', 'image'] }), + stream: async function* () { + yield { type: 'text-delta', index: 0, text: 'A red triangle on a bank statement dated 2026-11-01.' } + yield { type: 'finish', reason: { kind: 'stop' } } + }, + }, + }) + const rt = { + ctx, home, config: {}, warn: () => {}, load: async () => {}, readOnly: () => undefined, + roomOf: () => 'r1', isBotSession: () => true, ownRequest: () => false, + catalog: async () => [{ ref: 'deepseek/vision', name: 'Vision', input: ['image'] }], + defaultRef: () => 'deepseek/vision', tagUsage: messages => messages, + } + installImages(rt) + await rt.imagesForGroup([{ attachmentId: `sha256:${'b'.repeat(64)}`, name: 'statement.png' }], 'r1') + + const cache = join(home, 'ocr.json') + for (let i = 0; i < 100 && !existsSync(cache); i += 1) await tick(10) + assert.match(readFileSync(cache, 'utf8'), /bank statement/) + assert.equal(mode(cache), '0600') + assert.equal(mode(home), '0700') +}) + +test('the chat index of every part is private, SQLite or not', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const host = start(home) + t.after(host.stop) + + try { await host.tool('read_own_chat', { query: '银行卡' }, 'b2') } catch { /* no SQLite: nothing to check */ } + await tick(150) + + const indexPath = join(home, 'chat-index.sqlite') + // Without `node:sqlite` read_own_chat scans the logs and no file is made; the mode is then + // nothing this test can fail on. + if (!existsSync(indexPath)) return + assert.equal(mode(indexPath), '0600') + for (const suffix of ['-wal', '-shm']) { + if (existsSync(`${indexPath}${suffix}`)) assert.equal(mode(`${indexPath}${suffix}`), '0600') + } + // SQLite creates the WAL files itself, on its own time; the folder is what covers them. + assert.equal(mode(home), '0700') +}) + +test('the notes of a running turn are private', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const { home } = scratch(t) + const ctx = fakeContext() + const rt = { + ctx, home, readOnly: () => undefined, warn: () => {}, + deliver: async () => {}, chatOf: id => id, + } + installResume(rt) + t.after(() => ctx.dispose()) + + await rt.deliver({ toId: 'b2', text: '用户说:帮我把这封离职信改短一点' }) + const notes = join(home, 'inflight.json') + for (let i = 0; i < 100 && !existsSync(notes); i += 1) await tick(10) + assert.match(readFileSync(notes, 'utf8'), /离职信/) + assert.equal(mode(notes), '0600') + assert.equal(mode(home), '0700') +}) + +test('the helpers: a private folder is 0700, a new file 0600, a rewrite keeps the mode', async (t) => { + const before = process.umask() + process.umask(0o022) + t.after(() => process.umask(before)) + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-private-helpers-')) + t.after(() => rmSync(dir, { recursive: true, force: true, maxRetries: 5 })) + // The module is loaded here rather than at the top of the file, so the other tests show the + // mode the writers put on disk even before this helper exists. + const { ensurePrivateDir, ensurePrivateDirSync, replacePrivateFile, replacePrivateFileSync, PRIVATE_DIR_MODE, PRIVATE_FILE_MODE } = await import('../src/host/private-files.js') + assert.equal(PRIVATE_DIR_MODE, 0o700) + assert.equal(PRIVATE_FILE_MODE, 0o600) + + const created = join(dir, 'made', 'by', 'the', 'helper') + await ensurePrivateDir(created) + assert.equal(mode(created), '0700') + // An existing folder at 0755 is narrowed too: `mkdir` leaves it alone. + chmodSync(created, 0o755) + ensurePrivateDirSync(created) + assert.equal(mode(created), '0700') + + const fresh = join(created, 'fresh.md') + await replacePrivateFile(`${fresh}.tmp`, fresh, 'hello') + assert.equal(mode(fresh), '0600') + assert.equal(readFileSync(fresh, 'utf8'), 'hello') + + // A mode the owner chose — narrower or wider — is the one the rewrite lands with. + writeFileSync(fresh, 'old', { mode: 0o640 }) + chmodSync(fresh, 0o640) + replacePrivateFileSync(`${fresh}.tmp`, fresh, 'new') + assert.equal(mode(fresh), '0640') + assert.equal(readFileSync(fresh, 'utf8'), 'new') +})