Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2697-private-files.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
24 changes: 21 additions & 3 deletions core/src/host/chat-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 (?, ?, ?, ?, ?)'),
Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions core/src/host/decisions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -86,17 +87,20 @@ 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
}
function record(entry) {
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) {
Expand Down
11 changes: 6 additions & 5 deletions core/src/host/images.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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))
}

Expand Down
26 changes: 12 additions & 14 deletions core/src/host/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -557,25 +558,25 @@ 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
if (fileKindSync(journalPath) === 'other') {
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) => {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}
Expand Down
97 changes: 97 additions & 0 deletions core/src/host/private-files.js
Original file line number Diff line number Diff line change
@@ -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 */ })
}
10 changes: 6 additions & 4 deletions core/src/host/resume.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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) => {
Expand Down
15 changes: 10 additions & 5 deletions core/src/host/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand All @@ -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
Expand Down
Loading
Loading