Skip to main content

max / makenotwork

Rebuild authorized_keys out of process, since the sandbox forbids sudo The unit bootstrap-node.sh writes implies NoNewPrivileges through eight of its own options, and NoNewPrivileges=no does not undo the implication, so the sudo -u git rebuild the SSH key routes spawned could not have run under it. The server now writes a marker into its StateDirectory and a root oneshot started by makenotwork-rebuild-keys.path does the write; every failure to file the request logs at warn rather than debug. Also: GIT_HOME gets the same ReadWritePaths treatment GIT_REPOS_PATH already had, the build SSH calls pass an explicit identity so ProtectHome=yes cannot leave them authenticating against nothing, mnw-admin setup-git removes the dead sudoers rule instead of installing it, and a seal holds the unit's provisioned paths and the binary's constants to the same values.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 16:48 UTC
Signed with PGP, not checked
Commit: 3c1bd78ad90d8851705eef0ec2c49193c597f764
Parent: c26dff4
7 files changed, +312 insertions, -54 deletions
@@ -54,6 +54,12 @@
54 54 ENV_FILE="${ENV_FILE:-$ETC_DIR/makenotwork.env}"
55 55 STATE_DIR="${STATE_DIR:-/var/lib/mnw}"
56 56 GIT_REPOS_PATH="${GIT_REPOS_PATH:-$STATE_DIR/git}"
57 + # The git user's home, where `mnw-admin rebuild-keys` writes
58 + # `.ssh/authorized_keys`. The server reads it from `GIT_HOME` independently of
59 + # `GIT_REPOS_PATH`, so it gets its own ReadWritePaths treatment below: a node
60 + # where the two differ and one falls outside STATE_DIR would otherwise get an
61 + # unwritable authorized_keys with no warning.
62 + GIT_HOME="${GIT_HOME:-$GIT_REPOS_PATH}"
57 63 BIN_NAME="${BIN_NAME:-makenotwork}"
58 64 SERVICE_NAME="${SERVICE_NAME:-makenotwork.service}"
59 65 SERVICE_USER="${SERVICE_USER:-deploy}"
@@ -181,10 +187,18 @@
181 187 # above. Emit a second path only when the operator has put them elsewhere,
182 188 # so a sandbox that lists only STATE_DIR cannot silently break repository
183 189 # creation.
184 - case "$GIT_REPOS_PATH/" in
185 - "$STATE_DIR"/*) ;;
186 - *) printf '\nReadWritePaths=%s' "$GIT_REPOS_PATH" ;;
187 - esac
190 + emitted=""
191 + # GIT_HOME defaults to GIT_REPOS_PATH, so dedupe rather than emit twice.
192 + for extra in "$GIT_REPOS_PATH" "$GIT_HOME"; do
193 + case "$extra/" in
194 + "$STATE_DIR"/*) continue ;;
195 + esac
196 + case " $emitted " in
197 + *" $extra "*) continue ;;
198 + esac
199 + emitted="$emitted $extra"
200 + printf '\nReadWritePaths=%s' "$extra"
201 + done
188 202 )
189 203 Restart=on-failure
190 204 RestartSec=30
@@ -196,7 +210,11 @@
196 210 # adds it to ReadWritePaths. The path is mirrored in
197 211 # \`makenotwork::constants::SCAN_SPOOL_DIR\`, so it is /var/lib/makenotwork
198 212 # rather than \$STATE_DIR and the two names are not interchangeable.
199 - StateDirectory=makenotwork/scan-spool
213 + # The second entry is the drop-box for authorized_keys rebuild requests; see the
214 + # .path unit below. Named explicitly rather than relying on the parent of the
215 + # spool being writable. Both paths are mirrored in
216 + # \`makenotwork::constants\` (\`SCAN_SPOOL_DIR\`, \`KEYS_REBUILD_MARKER\`).
217 + StateDirectory=makenotwork/scan-spool makenotwork/keys
200 218 StateDirectoryMode=0700
201 219 # The ceilings server/docs/troubleshooting.md documents. Both are measured
202 220 # rather than chosen: prod's own cgroup reported 392M anonymous (unreclaimable)
@@ -243,8 +261,57 @@
243 261 [Install]
244 262 WantedBy=multi-user.target
245 263 EOF
264 +
265 + # The authorized_keys rebuild, out of the service's process tree.
266 + #
267 + # sshd's StrictModes refuses an authorized_keys whose file or .ssh directory is
268 + # group-writable, so the service user cannot be given write access to it, and
269 + # the sandbox above implies NoNewPrivileges (PrivateDevices, ProtectClock,
270 + # MemoryDenyWriteExecute, RestrictNamespaces, RestrictSUIDSGID, LockPersonality
271 + # and SystemCallFilter each imply it, and NoNewPrivileges=no does not undo the
272 + # implication), so sudo cannot raise privilege from inside the unit either.
273 + # Measured 2026-09-01: sudo under any one of those options exits 1 with "the
274 + # no new privileges flag is set". The service therefore writes a marker into
275 + # its StateDirectory and this pair does the write as root.
276 + #
277 + # The marker path is mirrored in `makenotwork::constants::KEYS_REBUILD_MARKER`
278 + # and is fixed at /var/lib/makenotwork, not $STATE_DIR, because systemd derives
279 + # it from StateDirectory.
280 + KEYS_MARKER="/var/lib/makenotwork/keys/rebuild"
281 + cat > /etc/systemd/system/makenotwork-rebuild-keys.path <<EOF
282 + [Unit]
283 + Description=Watch for makenotwork authorized_keys rebuild requests
284 +
285 + [Path]
286 + PathExists=$KEYS_MARKER
287 + PathModified=$KEYS_MARKER
288 + Unit=makenotwork-rebuild-keys.service
289 +
290 + [Install]
291 + WantedBy=multi-user.target
292 + EOF
293 +
294 + # Deletes the marker before rebuilding, so a rebuild that outlives its request
295 + # cannot re-trigger itself. A failed rebuild loses its marker and shows up as a
296 + # failed unit rather than a retry loop; the server's own warn line covers the
297 + # case where the request never lands.
298 + cat > /etc/systemd/system/makenotwork-rebuild-keys.service <<EOF
299 + [Unit]
300 + Description=Rebuild the git user's authorized_keys from the database
301 +
302 + [Service]
303 + Type=oneshot
304 + EnvironmentFile=$ENV_FILE
305 + ExecStartPre=-/bin/rm -f $KEYS_MARKER
306 + ExecStart=$DEPLOY_ROOT/current/mnw-admin rebuild-keys
307 + StandardOutput=journal
308 + StandardError=journal
309 + SyslogIdentifier=makenotwork-rebuild-keys
310 + EOF
311 +
246 312 systemctl daemon-reload
247 313 systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true
314 + systemctl enable --now makenotwork-rebuild-keys.path >/dev/null 2>&1 || true
248 315
249 316 if [[ "$ENABLE_FIREWALL" == "1" ]]; then
250 317 log "8/8 firewall (UFW: 22/80/443 in, all else deny)"
@@ -193,9 +193,33 @@
193 193 while `systemctl show` reports the 524288 hard limit.
194 194
195 195 Under the sandbox the only writable paths are `$STATE_DIR` (`/var/lib/mnw`,
196 - covering backups and the bare git repositories), the `StateDirectory` scan spool
197 - at `/var/lib/makenotwork/scan-spool`, and the unit's private `/tmp`. A permission
198 - error writing anywhere else is the sandbox, not a broken chown. `GIT_REPOS_PATH`
199 - in the env file has to stay inside `$STATE_DIR` or be listed as its own
200 - `ReadWritePaths`; the binary's own default is `/opt/git`, which the sandbox makes
201 - read-only.
196 + covering backups and the bare git repositories), the two `StateDirectory`
197 + entries (`/var/lib/makenotwork/scan-spool` and `/var/lib/makenotwork/keys`), and
198 + the unit's private `/tmp`. A permission error writing anywhere else is the
199 + sandbox, not a broken chown. `GIT_REPOS_PATH` and `GIT_HOME` in the env file
200 + each have to stay inside `$STATE_DIR` or be listed as their own
201 + `ReadWritePaths`; bootstrap emits a line for either one that falls outside, and
202 + the binary's own defaults are both `/opt/git`, which the sandbox makes
203 + read-only. `tests/unit_paths_seal.rs` holds the constants and the script to the
204 + same paths.
205 +
206 + ### The sandbox implies NoNewPrivileges, so nothing under the unit can sudo
207 +
208 + `PrivateDevices`, `ProtectClock`, `ProtectKernelTunables`, `RestrictNamespaces`,
209 + `RestrictSUIDSGID`, `MemoryDenyWriteExecute`, `LockPersonality` and
210 + `SystemCallFilter` each imply `NoNewPrivileges`, and writing
211 + `NoNewPrivileges=no` does not undo the implication. Any `sudo` in a child of the
212 + service exits 1 with "the no new privileges flag is set". Two consequences:
213 +
214 + - **`authorized_keys` is rebuilt out of process.** Adding or removing an SSH key
215 + writes `/var/lib/makenotwork/keys/rebuild`; `makenotwork-rebuild-keys.path`
216 + starts a root oneshot that deletes the marker and runs `mnw-admin
217 + rebuild-keys`. A key that does not work over SSH is either a marker that never
218 + landed (`journalctl -u makenotwork -p warning` names the path) or a failed
219 + rebuild (`systemctl status makenotwork-rebuild-keys.service`). The old
220 + `/etc/sudoers.d/mnw-git-ssh` rule is dead; `mnw-admin setup-git` removes it.
221 + - **Build hosts need a provisioned identity.** `ProtectHome=yes` presents
222 + `/home` as empty, so ssh finds no `~/.ssh`. `run_ssh_command` and
223 + `run_scp_download` pass `-i /etc/mnw/build_ssh_key` when that file exists and
224 + log a warning naming it when it does not. Pair it with
225 + `/etc/mnw/known_hosts`, which pins the build hosts' keys.
@@ -812,6 +812,37 @@
812 812 Ok((s3_key.into_string(), signature))
813 813 }
814 814
815 + /// Private key for build SSH connections.
816 + ///
817 + /// Passed with an explicit `-i` when present. Without it ssh falls back to the
818 + /// service user's `~/.ssh`, which the unit's `ProtectHome=yes` presents as
819 + /// empty, so an unprovisioned identity is a build pipeline that authenticates
820 + /// against nothing. Kept beside [`BUILD_SSH_KNOWN_HOSTS`] under `/etc/mnw`,
821 + /// which the sandbox leaves readable.
822 + const BUILD_SSH_IDENTITY: &str = "/etc/mnw/build_ssh_key";
823 +
824 + /// SSH identity options. Returned owned so callers can push them straight into
825 + /// an argv vector. Shared by [`run_ssh_command`] and [`run_scp_download`] so
826 + /// the two can't drift.
827 + fn ssh_identity_args() -> Vec<String> {
828 + if std::path::Path::new(BUILD_SSH_IDENTITY).exists() {
829 + vec![
830 + "-o".into(),
831 + "IdentitiesOnly=yes".into(),
832 + "-i".into(),
833 + BUILD_SSH_IDENTITY.into(),
834 + ]
835 + } else {
836 + tracing::warn!(
837 + identity = BUILD_SSH_IDENTITY,
838 + "build SSH identity absent; falling back to the service user's ~/.ssh, which \
839 + ProtectHome=yes hides. Provision {BUILD_SSH_IDENTITY} to authenticate builds \
840 + under the sandbox",
841 + );
842 + Vec::new()
843 + }
844 + }
845 +
815 846 /// Path to a known_hosts file for build SSH connections.
816 847 /// When present, StrictHostKeyChecking=yes is used (pinned keys).
817 848 /// When absent, StrictHostKeyChecking=accept-new (trust on first use).
@@ -850,6 +881,7 @@
850 881 "BatchMode=yes".into(),
851 882 ];
852 883 args.extend(ssh_host_key_args());
884 + args.extend(ssh_identity_args());
853 885 args.push(host.to_string());
854 886 args.push(command.to_string());
855 887 let mut child = tokio::process::Command::new("ssh")
@@ -929,6 +961,7 @@
929 961 "BatchMode=yes".into(),
930 962 ];
931 963 args.extend(ssh_host_key_args());
964 + args.extend(ssh_identity_args());
932 965 args.push(remote);
933 966 args.push(local_path.to_string());
934 967 let scp = tokio::process::Command::new("scp")
@@ -293,6 +293,19 @@
293 293 /// resolves to `/var/lib/makenotwork/scan-spool`. Override with
294 294 /// `MNW_SCAN_SPOOL_DIR` for dev.
295 295 pub const SCAN_SPOOL_DIR: &str = "/var/lib/makenotwork/scan-spool";
296 + /// Marker file the server touches to ask for an `authorized_keys` rebuild.
297 + ///
298 + /// The rebuild has to run as root: sshd's `StrictModes` refuses an
299 + /// `authorized_keys` whose file or `.ssh` directory is group-writable, so the
300 + /// service user cannot be given write access to it, and `sudo` cannot raise
301 + /// privilege from inside the unit (`NoNewPrivileges` is implied by
302 + /// `PrivateDevices`, `MemoryDenyWriteExecute`, `RestrictNamespaces` and the
303 + /// rest of the sandbox, and setting it back to `no` does not undo the
304 + /// implication). So the service writes here and a systemd `.path` unit runs
305 + /// `mnw-admin rebuild-keys` out of process. Both names are written by
306 + /// `sando/deploy/bootstrap-node.sh`; changing one means changing the other.
307 + /// Override with `MNW_KEYS_REBUILD_MARKER` for dev.
308 + pub const KEYS_REBUILD_MARKER: &str = "/var/lib/makenotwork/keys/rebuild";
296 309 /// Files in `SCAN_SPOOL_DIR` older than this are considered orphaned
297 310 /// (a panic, OOM, or hard kill left them behind) and reaped on the
298 311 /// next sweep. RAII drop in `SpoolHandle` covers the live path; this
@@ -20,7 +20,7 @@
20 20 //! mnw-admin git-auth <key_id> Authenticate an SSH git operation
21 21 //! mnw-admin install-hooks Install post-receive hooks on all repos
22 22 //! mnw-admin backfill-git-config Backfill resource limits into bare repos
23 - //! mnw-admin setup-git Set up SSH directories, permissions, sudoers
23 + //! mnw-admin setup-git Set up SSH directories and permissions
24 24 //! mnw-admin reindex-notes [user] Rebuild the git-notes index from the repos
25 25 //!
26 26 //! The SSH management verbs (`repo list`, `key rm`, ...) are not here: they
@@ -139,7 +139,7 @@
139 139 },
140 140 /// Backfill resource limits (receive.maxInputSize) into all existing bare repos
141 141 BackfillGitConfig,
142 - /// Set up SSH infrastructure for git access (directories, permissions, sudoers)
142 + /// Set up SSH infrastructure for git access (directories, permissions)
143 143 SetupGit,
144 144 }
145 145
@@ -868,31 +868,27 @@
868 868 );
869 869 }
870 870
871 - // 4. Install sudoers rule
871 + // 4. Retire the sudoers rule, if this node still carries one.
872 + //
873 + // The rule let the service run `sudo -u git mnw-admin rebuild-keys`. The
874 + // sandbox `sando/deploy/bootstrap-node.sh` writes implies NoNewPrivileges,
875 + // so sudo cannot raise privilege from inside the unit at all; the rebuild
876 + // now runs from `makenotwork-rebuild-keys.path`. Leaving the rule in place
877 + // grants a privilege nothing uses.
872 878 if sudoers_file.exists() {
879 + fs::remove_file(sudoers_file)?;
873 880 println!(
874 - "[setup] Sudoers rule already exists: {}",
881 + "[setup] Removed obsolete sudoers rule: {}",
875 882 sudoers_file.display()
876 883 );
884 + }
885 + if Path::new("/etc/systemd/system/makenotwork-rebuild-keys.path").exists() {
886 + println!("[setup] authorized_keys rebuild: makenotwork-rebuild-keys.path");
877 887 } else {
878 - let rule = format!(
879 - "makenotwork ALL=(git) NOPASSWD: {} rebuild-keys\n",
880 - mnw_admin.display(),
888 + println!(
889 + "[setup] WARNING: makenotwork-rebuild-keys.path not installed. \
890 + Adding or removing an SSH key will not reach sshd. Re-run bootstrap-node.sh."
881 891 );
882 - fs::write(sudoers_file, &rule)?;
883 - fs::set_permissions(sudoers_file, fs::Permissions::from_mode(0o440))?;
884 - println!("[setup] Created sudoers rule: {}", sudoers_file.display());
885 -
886 - // Verify syntax
887 - let status = std::process::Command::new("visudo")
888 - .args(["-cf", &sudoers_file.to_string_lossy()])
889 - .status()?;
890 - if !status.success() {
891 - anyhow::bail!(
892 - "sudoers syntax check failed, fix {} manually",
893 - sudoers_file.display()
894 - );
895 - }
896 892 }
897 893
898 894 println!("[setup] Git SSH infrastructure ready.");
@@ -159,31 +159,49 @@
159 159 Ok(StatusCode::NO_CONTENT.into_response())
160 160 }
161 161
162 - /// Trigger authorized_keys rebuild via mnw-admin.
162 + /// Ask for an `authorized_keys` rebuild.
163 163 ///
164 - /// Spawns the rebuild as a background process and does not wait for it.
165 - /// If sudo/mnw-admin is not available (e.g., dev environment), logs a warning.
164 + /// Writes the marker file `makenotwork-rebuild-keys.path` watches; the oneshot
165 + /// it starts runs `mnw-admin rebuild-keys` as root, deletes the marker and
166 + /// writes the file sshd reads. The rebuild cannot happen in this process or in
167 + /// a child of it: sshd's `StrictModes` refuses a group-writable
168 + /// `authorized_keys`, so the service user cannot own the write, and the unit's
169 + /// sandbox implies `NoNewPrivileges`, so `sudo` cannot raise privilege either.
170 + ///
171 + /// A key that is in the database but not yet in `authorized_keys` is a key the
172 + /// user believes works and does not, so every failure here logs at `warn` and
173 + /// names the marker path. Silence in `journalctl -u makenotwork -p warning`
174 + /// means the request was filed, not that the rebuild succeeded: that is
175 + /// `systemctl status makenotwork-rebuild-keys.service`.
166 176 fn rebuild_authorized_keys() {
167 - std::thread::spawn(|| {
168 - let result = std::process::Command::new("sudo")
169 - .args(["-u", "git", "/opt/mnw/current/mnw-admin", "rebuild-keys"])
170 - .output();
177 + let marker = std::env::var("MNW_KEYS_REBUILD_MARKER")
178 + .unwrap_or_else(|_| crate::constants::KEYS_REBUILD_MARKER.to_string());
179 + let path = std::path::Path::new(&marker);
171 180
172 - match result {
173 - Ok(output) if !output.status.success() => {
174 - let stderr = String::from_utf8_lossy(&output.stderr);
175 - tracing::warn!(
176 - status = %output.status,
177 - stderr = %stderr,
178 - "authorized_keys rebuild failed"
179 - );
180 - }
181 - Err(e) => {
182 - tracing::debug!(error = %e, "authorized_keys rebuild skipped (mnw-admin not available)");
183 - }
184 - _ => {}
185 - }
186 - });
181 + if let Some(parent) = path.parent()
182 + && !parent.exists()
183 + {
184 + tracing::warn!(
185 + marker = %marker,
186 + "authorized_keys rebuild not requested: marker directory missing. \
187 + The node predates makenotwork-rebuild-keys.path; re-run bootstrap-node.sh"
188 + );
189 + return;
190 + }
191 +
192 + // Content is a timestamp rather than an empty file so a `.path` unit using
193 + // PathModified sees a change even when the marker outlives one rebuild.
194 + let stamp = std::time::SystemTime::now()
195 + .duration_since(std::time::UNIX_EPOCH)
196 + .map(|d| d.as_secs())
197 + .unwrap_or_default();
198 + if let Err(error) = std::fs::write(path, format!("{stamp}\n")) {
199 + tracing::warn!(
200 + error = %error,
201 + marker = %marker,
202 + "authorized_keys rebuild not requested: marker unwritable"
203 + );
204 + }
187 205 }
188 206
189 207 /// View type for SSH key display in templates.
@@ -1,0 +1,107 @@
1 + //! Seal: the paths the systemd unit provisions are the paths the binary writes.
2 + //!
3 + //! `sando/deploy/bootstrap-node.sh` is the only writer of the makenotwork unit,
4 + //! and the unit's sandbox makes every path outside `ReadWritePaths` and the
5 + //! `StateDirectory` set read-only. So a constant in `makenotwork::constants`
6 + //! that names a directory the unit does not provision is not a compile error
7 + //! and not a failing request: it is an EPERM on a live box, at the moment a
8 + //! creator adds an SSH key or the scanner spools an upload.
9 + //!
10 + //! Both directions are checked. A path the code names must appear in the
11 + //! script, and every `StateDirectory` entry the script declares must be named
12 + //! by a constant, so a directory provisioned for nothing is caught too.
13 + //!
14 + //! Run with: cargo test --test unit_paths_seal
15 +
16 + use std::path::PathBuf;
17 +
18 + use makenotwork::constants::{KEYS_REBUILD_MARKER, SCAN_SPOOL_DIR};
19 +
20 + /// systemd puts every `StateDirectory=` entry under this root.
21 + const STATE_DIRECTORY_ROOT: &str = "/var/lib/";
22 +
23 + fn bootstrap_script() -> Option<String> {
24 + let path: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
25 + .join("../sando/deploy/bootstrap-node.sh")
26 + .components()
27 + .collect();
28 + std::fs::read_to_string(path).ok()
29 + }
30 +
31 + /// The `StateDirectory=` entries the script declares, as absolute paths.
32 + fn declared_state_directories(script: &str) -> Vec<String> {
33 + script
34 + .lines()
35 + .filter_map(|line| line.trim().strip_prefix("StateDirectory="))
36 + .flat_map(|value| value.split_whitespace())
37 + .map(|entry| format!("{STATE_DIRECTORY_ROOT}{entry}"))
38 + .collect()
39 + }
40 +
41 + #[test]
42 + fn every_provisioned_path_the_code_names_is_written_by_bootstrap() {
43 + let Some(script) = bootstrap_script() else {
44 + // A checkout of the server crate alone has no sibling sando. Nothing to
45 + // seal against, and failing here would only punish that layout.
46 + return;
47 + };
48 +
49 + let declared = declared_state_directories(&script);
50 + assert!(
51 + !declared.is_empty(),
52 + "bootstrap-node.sh declares no StateDirectory; the unit template moved"
53 + );
54 +
55 + for (name, path) in [
56 + ("SCAN_SPOOL_DIR", SCAN_SPOOL_DIR),
57 + ("KEYS_REBUILD_MARKER", KEYS_REBUILD_MARKER),
58 + ] {
59 + assert!(
60 + declared.iter().any(|dir| path.starts_with(dir)),
61 + "{name} = {path} is under no StateDirectory bootstrap-node.sh declares \
62 + ({declared:?}). The sandbox makes it read-only, so the first write EPERMs."
63 + );
64 + }
65 + }
66 +
67 + #[test]
68 + fn the_rebuild_marker_the_unit_watches_is_the_one_the_server_writes() {
69 + let Some(script) = bootstrap_script() else {
70 + return;
71 + };
72 +
73 + assert!(
74 + script.contains(&format!("KEYS_MARKER=\"{KEYS_REBUILD_MARKER}\"")),
75 + "the .path unit watches a different file than KEYS_REBUILD_MARKER ({KEYS_REBUILD_MARKER}); \
76 + adding an SSH key would never reach sshd"
77 + );
78 + assert!(
79 + script.contains("Unit=makenotwork-rebuild-keys.service"),
80 + "the marker is watched by no unit, so nothing rebuilds authorized_keys"
81 + );
82 + let sudo_rebuild = script
83 + .lines()
84 + .find(|line| line.contains("NOPASSWD") && line.contains("rebuild-keys"));
85 + assert!(
86 + sudo_rebuild.is_none(),
87 + "bootstrap installs a sudoers rule for rebuild-keys ({sudo_rebuild:?}); the unit's \
88 + sandbox implies NoNewPrivileges, so sudo cannot run from inside it"
89 + );
90 + }
91 +
92 + #[test]
93 + fn every_declared_state_directory_has_a_constant_that_names_it() {
94 + let Some(script) = bootstrap_script() else {
95 + return;
96 + };
97 +
98 + for dir in declared_state_directories(&script) {
99 + assert!(
100 + [SCAN_SPOOL_DIR, KEYS_REBUILD_MARKER]
101 + .iter()
102 + .any(|path| path.starts_with(&dir)),
103 + "bootstrap-node.sh provisions {dir} and no constant names it. Either the code \
104 + that used it was deleted, or a constant drifted off the provisioned path."
105 + );
106 + }
107 + }