Skip to content

fix(runtime)!: move the default provider-home root to ~/.ultrafuzz-provider-homes - #1242

Merged
aviggiano merged 2 commits into
mainfrom
claude/provider-home-default-root
Oct 1, 2026
Merged

aviggiano merged 2 commits into
mainfrom
claude/provider-home-default-root

Conversation

@aviggiano

@aviggiano aviggiano commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

resolveProviderHome in packages/runtime/src/templates/smithers/agents/provider-home.tsx refuses every group- or world-writable directory above a provider home unless it is sticky. Without ULTRAFUZZ_PROVIDER_HOME_ROOT, the root was $XDG_STATE_HOME/ultrafuzz/provider-homes, or ~/.local/state/ultrafuzz/provider-homes when XDG_STATE_HOME is unset.

Ubuntu's default umask 0002 makes ~/.local 0775 (drwxrwxr-x ubuntu:ubuntu on the test host), so that root fails with provider-home ancestors cannot be group/world writable. OpenRouterAgent and DeepSeekAgent always use the root. ClaudeAgent, CodexAgent and KimiAgent use it when they set config_dir. On such a host, those agents cannot start.

Change

  • New default root: ~/.ultrafuzz-provider-homes. It sits directly under the home directory, so on a normal host its only ancestors are /, /home and the home directory, and no other directory Ultrafuzz creates sits above it. The adapter still creates each missing directory with mkdirSync(…, { mode: 0o700 }). It now also runs chmodSync(…, 0o700), as openrouter.tsx already does for its config directory.
    • When XDG_STATE_HOME was unset, the new root's ancestors are a subset of the old root's, so any host where the old default passed the check passes with the new one. This includes the Modal sandbox, whose API-key rows run with HOME=/home/ubuntu and no ULTRAFUZZ_PROVIDER_HOME_ROOT.
    • It is deliberately not ~/.ultrafuzz/provider-homes, which this PR's first revision used. ultrafuzz init creates a project's .ultrafuzz with the umask. With the home directory as the project, umask 0002 leaves ~/.ultrafuzz 0775, and the check then refused that root for every default-root agent in every project (Greptile's P1).
  • The check is unchanged. assertSafeDirectory is byte-identical to main. An explicit root below a group-writable directory is still refused, and so is a pre-existing group-writable ~/.ultrafuzz-provider-homes.
  • XDG_STATE_HOME no longer selects the root. I chose to ignore it rather than honor an explicit value:
    • It is a session-wide setting for every XDG-aware program. Exporting it does not choose where Ultrafuzz keeps provider credentials. ULTRAFUZZ_PROVIDER_HOME_ROOT is the Ultrafuzz-specific override, and it stays.
    • An explicit value is often just the XDG default, ~/.local/state, spelled out. Honoring it would leave Provider homes are refused on Ubuntu's default umask because ~/.local is group-writable #1236 in place for those operators.
    • With one default and one override, both copies of the root computation lose a branch.
    • The reference cache still honors XDG_CACHE_HOME. A cache has no ancestor check.
  • data-governance.ts computes the same root. providerHome() reads a configured Claude, Codex or Kimi home's settings.json or config.toml to derive the route that planning acknowledges. It had its own copy of the XDG default. Left alone, planning would read the old directory while the adapter used the new one.
  • The template reads HOME before os.homedir(). This is the expression data-governance.ts already uses (env.HOME?.trim() || os.homedir()). The template now uses it for both the default root and the canonical homes (~/.claude, ~/.codex, ~/.kimi-code).
    • Bun's os.homedir() keeps the HOME its process started with. I found nothing in the runtime, the templates or Smithers that rewrites HOME, so production resolves the same paths as before.
    • The Bun contract test relies on this to point the default root at a fixture home.
  • Name. Nothing in the repo uses ~/.ultrafuzz-provider-homes.
    • It is a sibling of the .ultrafuzz project directory that init creates when the home directory is a project, not a child of it.
    • The CLI takes its project from --project or the working directory. It never searches upward for .ultrafuzz.
    • clean removes only runs/…, artifacts/… and workspaces/… below a project's .ultrafuzz.
  • No fallback. Nothing reads, migrates or deletes the old root. The CHANGELOG entry is under Breaking changes.

Who loses state in the old root

  • OpenRouterAgent and DeepSeekAgent authenticate with API keys. Their homes hold session history and, for OpenRouter, a config.toml that the adapter rewrites on every start. They start with a fresh home, and there is nothing to migrate.
  • ClaudeAgent, CodexAgent and KimiAgent with a config_dir (and no ULTRAFUZZ_PROVIDER_HOME_ROOT) find their home empty.
    • With auth = "subscription", their login lived there, for example Codex's auth.json.
    • A provider config file placed there also stops applying, such as a Codex config.toml that selects a route.
    • To keep that state, move the old root before an agent first runs on this release: mv "${XDG_STATE_HOME:-$HOME/.local/state}/ultrafuzz/provider-homes" ~/.ultrafuzz-provider-homes. The moved directories keep their 0700.
    • To log in again instead, first create the home with (umask 077; mkdir -p ~/.ultrafuzz-provider-homes/<provider>/<config_dir>), then log in with it as CLAUDE_CONFIG_DIR, CODEX_HOME or KIMI_CODE_HOME. Codex refuses a CODEX_HOME that does not exist, and a plain mkdir -p under umask 0002 makes the directories 0775, which recreates Provider homes are refused on Ubuntu's default umask because ~/.local is group-writable #1236.
  • An operator who set XDG_STATE_HOME to relocate these homes sets ULTRAFUZZ_PROVIDER_HOME_ROOT instead. That variable also moves the home of a Claude, Codex or Kimi agent without a config_dir, from ~/.claude, ~/.codex or ~/.kimi-code (or CLAUDE_CONFIG_DIR, CODEX_HOME or KIMI_CODE_HOME) to <root>/claude, <root>/codex or <root>/kimi.
  • The canonical homes are not affected: ~/.claude, ~/.codex, ~/.kimi-code, and CLAUDE_CONFIG_DIR, CODEX_HOME or KIMI_CODE_HOME when set.
  • The CHANGELOG entry says all of this. docs/config.md also gives the umask 077 form for creating a home to log in to.

Why not #1240

#1240 keeps the default root and teaches the check to accept a group-writable ancestor.

  • To show that only the operator can write to that ancestor, it parses /etc/passwd, /etc/group and /etc/nsswitch.conf, and it probes ACLs by running /bin/ls.
  • That is +750/−28 lines, including a new systemTools exemption in the adapter-boundary gate.
  • It loosens a security check, and Greptile still left a P1 on it. A GNU ls built without ACL support prints no +, so a directory whose ACL lets another account write would be accepted.
  • By its own Risk section, it fails closed on hosts with BusyBox ls, no /bin/ls, or LDAP/SSSD accounts.

This PR leaves the check alone and moves the default to a directory the check already accepts. The product code changes by +10/−17 lines.

Verification

Tests. Each one fails on origin/main. I put main's provider-home.tsx, or main's data-governance.ts with dist-test rebuilt, into this tree.

  • provider-home.test.ts has a new test, the default provider-home root is a private directory directly under HOME that XDG_STATE_HOME does not move.
    • It runs under umask 0002. HOME is 0750, its .local is 0775, as on Ubuntu, and its .ultrafuzz is 0775, as ultrafuzz init leaves it when the home directory is a project. XDG_STATE_HOME is $HOME/.local/state.
    • It asserts that the default root is accepted and that every directory the adapter created is 0700, also under umask 0o277.
    • It asserts that data governance finds a Codex route config.toml in the configured home.
    • It asserts that an explicit ULTRAFUZZ_PROVIDER_HOME_ROOT at the old default path, below the 0775 .local, is still refused.
    • It fails with provider-home ancestors cannot be group/world writable on main's template and on the first revision's (~/.ultrafuzz/provider-homes), under umask 0002 and 022.
  • runtime.test.ts, Bun adapter contract: generated OpenRouter adapter preserves opaque model IDs…
    • It now runs under umask 0002 without ULTRAFUZZ_PROVIDER_HOME_ROOT. It uses a fixture HOME with a 0775 .local, and XDG_STATE_HOME pointed at its .local/state.
    • It asserts that CODEX_HOME is <home>/.ultrafuzz-provider-homes/openrouter/openrouter-test-codex and that every directory up to HOME is 0700.
    • On main's template it fails with the same message, under umask 0002 and 022. I ran it with a scratch starting HOME.

Mutants. Each one is killed:

  • With data-governance.ts on main's root or on the first revision's, the route assertion gets model:openai.
  • With chmodSync removed, mkdir gives 0500 under umask 0o277, and the test fails with provider homes must be operator-owned with private 0700 permissions.
  • With the template using os.homedir() instead of HOME, the Bun test fails: CODEX_HOME lands under the process's starting HOME.
  • The reviewer also killed mutants that honour XDG_STATE_HOME or relax the check to accept group-writable directories.

Migration commands, run against the template under umask 0002 with fixture homes:

  • The mv above, with XDG_STATE_HOME unset and with a custom one: the root is accepted and auth.json is kept.
  • (umask 077; mkdir -p ~/.ultrafuzz-provider-homes/codex/teams/codex): accepted. A plain mkdir -p: refused.
  • Codex 0.159.2 with a missing CODEX_HOME: Error loading configuration: CODEX_HOME points to "…", but that path does not exist.

Suites. This head is rebased on 42af2ff6, and dist-test was rebuilt from it.

Gates. I checked each exit code directly, and all are 0:

  • pnpm -w format:check
  • pnpm -w lint
  • CI=1 ESLINT_PLUGIN_DIFF_COMMIT=origin/main pnpm -w lint:strict:ci
  • pnpm -w knip
  • pnpm --filter @ultrafuzz/runtime typecheck
  • node scripts/docs-check.mjs

Risk

  • Breaking, by design. State in the old root is no longer used (see Who loses state in the old root).
    • Existing projects must rerun ultrafuzz init before planning. Planning's usual refusal says so.
    • A run launched earlier executes the adapters sealed at its launch. It keeps the old root until resume --refresh-controller.
  • XDG_STATE_HOME is ignored. An operator who used it to relocate provider homes must now set ULTRAFUZZ_PROVIDER_HOME_ROOT, which also moves the homes without a config_dir, as before.
  • A group- or world-writable ~/.ultrafuzz-provider-homes is refused, with the existing message, which does not name the directory. Ultrafuzz never creates one; a plain mkdir -p under umask 0002 does, which is why the CHANGELOG and docs/config.md give the umask 077 form.
  • The check reads the mode bits of ancestors, not their owners. This is unchanged from main. A sticky world-writable directory that another account owns passes as an ancestor, and that account can rename entries in it after validation.
    • For the default root this now leaves only HOME and the directories above it, since the root is a direct child of HOME and must itself be the operator's and 0700. With HOME=/tmp, another account can no longer own a directory between HOME and the root, as it can own /tmp/.local on main. It could pre-create the root, but the owner check then refuses it. This is from reading the code; I did not test it with a second account.
    • An explicit root below such a directory is still accepted, as on main.
  • The canonical homes now read HOME first. They resolve differently only in a process that changes HOME after it starts.
  • fix(runtime)!: keep the Forge guard under group-writable umasks, warn about old cloud runs' Modal storage in clean, and drop the smithers shim #1235 has merged. This branch is rebased onto it, keeping this PR's side of the OpenRouter test. fix(runtime)!: keep the Forge guard under group-writable umasks, warn about old cloud runs' Modal storage in clean, and drop the smithers shim #1235's private temporary root would bypass the default root that the test now covers.
  • docs/reference/agent-adapter-boundaries.md is unchanged, because it never named the old default. The boundary policy for provider-home.tsx (no responsibilities) still holds: chmodSync is not a filesystem-walking API, and the boundary suite passes.
  • This PR replaces fix(runtime): accept provider-home ancestors writable only by the operator's private group #1240 but does not close it.

Closes #1236

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no new actionable issue or outstanding previous finding remains.

Summary

The PR moves the default provider-home root directly under HOME, keeps data-governance route lookup aligned with the adapters, and documents the breaking change and migration. The revised tests cover a group-writable ~/.ultrafuzz, private directory creation, and OpenRouter adapter behavior.

Reviews (2) · Last reviewed commit: "fix(runtime)!: put the default provider-..."

@aviggiano
aviggiano requested a review from a team as a code owner September 30, 2026 23:05
Comment thread packages/runtime/src/templates/smithers/agents/provider-home.tsx Outdated
Comment thread CHANGELOG.md Outdated
aviggiano and others added 2 commits September 30, 2026 23:44
…ovider-homes

Under Ubuntu's default umask 0002, ~/.local is 0775. The provider-home
check refuses a group- or world-writable directory above a provider home
unless it is sticky, so it refused the old default root,
~/.local/state/ultrafuzz/provider-homes. OpenRouterAgent, DeepSeekAgent,
and a ClaudeAgent, CodexAgent or KimiAgent with a config_dir could not
start (#1236).

Without ULTRAFUZZ_PROVIDER_HOME_ROOT, the root is now
~/.ultrafuzz/provider-homes, directly under HOME. The adapter creates each
missing directory itself with mode 0700 and chmods it to 0700, so the mode
does not depend on the umask. XDG_STATE_HOME no longer selects the root.
The check itself is unchanged, so an explicit root below a group-writable
directory is still refused. Nothing is read from or moved out of the old
root.

data-governance.ts reads a configured provider's route config from that
home, so it computes the same root. The template now reads HOME before
os.homedir(), as data-governance.ts does. Bun's os.homedir() keeps the
HOME its process started with, so this also lets the Bun contract test
point the default root at a fixture home.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
~/.ultrafuzz is not a directory the adapters own. `ultrafuzz init`
creates a project's .ultrafuzz with the umask, so under Ubuntu's 0002 a
project at HOME leaves ~/.ultrafuzz 0775. The unchanged ancestor check
then refused ~/.ultrafuzz/provider-homes for every default-root agent in
every project. The root is now ~/.ultrafuzz-provider-homes, whose only
ancestors are /, /home and HOME.

The CHANGELOG migration now moves the old root from a custom
XDG_STATE_HOME too. For a new login it creates the home under umask 077,
since Codex refuses a CODEX_HOME that does not exist and a plain
mkdir -p under umask 0002 recreates #1236. It names
ULTRAFUZZ_PROVIDER_HOME_ROOT as the replacement for XDG_STATE_HOME, with
the canonical homes that variable also moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@aviggiano
aviggiano force-pushed the claude/provider-home-default-root branch from e6a9e95 to a0d1b51 Compare October 1, 2026 00:19
@aviggiano aviggiano changed the title fix(runtime)!: move the default provider-home root to ~/.ultrafuzz/provider-homes fix(runtime)!: move the default provider-home root to ~/.ultrafuzz-provider-homes Oct 1, 2026
@aviggiano
aviggiano merged commit 9203045 into main Oct 1, 2026
17 checks passed
@aviggiano
aviggiano deleted the claude/provider-home-default-root branch October 1, 2026 00:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provider homes are refused on Ubuntu's default umask because ~/.local is group-writable

1 participant