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
349 changes: 0 additions & 349 deletions .claude/hooks/require-nonroot-tests.sh

This file was deleted.

12 changes: 0 additions & 12 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,6 @@
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR:-.}\"/.claude/hooks/require-nonroot-tests.sh",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "EnterWorktree",
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ CLAUDE.local.md
!.claude/hooks/
.claude/hooks/*
!.claude/hooks/install-deps.sh
!.claude/hooks/require-nonroot-tests.sh
TASKS.md
.DS_Store

Expand Down
24 changes: 18 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,8 @@ on a folder for the full list.
server.json # MCP server registry manifest
render.yaml # Render Blueprint; stays at the repo root, the only place Render reads it
Dockerfile # Two-target build: local (default) + remote
.claude/ # Committed Claude Code hook setup only (the rest is gitignored): settings.json registers the two hooks below
.claude/ # Committed Claude Code hook setup only (the rest is gitignored): settings.json registers the hook below
hooks/install-deps.sh # Runs at session start and worktree entry: loads nvm, installs dependencies when needed (npm ci, sst install), and at session start puts the checkout's Node (the .nvmrc version when nvm has it, else nvm's default alias) first on PATH for the session's later Bash commands
hooks/require-nonroot-tests.sh # Runs before each Bash command: blocks a root session's test runs, makes the folders they write writable for the nobody user, and replies to Claude with the setpriv prefix that reruns the command as nobody
obsidian-headless/ # Lockfile-pinned obsidian-headless Sync CLI for the :remote image
rootfs/ # Container filesystem overlay for the :remote image: s6 init chain and services in etc/s6-overlay/, and usr/local/bin/get-sync-token, an in-container terminal sign-in to Obsidian that prints the Sync token for .env
templates/memory/ # About Me/ memory file templates for new vaults
Expand Down Expand Up @@ -858,6 +857,12 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal):

### Test conventions

- A test that denies file access with `chmod` and expects the access to
fail can't pass as root, because root ignores file permissions. Mark it
`it.skipIf(runsAsRoot)`, importing `runsAsRoot` from
`src/__tests__/runs-as-root.ts`. CI runs it as a normal user. To run
these tests from a root shell, such as a cloud session, use
`npm run test:as-nobody`; it takes the same arguments as `vitest run`.
Comment thread
aliasunder marked this conversation as resolved.
- Tests read as a behavioral spec. One focused `it()` per
behavior — a failing test name should identify which behavior
regressed without reading the body.
Expand Down Expand Up @@ -939,17 +944,17 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal):
**Exception — shell scripts outside vitest's include paths.** The
remote image's s6 init scripts under `rootfs/etc/s6-overlay/scripts/`
are the `vault-mcp` server's boot chain for the `:remote` target, and
the Claude Code hooks sit in `.claude/hooks/`. Neither is a TypeScript
the Claude Code hook sits in `.claude/hooks/`. Neither is a TypeScript
module, and vitest's include paths (`src/`, `cli/src/`, `scripts/`)
reach neither folder. The init-script tests live in
`src/vault-mcp/__tests__/` (`init-check-auth.test.ts`,
`init-obsidian-login.test.ts`, `init-first-sync.test.ts`,
`init-setup-user.test.ts`, `init-setup-vault.test.ts`,
`print-derived-env.test.ts`, which covers the derivation that
`init-derive-env` publishes), and the hook tests in `src/__tests__/`
(`install-deps.test.ts`, `require-nonroot-tests.test.ts`). These script
`init-derive-env` publishes), and the hook's test in `src/__tests__/`
(`install-deps.test.ts`). These script
tests run the real script under its own shell (`sh` for the init
scripts, `bash` for the hooks) with stub binaries on `PATH`, and name
scripts, `bash` for the hook) with stub binaries on `PATH`, and name
the script they cover —
don't move them next to the scripts or widen vitest's include for
them. Whole-image behaviour (the init chain's ordering,
Expand Down Expand Up @@ -1321,6 +1326,13 @@ Contributor and release conventions live in
`BREAKING CHANGE:` footer in the PR description, so the generated release
notes list it (other accepted flags are in `CONTRIBUTING.md`).

Pick a commit's conventional-commit type by what it changes in what this
repo ships: the server's Docker images and the `vault-cortex` npm CLI.
Claude Code hooks, dev scripts and other local tooling
are never `feat` or `fix`: use `chore`, or `ci` or `build` where they fit,
so they stay out of the changelog's Features and Bug Fixes. A squash merge
lands the PR title, so the title takes the type of the PR's main purpose.

### Files that track feature surface

**No hardcoded tool or prompt counts in user-facing surfaces.** Never
Expand Down
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ each config combination serves the correct tool surface, that auth
rejects invalid tokens, that write operations actually mutate the vault
(read-back verified), and that misconfiguration fails fast at boot.

Some tests deny access to a file and expect the access to fail. Root
ignores file permissions, so those tests skip when you run the suite as
root (inside a container, for example). To run them from a root shell,
use `npm run test:as-nobody`, which runs the suite as the `nobody` user.
Comment thread
aliasunder marked this conversation as resolved.

`npm run test:coverage` runs the same suite with a statement-coverage
report (V8 provider); the `coverage/` output directory is gitignored.

Expand Down
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"src/__tests__/docker/fixtures/sync-engine-oracle.ts"
],
"project": ["src/**/*.ts", "cli/src/**/*.ts", "scripts/**/*.ts"],
"ignoreBinaries": ["optipng", "perl"]
"ignoreBinaries": ["optipng", "perl", "setpriv"]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"test": "vitest run",
"snapshot:update": "vitest run src/vault-mcp/mcp-core/__tests__/tool-surface-snapshot.test.ts --update",
"test:coverage": "vitest run --coverage",
"test:as-nobody": "tsx scripts/test-as-nobody.ts",
"test:watch": "vitest",
"test:cli-pty": "vitest run --config vitest.cli-pty.config.ts",
"build:remote-image": "docker build --target remote -t vault-cortex:remote-ci .",
Expand Down
1 change: 0 additions & 1 deletion scripts/__tests__/lint-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { describe, expect, it, onTestFinished } from "vitest"
/** Every shell script in the repo, sorted: what a run with no paths checks. */
const ALL_SHELL_SCRIPTS = [
".claude/hooks/install-deps.sh",
".claude/hooks/require-nonroot-tests.sh",
".github/scripts/generate-notes.sh",
".github/scripts/update-changelog.sh",
"cli/src/__tests__/integration/fixtures/docker",
Expand Down
47 changes: 47 additions & 0 deletions scripts/test-as-nobody.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Runs vitest as `nobody` from a root shell, so the tests that skip as root
* (they deny file access, which root ignores) run too. Extra arguments go to
* `vitest run`: `npm run test:as-nobody -- src/utils`.
*/

import { execFileSync, spawnSync } from "node:child_process"
import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs"
import { join } from "node:path"

const readNobodyId = (flag: "-u" | "-g"): string =>
execFileSync("id", [flag, "nobody"], { encoding: "utf8" }).trim()
const [uid, gid] = (["-u", "-g"] as const).map(readNobodyId)
const nodeModules = join(process.cwd(), "node_modules")
const testHome = join(nodeModules, ".test-home")

// nobody writes only vite's config bundle, vitest's results cache, and its HOME.
const writableFolders = [".vite-temp", ".vite/vitest", ".test-home"].map((folder) =>
join(nodeModules, folder),
)
Comment thread
aliasunder marked this conversation as resolved.
for (const folder of writableFolders) mkdirSync(folder, { recursive: true })
execFileSync("chown", ["-R", `${uid}:${gid}`, ...writableFolders])
Comment thread
aliasunder marked this conversation as resolved.

// Root's Node usually sits where nobody can't enter, such as /root/.nvm.
const nodeFolder = join(nodeModules, ".nobody-node", `node-${process.version}`)
const nodePath = join(nodeFolder, "node")

if (!existsSync(nodePath)) {
mkdirSync(nodeFolder, { recursive: true })
// A temporary name keeps a copy cut short (a full disk) from being reused.
copyFileSync(process.execPath, `${nodePath}.partial`)
chmodSync(`${nodePath}.partial`, 0o755)
renameSync(`${nodePath}.partial`, nodePath)
}
Comment thread
aliasunder marked this conversation as resolved.

const asNobody = [`--reuid=${uid}`, `--regid=${gid}`, "--clear-groups", "/usr/bin/env"]
// nobody may be unable to read root's CA bundle, and Node then warns on every start.
const nobodyEnv = ["-u", "NODE_EXTRA_CA_CERTS", `HOME=${testHome}`]
const path = `PATH=${nodeFolder}:${process.env.PATH ?? ""}`
const vitestEntry = join(nodeModules, "vitest", "vitest.mjs")
const vitest = [nodePath, vitestEntry, "run", ...process.argv.slice(2)]
const run = spawnSync("setpriv", [...asNobody, ...nobodyEnv, path, ...vitest], {
stdio: "inherit",
})

if (run.error) throw run.error
process.exit(run.status ?? 1)
108 changes: 61 additions & 47 deletions src/__tests__/integration/server-error-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
buildPasswordProtectedPdf,
buildZeroPagePdf,
} from "../../vault-mcp/obsidian-markdown/__tests__/pdf-fixture.js"
import { runsAsRoot } from "../runs-as-root.js"

vi.setConfig({ testTimeout: 15_000 })

Expand Down Expand Up @@ -722,7 +723,7 @@ describe("filesystem errors name the path vault-relative", () => {
})
}

it.each([
it.skipIf(runsAsRoot).each([
{ name: "vault_read_note", args: {} },
{ name: "vault_update_properties", args: { properties: { status: "draft" } } },
{ name: "vault_patch_note", args: { operation: "append", content: "added" } },
Expand All @@ -740,34 +741,44 @@ describe("filesystem errors name the path vault-relative", () => {
})
})

it("vault_read_file on a file it cannot open names the file vault-relative", async () => {
await plantFileWithMode({ filePath: "Locked.png", mode: 0o000 })
it.skipIf(runsAsRoot)(
"vault_read_file on a file it cannot open names the file vault-relative",
async () => {
await plantFileWithMode({ filePath: "Locked.png", mode: 0o000 })

const result = await callTool({ client, name: "vault_read_file", args: { path: "Locked.png" } })
expect(result).toEqual({
content: [{ type: "text", text: "[Error]: EACCES: permission denied, open 'Locked.png'" }],
isError: true,
})
})
const result = await callTool({
client,
name: "vault_read_file",
args: { path: "Locked.png" },
})
expect(result).toEqual({
content: [{ type: "text", text: "[Error]: EACCES: permission denied, open 'Locked.png'" }],
isError: true,
})
},
)

it("vault_get_daily_note on a daily note it cannot open names the note vault-relative", async () => {
await plantFileWithMode({ filePath: "Daily Notes/2026-03-01.md", mode: 0o000 })
it.skipIf(runsAsRoot)(
"vault_get_daily_note on a daily note it cannot open names the note vault-relative",
async () => {
await plantFileWithMode({ filePath: "Daily Notes/2026-03-01.md", mode: 0o000 })

const result = await callTool({
client,
name: "vault_get_daily_note",
args: { date: "2026-03-01" },
})
expect(result).toEqual({
content: [
{
type: "text",
text: "[Error]: EACCES: permission denied, open 'Daily Notes/2026-03-01.md'",
},
],
isError: true,
})
})
const result = await callTool({
client,
name: "vault_get_daily_note",
args: { date: "2026-03-01" },
})
expect(result).toEqual({
content: [
{
type: "text",
text: "[Error]: EACCES: permission denied, open 'Daily Notes/2026-03-01.md'",
},
],
isError: true,
})
},
)

// 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
Expand Down Expand Up @@ -801,27 +812,30 @@ describe("filesystem errors name the path vault-relative", () => {
)
})

it("vault_write_note into a read-only folder names the temp file vault-relative", async () => {
await plantFolderWithMode({ folderPath: "Sealed", mode: 0o555 })
it.skipIf(runsAsRoot)(
"vault_write_note into a read-only folder names the temp file vault-relative",
async () => {
await plantFolderWithMode({ folderPath: "Sealed", mode: 0o555 })

const result = await callTool({
client,
name: "vault_write_note",
args: { path: "Sealed/new.md", body: "refused" },
})
expect(result).toEqual({
content: [
{
type: "text",
// The temp file's name ends in a random UUID
text: expect.stringMatching(
/^\[Error\]: EACCES: permission denied, open 'Sealed\/new\.md\.[0-9a-f-]{36}\.tmp'$/,
),
},
],
isError: true,
})
})
const result = await callTool({
client,
name: "vault_write_note",
args: { path: "Sealed/new.md", body: "refused" },
})
expect(result).toEqual({
content: [
{
type: "text",
// The temp file's name ends in a random UUID
text: expect.stringMatching(
/^\[Error\]: EACCES: permission denied, open 'Sealed\/new\.md\.[0-9a-f-]{36}\.tmp'$/,
),
},
],
isError: true,
})
},
)

it("vault_write_note through a note names the note vault-relative", async () => {
const result = await callTool({
Expand Down Expand Up @@ -942,7 +956,7 @@ describe("filesystem errors name the path vault-relative", () => {
})
})

it("vault_list_notes on a vault holding a folder it cannot open", async () => {
it.skipIf(runsAsRoot)("vault_list_notes on a vault holding a folder it cannot open", async () => {
await plantFolderWithMode({ folderPath: "Sealed Folder", mode: 0o000 })

const result = await callTool({ client, name: "vault_list_notes", args: {} })
Expand Down
Loading
Loading