feat(env): add .dailybot/env.json per-repo API key override - #69
Merged
Conversation
## Summary
Introduce a new opt-in, gitignored `.dailybot/env.json` file that lets
developers configure per-repo API keys + URLs for multiple environments
(live, local, staging, ...) with one-command switching. When active, it
overrides `DAILYBOT_API_KEY`, `config.json`, and the login Bearer session
for the enclosing repo — enabling "logged into different orgs in different
repos" without touching global state.
## Change Log
### Schema
- New file: `<repo>/.dailybot/env.json` — `{ disabled?, active?, profiles[] }`.
- Each profile: `{ name, api_key, api_url?, app_url? }`.
- `active` top-level string points at one profile (or empty/null = inert).
- `disabled: true` is a kill-switch that preserves `active` for quick re-enable.
### CLI (new `env` command group)
- `dailybot env add --name --key [--api-url] [--app-url]`
- `dailybot env use <name>` (empty string clears active)
- `dailybot env show` (masked key, resolved URLs)
- `dailybot env list` (all profiles, active marked)
- `dailybot env remove <name> [--yes]`
- `dailybot env off` / `dailybot env on` (kill switch)
### Precedence (aditivo, non-breaking)
Insert env.json at position 2 in the auth resolution order — above
`agents.json` default, below `--profile` flag. Applies to `get_api_key()`,
`get_api_url()`, `get_app_url()` so every command (agent + user-scoped)
picks up the override transparently.
### Security
- Broad `.gitignore` (`.dailybot/*`) covers env.json automatically; explicit
comment added to make the intent obvious.
- `0o600` enforced on write AND defensively on load.
- **Fatal refuse-if-tracked guard**: `git ls-files --error-unmatch` runs on
every load; a tracked env.json raises `RepoEnvError` with an actionable
fix. The CLI refuses to operate until the file is untracked.
- `dailybot env add` runs `git check-ignore` after writing; warns loudly
when env.json is NOT covered by any ignore rule.
- API keys masked (`abcd****`) in every display path.
### Docs
- `AGENTS.md` rule 14 (auth resolution order) — env.json inserted at #2.
- `docs/CONFIGURATION.md` — full new section "Repo-level env override".
- `docs/SECURITY.md` — new subsection on the three-layer protection.
- `README.md` — new "Env commands" section in the commands table.
- `.gitignore` — explicit "NEVER excepted" comment for env.json.
### Tests
- `tests/repo_env_test.py` — 58 tests covering find/load/save/mutate,
active resolution, kill-switch, committed-guard (uses real `git init`),
precedence in `get_api_key`/`get_api_url`/`get_app_url`, and
`DailyBotClient` integration.
- `tests/env_commands_test.py` — 23 tests covering the full CLI surface
via `CliRunner`.
- Suite grows from 913 → 994 passing. Zero regressions.
## Risks
- None for backward compat — env.json is opt-in and non-existent by
default; the resolver falls through untouched when the file is absent
or inert.
- One behavioral change worth noting: `get_api_key()` / `get_api_url()` /
`get_app_url()` now do a walk-up filesystem check on every call. For
the ~1-2 client constructions per invocation this is <5ms and negligible;
no caching is needed. If profiling ever shows this as hot, a per-process
cache is trivial to add later.
Co-authored-by: Cursor <cursoragent@cursor.com>
3 tasks
## Summary
Makes `.dailybot/env.json` "just work" alongside a stale global Bearer
session — the CLI now auto-retries every user-scoped call once with the
alternative credential when the server rejects the primary with 401 OR
403, so `dailybot status --auth`, `user list`, `form list`, etc. all
transparently succeed even when the on-disk OTP token was issued
against a different API URL than the env.json profile points at.
## Change Log
- api_client: extended `_agent_request` retry to 401 OR 403 (was 401
only); Django/DRF answers 403 for rejected credentials just as often
as 401, so 401-only left the local-Django + env.json case broken.
- api_client: added sibling `_request()` helper that mirrors
`_agent_request` for every user-scoped endpoint that authenticates
via `_headers()` (auth_status, checkin, form, kudos, chat, ask, user,
team, ...). Same retry semantics on 401/403.
- api_client: migrated 34 authenticated `_headers()` call sites to the
new `_request()` helper (login/logout/register endpoints keep raw
`_headers()` — retrying there is semantically wrong).
- api_client: added `_dispatch_http` that routes to per-method
`httpx.get/post/patch/put` / `httpx.request` (DELETE), preserving the
per-method patchable surface used by the test suite so the migration
is invisible to existing tests.
- api_client: paginated GET helper also benefits from the auth retry
now (list endpoints inherit the fallback for free).
- api_client: promoted `120.0` to `LONG_TIMEOUT_SECS`; extracted
`_AUTH_RETRY_STATUS_CODES` constant with a comment explaining why 403
is retried alongside 401 (no more inline magic numbers).
- status: `dailybot status --auth` inspects the client's
`_agent_auth_mode` after the call and reports the credential that
actually succeeded on the wire ("Authenticated via API key" vs
"Authenticated via login (OTP)") so the UX is honest about the
effective auth path. Added a distinctive "Both credentials were
rejected" message when neither works.
- docs: rewrote the "Interaction with the login Bearer token" section
of CONFIGURATION.md with a step-by-step example and a rationale for
retrying on 403 (referencing DRF's behavior).
- tests: added `TestUserAuthFallback` (6 cases: 401 retry, 403 retry,
no-alt-credential, 2xx happy path, non-auth errors don't retry,
login endpoints never retry).
- tests: added 403-retry case to `TestAgentAuthFallback`; broadened
the non-auth exclusion test to sweep 400/404/422/500/502/503.
- tests: adapted the 4 `status_auth_*` tests to the new
single-`auth_status`-call architecture and added a
`both_credentials_rejected` case.
## Risks
- Behavioral change for callers that relied on getting a 403 back
without a retry attempt on the wire. In practice this is invisible:
the retry is single-shot, uses the alt credential if available, and
either succeeds (UX improvement) or fails the same way it would have
before. Login-lifecycle endpoints are excluded on purpose.
- Full pytest suite (1002 tests) + ruff + mypy all green.
- Smoke-tested end-to-end against a live local API:
`dailybot status --auth` and `dailybot user list` both succeed
transparently with a prod Bearer on disk and a local env.json.
Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary Follow-up on the previous commit — the CI `ruff format --check` gate caught unformatted whitespace in api_client.py and tests/api_client_test.py after the auth-retry refactor. Zero behavioral change; local pytest, ruff check, ruff format --check, and mypy all clean. Co-authored-by: Cursor <cursoragent@cursor.com>
…audit ## Summary Audit finding: the fatal refuse-if-tracked guard for `.dailybot/env.json` was only surfacing from the `env` subcommands. Every other command (`status`, `user list`, `form list`, `agent update`, ...) silently swallowed `RepoEnvError` in `_safe_active_env_profile()` and continued with fallback global auth. Result: if a developer accidentally `git add`-ed env.json, the CLI would keep operating happily while the API keys leaked in git history — precisely the disaster the guard is meant to prevent. ## Change Log - `dailybot_cli/main.py`: root `cli()` callback now calls `load_repo_env()` at startup and re-raises `RepoEnvError` as `SystemExit(1)` with a stderr `print_error()`. Every command is blocked; `--help` and `--version` still work (Click short-circuits them before the callback runs). - `tests/env_commands_test.py`: extracted `_stage_tracked_env_json()` helper; added `test_root_cli_refuses_every_command_when_env_json_tracked` (regression guard covering `status`, `user list`, `form list`, `me`, `config list`) and `test_root_cli_help_still_works_when_env_json_tracked`. - `docs/CONFIGURATION.md`: added a prominent "STOP — Read this before you author env.json" callout at the top of the section with the three-layer defense-in-depth model, the recovery recipe when a leak has already happened, and the explicit rotate-first-not-revert rule. Rewrote "Security guarantees" to state that the guard fires at the root callback for every command, not just env subcommands. - `docs/SECURITY.md`: upgraded the env.json subsection from 3 to 4 layers (added file permissions as its own numbered protection) and clarified that the guard fires at the root of every CLI invocation, cross-linking to the CONFIGURATION.md recovery recipe. - `AGENTS.md` rule 14: replaced the single-sentence "fatally refused when tracked" note with the full three-layer contract and pointed to the recovery recipe. ## Risks - The new startup check runs `git ls-files` once per CLI invocation when `.dailybot/env.json` exists in an ancestor of `$PWD`. Negligible overhead (single subprocess with `capture_output=True`); the check already existed inside `env` subcommands, this only widens the aperture. No overhead when env.json is absent (the check short- circuits on `find_repo_env_path()` returning None). - No behavior change for the vast majority of users (env.json is gitignored by default and never becomes tracked). - Backward compatible: no schema changes, no CLI flag changes. Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary Close every gap found in the adversarial audit of the env.json feature so the documented contract and the actual behavior are identical. ## Change Log - env.json-sourced API keys now go FIRST on the wire (X-API-KEY on attempt 1) instead of relying on the 401/403 retry: correct identity on same-server multi-org setups, no Bearer-token transmission to the env.json server, no doubled round-trips. Keys from env var / config.json keep the historical Bearer-first order. (get_api_key_source + DailyBotClient.prefer_api_key, auto-detected) - _resolve_agent_context now applies env.json above keyed agents.json profiles (except explicit --profile), matching agent profiles --resolve so display and runtime always agree; keyless profiles now use the full ambient chain instead of requiring a Bearer - Root refuse-if-tracked guard: hook group exempted (prints to stderr, exits 0 per the AGENT_HOOKS always-exit-0 contract); stale/deleted cwd no longer tracebacks (find_repo_env_path/find_repo_profile_path return None on OSError); git-missing-but-.git-present degrades to a loud warning - dailybot login warns when an active env.json profile redirects it (login rewrites the GLOBAL session api_url) - Non-bool "disabled" values (e.g. the string "true") now warn loudly instead of silently keeping the file active - env.json is created 0o600 from the first byte (os.open) — no umask window - env show renders an explicit "Disabled: no" row and resolved default URLs - Tests: +31 (wire preference both directions, retry no-loop, _dispatch_http branches, staged-not-committed guard, hook-under-guard exit 0, agent env.json-vs-profile regression suite, login warning, disabled-string, duplicate/non-dict profile entries, warn-once dedup, stale cwd); fixed the misnamed api_key-retry test and the tautological closest-ancestor test; extended login-lifecycle no-retry to verify_code/logout - Docs: AGENTS.md rule 14, CONFIGURATION.md, SECURITY.md, AGENT_HOOKS.md rewritten to the now-true contract (wire preference, --profile carve-out, hook exemption, four protections wording) ## Risks - Behavior change is scoped to env.json-sourced keys; every pre-existing auth flow (Bearer, DAILYBOT_API_KEY, config.json, keyed --profile) keeps its exact wire order. 1035 tests pass; ruff + mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduce a new opt-in, gitignored
.dailybot/env.jsonfile that lets developers configure per-repo API keys + URLs for multiple environments (live, local, staging, ...) with one-command switching. When active, it overridesDAILYBOT_API_KEY,config.json, and the login Bearer session for the enclosing repo — enabling "logged into different orgs in different repos" without touching global state.The motivating pain: today, switching between local dev orgs, staging, and production requires either exporting
DAILYBOT_API_URL/DAILYBOT_APP_URL/DAILYBOT_API_KEYfor every shell, or reconfiguring the globalagents.json.env.jsongives each repo its own credential context that follows the working directory.Design
Schema
{ "disabled": false, "active": "local org 1", "profiles": [ { "name": "live", "api_key": "sk_live_xxx" }, { "name": "local org 1", "api_key": "sk_local_xxx", "api_url": "http://localhost:8000", "app_url": "http://localhost:8090" }, { "name": "staging", "api_key": "sk_staging_xxx", "api_url": "https://staging-api.example.com", "app_url": "https://staging-app.example.com" } ] }active— top-level string (impossible to have two actives; empty/null/missing → file is inert).disabled: true— kill-switch that preservesactivefor quick re-enable (env off/env on).api_url/app_urlper profile — optional; fall through to defaults when absent.credentials.json,agents.json,config.json).CLI surface — new
envcommand groupPrecedence — additive, non-breaking
env.json inserted at position 2, above
agents.jsondefault, below--profileflag:--profile/--api-url/--app-urlCLI flags (escape hatch).dailybot/env.jsonactive profile (new).dailybot/profile.json::profile+agents.jsonagents.jsondefaultDAILYBOT_API_KEYenv varconfig.json::api_keyApplies uniformly to
get_api_key(),get_api_url(),get_app_url()inconfig.py, so every command (agent + user-scoped:checkin,form,kudos,chat,ask,hook, ...) picks up the override transparently.Security posture
Three layers of protection because this file does contain plain-text API keys inside the repo tree:
.gitignorecovers it automatically. The existing broad.dailybot/*rule (with exception only for!.dailybot/profile.json) coversenv.json. Explicit comment added to.gitignoremaking the intent obvious and highlighting that env.json is intentionally never excepted.0o600on every write and defensively on every load (in case an editor created the file with a lax umask).git ls-files --error-unmatch .dailybot/env.jsonruns and raisesRepoEnvErrorif the file is tracked. Any CLI command that would consume env.json exits non-zero with an actionable message:git check-ignore) when env.json is not currently covered by any ignore rule, so the developer catches misconfiguration early.API keys are masked (
abcd****) in every display path — same pattern asdailybot config key.Interaction with existing files (ortogonal)
.dailybot/profile.jsonname,default_metadata,report,vars— identitykeyfield fatally rejected..dailybot/env.jsonapi_key,api_url,app_url— auth contextprofile.jsonstill governs how reports are signed even whenenv.jsonprovides the credentials to send them.Docs
AGENTS.md— rule 14 (auth resolution order) updated to include env.json at fix(ci): unblock auto-release pipeline + cut v1.0.0 next merge #2 (annotated as a non-breaking additive layer).docs/CONFIGURATION.md— full new section "Repo-level env override" (schema, CLI, precedence, security, when NOT to use it).docs/SECURITY.md— new subsection on the three-layer protection.README.md— new "Env commands" section in the commands table..gitignore— explicit "NEVER excepted" comment.Tests
tests/repo_env_test.py— 58 tests: walk-up discovery, schema validation, active resolution,disabled: truekill-switch, committed-guard (uses realgit initin tmp), file permissions, precedence inget_api_key/get_api_url/get_app_url,DailyBotClientintegration, resolver-provenance (foragent profiles --resolve).tests/env_commands_test.py— 23 tests: full CLI surface viaCliRunner, including the gitignore warning + committed-guard bubbling.ruff checkandmypyboth clean.Manual smoke test (before opening this PR)
Verified end-to-end in a scratch git repo:
env addcreates the file with0o600and auto-active on first add ✅env showmasks the key ✅env listmarks the active profile ✅env useswitches ✅env off/env ontoggle without losing active ✅env addin a repo without.gitignore✅agent profiles --resolvereports env.json provenance ✅get_api_key()returns env.json value even whenDAILYBOT_API_KEYis set ✅Risks
get_api_key()/get_api_url()/get_app_url()now do a walk-up filesystem check on every call. For the ~1-2 client constructions per invocation this is <5ms and negligible; no caching needed. If profiling ever shows this as hot, a per-process cache is trivial to add later.Follow-up
A companion PR to
DailybotHQ/agent-skillwill pin the new CLI floor (once auto-release cuts this feature as a MINOR) and add ashared/env-json.mddoc so agents know how to guide developers into this workflow.Test plan
pytest— 994 passruff check dailybot_cli tests— cleanruff format --check— cleanmypy dailybot_cli— cleancode_check.ymlgreen on this PRMade with Cursor