Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
220fdca
fix(tools): save through a short hidden temporary file
aliasunder Oct 25, 2026
136c9a8
fix(tools): refuse memory names ending in .md and accept hand-edited …
aliasunder Oct 25, 2026
390a7bd
test(search): pin indexing of a save through the hidden temp file; up…
aliasunder Oct 25, 2026
a509e68
style: clarify temp-save, memory-folder and error-remedy comments
aliasunder Oct 25, 2026
b2d3176
docs(prompts): say why a prompt fallback carries only coded remedies
aliasunder Oct 25, 2026
1b0f26e
docs(prompts): reword the prompt-fallback remedy comment as a sentence
aliasunder Oct 25, 2026
e980e1b
test: pin a nested memory folder that is itself a file; assert whole …
aliasunder Oct 25, 2026
164e6bf
fix: wrap the memory folder's mkdir failure for a broken symlink; dro…
aliasunder Oct 25, 2026
72839e7
refactor: one helper for best-effort cleanup; inline the prompt fallb…
aliasunder Oct 25, 2026
9a69049
refactor(memory): one entry-line predicate for the parser and the mem…
aliasunder Oct 25, 2026
99842d5
docs: say why content-hash gating is a correctness guarantee
aliasunder Oct 25, 2026
d06030e
fix(tools): leave memory files named with .md twice out of memory lis…
aliasunder Oct 25, 2026
abc25ee
fix(tools): say that the memory listing skips names ending in .md twice
aliasunder Oct 25, 2026
94759fe
fix(tools): refuse a vault_delete_memory date with entry text after it
aliasunder Oct 25, 2026
67a6328
docs(memory): say which refused names the memory listing leaves out
aliasunder Oct 25, 2026
1203539
style: name the logged path, pass a file name to the doubled-name che…
aliasunder Oct 25, 2026
1c7b29a
test: pin the rebuild's removal of doubled-name memory entries and th…
aliasunder Oct 25, 2026
5b11a92
refactor(memory): drop the clause naming which folder blocks MEMORY_DIR
aliasunder Oct 25, 2026
6220a7b
test: say the memory folder wrap also covers a broken symlink
aliasunder Oct 25, 2026
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
27 changes: 18 additions & 9 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1196,12 +1196,19 @@ Docker hardening, and durability seatbelts above.
- **Atomic writes** (`atomicWriteFile` in `vault-filesystem.ts`):
write-to-temp-then-rename. The target is never truncated — the
Obsidian Sync process sees either old content or new content, never a
0-byte or partial write. The temp file is named after the note plus
`.<UUID>.tmp`, 41 bytes longer, so a note whose name sits fewer than
41 bytes under the filesystem's limit reads fine but cannot be saved; the
failed save's error carries a remedy (`coded-error-remedy.ts`) asking for
a name at least 41 bytes shorter. On failure the temp file is removed, by a `catch` in
`atomicWriteFile` and by a `finally` in `atomicWriteFileExclusive`.
0-byte or partial write. The temp file is `.<UUID>.tmp` in the note's
folder:
- hidden: Obsidian Sync and the file watcher skip dot-prefixed names,
so neither opens it;
- 41 bytes whatever the note's name, so a note named near the
filesystem's limit still saves;
- on the note's filesystem, so the rename (or the hard link of an
exclusive create, below) stays atomic.

A `catch` in `atomicWriteFile` removes the temp file after a failure. A
`finally` in `atomicWriteFileExclusive` removes it after every call,
because after a link it is a second name for the note.

- **Exclusive atomic creates** (`atomicWriteFileExclusive`): uses
`link()` for POSIX no-clobber semantics — fails atomically with
`EEXIST` if the target already exists, closing the TOCTOU race on
Expand Down Expand Up @@ -1246,8 +1253,10 @@ Docker hardening, and durability seatbelts above.
source is deleted last — a failure at any step leaves both copies rather
than losing content.
- **Content-hash gating** (`search-index.ts`): SHA-256 per chunk. Only
changed content re-embeds on both incremental updates and full rebuilds
— a correctness guarantee (not just performance).
changed content re-embeds on both incremental updates and full rebuilds.
Each chunk's hash is saved in the same transaction as its vector, so a
crash can never mark a chunk as embedded while its vector is missing; the
gating is a correctness guarantee, not just a performance one.
- **Symlink safety** (`filterValidSymlinks` in
`utils/filter-valid-symlinks.ts`): broken symlinks and symlinks to
non-file targets are filtered from directory listings before indexing
Expand Down Expand Up @@ -1321,7 +1330,7 @@ Docker hardening, and durability seatbelts above.
`tool-helpers.ts`): wraps every MCP tool handler with try/catch. Errors
return a structured `isError` response with the error's name and message,
plus one remedy sentence for a raw filesystem or search-index error
(`coded-error-remedy.ts`, keyed on the error's code, syscall and path) or
(`coded-error-remedy.ts`, keyed on the error's code and syscall) or
how to fix a properties-block refusal — no stack traces. A buggy tool
never crashes the server. The prompts' fallback messages carry the same
remedy sentence.
Expand Down
101 changes: 28 additions & 73 deletions src/__tests__/integration/server-error-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,8 +705,6 @@ const FOLDER_IS_FILE_REMEDY =
"A folder in the path is an existing file; choose a path whose folders are folders."
const PATH_TOO_LONG_REMEDY =
"The path is too long for the filesystem, so nothing can exist at it; check the path, or shorten it for a new file."
const TEMP_SAVE_REMEDY =
"The name leaves no room for the temporary file a save writes beside it; save it under a name at least 41 bytes shorter (rename an existing note with vault_move_note)."
const LINK_LOOP_REMEDY =
"The path goes through a symbolic-link loop; ask the vault's owner to fix the link."

Expand Down Expand Up @@ -803,75 +801,6 @@ describe("filesystem errors name the path vault-relative and carry their remedy"
},
)

// A name of 233 bytes reads, but the temp file a save writes beside it
// adds 41 bytes and goes over the filesystem's 255-byte limit
it.each([
{ name: "vault_replace_in_note", args: { old_text: "line one", new_text: "line 1" } },
{ name: "vault_delete_span", args: { start_anchor: "line one" } },
{ name: "vault_replace_span", args: { start_anchor: "line one", content: "line 1" } },
{ name: "vault_create_task", args: { description: "another task", block_id: "another" } },
])("$name on a note whose name is too long for the save's temp file", async ({ name, args }) => {
const nameTooLongForTempFile = `${"b".repeat(230)}.md`
await plantFileWithMode({ filePath: nameTooLongForTempFile, mode: 0o644 })

const result = await callTool({ client, name, args: { path: nameTooLongForTempFile, ...args } })
expect(result).toEqual({
content: [
{
type: "text",
// The temp file's name ends in a random UUID
text: expect.stringMatching(
new RegExp(
`^\\[Error\\]: ENAMETOOLONG: name too long, open '${RegExp.escape(nameTooLongForTempFile)}\\.[0-9a-f-]{36}\\.tmp'\\. ${RegExp.escape(TEMP_SAVE_REMEDY)}$`,
),
),
},
],
isError: true,
})
// The note opens under its own name and is unchanged, so only the save's
// temp file name was too long
await expect(readFile(join(serverVaultPath, nameTooLongForTempFile), "utf8")).resolves.toBe(
"line one\n- [ ] a task\n",
)
})

const memoryFileContent = "## Notes (newest first)\n- **2026-01-01**: kept\n"

it.each([
{ name: "vault_update_memory", args: { section: "Notes", entry: "added" } },
{ name: "vault_delete_memory", args: { section: "Notes", date: "2026-01-01", entry: "kept" } },
])(
"$name on a memory file whose name is too long for the save's temp file",
async ({ name, args }) => {
const fileTooLongForTempFile = "m".repeat(230)
const memoryFilePath = join(serverVaultPath, "About Me", `${fileTooLongForTempFile}.md`)
await writeFile(memoryFilePath, memoryFileContent)
onTestFinished(() => rm(memoryFilePath))

const result = await callTool({
client,
name,
args: { file: fileTooLongForTempFile, ...args },
})
expect(result).toEqual({
content: [
{
type: "text",
// The temp file's name ends in a random UUID
text: expect.stringMatching(
new RegExp(
`^\\[Error\\]: ENAMETOOLONG: name too long, open 'About Me/${fileTooLongForTempFile}\\.md\\.[0-9a-f-]{36}\\.tmp'\\. ${RegExp.escape(TEMP_SAVE_REMEDY)}$`,
),
),
},
],
isError: true,
})
await expect(readFile(memoryFilePath, "utf8")).resolves.toBe(memoryFileContent)
},
)

it.skipIf(runsAsRoot)(
"vault_write_note into a read-only folder names the temp file vault-relative",
async () => {
Expand All @@ -886,10 +815,10 @@ describe("filesystem errors name the path vault-relative and carry their remedy"
content: [
{
type: "text",
// The temp file's name ends in a random UUID
// The temp file's hidden name is a random UUID
text: expect.stringMatching(
new RegExp(
`^\\[Error\\]: EACCES: permission denied, open 'Sealed/new\\.md\\.[0-9a-f-]{36}\\.tmp'\\. ${RegExp.escape(PERMISSIONS_REMEDY)}$`,
`^\\[Error\\]: EACCES: permission denied, open 'Sealed/\\.[0-9a-f-]{36}\\.tmp'\\. ${RegExp.escape(PERMISSIONS_REMEDY)}$`,
),
),
},
Expand Down Expand Up @@ -1762,6 +1691,32 @@ describe("memory errors", () => {
'memory file must be a bare name without path separators: "Nested/Preferences"',
)
})

it.each([
{ name: "vault_get_memory", args: {} },
{ name: "vault_update_memory", args: { section: "Editor settings", entry: "never written" } },
{
name: "vault_delete_memory",
args: { section: "Editor settings", date: "2026-01-01", entry: "anything" },
},
])("$name with .md in the file name", async ({ name, args }) => {
const result = await callTool({ client, name, args: { file: "Preferences.md", ...args } })
expectToolError(result, 'memory file must be named without .md: "Preferences.md"')
})

it("vault_delete_memory with a date that is not YYYY-MM-DD", async () => {
const result = await callTool({
client,
name: "vault_delete_memory",
args: {
file: "Preferences",
section: "Editor settings",
date: "2026-1-1",
entry: "anything",
},
})
expectToolError(result, "date must be YYYY-MM-DD (e.g. 2026-07-02)")
})
})

// ── Parameter combinations ───────────────────────────────────
Expand Down
Loading
Loading