Skip to content

feat(stelae): authenticate a registry with basic credentials - #1184

Merged
scarmuega merged 6 commits into
mainfrom
feat/stelae-basic-credentials
Aug 9, 2026
Merged

feat(stelae): authenticate a registry with basic credentials#1184
scarmuega merged 6 commits into
mainfrom
feat/stelae-basic-credentials

Conversation

@scarmuega

@scarmuega scarmuega commented Aug 8, 2026

Copy link
Copy Markdown
Member

Plan

plans/dolos-stelae-registry-basic-auth.mdStelae: Basic credentials for the registry client (Brain/txpipe, org/coder). The Cloudflare registry deployment plan awaits it: its validation gates run through this client.

What changed

Client surface. stelae::oci::Options gains an auth value — anonymous / bearer / basic — supplied by the caller. The crate keeps the environment grammar (it owns the variable names) in Auth::from_env, but nothing calls it behind a caller's back: which identity a program authenticates as is that program's policy. Auth's Debug redacts, so a password cannot reach a backtrace through Options.

Consumer surface. [stelae.registry] in dolos.toml names the user; the password is compiled in. dolos init seeds the section from dolos_core::config::OFFICIAL_REGISTRY_USER and writes no password, and StelaeRegistryConfig::password() falls back to OFFICIAL_REGISTRY_PASSWORD when the file names none. A password copied into every generated config has to be found again in every one of them; a compiled-in default rotates with a release. A node pointing at a private registry sets password and keeps its own.

Both constants ship empty — one site, two lines, filled once the deployment plan provisions the registry. Until then init writes no section rather than one naming nobody.

Publisher surface. STELAE_REGISTRY_USER / STELAE_REGISTRY_PASSWORD, read where STELAE_REGISTRY_TOKEN is. dolos_snapshot::registry::auth resolves both directions in one rule: environment wins, configuration is the fallback, neither is anonymous. A token and a pair together is a refusal naming both; so is half a pair.

Testing. The canonical e2e harnesses are rebound, not duplicated: every registry they spawn is behind htpasswd and every transport carries the pair. Both server families are configured unconditionally — distribution reads REGISTRY_AUTH_* from the environment, zot reads /etc/zot/config.json, and neither notices the other's — so there is no per-image branch. credentials_are_required (stelae) and a_node_authenticates_with_its_configured_pair (dolos-snapshot) assert the server really refuses an unauthenticated request; that is also the fixture's honesty check, since a server it could not authenticate would run anonymous with every other test passing. A 401 read as absence would silently restart a publisher's history chain, so that refusal is now checked against a server that really answers 401.

Done criterion

1. Config credentials on restore, env pair on publish, precedence, and the refusal. Against a real htpasswd registry:2, with [stelae.registry] deliberately holding the wrong password:

############ 1. config credentials only (deliberately wrong)
Error:   × restoring the stele
  ╰─▶ Not authorized: url http://127.0.0.1:57120/v2/dolos/mainnet/manifests/latest

############ 2. same config, correct pair exported
  ╰─▶ Registry error: url http://127.0.0.1:57120/v2/dolos/mainnet/manifests/
      latest, envelope: OCI API errors: [OCI API error: manifest unknown]

The environment got past auth; the only remaining complaint is that the repository is empty. And the refusal:

$ STELAE_REGISTRY_TOKEN=… STELAE_REGISTRY_USER=… STELAE_REGISTRY_PASSWORD=… \
    dolos bootstrap stelae --source oci://…
Error:   × opening the repository
  ╰─▶ STELAE_REGISTRY_TOKEN and STELAE_REGISTRY_USER/STELAE_REGISTRY_PASSWORD
      are both set; registry credentials come from one of the two and which
      one was meant is not something to guess at — unset the one you did not
      mean

2. A fresh dolos init seeds the section, without a password. With the constants temporarily filled (OFFICIAL_REGISTRY_USER = "dolos"), the generated dolos.toml reads:

[mithril]
aggregator = "https://aggregator.release-mainnet.api.mithril.network/aggregator"
genesis_key = "5b3139312c…"
ancillary_key = "5b32332c…"

[stelae.registry]
user = "dolos"

[chain]
type = "cardano"
magic = 764824073
is_testnet = false

With them empty as they ship, the section is absent and nothing else changes. init::tests::a_fresh_config_seeds_the_official_registry_and_no_password holds both true — the generated config parses, round-trips to exactly StelaeConfig::official(), and never contains the word password — and a_configured_password_overrides_the_compiled_in_one covers the private-registry case and the fallback.

3. Canonical harness against a basic-authed registry:2.

running 9 tests
test a_layer_that_is_not_the_one_described_is_refused ... registry: registry:2 on 127.0.0.1:56820, plaintext, basic auth as "stelae"
...
test credentials_are_required ...
anonymous: registry error: Not authorized: url http://127.0.0.1:56845/v2/stelae/credentials/manifests/latest
the wrong password: registry error: Not authorized: url http://127.0.0.1:56845/v2/stelae/credentials/manifests/latest
ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 7 filtered out; finished in 13.32s

The whole matrix, authenticated: registry:2, registry:3 and zot v2.1.20 — 9/9 for stelae, 5/5 for dolos-snapshot's publish, 5/5 for restore_registry, on each, locally and in CI.

4. Docs. ADR-004's "What the transport requires of its host" now states the three shapes, the variable table and both refusals; its config sketch names [stelae.registry] and says where the password lives. docs/content/configuration/schema.mdx gains a stelae section documenting the user, the optional password and the environment variables.

Verification

  • cargo clippy --workspace --all-targets --all-features — clean
  • cargo test --workspace --all-targets — pass
  • cargo test --workspace --all-features --exclude dolos-minibf --exclude dolos-minikupo --exclude dolos-trp — pass
  • the three #[ignore]d registry suites against all three matrix images — pass
  • cargo tree -p stelae -e normal --all-features still matches no dolos-* package; no new dependency

Notes for the reviewer

  • The read-only credential is a published secret by owner decision (2026-08-08). Rotating it now means a release rather than an edit to every generated config; that risk is carried by the deployment plan, which owns rotation.
  • Half a pair is refused as well as pair-plus-token. The plan named only the latter; a user with no password in the environment is the same class of operator mistake. (A user with no password in dolos.toml is the opposite — it is the seeded shape, and means "use the compiled-in one".)
  • dolos snapshot publish also passes the configured credentials. They are read-only, so authenticating with them fails the push at the registry rather than a step earlier — the honest place for "these credentials cannot publish" to be said.
  • There is no dolos bootstrap stelae page under docs/content/ — a pre-existing gap, not opened by this change. The config section and the environment variables are documented in the schema page and ADR-004.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added configurable registry authentication for anonymous access, bearer tokens, and HTTP Basic credentials.
    • Added registry credential settings, default-password behavior, and environment-variable overrides.
    • Publishing and restoring repositories now use configured authentication automatically.
  • Bug Fixes

    • Added validation for conflicting or incomplete credentials.
    • Prevented authentication failures from appearing as empty repositories.
  • Documentation

    • Documented registry authentication options, defaults, overrides, and configuration examples.

The transport spoke bearer-token-or-anonymous, which is the shape GHCR
wanted. The registry stele distribution is heading for authenticates
every request with an HTTP Basic pair instead: access stays free and
identity-less, and is still credentialed.

`Options` now carries the credentials, and the caller decides them.
`Auth::from_env` still owns the environment grammar — the crate owns the
variable names — but nothing calls it behind a caller's back: which
identity a program authenticates as is that program's policy.

Two sources, one rule, resolved once in `dolos_snapshot::registry::auth`.
A consumer's published read-only pair comes from `[stelae.registry]` in
dolos.toml, which `dolos init` seeds from a single constants site (empty
until the registry that issues it exists, and a commented template in the
generated file until then). A publisher's full-access pair comes from
STELAE_REGISTRY_USER/_PASSWORD and overrides it, so a node carrying the
read-only pair can publish without having it removed first. A token and a
pair set together is a refusal naming both, not a precedence puzzle, and
so is half a pair.

The e2e harnesses are rebound rather than duplicated: every registry they
spawn is behind htpasswd and every transport they open carries the pair.
Both server families are configured unconditionally — distribution reads
REGISTRY_AUTH_* from the environment and zot reads /etc/zot/config.json,
and neither notices the other's — so there is no per-image branch. New
tests assert that the registry really does refuse an unauthenticated
request, which is also what keeps the fixture honest: a server it could
not put behind htpasswd would run anonymous with everything else passing.
A 401 read as absence would silently restart a publisher's history chain,
so the refusal is checked against a server that really answers 401.

Verified: the three registry suites against registry:2, registry:3 and
zot v2.1.20; `cargo test --workspace --all-targets`;
`cargo test --workspace --all-features` less the three service crates;
clippy clean; `cargo tree -p stelae -e normal --all-features` still
matches no dolos package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Registry authentication now uses caller-supplied Auth values. Dolos resolves Stelae registry credentials from configuration and environment variables. OCI and snapshot fixtures now enforce authenticated access.

Changes

Registry authentication

Layer / File(s) Summary
OCI authentication contract
crates/stelae/src/oci.rs, adrs/004_stelae_snapshots.md
Auth supports anonymous, bearer-token, and Basic credentials. Options::auth passes credentials to Registry::open. Debug output redacts secrets.
Configuration and authentication resolution
crates/core/src/config.rs, src/bin/dolos/common.rs, src/bin/dolos/init.rs, docs/content/configuration/schema.mdx, adrs/004_stelae_snapshots.md
Dolos adds Stelae registry configuration, official defaults, environment overrides, validation, tests, and documentation.
Registry call-site wiring
crates/snapshot/src/registry.rs, src/bin/dolos/bootstrap/stelae.rs, src/bin/dolos/snapshot/publish.rs
Restore and publish flows resolve registry authentication and pass it to registry opening.
Authenticated registry fixtures and validation
crates/stelae/tests/oci.rs, crates/snapshot/tests/registry_fixture/*, crates/snapshot/tests/restore_registry.rs
Distribution and Zot fixtures require Basic authentication. Tests cover valid, anonymous, and incorrect credentials during OCI access and snapshot restore.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Dolos
  participant stele_registry_auth
  participant registry_open as registry::open
  participant OCIRegistry as Registry::open
  Dolos->>stele_registry_auth: Resolve configured credentials
  stele_registry_auth->>registry_open: Supply Auth
  registry_open->>OCIRegistry: Pass Auth in registry options
  OCIRegistry->>OCIRegistry: Create authenticated OCI client
Loading

Possibly related PRs

  • txpipe/dolos#1168: Introduced the snapshot publishing call site that now passes registry authentication.
  • txpipe/dolos#1169: Modified the Stelae restoration flow that now supplies authenticated registry access.
  • txpipe/dolos#1170: Introduced the OCI registry authentication API extended here with caller-supplied Auth.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding basic-credential authentication for Stelae registries.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stelae-basic-credentials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`rustfmt.toml` turns on `wrap_comments`, which only nightly applies —
the CI job runs `cargo +nightly fmt --all -- --check` for exactly that
reason, and stable had nothing to say about these.

Two lines are reworded rather than rewrapped: a doc comment that nightly
would have split an identifier across, and one it would have split
`dolos init` across.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@scarmuega
scarmuega marked this pull request as ready for review August 8, 2026 23:23
@scarmuega

Copy link
Copy Markdown
Member Author

CI note: the first two attempts at Test were red on daemon_syncs_for_preprod_full_implicit (tests/e2e/sync.rs:59, before=0, after=0) — the 60-second live sync against preprod-node.world.dev.cardano.org. The preview scenarios passed in the same runs, main was green an hour earlier, and nothing here touches upstream sync, the peer path or bootstrap relay. It cleared on re-run without a code change. Recording it rather than passing over it: the scenario is network-dependent and there is no retry around it.

Two corrections to the shape `dolos init` writes.

The generated `dolos.toml` no longer carries a commented-out template.
Serde cannot emit comments, so it was appended after serialization —
a mechanism this repo uses nowhere else, for a section the schema page
already documents.

And the password is no longer seeded into the file. `dolos init` writes
`[stelae.registry]` with the official registry's `user` and nothing else;
`StelaeRegistryConfig::password()` falls back to a compiled-in constant
when the file names none. A password copied into every generated config
is a password that has to be found again in every one of them, whereas a
compiled-in default rotates with a release. A node pointing at a private
registry sets `password` and keeps its own; the environment still
overrides both.

`OFFICIAL_REGISTRY_USER` and `OFFICIAL_REGISTRY_PASSWORD` are the two
constants the registry deployment fills, still one site, still empty
here — so today init writes no section at all rather than one naming
nobody. With them set the file reads:

    [stelae.registry]
    user = "dolos"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@scarmuega

Copy link
Copy Markdown
Member Author

Pushed 304c5cc1, correcting two things in the shape dolos init writes.

No commented-out template. The generated dolos.toml had a commented [stelae.registry] block appended after serialization — a mechanism this repo uses nowhere else. Gone; the schema page documents the section.

The password is compiled in, not seeded. dolos init now writes the section with the official registry's user and nothing else, and StelaeRegistryConfig::password() falls back to OFFICIAL_REGISTRY_PASSWORD when the file names none. A password copied into every generated config has to be found again in every one of them; a compiled-in default rotates with a release. A private registry sets password and keeps its own, and the environment still overrides both.

Both constants still ship empty, so today init writes no section rather than one naming nobody. With them filled the generated file reads:

[stelae.registry]
user = "dolos"

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/stelae/src/oci.rs (1)

1688-1722: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

with_env leaks registry environment variables when a test panics. Both crates carry a copy of the same helper, and both restore the previous values only on the normal return path. A failed assertion inside body() unwinds past the restore loop, so STELAE_REGISTRY_TOKEN, STELAE_REGISTRY_USER, and STELAE_REGISTRY_PASSWORD stay set for every later test in the same binary. The result is that one genuine failure can turn into several unrelated ones, which is exactly what the helper's own doc comment says it exists to prevent.

  • crates/stelae/src/oci.rs#L1688-L1722: capture previous into a guard struct whose Drop writes the values back, then call body() as the tail expression.
  • crates/snapshot/src/registry.rs#L967-L1003: apply the same guard here, or move the helper into one shared test-support location so the two copies cannot diverge.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/stelae/src/oci.rs` around lines 1688 - 1722, Update with_env in
crates/stelae/src/oci.rs (lines 1688-1722) and crates/snapshot/src/registry.rs
(lines 967-1003) to store the captured environment values in a guard whose Drop
implementation restores them, then invoke body() as the tail expression so
restoration also occurs during unwinding; apply the same change to both copies
or consolidate them into shared test support.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/core/src/config.rs`:
- Around line 778-790: Update StelaeRegistryConfig::password to return
Option<&str> and treat both a missing configured password and an empty
OFFICIAL_REGISTRY_PASSWORD as absent, returning None in those cases. Preserve
non-empty configured passwords, and let dolos_snapshot::registry::auth handle
None by selecting Auth::Anonymous rather than sending an empty password.

In `@docs/content/configuration/schema.mdx`:
- Around line 428-434: Update the introductory sentence above the credentials
table to clarify that the two table rows describe three variables, while
preserving the existing explanation that these variables override the section
and contain publisher credentials.

---

Nitpick comments:
In `@crates/stelae/src/oci.rs`:
- Around line 1688-1722: Update with_env in crates/stelae/src/oci.rs (lines
1688-1722) and crates/snapshot/src/registry.rs (lines 967-1003) to store the
captured environment values in a guard whose Drop implementation restores them,
then invoke body() as the tail expression so restoration also occurs during
unwinding; apply the same change to both copies or consolidate them into shared
test support.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ae9f641-a83f-4e93-8c5e-8e99b523d269

📥 Commits

Reviewing files that changed from the base of the PR and between c4b2614 and 304c5cc.

📒 Files selected for processing (12)
  • adrs/004_stelae_snapshots.md
  • crates/core/src/config.rs
  • crates/snapshot/src/registry.rs
  • crates/snapshot/tests/registry_fixture/mod.rs
  • crates/snapshot/tests/restore_registry.rs
  • crates/stelae/src/lib.rs
  • crates/stelae/src/oci.rs
  • crates/stelae/tests/oci.rs
  • docs/content/configuration/schema.mdx
  • src/bin/dolos/bootstrap/stelae.rs
  • src/bin/dolos/init.rs
  • src/bin/dolos/snapshot/publish.rs

Comment thread crates/core/src/config.rs Outdated
Comment thread docs/content/configuration/schema.mdx Outdated
`stelae` had no business naming an environment variable. A protocol
library that reads one is choosing its host's credential policy, and
publishing the name freezes that choice into its API: every program
embedding the transport inherits three `STELAE_*` variables it never
asked for and cannot rename. The justification in the code was circular
— the crate owned the names because they started with STELAE.

So `TOKEN_ENV`, `USER_ENV`, `PASSWORD_ENV`, `Auth::from_env` and the two
refusal variants leave `stelae` entirely; the crate no longer references
`std::env` at all. What stays is the interface: `Options::auth` takes an
`Auth` the caller constructs.

`dolos_snapshot::registry::open` takes an `Auth` too rather than a
config type, so the profile crate does not source credentials either.
The names, the precedence and both refusals land in
`dolos::common::stele_registry_auth` — the binary whose deployment they
actually describe. It reads `&StelaeConfig` and the environment, and the
refusals become plain miette errors, which is also why the CLI now says:

    Error:   × STELAE_REGISTRY_TOKEN and STELAE_REGISTRY_USER/…
             are both set; …

rather than the same sentence three times through two error types.

ADR-004 follows: the protocol section states that credentials arrive
from the caller and names no variable and no configuration key. The
variable table moves to "CLI and configuration", where it is Dolos's
answer rather than the format's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/stelae/src/oci.rs (1)

1621-1644: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: assert redaction for the Bearer and Basic variant labels only, and add Anonymous.

The test covers Bearer and Basic. Add a case for Auth::Anonymous so a future Debug implementation change cannot silently make the anonymous case print something unexpected. This is low value; skip it if you prefer the current scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/stelae/src/oci.rs` around lines 1621 - 1644, The
credentials_are_redacted_in_debug_output test should also cover Auth::Anonymous.
Format the anonymous variant with Debug and assert its output preserves the
expected Anonymous variant label without introducing credential-like content,
while leaving the existing Basic and Bearer assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bin/dolos/common.rs`:
- Around line 430-470: Update with_env to restore the captured registry
environment variables during unwinding as well as normal completion. Introduce a
local Drop guard around the existing previous-value restoration, ensure the
guard is created after apply([token, user, password]), and remove the direct
restoration loop after body() so the guard handles cleanup before returning or
propagating a panic.

---

Nitpick comments:
In `@crates/stelae/src/oci.rs`:
- Around line 1621-1644: The credentials_are_redacted_in_debug_output test
should also cover Auth::Anonymous. Format the anonymous variant with Debug and
assert its output preserves the expected Anonymous variant label without
introducing credential-like content, while leaving the existing Basic and Bearer
assertions unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a8bea7f9-c449-4124-b87b-827469f239e5

📥 Commits

Reviewing files that changed from the base of the PR and between 304c5cc and 2507ee1.

📒 Files selected for processing (8)
  • adrs/004_stelae_snapshots.md
  • crates/snapshot/src/registry.rs
  • crates/snapshot/tests/registry_fixture/mod.rs
  • crates/snapshot/tests/restore_registry.rs
  • crates/stelae/src/oci.rs
  • src/bin/dolos/bootstrap/stelae.rs
  • src/bin/dolos/common.rs
  • src/bin/dolos/snapshot/publish.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/bin/dolos/snapshot/publish.rs
  • crates/snapshot/tests/restore_registry.rs
  • src/bin/dolos/bootstrap/stelae.rs
  • adrs/004_stelae_snapshots.md

Comment thread src/bin/dolos/common.rs Outdated
Comment on lines +430 to +470
fn with_env<T>(
token: Option<&str>,
user: Option<&str>,
password: Option<&str>,
body: impl FnOnce() -> T,
) -> T {
let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner());

let names = [
STELE_REGISTRY_TOKEN_ENV,
STELE_REGISTRY_USER_ENV,
STELE_REGISTRY_PASSWORD_ENV,
];

let previous: Vec<(&str, Option<String>)> = names
.into_iter()
.map(|name| (name, std::env::var(name).ok()))
.collect();

let apply = |values: [Option<&str>; 3]| {
for (name, value) in names.into_iter().zip(values) {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
};

apply([token, user, password]);

let outcome = body();

for (name, value) in previous {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}

outcome
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore the environment even when body panics.

Lines 462-467 run only if body() returns normally. An assertion failure inside a with_env closure panics, so the restore loop is skipped and the registry variables stay set for every later test in this binary. That is the exact leak the comment at lines 427-429 describes. Move the restore into a Drop guard so unwinding also restores it.

🛡️ Proposed fix using a restore guard
+        struct Restore(Vec<(&'static str, Option<String>)>);
+
+        impl Drop for Restore {
+            fn drop(&mut self) {
+                for (name, value) in self.0.drain(..) {
+                    match value {
+                        Some(value) => std::env::set_var(name, value),
+                        None => std::env::remove_var(name),
+                    }
+                }
+            }
+        }
+
         let previous: Vec<(&str, Option<String>)> = names
             .into_iter()
             .map(|name| (name, std::env::var(name).ok()))
             .collect();
+        let _restore = Restore(previous);
@@
         apply([token, user, password]);
 
-        let outcome = body();
-
-        for (name, value) in previous {
-            match value {
-                Some(value) => std::env::set_var(name, value),
-                None => std::env::remove_var(name),
-            }
-        }
-
-        outcome
+        body()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn with_env<T>(
token: Option<&str>,
user: Option<&str>,
password: Option<&str>,
body: impl FnOnce() -> T,
) -> T {
let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let names = [
STELE_REGISTRY_TOKEN_ENV,
STELE_REGISTRY_USER_ENV,
STELE_REGISTRY_PASSWORD_ENV,
];
let previous: Vec<(&str, Option<String>)> = names
.into_iter()
.map(|name| (name, std::env::var(name).ok()))
.collect();
let apply = |values: [Option<&str>; 3]| {
for (name, value) in names.into_iter().zip(values) {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
};
apply([token, user, password]);
let outcome = body();
for (name, value) in previous {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
outcome
}
fn with_env<T>(
token: Option<&str>,
user: Option<&str>,
password: Option<&str>,
body: impl FnOnce() -> T,
) -> T {
let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let names = [
STELE_REGISTRY_TOKEN_ENV,
STELE_REGISTRY_USER_ENV,
STELE_REGISTRY_PASSWORD_ENV,
];
struct Restore(Vec<(&'static str, Option<String>)>);
impl Drop for Restore {
fn drop(&mut self) {
for (name, value) in self.0.drain(..) {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
}
}
let previous: Vec<(&str, Option<String>)> = names
.into_iter()
.map(|name| (name, std::env::var(name).ok()))
.collect();
let _restore = Restore(previous);
let apply = |values: [Option<&str>; 3]| {
for (name, value) in names.into_iter().zip(values) {
match value {
Some(value) => std::env::set_var(name, value),
None => std::env::remove_var(name),
}
}
};
apply([token, user, password]);
body()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bin/dolos/common.rs` around lines 430 - 470, Update with_env to restore
the captured registry environment variables during unwinding as well as normal
completion. Introduce a local Drop guard around the existing previous-value
restoration, ensure the guard is created after apply([token, user, password]),
and remove the direct restoration loop after body() so the guard handles cleanup
before returning or propagating a panic.

@scarmuega

Copy link
Copy Markdown
Member Author

Pushed 2507ee1e, which corrects a layering mistake I made rather than inherited.

stelae had no business naming an environment variable. A protocol library that reads one is choosing its host's credential policy, and publishing the name freezes that choice into its API — every program embedding the transport inherits three STELAE_* variables it never asked for and cannot rename. The justification in the code was circular: the crate owned the names because they started with STELAE.

  • TOKEN_ENV, USER_ENV, PASSWORD_ENV, Auth::from_env and the two refusal variants leave stelae entirely. The crate no longer references std::env at all. What remains is the interface: Options::auth takes an Auth the caller constructs.
  • dolos_snapshot::registry::open now takes an Auth rather than a config type, so the profile crate does not source credentials either.
  • The names, the precedence and both refusals live in dolos::common::stele_registry_auth — the binary whose deployment they describe. It reads &StelaeConfig and the environment.
  • ADR-004's protocol section states that credentials arrive from the caller and names no variable and no configuration key; the variable table moved to "CLI and configuration", where it is Dolos's answer rather than the format's.

Side effect worth having: the refusals are now plain miette errors raised in the binary, so the CLI prints the sentence once instead of three times through two error types.

Error:   × STELAE_REGISTRY_TOKEN and STELAE_REGISTRY_USER/STELAE_REGISTRY_PASSWORD
  │ are both set; registry credentials come from one of the two and which one
  │ was meant is not something to guess at — unset the one you did not mean

The resolution tests moved with the code, to common::tests in the binary. The registry suites keep proving that credentials reach the wire and decide the outcome, which is the part that belongs at that layer.

…ing is

Dolos already has one answer to "how does an operator override a setting
from the environment": `load_config` layers `config::Environment` with
the `DOLOS` prefix over the whole of `RootConfig`, and the docs teach it
(`DOLOS_UPSTREAM_PEER_ADDRESS` and friends). Nothing in the workspace
uses clap's declarative `env`, and before the OCI transport landed
nothing read `std::env::var` outside a test.

So the three `STELAE_REGISTRY_*` variables were a second mechanism for
something the first already did. `DOLOS_STELAE_REGISTRY_USER` and
`DOLOS_STELAE_REGISTRY_PASSWORD` work today with no code at all — they
did before this commit, which is what makes the hand-rolled reader
redundant rather than merely inconsistent.

Breaking, deliberately: `STELAE_REGISTRY_TOKEN`, `STELAE_REGISTRY_USER`
and `STELAE_REGISTRY_PASSWORD` are gone with no alias.

`[stelae.registry]` gains `token` so the bearer case is reachable on the
same route, and `user`/`password` become optional so the section can name
one identity or the other. `stele_registry_auth` is now a pure function
of that section; the two refusals survive as validation over the resolved
configuration — `token` with `user`, and `password` with no `user`.

A test pins the mapping itself, building the same `config::Environment`
source `load_config` does. A rename of either field, or a change to the
prefix or separator, would otherwise fail silently at run time: the
override would stop applying and a publisher would authenticate as the
read-only user.

Production code is back to zero hand-rolled environment reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@scarmuega

Copy link
Copy Markdown
Member Author

Pushed 78611df1. Breaking: STELAE_REGISTRY_TOKEN, STELAE_REGISTRY_USER and STELAE_REGISTRY_PASSWORD are gone, with no alias.

Checking how the rest of the binary handles environment variables settled this. clap's declarative env is used nowhere in the workspace — zero #[arg(env = …)]. The convention is the config crate's layer in load_config:

s = s.add_source(::config::Environment::with_prefix("DOLOS").separator("_"));

Every RootConfig field is already overridable as DOLOS_<path>, and docs/content/configuration/introduction.mdx teaches it. Before the OCI transport landed, the only std::env::var in non-test code across the whole workspace was… none — the STELAE_REGISTRY_TOKEN read introduced in #1170 was the single outlier, and this PR had been extending it.

So DOLOS_STELAE_REGISTRY_USER / _PASSWORD already worked, with no code. Verified before changing anything, against a live htpasswd registry with no [stelae.registry] in the file at all:

# DOLOS_STELAE_REGISTRY_PASSWORD correct
  ╰─▶ Registry error: … [OCI API error: manifest unknown]     ← past auth
# wrong
  ╰─▶ Not authorized: url http://…/v2/dolos/mainnet/manifests/latest

What changed:

  • the three constants and the hand-rolled reader are gone; production code is back to zero hand-rolled environment reads;
  • [stelae.registry] gains token, and user/password become optional, so the section can name one identity or the other and the bearer case is reachable as DOLOS_STELAE_REGISTRY_TOKEN;
  • stele_registry_auth is a pure function of that section. The two refusals survive as validation over the resolved config — token with user, and password with no user:
Error:   × [stelae.registry] sets both `token` and `user`; a registry client
  │ authenticates as one identity and which one was meant is not something to
  │ guess at — drop the one you did not mean, or unset
  │ DOLOS_STELAE_REGISTRY_TOKEN / DOLOS_STELAE_REGISTRY_USER
  • one test pins the mapping itself, building the same config::Environment source load_config does. A rename of either field, or a change to the prefix or separator, would otherwise fail silently at run time: the override would stop applying and a publisher would quietly authenticate as the read-only user.

ADR-004 now says Dolos introduces no environment variable of its own for this, and the schema page points at the existing env-override docs instead of listing bespoke names.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/bin/dolos/common.rs`:
- Around line 97-106: Update the authentication validation before the token
branch in the relevant registry-auth function to reject any configuration where
token is set alongside either user or password, while preserving the existing
token-only and Basic-credential validation behavior. Add a test covering token
plus password without user and assert that configuration is rejected.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 11375c49-5c8e-4dbf-917f-46e97930d044

📥 Commits

Reviewing files that changed from the base of the PR and between 2507ee1 and 78611df.

📒 Files selected for processing (5)
  • adrs/004_stelae_snapshots.md
  • crates/core/src/config.rs
  • docs/content/configuration/schema.mdx
  • src/bin/dolos/common.rs
  • src/bin/dolos/init.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/bin/dolos/init.rs
  • adrs/004_stelae_snapshots.md
  • docs/content/configuration/schema.mdx

Comment thread src/bin/dolos/common.rs
Comment on lines +97 to +106
if registry.token.is_some() && registry.user.is_some() {
miette::bail!(
"[stelae.registry] sets both `token` and `user`; a registry client authenticates as \
one identity and which one was meant is not something to guess at — drop the one \
you did not mean, or unset DOLOS_STELAE_REGISTRY_TOKEN / DOLOS_STELAE_REGISTRY_USER"
);
}

if let Some(token) = &registry.token {
return Ok(Auth::Bearer(token.clone()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject a token with a password.

Line 105 returns Auth::Bearer before the password-only validation runs. Therefore, a configuration with token and password, but no user, silently ignores the password. This violates the credential policy for conflicting token/Basic credentials and incomplete Basic credentials.

Reject token when either user or password is set. Add a test for this configuration.

Proposed fix
-    if registry.token.is_some() && registry.user.is_some() {
+    if registry.token.is_some() && (registry.user.is_some() || registry.password.is_some()) {
         miette::bail!(
-            "[stelae.registry] sets both `token` and `user`; a registry client authenticates as \
+            "[stelae.registry] sets `token` with Basic credentials; a registry client authenticates as \
              one identity and which one was meant is not something to guess at — drop the one \
-             you did not mean, or unset DOLOS_STELAE_REGISTRY_TOKEN / DOLOS_STELAE_REGISTRY_USER"
+             you did not mean, or unset DOLOS_STELAE_REGISTRY_TOKEN / DOLOS_STELAE_REGISTRY_USER \
+             / DOLOS_STELAE_REGISTRY_PASSWORD"
         );
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if registry.token.is_some() && registry.user.is_some() {
miette::bail!(
"[stelae.registry] sets both `token` and `user`; a registry client authenticates as \
one identity and which one was meant is not something to guess at — drop the one \
you did not mean, or unset DOLOS_STELAE_REGISTRY_TOKEN / DOLOS_STELAE_REGISTRY_USER"
);
}
if let Some(token) = &registry.token {
return Ok(Auth::Bearer(token.clone()));
if registry.token.is_some() && (registry.user.is_some() || registry.password.is_some()) {
miette::bail!(
"[stelae.registry] sets `token` with Basic credentials; a registry client authenticates as \
one identity and which one was meant is not something to guess at — drop the one \
you did not mean, or unset DOLOS_STELAE_REGISTRY_TOKEN / DOLOS_STELAE_REGISTRY_USER \
/ DOLOS_STELAE_REGISTRY_PASSWORD"
);
}
if let Some(token) = &registry.token {
return Ok(Auth::Bearer(token.clone()));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bin/dolos/common.rs` around lines 97 - 106, Update the authentication
validation before the token branch in the relevant registry-auth function to
reject any configuration where token is set alongside either user or password,
while preserving the existing token-only and Basic-credential validation
behavior. Add a test covering token plus password without user and assert that
configuration is rejected.

…efaults

The read-only user and password the official stele registry is reached
with sat in `dolos-core`, next to the config types. Every other default a
generated `dolos.toml` points at — the Demeter and CF relay addresses,
the Mithril aggregators and their genesis keys — lives on `KnownNetwork`
in the binary, and this belongs there with them.

`StelaeRegistryConfig::password()` is what made the old placement wrong
rather than merely inconsistent: it put the answer to "which registry is
ours" on the config type itself, so a library shipped an opinion about a
specific deployment. The type now carries the shape and none of the
values. The seed is `init::official_stelae()`, and the runtime fallback
for a section naming a user and no password resolves in
`stele_registry_auth`, which is where the rest of the credential policy
already is — reaching into `init` the way `doctor` already reaches into
it for `KnownNetwork`.

The tests split on the same line: `init` asserts the file round-trip,
`common` asserts what a passwordless section authenticates with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/bin/dolos/common.rs (1)

109-126: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject a private user with no password.

The fallback at Lines 115-118 applies to every user. A private registry configuration with user = "publisher" and no password sends the official fallback password instead of failing validation.

Apply the fallback only when user equals the non-empty official registry user. Reject every other user with no configured password. Update a_seeded_user_takes_the_compiled_in_password to cover only the official identity. Add a test that rejects a private user with no password.

Proposed fix
-const OFFICIAL_REGISTRY_USER: &str = "";
+pub(crate) const OFFICIAL_REGISTRY_USER: &str = "";
-    match &registry.user {
-        Some(user) => Ok(Auth::Basic {
+    match (&registry.user, &registry.password) {
+        (Some(user), Some(password)) => Ok(Auth::Basic {
             user: user.clone(),
-            password: registry
-                .password
-                .clone()
-                .unwrap_or_else(|| crate::init::OFFICIAL_REGISTRY_PASSWORD.to_owned()),
+            password: password.clone(),
         }),
+        (Some(user), None)
+            if !crate::init::OFFICIAL_REGISTRY_USER.is_empty()
+                && user.as_str() == crate::init::OFFICIAL_REGISTRY_USER =>
+        {
+            Ok(Auth::Basic {
+                user: user.clone(),
+                password: crate::init::OFFICIAL_REGISTRY_PASSWORD.to_owned(),
+            })
+        }
+        (Some(_), None) => miette::bail!(
+            "[stelae.registry] sets `user` with no `password`; basic registry credentials are a pair"
+        ),
         // A password with nobody to be. Anonymous would be the quiet answer and
         // the wrong one: the operator supplied a secret and it would go unused.
-        None if registry.password.is_some() => miette::bail!(
+        (None, Some(_)) => miette::bail!(
             "[stelae.registry] sets `password` with no `user`; basic registry credentials are a \
              pair"
         ),
-        None => Ok(Auth::Anonymous),
+        (None, None) => Ok(Auth::Anonymous),
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/bin/dolos/common.rs` around lines 109 - 126, Update the registry
authentication match in the user-handling branch so the compiled-in password
fallback is used only when the configured user matches the non-empty official
registry identity; reject any other user lacking a password. Narrow
a_seeded_user_takes_the_compiled_in_password to the official identity and add
coverage proving a private user without a password is rejected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/bin/dolos/common.rs`:
- Around line 109-126: Update the registry authentication match in the
user-handling branch so the compiled-in password fallback is used only when the
configured user matches the non-empty official registry identity; reject any
other user lacking a password. Narrow
a_seeded_user_takes_the_compiled_in_password to the official identity and add
coverage proving a private user without a password is rejected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fa321ffe-e2c1-4b78-bcff-41934a735b63

📥 Commits

Reviewing files that changed from the base of the PR and between 78611df and a3e51f2.

📒 Files selected for processing (3)
  • crates/core/src/config.rs
  • src/bin/dolos/common.rs
  • src/bin/dolos/init.rs

@scarmuega
scarmuega merged commit d9760fc into main Aug 9, 2026
17 checks passed
@scarmuega
scarmuega deleted the feat/stelae-basic-credentials branch August 9, 2026 15:55
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