refactor(config): move .env/keyring writing to config/ so every setup surface shares one writer#4167
Conversation
…w refactor Both telegram setup paths - the onboard wizard configurator and `opensre integrations setup telegram` - had no test coverage at all. Characterize them first so the migration onto a shared setup flow is guarded: prompts asked, validate-before-persist ordering, the wizard's retry loop, the exact credential shape written, and the delivery advisory when no chat id is set. The wizard tests also pin the credential-resolution contract (store + keyring + .env) that the CLI path does not currently follow.
The generic '.env' writer lived in surfaces/cli/wizard/env_sync.py, so only the onboarding wizard could reach it. 'opensre integrations setup' (integrations/) and the interactive-shell action tools (tools/) cannot import a surfaces module, which is why they save credentials to the integration store only - and why an integration configured through either one is invisible to the deploy preflight, which reads the env var (platform/deployment/ecr_deploy/prep.py). Move the generic half down to config/, the layer floor every tier may import: sensitive-key classification, .env line rewriting, and the keyring/'.env' routing (sync_env_secret / sync_env_values). What stays in env_sync.py is the wizard's LLM-provider logic - which env keys a provider owns and stripping the previous provider's keys on a switch. No forwarding shim; the 17 importers move to the canonical path. Behavior is unchanged. Both modules resolve the same PROJECT_ENV_PATH via config.local_env.get_project_env_path(). sync_reasoning_model_env now resolves its default env path explicitly: letting sync_env_values fall back to its own module-level default made the two writers disagree about the target, which under test isolation (which patches env_sync.PROJECT_ENV_PATH) escaped tmp_path and wrote to the real project .env. Both .env-isolating test fixtures now pin the config.env_file default too, so no test can reach the real file.
Greptile code reviewThis repo uses Greptile for automated review. Before merge, aim for Confidence Score: 5/5 with zero unresolved review threads — see CONTRIBUTING.md. Run a review — add a PR comment with: Give it ~5-10 minutes (sometimes longer) for results, then fix feedback and re-trigger until you reach Confidence Score: 5/5. Optional: automate with the greploop skill. |
Greptile SummaryThis PR moves the
Confidence Score: 5/5Safe to merge — the change is a behavior-preserving refactor with a green full-gate build and test suite, and the one real regression found during development (sync_reasoning_model_env resolving against the wrong path) is fixed with 17 test-fixture patches that prevent recurrence. The shared writer in config/env_file.py faithfully reproduces the logic from env_sync.py. The previously-silent keyring failure now surfaces as an explicit RuntimeError, backed by a new test. The path-disagreement regression is fixed and hardened by fixture patches across all affected tests. Both new test files provide solid coverage of the pre-refactor contracts. The 17-site PROJECT_ENV_PATH patch pattern is applied consistently, and the import-linter and full test suite confirm no layering violations were introduced. No files require special attention. The _NON_SECRET_ENV_KEYS allowlist in config/env_file.py is worth keeping in mind when adding integrations whose public keys end in _KEY or another sensitive terminal — future authors will need to add entries there. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant W as Wizard
participant I as integrations/cli
participant T as Action Tool
participant EF as config/env_file.py
participant KR as System Keyring
participant ENV as .env file
Note over W,ENV: After this PR – all surfaces share config/env_file.py
W->>EF: sync_env_secret(key, value)
I->>EF: sync_env_secret(key, value)
T->>EF: sync_env_secret(key, value)
EF->>EF: is_sensitive_env_key() → True
EF->>KR: save_keyring_secret / save_api_key
EF-->>W: raises RuntimeError if unavailable
W->>EF: "sync_env_values({key: val})"
I->>EF: "sync_env_values({key: val})"
T->>EF: "sync_env_values({key: val})"
EF->>EF: is_sensitive_env_key() → False
EF->>EF: strip_secret_env_lines (existing file)
EF->>EF: set_env_value per key
EF->>EF: write_env_lines (strip + validate + chmod 0o600)
EF->>ENV: write public lines only
EF-->>W: Path to .env
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant W as Wizard
participant I as integrations/cli
participant T as Action Tool
participant EF as config/env_file.py
participant KR as System Keyring
participant ENV as .env file
Note over W,ENV: After this PR – all surfaces share config/env_file.py
W->>EF: sync_env_secret(key, value)
I->>EF: sync_env_secret(key, value)
T->>EF: sync_env_secret(key, value)
EF->>EF: is_sensitive_env_key() → True
EF->>KR: save_keyring_secret / save_api_key
EF-->>W: raises RuntimeError if unavailable
W->>EF: "sync_env_values({key: val})"
I->>EF: "sync_env_values({key: val})"
T->>EF: "sync_env_values({key: val})"
EF->>EF: is_sensitive_env_key() → False
EF->>EF: strip_secret_env_lines (existing file)
EF->>EF: set_env_value per key
EF->>EF: write_env_lines (strip + validate + chmod 0o600)
EF->>ENV: write public lines only
EF-->>W: Path to .env
Reviews (2): Last reviewed commit: "fix(config): address greptile feedback o..." | Re-trigger Greptile |
- raise RuntimeError when sync_env_secret cannot persist to keyring - raise ValueError from set_env_value for sensitive keys (match sync_env_values) - use platform-neutral sentinel path in telegram configurator test - add coverage for keyring failure and set_env_value rejection
|
@greptile review |
- patch observability configurator sync_env_secret like other integrations - CI keyring is unavailable; raise-on-failure made unmocked calls fail
|
😭 Clear commit message. Green tests. Kind review. @muddlebee, stop making the rest of us look bad. 👋 Join us on Discord - OpenSRE : hang out, contribute, or hunt for features and issues. Everyone's welcome. |

Relates to #4071, #3933
Describe the changes you have made in this PR -
Groundwork so that setting up an integration is written once, not once per surface. No user-visible behavior change.
The problem. Telegram (and every other integration) has three setup paths that drifted apart:
opensre onboard→surfaces/cli/wizard/configurators/chat_notifications.py.envopensre integrations setup telegram→integrations/cli.pyconfigure_telegramaction tool (proposed in #4071)All three look fine locally, because
integrations/telegram/credentials.pyresolves the store first. The divergence shows up at deploy time —platform/deployment/ecr_deploy/prep.py:75checks the env var, not the store:So you can configure Telegram via the CLI, verify it, send messages — and then have
make deploytell youTELEGRAM_BOT_TOKENis missing. Configure it viaonboardinstead and the same deploy succeeds.Why it drifted. The generic
.env/keyring writer lived insurfaces/cli/wizard/env_sync.py.integrations/(tier 2) andtools/(tier 2) cannot importsurfaces/(tier 1), so the other two paths physically could not reach it and each wrote what they could.This PR moves the generic half down to
config/— the tier-4 floor everything may import — and leaves the wizard-specific half behind:surfaces/cli/wizard/env_sync.pyconfig/env_file.pysync_provider_env,sync_reasoning_model_env(needProviderOption)sync_env_values,sync_env_secret.envline rewriting,0o600chmod, keyring persistconfig/local_env.pyalready owned reading the project env file (get_project_env_path,_load_env_file); writing now sits beside it. No import-linter exception was needed —integrations → configis an allowed edge, so the follow-up needs no layering debt entry.Per AGENTS.md there is no forwarding shim: all 17 importers move to the canonical path in this change.
Storage routing is decided by existing code, not by callers.
is_sensitive_env_key()classifies on the variable name, and the split is enforced rather than advised —sync_env_valuesrefuses a sensitive key andsync_env_secretrefuses a non-sensitive one. A mis-named credential fails loudly instead of landing in clear text.Demo/Screenshot for feature changes and bug fixes -
Routing is name-driven, so a new integration gets correct storage without the author deciding anything:
Layering holds, which is the whole point of the move:
Full gate:
Code Understanding and AI Usage
Did you use AI assistance (ChatGPT, Claude, Copilot, etc.) to write any part of this code?
If you used AI assistance:
Explain your implementation approach:
This is a behavior-preserving refactor, so it is TDD-guarded per AGENTS.md. Both Telegram setup paths had zero test coverage, so the first commit characterizes them against the pre-refactor code — prompts asked, validate-before-persist ordering, the wizard's retry loop on a bad token, the exact credential shape written, the delivery advisory when no chat id is set, and the credential tiers each path touches. Those 35 tests passed before the move and stayed green through it.
I deliberately did not pin "the CLI path writes only the store". That is the gap being closed, and asserting it would lock the bug in.
The alternative I considered was injecting a writer callback into
integrations/instead of moving the module — it avoids touching 17 files, but it pushes the decision of where credentials go back out to each caller, which is exactly the drift that caused this. Moving the writer to the floor means there is one answer and callers cannot disagree with it.One real regression surfaced during the change, and it is worth calling out because it is the interesting part of the diff.
sync_reasoning_model_envpassedenv_path=Noneand letsync_env_valuesfall back to its own module-level default. After the move that default resolved insideconfig/env_file.py, while the sibling writersync_provider_envstill resolved againstenv_sync.PROJECT_ENV_PATH— the name the.env-isolating test fixtures patch. The two writers disagreed about the target, so six tests escapedtmp_pathand wrote to the real project.env. Fixed by resolving the default explicitly at the call site so both writers agree, and hardened by pinning theconfig.env_filedefault in both isolating fixtures (17 patch sites) so no test can reach the real file again.Follow-up (not in this PR): with the writer reachable from
integrations/, a sharedapply_setup(service, values)can own merge → verify → persist-across-all-three-tiers, and the wizard,integrations setup, and the action tool from #4071 each keep only their own value collection. #4071 rebases onto that and shrinks to a thin adapter plus its execution gate.Checklist before requesting a review