diff --git a/.claude/hooks/require-nonroot-tests.sh b/.claude/hooks/require-nonroot-tests.sh deleted file mode 100755 index 3d2f925b8..000000000 --- a/.claude/hooks/require-nonroot-tests.sh +++ /dev/null @@ -1,349 +0,0 @@ -#!/usr/bin/env bash -# Stops a test run in a root session and replies with the setpriv prefix that -# runs the same command as the nobody user. Root ignores file permissions, so a -# test that chmods a file or folder to deny access, then expects the access to -# fail, breaks as root, while CI, which runs as a normal user, passes it. -# - Claude Code's cloud sessions run as root. In a non-root session the hook -# exits at its first check, so it costs nothing on a developer machine. -# - Before replying, it makes the folders a test run writes in the checkout -# writable for nobody. -# - .claude/settings.json registers the hook on PreToolUse for Bash with a -# 10-second timeout. Claude Code cancels a slower hook and runs the command -# anyway, so the hook must stay fast. -# - Exit 0 lets the command run. Exit 2 blocks it, and Claude Code passes the -# hook's stderr text to the model as the reply. -set -euo pipefail - -if [[ "$(id -u)" != "0" ]]; then - exit 0 -fi - -# Without setpriv or a nobody user there is no command to offer, so the hook -# lets the run through rather than blocking it with no way forward. -if ! command -v setpriv >/dev/null 2>&1 || ! id nobody >/dev/null 2>&1; then - exit 0 -fi - -# Claude Code sends the PreToolUse payload as JSON on stdin -# (`{ "tool_input": { "command": ... }, "cwd": ..., ... }`). node prints the -# payload's command and cwd (the session's current folder), each ended by a -# NUL, the one character a shell command cannot hold. -# - Without node, or with a payload node cannot parse, node prints nothing. -# Each read then fails and leaves its variable empty (`|| true` keeps set -e -# from ending the hook), and an empty command lets the call through. -# - The script is single-quoted on purpose: its ${...} is JavaScript, not shell. -# shellcheck disable=SC2016 -{ - IFS= read -r -d '' tool_command || true - IFS= read -r -d '' payload_cwd || true -} < <(node -e ' - const chunks = [] - // Buffer.concat runs before toString, because a multi-byte character can span two chunks. - process.stdin.on("data", (chunk) => chunks.push(chunk)).on("end", () => { - const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) - process.stdout.write(`${parsed.tool_input?.command ?? ""}\0${parsed.cwd ?? ""}\0`) - }) -' 2>/dev/null) - -# Two patterns match the flags between a command and what it runs. -# - flags_without_argument matches flags that take no argument (`env -i`, -# `time -p`). -# - flags_with_optional_argument matches flags that may each take one argument -# (`timeout -k 5`, `npx -p vitest`). A word after such a flag can be read as -# its argument or as the next part of the command (`npx -y vitest`, -# `timeout -k 5 600`), and the match takes whichever reading lets the rest -# of the pattern match. -flags_without_argument='([[:space:]]+-[^[:space:]]*)*' -flags_with_optional_argument='([[:space:]]+-[^[:space:]]*([[:space:]]+[^-[:space:]][^[:space:]]*)?)*' - -# Three patterns match a test runner. -# - vitest_runner matches vitest itself, bare or by path -# (node_modules/.bin/vitest), with or without a version (`vitest@latest`), -# after npx and its flags (`npx -y vitest`), or as -# `node node_modules/vitest/vitest.mjs`. -# - npm_exec_runner matches vitest after `npm exec` or `npm x`, each with -# npm's flags (`npm x -y vitest`). -# - npm_runner matches `npm test`, `npm t`, and `npm run` or `npm run-script` -# of the scripts test, test:coverage, test:watch and snapshot:update, each -# after any npm flags (`npm -s test`, `npm run --silent test`). It leaves -# out test:remote-boot and test:cli-pty, the two suites exempt from this -# hook because they cannot run as nobody; the exemptions below give each -# reason. -vitest_binary='([^[:space:]]*/)?vitest(@[^[:space:]]*)?' -vitest_runner="(npx${flags_with_optional_argument}[[:space:]]+)?${vitest_binary}|node[[:space:]]+[^[:space:]]*vitest\.mjs" -npm_exec_runner="npm${flags_with_optional_argument}[[:space:]]+(exec|x)${flags_with_optional_argument}[[:space:]]+${vitest_binary}" -npm_runner="npm${flags_with_optional_argument}[[:space:]]+(test|t|run(-script)?${flags_with_optional_argument}[[:space:]]+(test|test:coverage|test:watch|snapshot:update))" -test_runner="${vitest_runner}|${npm_exec_runner}|${npm_runner}" - -# variable_setting matches a VAR=value setting before the command -# (`CI=1 npm test`), with the value in one of three forms. -# - double_quoted matches text in double quotes, with no " inside. -# - single_quoted matches text in single quotes, with no ' inside. -# - unquoted matches text up to the first space, ' or ". -double_quoted='"[^"]*"' -single_quoted="'[^']*'" -unquoted="[^[:space:]'\"]*" -variable_setting="[A-Za-z_][A-Za-z0-9_]*=(${double_quoted}|${single_quoted}|${unquoted})[[:space:]]+" - -# wrapper matches these commands, which run the command after them: -# - env and time, with flags that take no argument, because a flag's argument -# would look the same as the command it runs. -# - exec and command, without flags, so `command -v vitest` is not a run. -# - timeout, whose flags may each take one argument, then the duration -# (`timeout -s KILL 600 npm test`, `timeout -k 5 600 npm test`). -# - bash, sh and zsh with their flags, where a group of one-letter flags that -# holds o or O takes an argument (`bash -euo pipefail -c`, -# `bash -O extglob -c`), then the opening quote of the command they run and -# any spaces after it (`bash -lc 'npm test'`). The split below ignores -# quotes, so a run or a cd after ; or && inside the quoted text counts too -# (`bash -c 'npm ci && cd /repo && npm test'`), and a mention there -# (`bash -c 'echo npm test'`) does not. -# setpriv is not a wrapper here: a run through setpriv is the nobody run the -# reply asks for, so the runner after it never counts. -timeout_wrapper="timeout${flags_with_optional_argument}[[:space:]]+[0-9][^[:space:]]*" -shell_flags='([[:space:]]+([-+][[:alpha:]]*[oO][[:alpha:]]*[[:space:]]+[^[:space:]]+|[-+][^[:space:]]*))*' -shell_wrapper="(bash|sh|zsh)${shell_flags}[[:space:]]+([\"'][[:space:]]*)?" -wrapper="((env|time)${flags_without_argument}|exec|command|${timeout_wrapper})[[:space:]]+|${shell_wrapper}" - -# shell_keyword matches a shell keyword a command can follow: if -# (`if npm test`), then, do, else, elif, while, until, ! (`! npm test`) and { -# (`{ npm test; }`). -shell_keyword='(if|then|do|else|elif|while|until|!|\{)[[:space:]]+' - -command_prefix="(${wrapper}|${shell_keyword}|${variable_setting})*" - -# The runner ends at a space, a ), the end of its command, or a closing quote -# followed only by spaces and then a redirection (`bash -c 'npm test' 2>`), a -# ) (`$(bash -c 'npm test')`), a comment or the end. So `npm run testx` and -# `npm run test:cli-pty` are not runs, and neither is the `vitest' src/` that -# `grep -E 'npm test|vitest' src/` splits into. -runner_end="([[:space:]]|\\)|[\"'][[:space:]]*([0-9]*[<>]|\\)|$)|[\"'][[:space:]]+#|$)" - -# A command that runs tests starts with the runner, after any prefix. A -# mention elsewhere, as in `grep vitest package.json`, is not a run. -test_run="^[[:space:]]*${command_prefix}(${test_runner})${runner_end}" - -# exempt_suite_run matches a run of the remote-boot or cli-pty suite, the two -# exempt suites, by the config flag that names the suite's config file, with -# or without quotes around the name. The file name must end where a runner -# ends, so `vitest.cli-pty.config.ts.bak` does not count. -# - arguments_before_double_dash matches words other than a lone `--`. vitest -# reads no flags after a lone `--`, so -# `vitest run -- --config vitest.cli-pty.config.ts` runs the main suite. -# - vitest_exempt_suite_run matches the flag anywhere among vitest's arguments -# before a lone `--` (`vitest run --config vitest.cli-pty.config.ts`). -# - npm_exempt_suite_run matches the flag only after npm's first lone `--`, -# which hands the flag to vitest, and before a second one. npm itself -# consumes the flag in `npm test --config x`, and the script runs the main -# suite. -# - Neither matches a run through `npm exec` or `npm x`. npm reads a flag -# after the command as its own unless a lone `--` comes first, so vitest -# never sees the flag in `npm exec vitest run --config -# vitest.cli-pty.config.ts`. The hook stops every such run rather than -# track where npm's `--` falls. -exempt_suite_config="(-c|--config)(=|[[:space:]]+)[\"']?([^[:space:]\"']*/)?vitest\.(remote-boot|cli-pty)\.config\.ts[\"']?${runner_end}" -arguments_before_double_dash='([[:space:]]+(-|-?[^-[:space:]][^[:space:]]*|--[^[:space:]]+))*' -vitest_exempt_suite_run="^[[:space:]]*${command_prefix}(${vitest_runner})${arguments_before_double_dash}[[:space:]]+${exempt_suite_config}" -npm_exempt_suite_run="^[[:space:]]*${command_prefix}(${npm_runner})${arguments_before_double_dash}[[:space:]]+--${arguments_before_double_dash}[[:space:]]+${exempt_suite_config}" -exempt_suite_run="${vitest_exempt_suite_run}|${npm_exempt_suite_run}" - -# The walk below tells four kinds of directory change apart. The same prefix -# a test run may carry (a wrapper, a shell keyword, a VAR=value setting: -# `{ cd /repo; npm test; }`, `then cd /repo`, `env bash -c 'cd /repo && npm -# test'`) is cut off first, with leading_command_prefix, so the first three -# patterns start at cd or pushd and the path is the second group of their -# match. any_cd accepts that prefix itself for the grep below. -# - cd_to_absolute_path matches an unquoted path that starts with /. -# - cd_to_quoted_absolute_path matches a quoted path that starts with / and -# holds no $, backtick or ~, so the shell would use it as written. -# - cd_to_relative_path matches an unquoted relative path. Its first -# character cannot be - (`cd -` or a flag), # (a comment, which leaves a -# bare `cd`), or ~, $ or a quote, which only the shell can expand. -# - any_cd matches any cd, pushd or popd. The walk tries it last, so it -# catches the rest: a bare `cd`, with or without a comment after it, -# `cd -`, popd, and a ~, $ or quoted path the others do not take. -directory_change="^[[:space:]]*(cd|pushd)[[:space:]]+" -cd_to_absolute_path="${directory_change}(/[^[:space:]]*)" -cd_to_quoted_absolute_path="${directory_change}[\"'](/[^\"'\$\`~]*)[\"']" -cd_to_relative_path="${directory_change}([^-~#\"'\$[:space:]][^[:space:]]*)" -leading_command_prefix="^[[:space:]]*${command_prefix}" -any_cd="^[[:space:]]*${command_prefix}(cd|pushd|popd)([[:space:]]|$)" - -# The command is split into the commands it runs, one per line, and only the -# cds and test runs the walk below acts on are kept. -# - A command starts at the start of a line, or after ;, &, |, ( or a -# backtick. The ( also covers $( ). -# - tr maps each of the five characters to a line break, which SC2020 takes -# for a word replacement. tr, not bash's ${//}, because bash's substitution -# slows to seconds on a large multi-byte command. -# - grep drops the other commands in one pass. The walk's =~ tests take -# seconds when they run on each line of a command thousands of lines long. -# - grep exits 1 when it keeps no line, so `|| true` stops set -e and -# pipefail from ending the hook. -# shellcheck disable=SC2020 -cd_or_test_commands="$(printf '%s\n' "${tool_command}" | - tr ';&|(`' '\n\n\n\n\n' | - grep -E "${any_cd}|${test_run}")" || true - -# The walk checks each command on its own, so an exemption covers only its -# own command, and it tracks the folder the first stopped run runs in. -# - Two kinds of run are exempt. -# - A run through setpriv never matches test_run, because setpriv is not -# one of the wrappers above. -# - Two suites have no permission tests and cannot run as nobody. Their -# npm scripts never match test_run, and exempt_suite_run drops a run with -# either suite's config. -# - remote-boot needs the Docker socket, which nobody cannot open. -# - cli-pty starts the CLI with npx from a temp folder, so npx downloads -# tsx into nobody's empty npm cache. Cloud sessions reach the npm -# registry through a proxy whose CA bundle sits under /root, where -# nobody cannot read it, so that download fails. -# - The run's folder starts as the session's folder, the first of these that -# is set: the payload's cwd, CLAUDE_PROJECT_DIR (which Claude Code sets to -# the project root), and `.`. Claude Code runs the hook in the session's -# current folder, so `.` and any path built on it resolve against that -# folder. -# - Each `cd` or `pushd` before the run moves it: to an absolute path, -# unquoted or quoted as written (`git pull && cd /other/checkout && npm -# test`), or relative to the folder so far for an unquoted relative path -# (`cd /other/checkout && cd src`). For any other cd, and for popd, the -# session's folder stands in for the target, which the hook does not -# resolve. A cd after the run does not count. -# - A cd after env, exec or timeout moves it too, though the shell's folder -# stays put: those commands run a program, and cd is a shell builtin. -# - npm's --prefix folder is not followed: `npm --prefix /other/checkout -# test` counts as `npm test`. -session_dir="${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}" -run_dir="${session_dir}" -current_dir="${session_dir}" - -# root_test_runs collects the stopped runs' commands, one per line, so the -# coverage hint at the end can look for a coverage run among them. -root_test_runs="" -while IFS= read -r split_command; do - cd_candidate="${split_command}" - if [[ "${split_command}" =~ ${leading_command_prefix} ]]; then - cd_candidate="${split_command:${#BASH_REMATCH[0]}}" - fi - if [[ "${cd_candidate}" =~ ${cd_to_absolute_path} ]]; then - current_dir="${BASH_REMATCH[2]}" - continue - fi - if [[ "${cd_candidate}" =~ ${cd_to_quoted_absolute_path} ]]; then - current_dir="${BASH_REMATCH[2]}" - continue - fi - if [[ "${cd_candidate}" =~ ${cd_to_relative_path} ]]; then - current_dir="${current_dir}/${BASH_REMATCH[2]}" - continue - fi - if [[ "${cd_candidate}" =~ ${any_cd} ]]; then - current_dir="${session_dir}" - continue - fi - - # Every line left is a test run, except the one empty line the here-string - # passes when grep kept no line. - [[ "${split_command}" =~ ${test_run} ]] || continue - - if [[ "${split_command}" =~ ${exempt_suite_run} ]]; then - continue - fi - - if [[ -z "${root_test_runs}" ]]; then - run_dir="${current_dir}" - fi - root_test_runs+="${split_command}"$'\n' -done <<<"${cd_or_test_commands}" - -if [[ -z "${root_test_runs}" ]]; then - exit 0 -fi - -# The hook prepares the checkout that holds the run's folder. -# - When the run's folder is in no checkout, the session's folder's checkout -# stands in, and the run is still stopped rather than let through as root. -# - When neither folder is in a checkout, there are no folders to prepare, -# and the run is let through. -checkout="$(git -C "${run_dir}" rev-parse --show-toplevel 2>/dev/null || - git -C "${session_dir}" rev-parse --show-toplevel 2>/dev/null)" || exit 0 - -# Without node_modules there is nothing to run yet. A folder made under -# node_modules here would also stop the install: install-deps.sh skips npm ci -# in a checkout that has a node_modules folder it did not install. -if [[ ! -d "${checkout}/node_modules" ]]; then - exit 0 -fi - -nobody_uid="$(id -u nobody)" -nobody_gid="$(id -g nobody)" - -# The prefix runs whichever node nobody finds on PATH. nobody cannot read -# root's home, where nvm installs Node, so that node can be another version -# than the .nvmrc one CI runs, or missing. Without one there is no command to -# offer, so the run goes through, as it does without setpriv or a nobody user. -nobody_node_version="$(setpriv --reuid="${nobody_uid}" --regid="${nobody_gid}" --clear-groups node --version 2>/dev/null)" || nobody_node_version="" - -if [[ -z "${nobody_node_version}" ]]; then - exit 0 -fi - -# A test run writes in these folders: vitest's caches under node_modules, a -# fake HOME that keeps npm's cache and logs out of root's home, and coverage/ -# for test:coverage's report (gitignored). -nobody_writable=( - "${checkout}/node_modules/.vite-temp" - "${checkout}/node_modules/.vitest" - "${checkout}/node_modules/.nobody-home" - "${checkout}/coverage" -) -mkdir -p "${nobody_writable[@]}" - -# snapshot:update rewrites the tool-surface baseline in this folder, the only -# snapshot folder in the repo. git tracks neither owner nor write bits, so the -# changes below add nothing to a diff. -snapshots_dir="${checkout}/src/vault-mcp/mcp-core/__tests__/__snapshots__" - -if [[ -d "${snapshots_dir}" ]]; then - nobody_writable+=("${snapshots_dir}") -fi - -# The folders go to nobody and are not writable by group or other users, -# because vitest runs code from its caches and checks test output against the -# snapshots. Both commands recurse, so the files a root run left inside also -# go to nobody and lose group and other write access. -chown -R "${nobody_uid}:${nobody_gid}" "${nobody_writable[@]}" -chmod -R go-w "${nobody_writable[@]}" - -# %q quotes the HOME path for the shell, so the prefix still works when the -# checkout's path holds a space. A path of plain characters prints unchanged. -printf -v nobody_home_for_shell '%q' "${checkout}/node_modules/.nobody-home" - -cat >&2 </dev/null)" || nvmrc_version="" -nvmrc_version="${nvmrc_version%%[[:space:]]*}" -nvmrc_major="${nvmrc_version#v}" -nvmrc_major="${nvmrc_major%%.*}" -nobody_node_major="${nobody_node_version#v}" -nobody_node_major="${nobody_node_major%%.*}" - -if [[ "${nvmrc_major}" =~ ^[0-9]+$ && "${nobody_node_major}" != "${nvmrc_major}" ]]; then - echo "nobody's node is ${nobody_node_version}, not the ${nvmrc_version} that .nvmrc names and CI runs, so a result can differ from CI's." >&2 -fi - -# vitest deletes coverage/ before a coverage run, and deleting it needs write -# access to the checkout root, so the run must keep the folder instead. -if [[ "${root_test_runs}" == *test:coverage* || "${root_test_runs}" == *--coverage* ]]; then - echo "For a coverage run, also pass --coverage.clean=false (after -- for npm run): vitest otherwise deletes coverage/ first, which nobody cannot do in a root-owned checkout." >&2 -fi - -exit 2 diff --git a/.claude/settings.json b/.claude/settings.json index e1a26bbef..853d77bc3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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", diff --git a/.gitignore b/.gitignore index 63a26cb49..3e9e36875 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index fbc9969fd..418df0793 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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`. - Tests read as a behavioral spec. One focused `it()` per behavior — a failing test name should identify which behavior regressed without reading the body. @@ -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, @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2258d4419..2ec2b8ed0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. + `npm run test:coverage` runs the same suite with a statement-coverage report (V8 provider); the `coverage/` output directory is gitignored. diff --git a/knip.json b/knip.json index 2fe2405de..ce7768f6f 100644 --- a/knip.json +++ b/knip.json @@ -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"] } diff --git a/package.json b/package.json index 129eec77f..6a162b823 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/scripts/__tests__/lint-shell.test.ts b/scripts/__tests__/lint-shell.test.ts index b1edc7dfd..65f524662 100644 --- a/scripts/__tests__/lint-shell.test.ts +++ b/scripts/__tests__/lint-shell.test.ts @@ -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", diff --git a/scripts/test-as-nobody.ts b/scripts/test-as-nobody.ts new file mode 100644 index 000000000..a7e5667b5 --- /dev/null +++ b/scripts/test-as-nobody.ts @@ -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), +) +for (const folder of writableFolders) mkdirSync(folder, { recursive: true }) +execFileSync("chown", ["-R", `${uid}:${gid}`, ...writableFolders]) + +// 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) +} + +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) diff --git a/src/__tests__/integration/server-error-contracts.test.ts b/src/__tests__/integration/server-error-contracts.test.ts index 6e7876af8..a4d29a56f 100644 --- a/src/__tests__/integration/server-error-contracts.test.ts +++ b/src/__tests__/integration/server-error-contracts.test.ts @@ -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 }) @@ -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" } }, @@ -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 @@ -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({ @@ -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: {} }) diff --git a/src/__tests__/require-nonroot-tests.test.ts b/src/__tests__/require-nonroot-tests.test.ts deleted file mode 100644 index 98f8ff195..000000000 --- a/src/__tests__/require-nonroot-tests.test.ts +++ /dev/null @@ -1,1078 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process" -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - realpathSync, - rmSync, - statSync, - symlinkSync, - writeFileSync, -} from "node:fs" -import { tmpdir } from "node:os" -import { basename, dirname, join, resolve } from "node:path" - -import { describe, expect, it, onTestFinished } from "vitest" - -/** - * Behavioral spec for the Claude Code hook that stops test runs in a root - * session (.claude/hooks/require-nonroot-tests.sh): which commands it stops, - * which checkout's folders it prepares, and the reply it gives. The real hook - * runs under `bash` with real node and git, and with stub `id`, `setpriv` and - * `chown` executables first on PATH. The stub `id` poses as root and reports - * the test runner's own ids for nobody, so the hook's chown needs no - * privileges. - */ - -const HOOK_PATH = resolve(import.meta.dirname, "../../.claude/hooks/require-nonroot-tests.sh") - -/** Stub `id`: `-u` prints STUB_SESSION_UID (0 poses as root), `nobody` - * succeeds only when STUB_NOBODY_EXISTS is 1, and `-u nobody` / `-g nobody` - * print STUB_NOBODY_UID / STUB_NOBODY_GID. Any other call fails loudly. */ -const ID_STUB = `#!/bin/sh -case "$*" in - "-u") echo "$STUB_SESSION_UID" ;; - "nobody") - if [ "$STUB_NOBODY_EXISTS" = "1" ]; then - echo "uid=$STUB_NOBODY_UID(nobody) gid=$STUB_NOBODY_GID(nogroup)" - else - echo "id: 'nobody': no such user" >&2 - exit 1 - fi - ;; - "-u nobody") echo "$STUB_NOBODY_UID" ;; - "-g nobody") echo "$STUB_NOBODY_GID" ;; - *) - echo "stub id: unexpected arguments: $*" >&2 - exit 64 - ;; -esac -` - -/** Stub `setpriv`: drops its own options and runs the rest of the command - * with STUB_NOBODY_PATH as PATH, standing in for the PATH entries nobody - * can read. */ -const SETPRIV_STUB = `#!/bin/sh -while [ "$#" -gt 0 ]; do - case "$1" in - --*) shift ;; - *) break ;; - esac -done -PATH="$STUB_NOBODY_PATH" -export PATH -exec "$@" -` - -/** Stub `chown`: appends its arguments to STUB_CHOWN_LOG, one call per line, - * then runs the real chown at STUB_REAL_CHOWN. The stub cannot look the - * real one up itself: macOS's /bin/sh is bash 3.2, whose `command -p -v` - * searches the current PATH, where the stub comes first. */ -const CHOWN_STUB = `#!/bin/sh -printf '%s\\n' "$*" >> "$STUB_CHOWN_LOG" -exec "$STUB_REAL_CHOWN" "$@" -` - -/** The node nobody finds, which prints STUB_NOBODY_NODE_VERSION as its version. */ -const NOBODY_NODE_STUB = `#!/bin/sh -echo "$STUB_NOBODY_NODE_VERSION" -` - -/** The fixture checkout's .nvmrc, and a nobody node version that matches it. */ -const NVMRC_VERSION = "24" -const MATCHING_NODE_VERSION = "v24.11.0" - -const SNAPSHOTS_FOLDER = "src/vault-mcp/mcp-core/__tests__/__snapshots__" - -type HookFixture = { - /** A fresh git checkout with a node_modules folder and an .nvmrc, symlinks - * resolved the way `git rev-parse --show-toplevel` reports it. */ - checkout: string - /** A folder in no checkout: the hook's working directory and HOME. */ - outsideDir: string - stubBinDir: string - /** nobody's PATH when it has a node: holds only the node stub. */ - nobodyBinDir: string - /** nobody's PATH when it has no node. */ - emptyBinDir: string - chownLog: string -} - -type HookRun = { - status: number | null - stdout: string - stderr: string -} - -type HookRunOptions = { - fixture: HookFixture - /** The hook's stdin, usually built by payloadFor. */ - stdin: string - /** What the stub `id -u` prints. Defaults to 0, posing as root. */ - sessionUid?: number - /** Whether the stub `id nobody` succeeds. Defaults to true. */ - nobodyExists?: boolean - /** Whether nobody finds a node on PATH. Defaults to true. */ - nobodyHasNode?: boolean - /** What nobody's node prints for --version. Defaults to a version that - * matches the fixture's .nvmrc. */ - nobodyNodeVersion?: string - claudeProjectDir?: string - /** The hook's PATH. Defaults to the stubs ahead of the runner's PATH. */ - path?: string -} - -/** The test runner's own ids, which the stub reports for nobody: chown to - * your own uid and primary group needs no privileges. */ -const runnerIds = (): { uid: number; gid: number } => { - const uid = process.geteuid?.() - const gid = process.getegid?.() - - // Compared to undefined, not checked for truth: 0 is root's valid id. - if (uid === undefined || gid === undefined) throw new Error("the hook tests need POSIX ids") - return { uid, gid } -} - -/** The runner's own PATH, which the hook's real tools are found on. */ -const runnerPath = (): string => { - const path = process.env.PATH - - if (!path) throw new Error("the hook tests need a PATH") - return path -} - -/** The real chown on the runner's PATH, which has no stub on it. */ -const realChownPath = (): string => { - return execFileSync("bash", ["-c", "command -v chown"], { encoding: "utf8" }).trimEnd() -} - -/** PATH lookup skips a file without the executable bit, and would run the - * real binary instead. */ -const writeExecutable = (path: string, script: string): void => { - writeFileSync(path, script) - chmodSync(path, 0o755) -} - -const createHookFixture = (): HookFixture => { - // realpath: git reports the checkout with symlinks resolved, and macOS's - // tmpdir is a symlink. - const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "require-nonroot-tests-"))) - onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })) - const stubBinDir = join(tempDir, "bin") - const nobodyBinDir = join(tempDir, "nobody-bin") - const emptyBinDir = join(tempDir, "empty-bin") - const checkout = join(tempDir, "checkout") - const outsideDir = join(tempDir, "outside") - for (const folder of [stubBinDir, nobodyBinDir, emptyBinDir, outsideDir]) { - mkdirSync(folder) - } - - writeExecutable(join(stubBinDir, "id"), ID_STUB) - writeExecutable(join(stubBinDir, "setpriv"), SETPRIV_STUB) - writeExecutable(join(stubBinDir, "chown"), CHOWN_STUB) - writeExecutable(join(nobodyBinDir, "node"), NOBODY_NODE_STUB) - - execFileSync("git", ["init", "--quiet", checkout], { - env: { PATH: runnerPath(), HOME: outsideDir }, - stdio: "pipe", - }) - mkdirSync(join(checkout, "node_modules")) - writeFileSync(join(checkout, ".nvmrc"), `${NVMRC_VERSION}\n`) - return { - checkout, - outsideDir, - stubBinDir, - nobodyBinDir, - emptyBinDir, - chownLog: join(tempDir, "chown.log"), - } -} - -/** A second git checkout with a node_modules folder, beside the fixture's - * checkout, so a test can tell which of the two the hook prepares. */ -const createOtherCheckout = (fixture: HookFixture, folderName = "other-checkout"): string => { - const otherCheckout = join(dirname(fixture.checkout), folderName) - - execFileSync("git", ["init", "--quiet", otherCheckout], { - env: { PATH: runnerPath(), HOME: fixture.outsideDir }, - stdio: "pipe", - }) - mkdirSync(join(otherCheckout, "node_modules")) - return otherCheckout -} - -/** Every tool the hook runs on a stopped run except setpriv. */ -const HOOK_TOOLS_BESIDES_SETPRIV = [ - "bash", - "node", - "git", - "grep", - "tr", - "cat", - "mkdir", - "chown", - "chmod", -] - -/** A PATH folder linking the stub `id` and the real tools, without setpriv. - * With the tools present, only the missing setpriv can let the run through: - * the hook's setpriv check, or its nobody node check, which runs setpriv. */ -const createBinDirWithoutSetpriv = (fixture: HookFixture): string => { - const binDir = join(fixture.outsideDir, "bin-without-setpriv") - mkdirSync(binDir) - const toolPaths = execFileSync( - "bash", - ["-c", 'command -v "$@"', "bash", ...HOOK_TOOLS_BESIDES_SETPRIV], - { encoding: "utf8" }, - ) - .trimEnd() - .split("\n") - for (const toolPath of toolPaths) { - symlinkSync(toolPath, join(binDir, basename(toolPath))) - } - symlinkSync(join(fixture.stubBinDir, "id"), join(binDir, "id")) - return binDir -} - -const hookEnv = (options: Omit): Record => { - const { uid, gid } = runnerIds() - const { fixture } = options - - // Built from scratch, so a CLAUDE_PROJECT_DIR or GIT_DIR in the runner's - // own environment never reaches the hook. - return { - PATH: options.path ?? `${fixture.stubBinDir}:${runnerPath()}`, - HOME: fixture.outsideDir, - STUB_SESSION_UID: String(options.sessionUid ?? 0), - STUB_NOBODY_EXISTS: options.nobodyExists === false ? "0" : "1", - STUB_NOBODY_UID: String(uid), - STUB_NOBODY_GID: String(gid), - STUB_NOBODY_PATH: options.nobodyHasNode === false ? fixture.emptyBinDir : fixture.nobodyBinDir, - STUB_NOBODY_NODE_VERSION: options.nobodyNodeVersion ?? MATCHING_NODE_VERSION, - STUB_CHOWN_LOG: fixture.chownLog, - STUB_REAL_CHOWN: realChownPath(), - ...(options.claudeProjectDir ? { CLAUDE_PROJECT_DIR: options.claudeProjectDir } : {}), - } -} - -const runHook = (options: HookRunOptions): HookRun => { - const result = spawnSync("bash", [HOOK_PATH], { - // Outside any checkout, so the hook's "." fallback can never reach the - // repo running these tests. - cwd: options.fixture.outsideDir, - input: options.stdin, - encoding: "utf8", - env: hookEnv(options), - }) - - return { status: result.status, stdout: result.stdout, stderr: result.stderr } -} - -const payloadFor = ({ command, cwd }: { command: string; cwd?: string }): string => { - return JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) }) -} - -/** Runs the hook on a command issued from the fixture's checkout. */ -const runCommandThroughHook = (fixture: HookFixture, command: string): HookRun => { - return runHook({ fixture, stdin: payloadFor({ command, cwd: fixture.checkout }) }) -} - -/** The hook's reply to a stopped run, test-owned so a change to the prefix - * or the wording shows up here. nobodyHomeInPrefix is the HOME path as the - * prefix spells it: the plain path, unless the path needs quoting. */ -const expectedRefusal = ( - checkout: string, - { - nobodyHomeInPrefix = `${checkout}/node_modules/.nobody-home`, - }: { nobodyHomeInPrefix?: string } = {}, -): string => { - const { uid, gid } = runnerIds() - - return [ - "Tests do not run as root in this repo: root reads the files the permission tests make unreadable, so those tests fail here but pass in CI.", - "Run the same test command as the nobody user, with this prefix in front of the test command itself (after any cd):", - ` setpriv --reuid=${uid} --regid=${gid} --clear-groups env HOME=${nobodyHomeInPrefix}`, - `The folders nobody needs to write in ${checkout} are ready.`, - "", - ].join("\n") -} - -const scratchFolders = (checkout: string): string[] => [ - join(checkout, "node_modules", ".vite-temp"), - join(checkout, "node_modules", ".vitest"), - join(checkout, "node_modules", ".nobody-home"), - join(checkout, "coverage"), -] - -const permissionBits = (path: string): number => statSync(path).mode & 0o777 - -/** The chown calls the hook made, each as its space-joined arguments. */ -const recordedChownCalls = (fixture: HookFixture): string[] => { - if (!existsSync(fixture.chownLog)) return [] - return readFileSync(fixture.chownLog, "utf8").trimEnd().split("\n") -} - -const LET_THROUGH: HookRun = { status: 0, stdout: "", stderr: "" } - -describe("require-nonroot-tests hook", () => { - it("resolves id, setpriv and chown to the stubs ahead of the real binaries", () => { - const fixture = createHookFixture() - - const lookup = spawnSync( - "bash", - ["-c", "command -v id; command -v setpriv; command -v chown"], - { cwd: fixture.outsideDir, encoding: "utf8", env: hookEnv({ fixture }) }, - ) - - expect(lookup.stdout).toBe( - `${fixture.stubBinDir}/id\n${fixture.stubBinDir}/setpriv\n${fixture.stubBinDir}/chown\n`, - ) - }) - - describe("in a root session", () => { - it.each([ - { label: "npm test", command: "npm test" }, - { label: "npm t", command: "npm t" }, - { label: "npm run test:watch", command: "npm run test:watch" }, - { label: "a test command on a second line", command: "git status\nnpm test" }, - { label: "a test command after cd and &&", command: "cd / && npm test" }, - { label: "npx vitest", command: "npx vitest run x" }, - { label: "npx with a flag", command: "npx -y vitest run x" }, - { label: "npx with a versioned vitest", command: "npx vitest@latest run" }, - { label: "npm exec vitest", command: "npm exec vitest -- run" }, - { label: "npm x with a flag before vitest", command: "npm x -y vitest run" }, - { label: "npm run-script test", command: "npm run-script test" }, - { label: "bash -c with a quoted test run", command: "bash -lc 'npm test'" }, - { label: "sh -c with a double-quoted vitest run", command: 'sh -c "npx vitest run"' }, - { label: "bash -c with a redirection after the quote", command: "bash -c 'npm test' 2>&1" }, - { label: "bash -c inside a command substitution", command: "out=$(bash -c 'npm test')" }, - { - label: "bash -c with a comment after the quote", - command: "bash -c 'npm test' # main suite", - }, - { label: "bash -c with a space after the opening quote", command: "bash -c ' npm test'" }, - { - label: "bash with -o and its argument before -c", - command: "bash -o pipefail -c 'npm test'", - }, - { - label: "bash with a flag group ending in o and its argument before -c", - command: "bash -euo pipefail -c 'npm test'", - }, - { - label: "sh with o before c in one flag group and o's argument after it", - command: "sh -oc pipefail 'npx vitest run'", - }, - { - label: "vitest with the main config in quotes", - command: 'npx vitest run --config "vitest.config.ts"', - }, - // npm reads the config flag as its own, so vitest never sees it. - { - label: "npm exec vitest with the cli-pty config", - command: "npm exec vitest run --config vitest.cli-pty.config.ts", - }, - // @vitest/ui is not a runner on its own, so only reading it as -p's - // argument reaches the vitest after it. - { - label: "npx with a package flag and its argument", - command: "npx -p @vitest/ui vitest run", - }, - { - label: "npm test with a config flag npm consumes", - command: "npm test --config vitest.cli-pty.config.ts", - }, - { label: "npm with a flag before the script", command: "npm -s test" }, - { - label: "npm with a flag and its argument before the script", - command: "npm --prefix . test", - }, - { - label: "npm run with a flag before the script", - command: "npm run --silent snapshot:update", - }, - { - label: "npm run with a flag and its argument before the script", - command: "npm run --loglevel silent test", - }, - { - label: "vitest by its path under node_modules/.bin", - command: "node_modules/.bin/vitest run", - }, - { label: "node running vitest.mjs", command: "node node_modules/vitest/vitest.mjs run" }, - { label: "npm run snapshot:update", command: "npm run snapshot:update" }, - { label: "a subshell", command: "(cd sub; npm test)" }, - { label: "a subshell inside command substitution", command: "out=$( (npm test) )" }, - { label: "a backtick substitution", command: "out=`npm test 2>&1`" }, - { label: "a variable setting before npx", command: "CI=1 npx vitest run" }, - { label: "env with a variable setting", command: "env CI=1 npm test" }, - { label: "env with a flag", command: "env -i npm test" }, - { label: "a double-quoted value with a space", command: 'FOO="a b" npm test' }, - { label: "time", command: "time npm test" }, - { label: "time with a flag", command: "time -p npm test" }, - { - label: "timeout with a flag argument and a duration", - command: "timeout -k 5 600 npm test", - }, - { label: "timeout with a named signal", command: "timeout -s KILL 600 npm test" }, - { label: "an if condition", command: "if npm test; then :; fi" }, - { label: "a while condition", command: "while npm test; do :; done" }, - { label: "an until condition", command: "until npm test; do sleep 1; done" }, - { label: "a brace group", command: "{ npm test; }" }, - { - label: "a test run after an exempt suite's npm script", - command: "npm run test:cli-pty && npm test", - }, - { - label: "a vitest run whose test filter names an exempt suite", - command: "npm run test:remote-boot; npx vitest run -t cli-pty", - }, - { - label: "vitest with an exempt suite's config placed after --", - command: "npx vitest run -- --config vitest.cli-pty.config.ts", - }, - { - label: "npm handing an exempt suite's config to vitest after a second --", - command: "npm test -- -- --config vitest.cli-pty.config.ts", - }, - { - label: "vitest with a config whose name only starts with an exempt suite's", - command: "npx vitest run --config vitest.cli-pty.config.ts.bak", - }, - { - label: "a test run after a command that mentions setpriv", - command: "grep setpriv notes; npm test", - }, - { - label: "a test run after a run through setpriv", - command: - "setpriv --reuid=65534 --regid=65534 --clear-groups env HOME=/repo/node_modules/.nobody-home npm test; npm test", - }, - ])("stops $label and replies with the nobody prefix", ({ command }) => { - const fixture = createHookFixture() - - const run = runCommandThroughHook(fixture, command) - - expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) - }) - - // Each case also stops `npm test` in the same fixture, so the pass-through - // comes from the command itself, not from a fixture the hook bails on. - it.each([ - { label: "git status", command: "git status" }, - { label: "a mention of vitest as an argument", command: "grep vitest package.json" }, - { label: "a lookup of vitest with command -v", command: "command -v vitest" }, - { label: "a quoted mention of npm test", command: 'echo "npm test"' }, - { label: "a script whose name only starts with test", command: "npm run testx" }, - { label: "npm run test:remote-boot", command: "npm run test:remote-boot" }, - { label: "npm run test:cli-pty", command: "npm run test:cli-pty" }, - { label: "npm run-script of another script", command: "npm run-script lint" }, - { label: "npm exec of another binary", command: "npm exec prettier -- --version" }, - { label: "bash -c with a quoted non-test command", command: "bash -c 'npm run lint'" }, - { - label: "a quoted grep pattern that a | splits after a runner name", - command: "grep -E 'npm test|vitest' src/", - }, - { - label: "vitest with the cli-pty config in quotes", - command: 'npx vitest run --config "vitest.cli-pty.config.ts"', - }, - { - label: "vitest with the remote-boot config in quotes after =", - command: "npx vitest run --config='vitest.remote-boot.config.ts'", - }, - { - label: "a versioned vitest with the cli-pty config", - command: "npx vitest@4 run --config vitest.cli-pty.config.ts", - }, - // vitest refuses a second config flag and runs nothing. - { - label: "vitest with a second config flag", - command: "npx vitest run --config vitest.cli-pty.config.ts --config vitest.config.ts", - }, - // vitest ignores a flag after a lone --, so the cli-pty suite runs. - { - label: "vitest with a second config after --", - command: "npx vitest run --config vitest.cli-pty.config.ts -- --config vitest.config.ts", - }, - { - label: "vitest with the cli-pty config", - command: "npx vitest run --config vitest.cli-pty.config.ts", - }, - { - label: "vitest with the remote-boot config, given with = and a path", - command: "npx vitest run --config=./vitest.remote-boot.config.ts", - }, - { - label: "npx with a flag running vitest with the cli-pty config", - command: "npx -y vitest run --config vitest.cli-pty.config.ts", - }, - { - label: "npm test handing the cli-pty config to vitest after --", - command: "npm test -- --config vitest.cli-pty.config.ts", - }, - { - label: "npm with a flag handing the cli-pty config to vitest after --", - command: "npm -s test -- --config vitest.cli-pty.config.ts", - }, - { - label: "npm test handing the cli-pty config to vitest among other arguments", - command: "npm test --silent -- --reporter=dot --config vitest.cli-pty.config.ts", - }, - { label: "a variable setting as an argument", command: "echo CI=1 npm test" }, - { label: "a variable setting before another command", command: "FOO=1 echo npm test" }, - { label: "env with a flag running another command", command: "env -i echo npm test" }, - { label: "timeout running another command", command: "timeout 600 echo npm test" }, - { - label: "a command already run through setpriv", - command: - "setpriv --reuid=65534 --regid=65534 --clear-groups env HOME=/repo/node_modules/.nobody-home npm test", - }, - ])("lets $label through without preparing folders", ({ command }) => { - const fixture = createHookFixture() - - const run = runCommandThroughHook(fixture, command) - const foldersMade = scratchFolders(fixture.checkout).filter((folder) => existsSync(folder)) - const controlRun = runCommandThroughHook(fixture, "npm test") - - expect({ run, foldersMade, controlStatus: controlRun.status }).toEqual({ - run: LET_THROUGH, - foldersMade: [], - controlStatus: 2, - }) - }) - - it("lets a payload that is not JSON through", () => { - const fixture = createHookFixture() - - const run = runHook({ fixture, stdin: "{not json" }) - const controlRun = runCommandThroughHook(fixture, "npm test") - - expect({ run, controlStatus: controlRun.status }).toEqual({ - run: LET_THROUGH, - controlStatus: 2, - }) - }) - - it.each([ - { label: "npm run test:coverage", command: "npm run test:coverage" }, - { label: "vitest --coverage", command: "npx vitest run --coverage" }, - ])("adds the coverage hint to the reply for $label", ({ command }) => { - const coverageHint = - "For a coverage run, also pass --coverage.clean=false (after -- for npm run): vitest otherwise deletes coverage/ first, which nobody cannot do in a root-owned checkout.\n" - const fixture = createHookFixture() - - const run = runCommandThroughHook(fixture, command) - - expect(run).toEqual({ - status: 2, - stdout: "", - stderr: `${expectedRefusal(fixture.checkout)}${coverageHint}`, - }) - }) - - it("leaves the coverage hint out when only an exempt suite's run asks for coverage", () => { - const fixture = createHookFixture() - - const run = runCommandThroughHook( - fixture, - "npx vitest run --config vitest.cli-pty.config.ts --coverage; npm test", - ) - - expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) - }) - - it("adds a line to the reply when nobody's node is another major version than .nvmrc", () => { - const fixture = createHookFixture() - - const run = runHook({ - fixture, - stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), - nobodyNodeVersion: "v22.22.0", - }) - - expect(run).toEqual({ - status: 2, - stdout: "", - stderr: `${expectedRefusal(fixture.checkout)}nobody's node is v22.22.0, not the 24 that .nvmrc names and CI runs, so a result can differ from CI's.\n`, - }) - }) - - it("lets a test run through, preparing nothing, when nobody finds no node", () => { - const fixture = createHookFixture() - const stdin = payloadFor({ command: "npm test", cwd: fixture.checkout }) - - const run = runHook({ fixture, stdin, nobodyHasNode: false }) - const foldersMade = scratchFolders(fixture.checkout).filter((folder) => existsSync(folder)) - const controlRun = runHook({ fixture, stdin }) - - expect({ run, foldersMade, controlStatus: controlRun.status }).toEqual({ - run: LET_THROUGH, - foldersMade: [], - controlStatus: 2, - }) - }) - - it("quotes the HOME path in the prefix when the checkout's path holds a space", () => { - const fixture = createHookFixture() - const spacedCheckout = createOtherCheckout(fixture, "spaced checkout") - - const run = runHook({ - fixture, - stdin: payloadFor({ command: "npm test", cwd: spacedCheckout }), - }) - - expect(run).toEqual({ - status: 2, - stdout: "", - stderr: expectedRefusal(spacedCheckout, { - nobodyHomeInPrefix: `${spacedCheckout.replaceAll(" ", "\\ ")}/node_modules/.nobody-home`, - }), - }) - }) - - it("compares the major versions when .nvmrc ends its line with CRLF", () => { - const fixture = createHookFixture() - writeFileSync(join(fixture.checkout, ".nvmrc"), `${NVMRC_VERSION}\r\n`) - - const run = runHook({ - fixture, - stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), - nobodyNodeVersion: "v22.22.0", - }) - - expect(run).toEqual({ - status: 2, - stdout: "", - stderr: `${expectedRefusal(fixture.checkout)}nobody's node is v22.22.0, not the 24 that .nvmrc names and CI runs, so a result can differ from CI's.\n`, - }) - }) - - it("leaves the version line out when .nvmrc names an alias rather than a version", () => { - const fixture = createHookFixture() - writeFileSync(join(fixture.checkout, ".nvmrc"), "lts/*\n") - - const run = runHook({ - fixture, - stdin: payloadFor({ command: "npm test", cwd: fixture.checkout }), - nobodyNodeVersion: "v22.22.0", - }) - - expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) - }) - - it("creates the four scratch folders a test run writes", () => { - const fixture = createHookFixture() - - const run = runCommandThroughHook(fixture, "npm test") - const missingFolders = scratchFolders(fixture.checkout).filter( - (folder) => !existsSync(folder), - ) - - expect({ status: run.status, missingFolders }).toEqual({ status: 2, missingFolders: [] }) - }) - - it("gives the scratch folders to nobody", () => { - const fixture = createHookFixture() - const { uid, gid } = runnerIds() - - const run = runCommandThroughHook(fixture, "npm test") - - expect({ status: run.status, chownCalls: recordedChownCalls(fixture) }).toEqual({ - status: 2, - chownCalls: [`-R ${uid}:${gid} ${scratchFolders(fixture.checkout).join(" ")}`], - }) - }) - - it("gives the tool-surface snapshot folder to nobody along with the scratch folders", () => { - const fixture = createHookFixture() - const { uid, gid } = runnerIds() - const snapshotsFolder = join(fixture.checkout, SNAPSHOTS_FOLDER) - mkdirSync(join(snapshotsFolder, "tool-surface"), { recursive: true }) - - const run = runCommandThroughHook(fixture, "npm test") - const nobodyOwnedFolders = [...scratchFolders(fixture.checkout), snapshotsFolder] - - expect({ status: run.status, chownCalls: recordedChownCalls(fixture) }).toEqual({ - status: 2, - chownCalls: [`-R ${uid}:${gid} ${nobodyOwnedFolders.join(" ")}`], - }) - }) - - it("closes the scratch folders, and files left inside them, to group and other writes", () => { - const fixture = createHookFixture() - const folders = scratchFolders(fixture.checkout) - for (const folder of folders) { - mkdirSync(folder, { recursive: true }) - chmodSync(folder, 0o777) - } - // A file an earlier root run left behind. - const leftoverFile = join(fixture.checkout, "node_modules", ".vitest", "results.json") - writeFileSync(leftoverFile, "{}") - chmodSync(leftoverFile, 0o666) - - const run = runCommandThroughHook(fixture, "npm test") - - expect({ - status: run.status, - folderModes: folders.map(permissionBits), - leftoverFileMode: permissionBits(leftoverFile), - }).toEqual({ - status: 2, - folderModes: [0o755, 0o755, 0o755, 0o755], - leftoverFileMode: 0o644, - }) - }) - - it("closes the tool-surface snapshot folder, and the files in it, to group and other writes", () => { - const fixture = createHookFixture() - const snapshotFolder = join(fixture.checkout, SNAPSHOTS_FOLDER, "tool-surface") - const snapshotFile = join(snapshotFolder, "default.json") - mkdirSync(snapshotFolder, { recursive: true }) - chmodSync(snapshotFolder, 0o777) - writeFileSync(snapshotFile, "{}") - chmodSync(snapshotFile, 0o666) - - const run = runCommandThroughHook(fixture, "npm test") - - expect({ - status: run.status, - folderMode: permissionBits(snapshotFolder), - fileMode: permissionBits(snapshotFile), - }).toEqual({ status: 2, folderMode: 0o755, fileMode: 0o644 }) - }) - - it("prepares the checkout a leading cd names, not the session's folder", () => { - const fixture = createHookFixture() - - const run = runHook({ - fixture, - stdin: payloadFor({ - command: `cd ${fixture.checkout} && npm test`, - cwd: fixture.outsideDir, - }), - }) - // Without the cd, the session's folder is in no checkout. - const controlRun = runHook({ - fixture, - stdin: payloadFor({ command: "npm test", cwd: fixture.outsideDir }), - }) - - expect({ run, controlStatus: controlRun.status }).toEqual({ - run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, - controlStatus: 0, - }) - }) - - it.each([ - { - label: "after another command", - command: (checkout: string) => `git pull && cd ${checkout} && npm test`, - }, - { - label: "on an earlier line", - command: (checkout: string) => `set -e\ncd ${checkout}\nnpm test`, - }, - { - label: "after a cd to a folder in no checkout", - command: (checkout: string, outsideDir: string) => { - return `cd ${outsideDir} && cd ${checkout} && npm test` - }, - }, - { - label: "before the first of two runs", - command: (checkout: string, outsideDir: string) => { - return `cd ${checkout} && npm test; cd ${outsideDir} && npm test` - }, - }, - { - label: "in double quotes", - command: (checkout: string) => `cd "${checkout}" && npm test`, - }, - { - label: "in single quotes after an unresolvable cd", - command: (checkout: string) => `cd ~ && cd '${checkout}' && npm test`, - }, - { - label: "with pushd", - command: (checkout: string) => `pushd ${checkout} && npm test`, - }, - { - label: "inside a brace group", - command: (checkout: string) => `{ cd ${checkout}; npm test; }`, - }, - { - label: "inside an if block", - command: (checkout: string) => `if true; then cd ${checkout}; fi; npm test`, - }, - { - label: "inside a bash -c body run through env", - command: (checkout: string) => `env bash -c 'cd ${checkout} && npm test'`, - }, - { - label: "after a variable setting", - command: (checkout: string) => `CI=1 cd ${checkout} && npm test`, - }, - { - label: "inside a bash -c body", - command: (checkout: string) => `bash -c 'cd ${checkout} && npm test'`, - }, - { - label: "inside a bash -c body that opens with a space", - command: (checkout: string) => `bash -c ' cd ${checkout} && npm test'`, - }, - { - label: "inside a bash -c body after a flag group that takes an argument", - command: (checkout: string) => `bash -euo pipefail -c 'cd ${checkout} && npm test'`, - }, - ])("prepares the checkout an absolute cd names $label", ({ command }) => { - const fixture = createHookFixture() - - const run = runHook({ - fixture, - stdin: payloadFor({ - command: command(fixture.checkout, fixture.outsideDir), - cwd: fixture.outsideDir, - }), - }) - - expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) - }) - - it("prepares the checkout a relative cd reaches from the folder an earlier cd names", () => { - const fixture = createHookFixture() - mkdirSync(join(fixture.checkout, "src")) - // The parent folder is in no checkout, so the run reaches the checkout - // only when the hook joins the relative path onto the parent. - const checkoutParent = dirname(fixture.checkout) - const relativeSubfolder = join(basename(fixture.checkout), "src") - - const run = runHook({ - fixture, - stdin: payloadFor({ - command: `cd ${checkoutParent} && cd ${relativeSubfolder} && npm test`, - cwd: fixture.outsideDir, - }), - }) - - expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) - }) - - it.each([ - { label: "a ~ path", target: "~" }, - { label: "a variable", target: "$HOME" }, - { label: "a double-quoted path", target: '"src"' }, - { label: "a single-quoted path", target: "'src'" }, - { label: "the previous folder", target: "-" }, - ])( - "prepares the session's checkout for a cd to $label, which only the shell can resolve", - ({ target }) => { - const fixture = createHookFixture() - const otherCheckout = createOtherCheckout(fixture) - // A folder named with the target's literal text: a hook that read the - // target as a relative path would land in the other checkout. - mkdirSync(join(otherCheckout, target)) - - const run = runCommandThroughHook( - fixture, - `cd ${otherCheckout} && cd ${target} && npm test`, - ) - // The first cd alone moves the run to the other checkout, so the run - // above reaches the session's checkout through the second cd, not - // through a hook that ignores every cd. - const controlRun = runCommandThroughHook(fixture, `cd ${otherCheckout} && npm test`) - - expect({ run, controlRun }).toEqual({ - run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, - controlRun: { status: 2, stdout: "", stderr: expectedRefusal(otherCheckout) }, - }) - }, - ) - - it("prepares the session's checkout for a cd followed only by a comment, which bash runs as a bare cd", () => { - const fixture = createHookFixture() - const otherCheckout = createOtherCheckout(fixture) - // A folder named #: a hook that read the comment as a relative path - // would land in the other checkout. - mkdirSync(join(otherCheckout, "#")) - - const run = runCommandThroughHook(fixture, `cd ${otherCheckout}\ncd # back home\nnpm test`) - // The first cd alone moves the run to the other checkout, so the run - // above reaches the session's checkout through the commented cd. - const controlRun = runCommandThroughHook(fixture, `cd ${otherCheckout}\nnpm test`) - - expect({ run, controlRun }).toEqual({ - run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, - controlRun: { status: 2, stdout: "", stderr: expectedRefusal(otherCheckout) }, - }) - }) - - it("prepares the checkout of the first stopped run, not of an exempt run before it", () => { - const fixture = createHookFixture() - const otherCheckout = createOtherCheckout(fixture) - - const run = runCommandThroughHook( - fixture, - `npx vitest run --config vitest.cli-pty.config.ts && cd ${otherCheckout} && npm test`, - ) - - expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(otherCheckout) }) - }) - - it("prepares the session's checkout, not npm's --prefix folder", () => { - const fixture = createHookFixture() - const otherCheckout = createOtherCheckout(fixture) - - const run = runCommandThroughHook(fixture, `npm --prefix ${otherCheckout} test`) - // A cd to the other checkout moves the run there, so the fixture can - // tell the two checkouts apart. - const controlRun = runCommandThroughHook(fixture, `cd ${otherCheckout} && npm test`) - - expect({ run, controlRun }).toEqual({ - run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, - controlRun: { status: 2, stdout: "", stderr: expectedRefusal(otherCheckout) }, - }) - }) - - it("stops a test run thousands of commands after the cd that names its checkout", () => { - const fixture = createHookFixture() - const otherCommands = Array.from({ length: 5000 }, (_, lineIndex) => `echo line ${lineIndex}`) - const command = [`cd ${fixture.checkout}`, ...otherCommands, "npm test"].join("\n") - - const run = runHook({ fixture, stdin: payloadFor({ command, cwd: fixture.outsideDir }) }) - - expect(run).toEqual({ status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }) - }) - - it.each([ - { - label: "a cd after the test run", - command: (checkout: string) => `npm test; cd ${checkout}`, - }, - { - label: "a relative cd out of the checkout", - command: (checkout: string) => `cd ${checkout} && cd .. && npm test`, - }, - { - label: "a quoted cd holding an expansion after an absolute one", - command: (checkout: string) => `cd ${checkout} && cd "$PWD" && npm test`, - }, - { - label: "a popd after an absolute cd", - command: (checkout: string) => `cd ${checkout} && popd && npm test`, - }, - ])("lets a test run through from a folder in no checkout despite $label", ({ command }) => { - const fixture = createHookFixture() - const stdin = payloadFor({ command: command(fixture.checkout), cwd: fixture.outsideDir }) - - const run = runHook({ fixture, stdin }) - const foldersMade = scratchFolders(fixture.checkout).filter((folder) => existsSync(folder)) - // The same cd at the start stops the run, so the pass-through comes from - // the cd's position or form. - const controlRun = runHook({ - fixture, - stdin: payloadFor({ - command: `cd ${fixture.checkout} && npm test`, - cwd: fixture.outsideDir, - }), - }) - - expect({ run, foldersMade, controlStatus: controlRun.status }).toEqual({ - run: LET_THROUGH, - foldersMade: [], - controlStatus: 2, - }) - }) - - it("uses CLAUDE_PROJECT_DIR when the payload has no cwd", () => { - const fixture = createHookFixture() - const stdin = payloadFor({ command: "npm test" }) - - const run = runHook({ fixture, stdin, claudeProjectDir: fixture.checkout }) - // Without it, the hook falls back to its own working directory, which is - // in no checkout. - const controlRun = runHook({ fixture, stdin }) - - expect({ run, controlStatus: controlRun.status }).toEqual({ - run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, - controlStatus: 0, - }) - }) - - it("lets a test run through, creating nothing, in a checkout without node_modules", () => { - const fixture = createHookFixture() - const nodeModules = join(fixture.checkout, "node_modules") - rmSync(nodeModules, { recursive: true }) - - const run = runCommandThroughHook(fixture, "npm test") - const nodeModulesCreated = existsSync(nodeModules) - mkdirSync(nodeModules) - const controlRun = runCommandThroughHook(fixture, "npm test") - - expect({ run, nodeModulesCreated, controlStatus: controlRun.status }).toEqual({ - run: LET_THROUGH, - nodeModulesCreated: false, - controlStatus: 2, - }) - }) - - it("prepares the session's checkout when a leading cd names a folder in no checkout", () => { - const fixture = createHookFixture() - - const run = runCommandThroughHook(fixture, `cd ${fixture.outsideDir} && npm test`) - const missingFolders = scratchFolders(fixture.checkout).filter( - (folder) => !existsSync(folder), - ) - - expect({ run, missingFolders }).toEqual({ - run: { status: 2, stdout: "", stderr: expectedRefusal(fixture.checkout) }, - missingFolders: [], - }) - }) - - it("lets a test run through, preparing nothing, when setpriv is missing", () => { - const fixture = createHookFixture() - const stdin = payloadFor({ command: "npm test", cwd: fixture.checkout }) - - const run = runHook({ fixture, stdin, path: createBinDirWithoutSetpriv(fixture) }) - const foldersMade = scratchFolders(fixture.checkout).filter((folder) => existsSync(folder)) - const controlRun = runHook({ fixture, stdin }) - - expect({ run, foldersMade, controlStatus: controlRun.status }).toEqual({ - run: LET_THROUGH, - foldersMade: [], - controlStatus: 2, - }) - }) - - it("lets a test run through when there is no nobody user", () => { - const fixture = createHookFixture() - const stdin = payloadFor({ command: "npm test", cwd: fixture.checkout }) - - const run = runHook({ fixture, stdin, nobodyExists: false }) - const controlRun = runHook({ fixture, stdin }) - - expect({ run, controlStatus: controlRun.status }).toEqual({ - run: LET_THROUGH, - controlStatus: 2, - }) - }) - }) - - it("lets a test run through in a non-root session without preparing folders", () => { - const fixture = createHookFixture() - const stdin = payloadFor({ command: "npm test", cwd: fixture.checkout }) - - const run = runHook({ fixture, stdin, sessionUid: 1000 }) - const foldersMade = scratchFolders(fixture.checkout).filter((folder) => existsSync(folder)) - const controlRun = runHook({ fixture, stdin, sessionUid: 0 }) - - expect({ run, foldersMade, controlStatus: controlRun.status }).toEqual({ - run: LET_THROUGH, - foldersMade: [], - controlStatus: 2, - }) - }) -}) diff --git a/src/__tests__/runs-as-root.ts b/src/__tests__/runs-as-root.ts new file mode 100644 index 000000000..f118da8fa --- /dev/null +++ b/src/__tests__/runs-as-root.ts @@ -0,0 +1,3 @@ +/** Root ignores file permissions, so a test that denies access and expects the + * access to fail can't pass as root. Skip it there; CI runs as a normal user. */ +export const runsAsRoot = process.getuid?.() === 0 diff --git a/src/utils/__tests__/fs.test.ts b/src/utils/__tests__/fs.test.ts index 40cbb56a7..09c9a95a5 100644 --- a/src/utils/__tests__/fs.test.ts +++ b/src/utils/__tests__/fs.test.ts @@ -14,6 +14,7 @@ import { lstatOrNull, statOrNull, } from "../fs.js" +import { runsAsRoot } from "../../__tests__/runs-as-root.js" const makeTempDir = async (): Promise => { const dir = await mkdtemp(join(tmpdir(), "utils-fs-test-")) @@ -62,11 +63,14 @@ describe("readFileOrNull", () => { expect(await readFileOrNull(dir)).toBeNull() }) - it("rethrows a permission error rather than swallowing it as missing", async () => { - const dir = await makeTempDir() - const lockedDir = await makeUnreadableDir(dir) - await expect(readFileOrNull(join(lockedDir, "inner.md"))).rejects.toThrow(/EACCES/) - }) + it.skipIf(runsAsRoot)( + "rethrows a permission error rather than swallowing it as missing", + async () => { + const dir = await makeTempDir() + const lockedDir = await makeUnreadableDir(dir) + await expect(readFileOrNull(join(lockedDir, "inner.md"))).rejects.toThrow(/EACCES/) + }, + ) }) describe("readdirOrNull", () => { @@ -132,7 +136,7 @@ describe("statOrNull", () => { expect(await statOrNull(join(filePath, "child"))).toBeNull() }) - it("rethrows a permission error rather than swallowing it", async () => { + it.skipIf(runsAsRoot)("rethrows a permission error rather than swallowing it", async () => { const dir = await makeTempDir() const lockedDir = await makeUnreadableDir(dir) await expect(statOrNull(join(lockedDir, "inner.md"))).rejects.toThrow(/EACCES/) @@ -169,7 +173,7 @@ describe("lstatOrNull", () => { expect(await lstatOrNull(join(filePath, "child"))).toBeNull() }) - it("rethrows a permission error rather than swallowing it", async () => { + it.skipIf(runsAsRoot)("rethrows a permission error rather than swallowing it", async () => { const dir = await makeTempDir() const lockedDir = await makeUnreadableDir(dir) await expect(lstatOrNull(join(lockedDir, "inner.md"))).rejects.toThrow(/EACCES/) @@ -199,7 +203,7 @@ describe("realpathOrNull", () => { expect(await realpathOrNull(join(filePath, "child"))).toBeNull() }) - it("rethrows a permission error rather than swallowing it", async () => { + it.skipIf(runsAsRoot)("rethrows a permission error rather than swallowing it", async () => { const dir = await makeTempDir() const lockedDir = await makeUnreadableDir(dir) await expect(realpathOrNull(join(lockedDir, "inner.md"))).rejects.toThrow(/EACCES/) @@ -226,9 +230,12 @@ describe("fileExists", () => { expect(await fileExists(join(filePath, "child.md"))).toBe(false) }) - it("rethrows a permission error rather than reporting the path missing", async () => { - const dir = await makeTempDir() - const lockedDir = await makeUnreadableDir(dir) - await expect(fileExists(join(lockedDir, "inner.md"))).rejects.toThrow(/EACCES/) - }) + it.skipIf(runsAsRoot)( + "rethrows a permission error rather than reporting the path missing", + async () => { + const dir = await makeTempDir() + const lockedDir = await makeUnreadableDir(dir) + await expect(fileExists(join(lockedDir, "inner.md"))).rejects.toThrow(/EACCES/) + }, + ) }) diff --git a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts index 9b429ea35..b275738b4 100644 --- a/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/memory-store.test.ts @@ -19,6 +19,7 @@ import { } from "../../obsidian-markdown/frontmatter.js" import { createMemoryStore } from "../memory-store.js" import { logger } from "../../../logger.js" +import { runsAsRoot } from "../../../__tests__/runs-as-root.js" vi.mock("node:fs/promises", { spy: true }) const createBlockedMemoryReads = async (vaultPath: string) => { @@ -176,14 +177,17 @@ describe("getMemory", () => { expect(await getMemory({ vaultPath: vault }, logger)).toBe(withoutSymlink) }) - it("rejects an unreadable memory file with a vault-relative message in the all-files read", async () => { - // afterEach deletes the vault, which removes a mode-000 file inside it, so - // the permissions need no restore. - await chmod(join(vault, "About Me", "Opinions.md"), 0o000) - await expect(getMemory({ vaultPath: vault }, logger)).rejects.toThrow( - new Error('cannot read memory file "About Me/Opinions.md"'), - ) - }) + it.skipIf(runsAsRoot)( + "rejects an unreadable memory file with a vault-relative message in the all-files read", + async () => { + // afterEach deletes the vault, which removes a mode-000 file inside it, so + // the permissions need no restore. + await chmod(join(vault, "About Me", "Opinions.md"), 0o000) + await expect(getMemory({ vaultPath: vault }, logger)).rejects.toThrow( + new Error('cannot read memory file "About Me/Opinions.md"'), + ) + }, + ) it("concatenates all memory files when no file specified", async () => { const result = await getMemory({ vaultPath: vault }, logger) @@ -271,23 +275,26 @@ describe("getMemory", () => { ) }) - it("rejects an unreadable memory file with a vault-relative message in the named read and logs the raw error", async () => { - const lockedFile = join(vault, "About Me", "Opinions.md") - // afterEach deletes the vault, which removes a mode-000 file inside it, so - // the permissions need no restore. - await chmod(lockedFile, 0o000) - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}) - onTestFinished(() => warnSpy.mockRestore()) + it.skipIf(runsAsRoot)( + "rejects an unreadable memory file with a vault-relative message in the named read and logs the raw error", + async () => { + const lockedFile = join(vault, "About Me", "Opinions.md") + // afterEach deletes the vault, which removes a mode-000 file inside it, so + // the permissions need no restore. + await chmod(lockedFile, 0o000) + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}) + onTestFinished(() => warnSpy.mockRestore()) - await expect(getMemory({ vaultPath: vault, file: "Opinions" }, logger)).rejects.toThrow( - new Error('cannot read memory file "About Me/Opinions.md"'), - ) - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith("cannot read memory file", { - file: "Opinions.md", - error: `[Error]: EACCES: permission denied, open '${lockedFile}'`, - }) - }) + await expect(getMemory({ vaultPath: vault, file: "Opinions" }, logger)).rejects.toThrow( + new Error('cannot read memory file "About Me/Opinions.md"'), + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("cannot read memory file", { + file: "Opinions.md", + error: `[Error]: EACCES: permission denied, open '${lockedFile}'`, + }) + }, + ) it("throws on non-existent section", async () => { await expect( @@ -565,23 +572,26 @@ created: 2026-01-01T00:00:00-05:00 expect(result).toBe("- **2026-05-15**: first entry") }) - it("rejects an unreadable memory file with a vault-relative message", async () => { - // afterEach deletes the vault, which removes a mode-000 file inside it, so - // the permissions need no restore. - await chmod(join(vault, "About Me", "Opinions.md"), 0o000) - await expect( - updateMemory( - { - vaultPath: vault, - file: "Opinions", - section: "Code patterns", - entry: "never written", - date: "2026-05-15", - }, - logger, - ), - ).rejects.toThrow(new Error('cannot read memory file "About Me/Opinions.md"')) - }) + it.skipIf(runsAsRoot)( + "rejects an unreadable memory file with a vault-relative message", + async () => { + // afterEach deletes the vault, which removes a mode-000 file inside it, so + // the permissions need no restore. + await chmod(join(vault, "About Me", "Opinions.md"), 0o000) + await expect( + updateMemory( + { + vaultPath: vault, + file: "Opinions", + section: "Code patterns", + entry: "never written", + date: "2026-05-15", + }, + logger, + ), + ).rejects.toThrow(new Error('cannot read memory file "About Me/Opinions.md"')) + }, + ) it("leaves a symlink to a folder in place when it has the memory file's name", async () => { await mkdir(join(vault, "linked-folder")) @@ -1874,15 +1884,18 @@ describe("listMemoryFiles", () => { expect(outlines.map((outline) => outline.file)).toEqual(["Opinions", "Principles"]) }) - it("rejects an unreadable memory file with a vault-relative message", async () => { - const lockedFile = join(vault, "About Me", "Opinions.md") - // afterEach deletes the vault, which removes a mode-000 file inside it, so - // the permissions need no restore. - await chmod(lockedFile, 0o000) - await expect(listMemoryFiles({ vaultPath: vault }, logger)).rejects.toThrow( - new Error('cannot read memory file "About Me/Opinions.md"'), - ) - }) + it.skipIf(runsAsRoot)( + "rejects an unreadable memory file with a vault-relative message", + async () => { + const lockedFile = join(vault, "About Me", "Opinions.md") + // afterEach deletes the vault, which removes a mode-000 file inside it, so + // the permissions need no restore. + await chmod(lockedFile, 0o000) + await expect(listMemoryFiles({ vaultPath: vault }, logger)).rejects.toThrow( + new Error('cannot read memory file "About Me/Opinions.md"'), + ) + }, + ) it("returns outlines sorted by filename", async () => { const outlines = await listMemoryFiles({ vaultPath: vault }, logger) @@ -2123,27 +2136,30 @@ describe("listMemoryFileNames", () => { ).rejects.toThrow(new Error('cannot list memory folder "About Me"')) }) - it("rejects an unreadable memory folder with a vault-relative message and logs the raw error", async () => { - const vaultWithLockedMemoryFolder = await mkdtemp(join(tmpdir(), "locked-memory-folder-")) - onTestFinished(() => rm(vaultWithLockedMemoryFolder, { recursive: true })) - const memoryFolder = join(vaultWithLockedMemoryFolder, "About Me") - await mkdir(memoryFolder) - await chmod(memoryFolder, 0o000) - // Registered after the removal so that it runs first, which makes the - // folder readable again before the vault is removed. - onTestFinished(() => chmod(memoryFolder, 0o700)) - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}) - onTestFinished(() => warnSpy.mockRestore()) + it.skipIf(runsAsRoot)( + "rejects an unreadable memory folder with a vault-relative message and logs the raw error", + async () => { + const vaultWithLockedMemoryFolder = await mkdtemp(join(tmpdir(), "locked-memory-folder-")) + onTestFinished(() => rm(vaultWithLockedMemoryFolder, { recursive: true })) + const memoryFolder = join(vaultWithLockedMemoryFolder, "About Me") + await mkdir(memoryFolder) + await chmod(memoryFolder, 0o000) + // Registered after the removal so that it runs first, which makes the + // folder readable again before the vault is removed. + onTestFinished(() => chmod(memoryFolder, 0o700)) + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}) + onTestFinished(() => warnSpy.mockRestore()) - await expect( - listMemoryFileNames({ vaultPath: vaultWithLockedMemoryFolder }, logger), - ).rejects.toThrow(new Error('cannot list memory folder "About Me"')) - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith("cannot list memory folder", { - folder: "About Me", - error: `[Error]: EACCES: permission denied, scandir '${memoryFolder}'`, - }) - }) + await expect( + listMemoryFileNames({ vaultPath: vaultWithLockedMemoryFolder }, logger), + ).rejects.toThrow(new Error('cannot list memory folder "About Me"')) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("cannot list memory folder", { + folder: "About Me", + error: `[Error]: EACCES: permission denied, scandir '${memoryFolder}'`, + }) + }, + ) }) describe("custom memoryDir", () => { diff --git a/src/vault-mcp/vault-operations/__tests__/trash-sweeper.test.ts b/src/vault-mcp/vault-operations/__tests__/trash-sweeper.test.ts index 32cca1beb..84086379f 100644 --- a/src/vault-mcp/vault-operations/__tests__/trash-sweeper.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/trash-sweeper.test.ts @@ -24,6 +24,7 @@ import { readTrashFileState, vaultFs } from "../vault-filesystem.js" import { createSearchIndex } from "../../search/search-index.js" import type { TrashEntry, TrashEntryStore } from "../../search/search-index.js" import { logger } from "../../../logger.js" +import { runsAsRoot } from "../../../__tests__/runs-as-root.js" /** A temp vault with a .trash/ folder, removed when the test finishes. */ const createTestVault = async (): Promise => { @@ -437,57 +438,65 @@ describe("sweepExpiredTrashEntries", () => { }) }) - it("keeps the row and warns when realpath fails with a non-ENOENT error", async () => { - const vault = await createTestVault() - const lockedDir = join(vault, ".trash", "noaccess") - await mkdir(lockedDir, { recursive: true }) - await writeFile(join(lockedDir, "stuck.md"), "perm error", "utf8") - const index = createSearchIndex(":memory:") - index.recordTrashEntry(await recordedTrashFileAt(vault, ".trash/noaccess/stuck.md")) - // Remove all permissions from .trash/ so realpath on the parent fails. - await chmod(join(vault, ".trash"), 0o000) - onTestFinished(() => chmod(join(vault, ".trash"), 0o755)) - const warnSpy = vi.spyOn(logger, "warn") - onTestFinished(() => warnSpy.mockRestore()) + it.skipIf(runsAsRoot)( + "keeps the row and warns when realpath fails with a non-ENOENT error", + async () => { + const vault = await createTestVault() + const lockedDir = join(vault, ".trash", "noaccess") + await mkdir(lockedDir, { recursive: true }) + await writeFile(join(lockedDir, "stuck.md"), "perm error", "utf8") + const index = createSearchIndex(":memory:") + index.recordTrashEntry(await recordedTrashFileAt(vault, ".trash/noaccess/stuck.md")) + // Remove all permissions from .trash/ so realpath on the parent fails. + await chmod(join(vault, ".trash"), 0o000) + onTestFinished(() => chmod(join(vault, ".trash"), 0o755)) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) - await sweepAfterRetention(vault, index) + await sweepAfterRetention(vault, index) - expect(index.getTrashEntry(".trash/noaccess/stuck.md")?.trashPath).toBe( - ".trash/noaccess/stuck.md", - ) - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith("failed to resolve trash entry path", { - trashPath: ".trash/noaccess/stuck.md", - error: `[Error]: EACCES: permission denied, realpath '${lockedDir}'`, - }) - }) + expect(index.getTrashEntry(".trash/noaccess/stuck.md")?.trashPath).toBe( + ".trash/noaccess/stuck.md", + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("failed to resolve trash entry path", { + trashPath: ".trash/noaccess/stuck.md", + error: `[Error]: EACCES: permission denied, realpath '${lockedDir}'`, + }) + }, + ) - it("keeps the row and warns when unlink fails with a non-ENOENT error", async () => { - // A permission error during unlink keeps the row so the next sweep - // retries; one bad row never aborts the sweep. - const vault = await createTestVault() - const lockedDir = join(vault, ".trash", "locked") - await mkdir(lockedDir, { recursive: true }) - await writeFile(join(lockedDir, "stuck.md"), "perm error", "utf8") - const index = createSearchIndex(":memory:") - index.recordTrashEntry(await recordedTrashFileAt(vault, ".trash/locked/stuck.md")) - // Remove write permission from the parent so unlink fails with EACCES. - await chmod(lockedDir, 0o555) - onTestFinished(() => chmod(lockedDir, 0o755)) - const warnSpy = vi.spyOn(logger, "warn") - onTestFinished(() => warnSpy.mockRestore()) + it.skipIf(runsAsRoot)( + "keeps the row and warns when unlink fails with a non-ENOENT error", + async () => { + // A permission error during unlink keeps the row so the next sweep + // retries; one bad row never aborts the sweep. + const vault = await createTestVault() + const lockedDir = join(vault, ".trash", "locked") + await mkdir(lockedDir, { recursive: true }) + await writeFile(join(lockedDir, "stuck.md"), "perm error", "utf8") + const index = createSearchIndex(":memory:") + index.recordTrashEntry(await recordedTrashFileAt(vault, ".trash/locked/stuck.md")) + // Remove write permission from the parent so unlink fails with EACCES. + await chmod(lockedDir, 0o555) + onTestFinished(() => chmod(lockedDir, 0o755)) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) - await sweepAfterRetention(vault, index) + await sweepAfterRetention(vault, index) - expect(index.getTrashEntry(".trash/locked/stuck.md")?.trashPath).toBe(".trash/locked/stuck.md") - const content = await readFile(join(lockedDir, "stuck.md"), "utf8") - expect(content).toBe("perm error") - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith("failed to remove expired trash entry", { - trashPath: ".trash/locked/stuck.md", - error: `[Error]: EACCES: permission denied, unlink '${join(lockedDir, "stuck.md")}'`, - }) - }) + expect(index.getTrashEntry(".trash/locked/stuck.md")?.trashPath).toBe( + ".trash/locked/stuck.md", + ) + const content = await readFile(join(lockedDir, "stuck.md"), "utf8") + expect(content).toBe("perm error") + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("failed to remove expired trash entry", { + trashPath: ".trash/locked/stuck.md", + error: `[Error]: EACCES: permission denied, unlink '${join(lockedDir, "stuck.md")}'`, + }) + }, + ) it("an unrecorded delete clears a stale row at its landed path before any sweep runs", async () => { // A row can outlive its file in .trash/. A keep-forever "local" delete @@ -736,34 +745,37 @@ describe("sweepExpiredTrashEntries", () => { }) }) - it("keeps the row and warns when the identity read fails with a non-ENOENT error", async () => { - const vault = await createTestVault() - const index = createSearchIndex(":memory:") - const lockedDir = join(vault, ".trash", "nosearch") - await mkdir(lockedDir) - await writeFile(join(lockedDir, "stuck.md"), "stays", "utf8") - index.recordTrashEntry(await recordedTrashFileAt(vault, ".trash/nosearch/stuck.md")) - // Without search permission the folder itself still resolves, so the - // containment gate passes, but no entry inside it can be looked up. - await chmod(lockedDir, 0o600) - onTestFinished(() => chmod(lockedDir, 0o755)) - const warnSpy = vi.spyOn(logger, "warn") - onTestFinished(() => warnSpy.mockRestore()) + it.skipIf(runsAsRoot)( + "keeps the row and warns when the identity read fails with a non-ENOENT error", + async () => { + const vault = await createTestVault() + const index = createSearchIndex(":memory:") + const lockedDir = join(vault, ".trash", "nosearch") + await mkdir(lockedDir) + await writeFile(join(lockedDir, "stuck.md"), "stays", "utf8") + index.recordTrashEntry(await recordedTrashFileAt(vault, ".trash/nosearch/stuck.md")) + // Without search permission the folder itself still resolves, so the + // containment gate passes, but no entry inside it can be looked up. + await chmod(lockedDir, 0o600) + onTestFinished(() => chmod(lockedDir, 0o755)) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) - await sweepAfterRetention(vault, index) + await sweepAfterRetention(vault, index) - expect(index.getTrashEntry(".trash/nosearch/stuck.md")?.trashPath).toBe( - ".trash/nosearch/stuck.md", - ) - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith("failed to read trash entry identity", { - trashPath: ".trash/nosearch/stuck.md", - error: `[Error]: EACCES: permission denied, lstat '${join(lockedDir, "stuck.md")}'`, - }) - await chmod(lockedDir, 0o755) - const stuckContent = await readFile(join(lockedDir, "stuck.md"), "utf8") - expect(stuckContent).toBe("stays") - }) + expect(index.getTrashEntry(".trash/nosearch/stuck.md")?.trashPath).toBe( + ".trash/nosearch/stuck.md", + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("failed to read trash entry identity", { + trashPath: ".trash/nosearch/stuck.md", + error: `[Error]: EACCES: permission denied, lstat '${join(lockedDir, "stuck.md")}'`, + }) + await chmod(lockedDir, 0o755) + const stuckContent = await readFile(join(lockedDir, "stuck.md"), "utf8") + expect(stuckContent).toBe("stays") + }, + ) it("prunes the folders an unlink empties, keeping .trash/ itself", async () => { const vault = await createTestVault() @@ -1349,31 +1361,36 @@ describe("purgeOrphanedTrashEntries", () => { }) }) - it("keeps the row and warns when stat fails with a non-ENOENT error", async () => { - const vault = await createTestVault() - const lockedDir = join(vault, ".trash", "locked") - await mkdir(lockedDir, { recursive: true }) - await writeFile(join(lockedDir, "stuck.md"), "perm error", "utf8") - const index = createSearchIndex(":memory:") - index.recordTrashEntry(await recordedTrashFileAt(vault, ".trash/locked/stuck.md")) - // Remove traverse permission from the parent so stat fails with EACCES. - await chmod(lockedDir, 0o000) - onTestFinished(() => chmod(lockedDir, 0o755)) - const warnSpy = vi.spyOn(logger, "warn") - onTestFinished(() => warnSpy.mockRestore()) + it.skipIf(runsAsRoot)( + "keeps the row and warns when stat fails with a non-ENOENT error", + async () => { + const vault = await createTestVault() + const lockedDir = join(vault, ".trash", "locked") + await mkdir(lockedDir, { recursive: true }) + await writeFile(join(lockedDir, "stuck.md"), "perm error", "utf8") + const index = createSearchIndex(":memory:") + index.recordTrashEntry(await recordedTrashFileAt(vault, ".trash/locked/stuck.md")) + // Remove traverse permission from the parent so stat fails with EACCES. + await chmod(lockedDir, 0o000) + onTestFinished(() => chmod(lockedDir, 0o755)) + const warnSpy = vi.spyOn(logger, "warn") + onTestFinished(() => warnSpy.mockRestore()) - await trashSweeper.purgeOrphanedTrashEntries( - { vaultPath: vault, trashEntryStore: index }, - logger, - ) + await trashSweeper.purgeOrphanedTrashEntries( + { vaultPath: vault, trashEntryStore: index }, + logger, + ) - expect(index.getTrashEntry(".trash/locked/stuck.md")?.trashPath).toBe(".trash/locked/stuck.md") - expect(warnSpy).toHaveBeenCalledTimes(1) - expect(warnSpy).toHaveBeenCalledWith("failed to stat trash entry", { - trashPath: ".trash/locked/stuck.md", - error: `[Error]: EACCES: permission denied, lstat '${join(lockedDir, "stuck.md")}'`, - }) - }) + expect(index.getTrashEntry(".trash/locked/stuck.md")?.trashPath).toBe( + ".trash/locked/stuck.md", + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith("failed to stat trash entry", { + trashPath: ".trash/locked/stuck.md", + error: `[Error]: EACCES: permission denied, lstat '${join(lockedDir, "stuck.md")}'`, + }) + }, + ) it("retains a traversal-path row when the target exists outside .trash/", async () => { const base = await mkdtemp(join(tmpdir(), "trash-traversal-"))