Skip to content

refactor(config): write .env secrets through abnegate-config - #167

Open
abnegate wants to merge 2 commits into
mainfrom
migrate/clf
Open

abnegate wants to merge 2 commits into
mainfrom
migrate/clf

Conversation

@abnegate

@abnegate abnegate commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Depends on #161 (Docker toolchain): the image must build on Rust 1.98 before it can compile abnegate-config (edition 2024, rust-version 1.97).

.env breaks

  • webhook --setup now needs write access to the .env file's directory. The file is replaced atomically: a temporary file in the same directory is renamed over it. A layout such as EnvironmentFile=/etc/claudear/env in a root-owned 0755 /etc/claudear, with the file chowned to a non-root service user, now makes claudear webhook --setup --env-file /etc/claudear/env fail with Configuration error: Failed to write '/etc/claudear/env': Permission denied (os error 13), where the old in-place write succeeded. The systemd unit in the docs has no User= and runs as root, so it keeps working. Fix: make the directory writable by the user that runs setup, or point --env-file into a directory it owns. The check runs before any webhook is created remotely, so a failing path never leaves orphaned webhooks without their saved secrets.
  • The rewritten file is a new inode: owner and group become the user that ran claudear, hard links to it stop seeing updates, and a symlink at the path is replaced rather than written through.
  • A .env bind-mounted into a container as a single file cannot be renamed over (EBUSY). webhook --setup now refuses it before contacting any provider: Configuration error: Failed to write '/app/.env': it is a mount point, such as a single file bind-mounted into a container, so a rename cannot replace it; mount its directory instead. The shipped docker-compose.yml does not mount it that way. Fix: mount the directory that holds the file.
  • Another user's .env in a sticky directory (such as /tmp) that the running user does not own either is refused the same way (another user owns it in a sticky directory, …), since the rename would fail with EPERM.
  • The file is always 0600, and a missing parent directory is created 0700.
  • Quoting: values the shell would interpret (spaces, $, #, ", \) are now single quoted ('pa$$word') instead of double quoted. Only values containing ' or a line break are double quoted, with \n, \r, \\, \", \$ and \` escapes; none of the values claudear writes (generated secrets, ids, URLs) do. Those escapes are not read the same everywhere: dotenvy 0.15 rejects \r and \` inside double quotes, and systemd's EnvironmentFile= keeps \n literal. Bare and single-quoted values read the same in docker-compose, dotenv readers, systemd and a POSIX shell.
  • The # Auto-configured webhook secrets header is no longer added. Existing files keep working: comments, blank lines, the old header and old double-quoted values are left untouched and still parse.

API changes

  • Removed the public re-exports claudear::env_writer, claudear_config::env_writer and claudear_config::update_env_file. Use claudear_config::environment::update.

What changed

  • Deleted crates/claudear-config/src/env_writer.rs. New claudear_config::environment:
    • update(path, values) wraps abnegate_config::EnvironmentFile::update (abnegate-config 0.1.3 from crates.io) and maps failures to Error::Config with the full cause chain.
    • probe(path) fails the way update would without changing anything. It checks that an existing file is readable. When the directory exists it rehearses the writer there: a sibling temporary file is renamed over a scratch file in the same directory and the directory is synced, as abnegate_config's PrivateFile does after its rename. It then refuses a destination a rename cannot replace: a mount point (its device differs from its directory's, or /proc/self/mountinfo lists it, which also catches a same-filesystem bind mount on Linux), or another user's file in a sticky directory that the running user does not own (the running user is the owner of the file the probe just created, so root-squashed and remapped users are judged as the kernel judges them). When the directory is still to be created, its nearest existing ancestor must accept a new entry.
    • The decision lives in environment/replacement.rs (Replacement, the facts the kernel weighs), environment/refusal.rs (Refusal) and environment/mount_table.rs (MountTable, the /proc/self/mountinfo reader with its octal-escape decoding). Unix only; elsewhere only the rehearsal runs.
    • pub const values for the 13 keys claudear writes, plus KEYS.
  • webhook/configurator.rs configure() calls environment::probe before any remote call, then environment::update at the end. github_app/routes.rs save_credentials calls environment::update. Both use the key constants. Nearby house-rule fixes: full-word names (credentials, permissions, error, updates), one import per statement, no narrating comments, and the duplicated step number in the configure doc comment.
  • Added crates/claudear-config/tests/environment.rs (13 tests):
    • every value round-trips under every written key;
    • existing lines survive, and new keys are appended sorted without a header;
    • the exact quoting forms;
    • a file written by the old writer still reads;
    • rewrites are idempotent;
    • the file is 0600 and a created directory is 0700;
    • a read-only directory yields Error::Config naming "Permission denied";
    • the probe leaves no trace and fails the same way;
    • the probe refuses a directory that cannot be opened to sync the rename (0300), where update also fails (after it has already renamed the file);
    • the probe accepts this user's own file in its own sticky (01700) directory.
  • Unit tests for the decision: a destination on another device than its directory, a same-device mount point, another user's file in another user's sticky directory, the file owner, the directory owner and root being allowed, no sticky bit, and the mountinfo parser (bind-mounted file, \040/\134 escapes, malformed escapes kept).
  • Added a configurator regression test: with env_path in a 0500 directory, configure() returns that Error::Config before its stand-in Jira server sees a single connection.

Why

Part of moving claudear onto the published abnegate-* crates: one shared, tested .env upserter instead of a local copy. The crate's write is also safer: a crash can no longer leave the file truncated or briefly world-readable, and a secret value can never inject an extra line.

How it was verified

  • After rebasing onto main (95fab34): claudear-verify.sh <worktree> clf ready, which runs cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings -A clippy::double_must_use and cargo test over claudear-core, claudear-config, claudear-storage, claudear-analysis, claudear-integrations, claudear-engine and claudear (default features, nomic fastembed model and CLAUDEAR_VECTORLITE_PATH set). Passed: claudear-config 330 unit + 13 integration, claudear-integrations 4299 (3 ignored), claudear 315 + 2 + 1 + 13, and every other crate.
  • Regression test seen failing: with the new tests and the previous probe, test_probe_fails_like_update_when_the_directory_cannot_be_opened_to_sync_the_rename failed with expected a config error, got Ok(()) (12 passed, 1 failed). It passes with the change.
  • The configurator test from the first commit fails without the probe (1 connection reached the stand-in server) and passes with it.

What was NOT verified

  • The bind-mount and foreign-sticky-file refusals end to end: making a bind mount or a file owned by another user needs root, so those branches are covered by unit tests of Replacement::refusal and of the mountinfo parser, plus an inspection of a real own file in an own sticky directory. No Linux container run of webhook --setup against a bind-mounted .env.
  • --all-features (CUDA) build; CI covers it.
  • Already-ignored tests: create_chat_router_constructs_successfully (model download), two macOS port-forward integration tests (sudo).
  • No end-to-end run of webhook --setup or the GitHub App callback against live providers. The write path is covered through environment::update and environment::probe.
  • tests/shutdown.rs::test_the_daemon_keeps_runtime_files_while_it_runs failed once locally with EADDRINUSE, a race in that file's free_port() that this change does not touch. It passed on re-run.

Behaviour differences

  • Atomic replace: the directory must be writable, the file gets a new inode (owner/group change, hard links break, symlinks replaced), the file is 0600 and a missing parent is created 0700.
  • webhook --setup refuses, before any remote call, a .env that is a mount point (single-file bind mount), another user's file in a sticky directory, or a file in a directory that cannot be opened to sync the rename. Previously the bind mount failed with EBUSY only after the webhooks were created.
  • webhook --setup now checks the .env path before contacting any provider and fails fast if it could not write it.
  • Quoting style and header removal as above. Keys must be shell variable names (InvalidKey); every key claudear writes qualifies.
  • Error text is now Configuration error: Failed to write '<path>': <cause> (was Failed to write .env file at "<path>": <cause>).
  • Unchanged: comments and blank lines are kept, existing keys keep their order, new keys are appended sorted, rewrites are idempotent, and the file ends with a newline.

Overlapping PRs

🤖 Generated with Claude Code

@hansi-codes

hansi-codes Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🟡 Tier B · Needs changes before merging

Limited by an open major finding: Update the Docker toolchain alongside the new dependency

This change replaces the local .env writer with abnegate-config, adds key constants and a write preflight, and migrates webhook and GitHub App credential persistence to the shared API. It also adds tests for serialization, permissions, existing-file preservation, and webhook setup ordering.

Verdict New comments Fixed Still open
💬 Commented 0 0 1
Fix with agent prompt
### Issue 1
Cargo.toml:153
**Update the Docker toolchain alongside the new dependency**

The shipped Dockerfile still defaults to Rust 1.93 (line 2), while abnegate-config requires Rust 1.97. Its unconditional use by claudear-config makes the Docker build fail; please include the toolchain update here or land the prerequisite before this change.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 6
File Change
Cargo.toml, crates/claudear-config/Cargo.toml Adds the shared configuration dependency and the tempfile runtime dependency.
crates/claudear-config/src/environment.rs and crates/claudear-config/src/environment/ Adds the shared environment update API, key constants, write probe, and Unix replacement checks.
crates/claudear-config/src/lib.rs, crates/claudear-config/src/env_writer.rs, src/lib.rs Replaces the old writer module and updates the public re-exports.
crates/claudear-config/tests/environment.rs Covers environment updates, quoting, permissions, and probe behavior.
crates/claudear-integrations/src/github_app/routes.rs Persists GitHub App credentials through the shared environment API.
crates/claudear-integrations/src/webhook/configurator.rs Preflights the environment path before webhook setup and uses shared key constants.
⏳ Still open from earlier reviews · 1
  • 🟠 Update the Docker toolchain alongside the new dependency · Cargo.toml:153

Reviewed e298326 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · 1 blocking finding to address. Summary

Comment thread Cargo.toml
http-body-util = "0.1"

# Shared infrastructure
abnegate-config = "0.1.3"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the Docker toolchain alongside the new dependency

The shipped Dockerfile still defaults to Rust 1.93 (line 2), while abnegate-config requires Rust 1.97. Its unconditional use by claudear-config makes the Docker build fail; please include the toolchain update here or land the prerequisite before this change.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Cargo.toml
Line: 153

Comment:
**Update the Docker toolchain alongside the new dependency**

The shipped Dockerfile still defaults to Rust 1.93 (line 2), while abnegate-config requires Rust 1.97. Its unconditional use by claudear-config makes the Docker build fail; please include the toolchain update here or land the prerequisite before this change.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟠 Major · bug · Reply if this doesn't apply.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is handled by #161, which moves the Docker image to Rust 1.98 (abnegate-config needs 1.97). #161 is rebased onto main and its CI is green; #167 now says "Depends on #161 (Docker toolchain)" at the top of its body and should merge after it. Keeping the toolchain bump in its own PR because every abnegate migration PR depends on it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sequencing resolves the issue only once #161 is merged and its toolchain bump is present in the base. In this PR, claudear-config unconditionally depends on abnegate-config (Cargo.toml), while the Docker builder still defaults to Rust 1.93 (Dockerfile, builder stage). The dependency is added at Cargo.toml:154.

Please keep this PR blocked until #161 is merged and the resulting base has the required toolchain, or update the Dockerfile here. A note that it should merge afterward doesn’t prevent this PR from being merged first.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed: #167 stays blocked until #161 is merged into main. After that it gets rebased onto the new base (which then has RUST_VERSION=1.98) before it is merged, so the toolchain bump lives in one place rather than being duplicated across every migration PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sequencing is fine as a plan, but this PR’s current head still fails the Docker build: Dockerfile defaults to Rust 1.93 (lines 2, 62), while claudear-config unconditionally adds abnegate-config (lines 12–14). I’ll keep this finding open until #161 is merged, this branch is rebased onto the toolchain update, and the Docker build passes.

Comment thread crates/claudear-config/src/environment.rs Outdated
abnegate and others added 2 commits October 9, 2026 15:08
claudear kept its own .env upserter (env_writer.rs). abnegate-config's
EnvironmentFile does the same job and is the shared implementation, so
the local copy goes, together with its public re-exports
`claudear::env_writer`, `claudear_config::env_writer` and
`claudear_config::update_env_file`. claudear-config keeps one entry
point, environment::update, which both writers (the GitHub App manifest
callback and `webhook --setup`) call, with constants for the 13 keys
claudear writes. It renders the abnegate-config error chain into
Error::Config, so failures still name their cause ("Failed to write
'.env': Permission denied (os error 13)").

The write is now atomic: a temporary file in the same directory is
renamed over the target. That has consequences an operator can hit:
- The parent DIRECTORY must be writable by the user running claudear.
  A layout such as EnvironmentFile=/etc/claudear/env in a root-owned
  0755 directory, with the file chowned to a non-root service user, now
  makes `claudear webhook --setup --env-file /etc/claudear/env` fail
  with Permission denied, where the old in-place write succeeded. (The
  systemd unit in the docs has no User= and runs as root, so it keeps
  working.)
- The file gets a new inode, so its owner and group become the writing
  user's and any hard link to it no longer sees updates. A symlink at
  the path is replaced rather than written through.
- A .env bind-mounted into a container as a single file cannot be
  renamed over and fails with EBUSY. The shipped docker-compose.yml
  does not mount it that way.
- A missing parent directory is created 0700; the file is always 0600.
  Previously it was written in place and chmodded afterwards, best
  effort, so a crash could leave it truncated or briefly world readable.

Because `webhook --setup` writes the secrets only after creating every
webhook remotely, a path that can no longer be written would have
failed after Linear, Sentry, GitLab, GitHub and Telegram hooks existed,
dropping their secrets, and Linear setup refuses to re-run against an
existing webhook URL. configure() therefore calls environment::probe
first: it fails the way update would (unreadable file, or a directory
that will not accept a new file, checking the nearest existing ancestor
when the directory is still to be created) without leaving anything
behind, before any remote call.

Quoting changes: values the shell would interpret (spaces, $, #, ", \)
are single quoted instead of double quoted. Only values holding ' or a
line break are double quoted, with \n, \r, \\, \", \$ and \` escapes;
none of the values claudear writes (generated secrets, ids, URLs) do.
Those escapes are not universal: dotenvy 0.15 rejects \r and \` inside
double quotes and systemd's EnvironmentFile= keeps \n literal. Bare and
single-quoted values read the same everywhere.

New keys are appended sorted after a blank line without the
"# Auto-configured webhook secrets" header. Existing files keep working:
comments, blank lines, the old header and old double-quoted values are
left as they are and still read back. Keys must be shell variable names;
all 13 are.

Tests in claudear-config pin what claudear relies on: every value
round-trips under every written key, existing lines survive, the
quoting forms, a file from the old writer still reads, rewrites are
idempotent, the file is 0600 and a created directory 0700, a read-only
directory yields Error::Config naming the permission error, and the
probe fails the same way while leaving no trace. A configurator test
points env_path into a 0500 directory and asserts configure() fails
with that error before its stand-in Jira server sees any connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng webhooks

The probe only created a temporary file next to the destination. That
succeeds for a single file bind-mounted into a container and for another
user's file in a sticky directory, yet the rename that replaces it then
fails with EBUSY, EXDEV or EPERM, after webhook setup has already created
the remote webhooks whose secrets it cannot save. It also passed a
directory that cannot be opened for the post-rename sync, which the
writer reports as a failure.

The probe now rehearses the writer: it renames a sibling temporary file
over a scratch file in the same directory and syncs the directory, and
it refuses a destination that is a mount point (its device differs from
its directory's, or /proc/self/mountinfo lists it) or that belongs to
another user in a sticky directory neither of them owns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant