Skip to content

feat(env): add .dailybot/env.json per-repo API key override - #69

Merged
xergioalex merged 5 commits into
mainfrom
feat/env-json-per-repo-auth
Jul 14, 2026
Merged

feat(env): add .dailybot/env.json per-repo API key override#69
xergioalex merged 5 commits into
mainfrom
feat/env-json-per-repo-auth

Conversation

@xergioalex

Copy link
Copy Markdown
Member

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.

The motivating pain: today, switching between local dev orgs, staging, and production requires either exporting DAILYBOT_API_URL / DAILYBOT_APP_URL / DAILYBOT_API_KEY for every shell, or reconfiguring the global agents.json. env.json gives 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 preserves active for quick re-enable (env off / env on).
  • api_url / app_url per profile — optional; fall through to defaults when absent.
  • snake_case across the board (consistent with credentials.json, agents.json, config.json).

CLI surface — new env command group

dailybot env add --name --key [--api-url] [--app-url]   # create/append
dailybot env use <name>                                  # switch active (empty = clear)
dailybot env show                                        # inspect resolved (key masked)
dailybot env list                                        # all profiles, active marked
dailybot env remove <name> [--yes]                       # delete, clears active if needed
dailybot env off                                         # kill switch (preserves active)
dailybot env on                                          # re-enable

Precedence — additive, non-breaking

env.json inserted at position 2, above agents.json default, below --profile flag:

  1. --profile / --api-url / --app-url CLI flags (escape hatch)
  2. .dailybot/env.json active profile (new)
  3. .dailybot/profile.json::profile + agents.json
  4. agents.json default
  5. DAILYBOT_API_KEY env var
  6. config.json::api_key
  7. Login session Bearer token

Applies uniformly to get_api_key(), get_api_url(), get_app_url() in config.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:

  1. .gitignore covers it automatically. The existing broad .dailybot/* rule (with exception only for !.dailybot/profile.json) covers env.json. Explicit comment added to .gitignore making the intent obvious and highlighting that env.json is intentionally never excepted.
  2. 0o600 on every write and defensively on every load (in case an editor created the file with a lax umask).
  3. Fatal refuse-if-tracked guard. On every load, git ls-files --error-unmatch .dailybot/env.json runs and raises RepoEnvError if the file is tracked. Any CLI command that would consume env.json exits non-zero with an actionable message:
    Error: /path/.dailybot/env.json is tracked by git. This file contains API keys and
    must never be committed. Fix with:
      git rm --cached .dailybot/env.json
      # ensure your .gitignore ignores .dailybot/env.json
      git commit -m 'chore: untrack .dailybot/env.json'
    The CLI refuses to load env.json while it is tracked.
    
    Plus a soft warning at write time (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 as dailybot config key.

Interaction with existing files (ortogonal)

File Committed Contains Rule
.dailybot/profile.json Yes name, default_metadata, report, varsidentity key field fatally rejected.
.dailybot/env.json No (gitignored) api_key, api_url, app_urlauth context Fatally rejected when tracked.

profile.json still governs how reports are signed even when env.json provides 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: true kill-switch, committed-guard (uses real git init in tmp), file permissions, precedence in get_api_key/get_api_url/get_app_url, DailyBotClient integration, resolver-provenance (for agent profiles --resolve).
  • tests/env_commands_test.py — 23 tests: full CLI surface via CliRunner, including the gitignore warning + committed-guard bubbling.
  • Suite: 913 → 994 passing, zero regressions.
  • ruff check and mypy both clean.

Manual smoke test (before opening this PR)

Verified end-to-end in a scratch git repo:

  1. env add creates the file with 0o600 and auto-active on first add ✅
  2. env show masks the key ✅
  3. env list marks the active profile ✅
  4. env use switches ✅
  5. env off / env on toggle without losing active ✅
  6. Fatal guard fires when the file is force-added and committed ✅
  7. Gitignore warning fires on env add in a repo without .gitignore
  8. Walk-up works from a nested subdirectory ✅
  9. agent profiles --resolve reports env.json provenance ✅
  10. get_api_key() returns env.json value even when DAILYBOT_API_KEY is set ✅

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, disabled, or inert. All 913 existing tests still pass.
  • 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 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-skill will pin the new CLI floor (once auto-release cuts this feature as a MINOR) and add a shared/env-json.md doc so agents know how to guide developers into this workflow.

Test plan

  • pytest — 994 pass
  • ruff check dailybot_cli tests — clean
  • ruff format --check — clean
  • mypy dailybot_cli — clean
  • Manual smoke test (above)
  • CI code_check.yml green on this PR

Made with Cursor

## 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>
xergioalex and others added 4 commits July 14, 2026 00:35
## 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>
@xergioalex
xergioalex merged commit b8ca2a3 into main Jul 14, 2026
4 checks passed
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