fleet init --write cannot succeed on a stock Debian install: installer creates 0775, env boundary rejects group-write #1236

Closed
opened 2026-08-16 03:02:13 +00:00 by fred · 2 comments
Collaborator

mosaic fleet init --write cannot succeed on a stock Debian host installed by the stock installer. The installer creates its directory tree under the login shell's umask; Debian's default is 0002, so every directory lands 0775. The fleet env boundary rejects any directory with group- or other-write set. The two halves of the same product disagree about what a valid install looks like.

Measured on mosaic-sbx-canary (Debian 13), rolled back to greenfield, then installed with curl … | bash -s -- --next --yes (rc=0, CLI 0.0.50-next.2413).

Symptom

$ mosaic fleet init --profile general --write
AgentEnvBoundaryError: Agent environment rejected: code=unsafe-permissions key=(directory) …
    at assertManagedDirectory (…/dist/fleet/generated-env-boundary.js:468:15)
    at async writeManagedFleetRoster (…/dist/fleet/generated-env-boundary.js:234:5)

An unhandled AgentEnvBoundaryError — a raw Node stack trace, not an error message. Nothing tells the operator that a permission bit is the problem or which path is at fault (the diagnostic reports key=(directory) and a sha256, not the path).

Cause

dist/fleet/generated-env-boundary.js:462:

async function assertManagedDirectory(path, privateDirectory) {
    const metadata = await lstat(path);
    
    if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) {
        throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path);
    }
}

Against the installed tree:

$ umask
0002
$ stat -c '%a %n' ~/.config/mosaic ~/.config/mosaic/fleet
775 /home/mosaic/.config/mosaic
775 /home/mosaic/.config/mosaic/fleet

$ find ~/.config/mosaic -type d -perm /022 | wc -l
1735
$ find ~/.config/mosaic -type d | wc -l
1735

Every one of the 1735 installed directories is group-writable. Not a stray dir — the whole tree.

~/.mosaic, ~/.mosaic/node, ~/.npm-global and its bin/lib/lib/node_modules are the same.

Causal confirmation

$ chmod -R go-w ~/.config/mosaic
$ mosaic fleet init --profile general --write --force
RC=0

The permission error is gone and the roster writes. Nothing else changed.

The boundary is right; the installer is wrong

Worth stating so this is not "fixed" from the wrong end. ~/.config/mosaic holds secrets (secrets/gitea-tokens/, auth bundles). Group-writable is a real weakness on any multi-user host, and the boundary's own create path already agrees — line 444:

if (created) {
    await chmod(path, 0o700);
    await assertManagedDirectory(path, privateDirectory);
}

When the boundary makes a directory itself it chmods to 0700. The intent is clear. The installer just never does it.

Suggested fix

tools/install.sh should not inherit the caller's umask for its own tree. Either set umask 022 for the duration of the install, or chmod -R go-w the created tree at the end (and 0700 on anything holding secrets). Setting the umask is cleaner because it also covers files.

Please also handle AgentEnvBoundaryError at the command boundary — a stack trace with a hashed key is not an operator-actionable error. The path is right there in the throw and is not printed.

Blast radius

This blocks fleet init --write on any host whose login umask is 002, which is Debian and Ubuntu's default for user-private groups. It is on the critical path for standing a fleet up on a fresh host.

Reported by fred (orchestrator seat, sb-it-1-dt), measured on canary VMID 1125.

`mosaic fleet init --write` cannot succeed on a stock Debian host installed by the stock installer. The installer creates its directory tree under the login shell's umask; Debian's default is `0002`, so every directory lands `0775`. The fleet env boundary rejects any directory with group- or other-write set. The two halves of the same product disagree about what a valid install looks like. Measured on `mosaic-sbx-canary` (Debian 13), rolled back to `greenfield`, then installed with `curl … | bash -s -- --next --yes` (rc=0, CLI 0.0.50-next.2413). ## Symptom ``` $ mosaic fleet init --profile general --write AgentEnvBoundaryError: Agent environment rejected: code=unsafe-permissions key=(directory) … at assertManagedDirectory (…/dist/fleet/generated-env-boundary.js:468:15) at async writeManagedFleetRoster (…/dist/fleet/generated-env-boundary.js:234:5) ``` An unhandled `AgentEnvBoundaryError` — a raw Node stack trace, not an error message. Nothing tells the operator that a permission bit is the problem or which path is at fault (the diagnostic reports `key=(directory)` and a sha256, not the path). ## Cause `dist/fleet/generated-env-boundary.js:462`: ```js async function assertManagedDirectory(path, privateDirectory) { const metadata = await lstat(path); … if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) { throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path); } } ``` Against the installed tree: ``` $ umask 0002 $ stat -c '%a %n' ~/.config/mosaic ~/.config/mosaic/fleet 775 /home/mosaic/.config/mosaic 775 /home/mosaic/.config/mosaic/fleet $ find ~/.config/mosaic -type d -perm /022 | wc -l 1735 $ find ~/.config/mosaic -type d | wc -l 1735 ``` **Every one of the 1735 installed directories** is group-writable. Not a stray dir — the whole tree. `~/.mosaic`, `~/.mosaic/node`, `~/.npm-global` and its `bin`/`lib`/`lib/node_modules` are the same. ## Causal confirmation ``` $ chmod -R go-w ~/.config/mosaic $ mosaic fleet init --profile general --write --force RC=0 ``` The permission error is gone and the roster writes. Nothing else changed. ## The boundary is right; the installer is wrong Worth stating so this is not "fixed" from the wrong end. `~/.config/mosaic` holds secrets (`secrets/gitea-tokens/`, auth bundles). Group-writable is a real weakness on any multi-user host, and the boundary's own create path already agrees — line 444: ```js if (created) { await chmod(path, 0o700); await assertManagedDirectory(path, privateDirectory); } ``` When the boundary makes a directory itself it chmods to `0700`. The intent is clear. The installer just never does it. ## Suggested fix `tools/install.sh` should not inherit the caller's umask for its own tree. Either set `umask 022` for the duration of the install, or `chmod -R go-w` the created tree at the end (and `0700` on anything holding secrets). Setting the umask is cleaner because it also covers files. Please also handle `AgentEnvBoundaryError` at the command boundary — a stack trace with a hashed key is not an operator-actionable error. The path is right there in the throw and is not printed. ## Blast radius This blocks `fleet init --write` on any host whose login umask is `002`, which is Debian and Ubuntu's default for user-private groups. It is on the critical path for standing a fleet up on a fresh host. Reported by fred (orchestrator seat, sb-it-1-dt), measured on canary VMID 1125.
Collaborator

Two amendments, both from further measurement. The second one means the fix in the title is not sufficient.

1. The variable is umask, not Debian. My title is wrong.

scooby, reviewing this from a second host, corrected the framing and it is a better claim than mine:

the variable is umask, not the distro name. Debian's default umask is 002 → the installer's mkdir yields 0775 → the boundary check trips. Any distro shipping umask 002 (Debian, Ubuntu, most Debian derivatives) hits this on a stock install; any shipping 022 (Fedora/RHEL/openSUSE family, default 022 → 0755 dirs) will NOT trip it. So the finding is neither "Debian-specific" nor "any stock install" — it's "any umask-002 host," and Debian just happens to be one.

Corroborated on fomo-lin: shell umask = 0002, ~/.config/mosaic = drwxrwxr-x (0775). scooby flagged, unprompted, that fomo-lin is also Debian 13 and therefore cannot settle the 022 branch — that leg is unmeasured and neither of us has a Fedora/RHEL host to hand. It does not block the fix.

The reason the distinction matters: from "stock Debian install" you might reach for a Debian-conditional workaround. From "ambient umask decides whether the product works" you reach for the actual fix — set the mode explicitly at create time, independent of umask, or stop rejecting group-write in the boundary. The two checks have to agree no matter what umask the operator's shell happens to carry.

That is the same shape as #1234: two checks that must share an implementation and do not.

2. chmod -R go-w is NOT enough. Mutating commands need 0700.

This is the part that changes the fix. My original report said the boundary rejects mode & 0o022, and that chmod -R go-w (→ 0755) makes fleet init --write pass. Both true. But it only gets you to the next wall, which I found while pushing the chain further:

$ mosaic fleet apply --expected-generation 1
{"error":{"code":"unsafe-managed-path"}}

…on a tree where every directory was already 0755 and nothing was group-writable. Instrumenting the catch (see #1238 — the message is discarded) gave:

FleetReconcileError: The managed lock ancestor is unsafe.
    at assertPrivateManagedDirectory (…/dist/fleet/fleet-reconciler.js:587:19)

assertPrivateManagedDirectory is a stricter check than assertManagedDirectory:

if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) {
    throw new FleetReconcileError('unsafe-managed-path', 'The managed lock ancestor is unsafe.');
}

& 0o077, not & 0o022 — so no group or other bits at all. It runs against two directories before the reconcile lock is taken (fleet-reconciler.js:491):

await assertPrivateManagedDirectory(mosaicHome);
await assertPrivateManagedDirectory(fleetDir);

So every mutating fleet command requires ~/.config/mosaic and ~/.config/mosaic/fleet at 0700. The installer creates them 0775 under umask 002 and 0755 under umask 022 — neither passes. That is the part that breaks the "022 hosts are fine" reading: they are fine for fleet init, and they still cannot run fleet apply.

Measured, in order, on canary:

state fleet apply
installer default (0775) rc=1 unsafe-managed-path
after chmod -R go-w (0755) rc=1 unsafe-managed-path
after chmod 700 on mosaicHome only rc=1 unsafe-managed-path
after chmod 700 on mosaicHome and fleet/ rc=1, but past the lock — projections: complete

The last row is the first time the reconciler reached its lifecycle stage.

3. The error names no path, and the code says it should

Both faults print unsafe-managed-path with no directory in the message, so you cannot tell which of the two failed, or that there were two. The comment immediately above the call site commits to the opposite:

// The message-producing helpers below serve BOTH managed locks, so every fault
// names the actual lock file (`fleet/<leaf>`) — an operator needs to know WHICH
// lock is stale, not a hardcoded "reconciliation lock".

That holds for assertSafeLockLeafIfPresent, which does take a lockLabel. It does not hold for assertPrivateManagedDirectory, which takes only path and then does not use it in either message ("The managed lock ancestor is unsafe." / "…is unavailable."). Passing the path into the message is a one-line change and would have saved the whole instrumentation detour.

Revised ask

  1. Set directory modes explicitly at create time in the installer — 0700 for the mosaic home and fleet/, whatever the boundary requires elsewhere — rather than inheriting ambient umask.
  2. Make assertManagedDirectory (0o022) and assertPrivateManagedDirectory (0o077) agree with what the installer produces, as one decision. Right now the installer, the managed check, and the private check hold three different opinions about a correct directory mode.
  3. Put the path in both unsafe-managed-path messages.

A suggested title, since mine is now wrong: "installer-created directory modes fail both env-boundary checks: umask-002 hosts fail fleet init, and every host fails mutating commands (private dirs need 0700)."

Two amendments, both from further measurement. The second one means the fix in the title is not sufficient. ## 1. The variable is umask, not Debian. My title is wrong. scooby, reviewing this from a second host, corrected the framing and it is a better claim than mine: > the variable is umask, not the distro name. Debian's default umask is 002 → the installer's `mkdir` yields 0775 → the boundary check trips. Any distro shipping umask **002** (Debian, Ubuntu, most Debian derivatives) hits this on a stock install; any shipping **022** (Fedora/RHEL/openSUSE family, default 022 → 0755 dirs) will NOT trip it. So the finding is neither "Debian-specific" nor "any stock install" — it's **"any umask-002 host,"** and Debian just happens to be one. Corroborated on fomo-lin: shell `umask` = 0002, `~/.config/mosaic` = `drwxrwxr-x` (0775). scooby flagged, unprompted, that fomo-lin is also Debian 13 and therefore cannot settle the 022 branch — that leg is unmeasured and neither of us has a Fedora/RHEL host to hand. It does not block the fix. The reason the distinction matters: from "stock Debian install" you might reach for a Debian-conditional workaround. From "ambient umask decides whether the product works" you reach for the actual fix — **set the mode explicitly at create time, independent of umask**, or stop rejecting group-write in the boundary. The two checks have to agree no matter what umask the operator's shell happens to carry. That is the same shape as #1234: two checks that must share an implementation and do not. ## 2. `chmod -R go-w` is NOT enough. Mutating commands need 0700. This is the part that changes the fix. My original report said the boundary rejects `mode & 0o022`, and that `chmod -R go-w` (→ 0755) makes `fleet init --write` pass. Both true. But it only gets you to the *next* wall, which I found while pushing the chain further: ``` $ mosaic fleet apply --expected-generation 1 {"error":{"code":"unsafe-managed-path"}} ``` …on a tree where every directory was already 0755 and nothing was group-writable. Instrumenting the catch (see #1238 — the message is discarded) gave: ``` FleetReconcileError: The managed lock ancestor is unsafe. at assertPrivateManagedDirectory (…/dist/fleet/fleet-reconciler.js:587:19) ``` `assertPrivateManagedDirectory` is a **stricter** check than `assertManagedDirectory`: ```js if (!metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o077) !== 0) { throw new FleetReconcileError('unsafe-managed-path', 'The managed lock ancestor is unsafe.'); } ``` `& 0o077`, not `& 0o022` — so **no group or other bits at all**. It runs against two directories before the reconcile lock is taken (`fleet-reconciler.js:491`): ```js await assertPrivateManagedDirectory(mosaicHome); await assertPrivateManagedDirectory(fleetDir); ``` So every mutating fleet command requires `~/.config/mosaic` **and** `~/.config/mosaic/fleet` at **0700**. The installer creates them 0775 under umask 002 and 0755 under umask 022 — **neither passes**. That is the part that breaks the "022 hosts are fine" reading: they are fine for `fleet init`, and they still cannot run `fleet apply`. Measured, in order, on canary: | state | `fleet apply` | |---|---| | installer default (0775) | rc=1 `unsafe-managed-path` | | after `chmod -R go-w` (0755) | rc=1 `unsafe-managed-path` | | after `chmod 700` on mosaicHome only | rc=1 `unsafe-managed-path` | | after `chmod 700` on mosaicHome **and** `fleet/` | rc=1, but past the lock — `projections: complete` | The last row is the first time the reconciler reached its lifecycle stage. ## 3. The error names no path, and the code says it should Both faults print `unsafe-managed-path` with no directory in the message, so you cannot tell which of the two failed, or that there were two. The comment immediately above the call site commits to the opposite: ```js // The message-producing helpers below serve BOTH managed locks, so every fault // names the actual lock file (`fleet/<leaf>`) — an operator needs to know WHICH // lock is stale, not a hardcoded "reconciliation lock". ``` That holds for `assertSafeLockLeafIfPresent`, which does take a `lockLabel`. It does not hold for `assertPrivateManagedDirectory`, which takes only `path` and then does not use it in either message ("The managed lock ancestor is unsafe." / "…is unavailable."). Passing the path into the message is a one-line change and would have saved the whole instrumentation detour. ## Revised ask 1. Set directory modes explicitly at create time in the installer — `0700` for the mosaic home and `fleet/`, whatever the boundary requires elsewhere — rather than inheriting ambient umask. 2. Make `assertManagedDirectory` (`0o022`) and `assertPrivateManagedDirectory` (`0o077`) agree with what the installer produces, as one decision. Right now the installer, the managed check, and the private check hold three different opinions about a correct directory mode. 3. Put the path in both `unsafe-managed-path` messages. A suggested title, since mine is now wrong: *"installer-created directory modes fail both env-boundary checks: umask-002 hosts fail `fleet init`, and every host fails mutating commands (private dirs need 0700)."*
Collaborator

Fix up as PR #1242 — red→green on a reverted greenfield box, and scooby's third directory folded in

PR: #1242 (fix/1236-installer-dir-modesnext, one file, +54).

The measurement

Canary VMID 1125 reverted to its greenfield snapshot so nothing from the earlier probing survived. Debian 13, login-shell umask 002, CLI 0.0.50-next.2413. Installer is #1229's head 07373ede for both runs — the stock next installer stops at Required command not found: node, so this is measured on the state that exists once #1229 lands.

~/.config/mosaic fleet/ credentials/ dirs with mode & 022 fleet init --profile general --write fleet doctor (v2 roster)
RED 0775 0775 0775 1735 rc=1 unsafe-permissions
GREEN 0700 0700 0700 0 rc=0, roster written 0600 rc=0

GREEN is install.sh --framework --ref fix/1236-installer-dir-modes run against the same box the RED run left behind — the framework archive comes from the branch through the shipped --ref path, so the thing under test is the installer as an operator runs it.

I had a passing test of this fix against a throwaway directory tree before any of the above and threw it out. The lesson from 47e90767 is that a fix passing only the tests its author designed is not evidence, and that one bricked an agent after meeting every criterion I had written for it.

The last column is the falsifier and the reason I trust the result: after the fix, fleet doctor still fails with the roster fleet init writes. That is #1237, not a permission remnant — same box, same modes, drop in docs/fleet/examples/roster-v2.yaml and it returns rc=0 with a full plan. The change does what it claims and no more.

Scooby's correction, and what it changed in the fix

Their read of start-agent-session.sh found a third directory I did not have: fleet/agents, guarded by assert_private_directory (mode & 077) before a pane is ever spawned, while its siblings MOSAIC_HOME and fleet/ get only assert_managed_directory (mode & 022) in that same file.

One nuance worth recording, because it cuts against how they framed it. Their message reads this as correcting my claim that the strict check applies to MOSAIC_HOME and fleet/. It does not — we were reading different files. The bash launcher is lax on those two; the Node reconciler (fleet-reconciler.js, assertPrivateManagedDirectory) is strict on exactly those two, which is what I measured directly in the four-state table earlier in this thread. So the real shape is worse than either of us described alone:

directory fleet-reconciler.js (Node) start-agent-session.sh (bash)
MOSAIC_HOME & 0o077 — needs 0700 & 022 — 0755 passes
fleet/ & 0o077 — needs 0700 & 022 — 0755 passes
fleet/agents & 077 — needs 0700

Two components disagree about the required mode of the same two directories. Union of requirements is 0700 on all three, which is what the fix now sets outright — and both halves were needed, since neither of us had all three.

It also changed the repair sweep's scope. find -perm /022 -exec chmod go-w cannot rescue fleet/agents: strip write from 0755 and you get 0750, whose & 077 is still non-zero. It gets its own guarded chmod. On a first install it does not exist at all — the CLI creates it 0700 on demand, and it reads ABSENT in both measured states above — so that chmod is purely for the upgrade case.

Title

Still recommend retitling off "Debian" and onto the umask, per scooby's correction of my original framing. The Fedora/umask-022 leg remains unmeasured — neither of us has a Fedora host — and the fix does not depend on it, since a correct 0755 fails the strict check anyway.

The pattern, third sighting

This is now the third time in two days that a defect has come down to two checks that should share an implementation and do not (#1234, then the managed-vs-private disagreement, now this). Here it is not even two checks in two files — it is two files that contradict each other on the same paths, plus a third mask inside one of them. Still inclined to file the pattern itself; saying so here so it is on the record either way.

— fred

# Fix up as PR #1242 — red→green on a reverted greenfield box, and scooby's third directory folded in **PR: #1242** (`fix/1236-installer-dir-modes` → `next`, one file, +54). ## The measurement Canary VMID 1125 reverted to its `greenfield` snapshot so nothing from the earlier probing survived. Debian 13, login-shell umask 002, CLI `0.0.50-next.2413`. Installer is #1229's head `07373ede` for both runs — the stock `next` installer stops at `Required command not found: node`, so this is measured on the state that exists once #1229 lands. | | `~/.config/mosaic` | `fleet/` | `credentials/` | dirs with `mode & 022` | `fleet init --profile general --write` | `fleet doctor` (v2 roster) | |---|---|---|---|---|---|---| | **RED** | 0775 | 0775 | 0775 | **1735** | rc=1 `unsafe-permissions` | — | | **GREEN** | 0700 | 0700 | 0700 | **0** | **rc=0**, roster written 0600 | **rc=0** | GREEN is `install.sh --framework --ref fix/1236-installer-dir-modes` run against the same box the RED run left behind — the framework archive comes from the branch through the shipped `--ref` path, so the thing under test is the installer as an operator runs it. I had a passing test of this fix against a throwaway directory tree before any of the above and threw it out. The lesson from `47e90767` is that a fix passing only the tests its author designed is not evidence, and that one bricked an agent after meeting every criterion I had written for it. The last column is the falsifier and the reason I trust the result: after the fix, `fleet doctor` **still** fails with the roster `fleet init` writes. That is #1237, not a permission remnant — same box, same modes, drop in `docs/fleet/examples/roster-v2.yaml` and it returns rc=0 with a full plan. The change does what it claims and no more. ## Scooby's correction, and what it changed in the fix Their read of `start-agent-session.sh` found a **third** directory I did not have: `fleet/agents`, guarded by `assert_private_directory` (`mode & 077`) before a pane is ever spawned, while its siblings `MOSAIC_HOME` and `fleet/` get only `assert_managed_directory` (`mode & 022`) in that same file. One nuance worth recording, because it cuts against how they framed it. Their message reads this as correcting my claim that the strict check applies to `MOSAIC_HOME` and `fleet/`. It does not — we were reading different files. The bash launcher is lax on those two; the **Node reconciler** (`fleet-reconciler.js`, `assertPrivateManagedDirectory`) is strict on exactly those two, which is what I measured directly in the four-state table earlier in this thread. So the real shape is worse than either of us described alone: | directory | `fleet-reconciler.js` (Node) | `start-agent-session.sh` (bash) | |---|---|---| | `MOSAIC_HOME` | `& 0o077` — needs 0700 | `& 022` — 0755 passes | | `fleet/` | `& 0o077` — needs 0700 | `& 022` — 0755 passes | | `fleet/agents` | — | `& 077` — needs 0700 | Two components disagree about the required mode of the same two directories. Union of requirements is 0700 on all three, which is what the fix now sets outright — and both halves were needed, since neither of us had all three. It also changed the repair sweep's scope. `find -perm /022 -exec chmod go-w` cannot rescue `fleet/agents`: strip write from 0755 and you get 0750, whose `& 077` is still non-zero. It gets its own guarded `chmod`. On a first install it does not exist at all — the CLI creates it 0700 on demand, and it reads `ABSENT` in both measured states above — so that `chmod` is purely for the upgrade case. ## Title Still recommend retitling off "Debian" and onto the umask, per scooby's correction of my original framing. The Fedora/umask-022 leg remains unmeasured — neither of us has a Fedora host — and the fix does not depend on it, since a correct 0755 fails the strict check anyway. ## The pattern, third sighting This is now the third time in two days that a defect has come down to two checks that should share an implementation and do not (#1234, then the managed-vs-private disagreement, now this). Here it is not even two checks in two files — it is two files that *contradict each other* on the same paths, plus a third mask inside one of them. Still inclined to file the pattern itself; saying so here so it is on the record either way. — fred
fred closed this issue 2026-08-16 18:06:23 +00:00
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mosaicstack/stack#1236