Skip to content

fix(cli): recognize whitespace around '=' in .env save/remove - #67488

Open
Frowtek wants to merge 1 commit into
NousResearch:mainfrom
Frowtek:fix/env-writer-whitespace-parity
Open

fix(cli): recognize whitespace around '=' in .env save/remove#67488
Frowtek wants to merge 1 commit into
NousResearch:mainfrom
Frowtek:fix/env-writer-whitespace-parity

Conversation

@Frowtek

@Frowtek Frowtek commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

_env_line_defines_key() decides which .env lines the writers are allowed to
rewrite or drop. It matched on the KEY= prefix:

stripped = line.strip()
if stripped.startswith("export "):
    stripped = stripped[7:].lstrip()
return stripped.startswith(f"{key}=")

But load_env() splits on the first = and strips the name:

key, _, value = line.partition('=')
env_vars[key.strip()] = _parse_env_value(value)

So OPENAI_API_KEY = sk-... is a live assignment — the key resolves, the
provider authenticates, and every UI shows it as set. The writers never saw it.

This is the same resurrection hole #40041 just fixed for export KEY=, still
open for the whitespace form.

Impact

A credential the user revokes through the UI is not actually revoked:

step observed
.env contains OPENAI_API_KEY = sk-COMPROMISED key is live; UI shows it set
DELETE /api/env 404 "not found in .env" — key stays active
PUT /api/env (rotate) appends a second line; the old line survives
later DELETE removes only the appended line → old key comes back

Reproduced end-to-end against the real endpoint handlers:

after rotate : sk-ROTATED
after delete : sk-COMPROMISED     <-- rotated-away credential resurrected

Fix

Mirror load_env()'s parse instead of prefix-matching, so the writers accept
precisely what the reader accepts — skip blank/comment/no-= lines, strip an
export prefix, then compare the stripped name:

stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
    return False
if stripped.startswith("export "):
    stripped = stripped[7:]
name, _, _ = stripped.partition("=")
return name.strip() == key

Both writers (save_env_value, remove_env_value) go through this one helper,
so the single change covers save and remove together.

Parity after the fix — writer matches a line iff load_env() accepts it:

line load_env() writer
KEY=v
export KEY=v
KEY = v (was ❌)
KEY\t=\tv (was ❌)
export KEY = v (was ❌)
# KEY=v
KEY_EXTRA=v
MY_KEY=v

Commented-out lines are still left untouched, and no prefix/substring key
collides.

Testing

Three tests added to tests/hermes_cli/test_env_export_line_lifecycle.py,
driving the real dashboard endpoints against a temp HERMES_HOME (fake tokens
constructed at runtime, matching the file's existing convention):

  • test_remove_token_written_with_spaces_around_equals
  • test_spaced_token_rotate_then_delete_does_not_resurrect
  • test_writer_matches_exactly_what_load_env_accepts

All three fail on main with the resurrection above and pass with this
change. The 5 existing export-line tests are unaffected.

tests/hermes_cli/  env + credential suites .......... 80 passed

Config and managed-scope suites compared against a clean origin/main
worktree (09109fec9): identical pre-existing failure set
(TestGetHermesHome::test_default_path, two SaveEnvValueSecure quoting
tests), no new failures.

Checklist

  • Bug is reproducible on main and covered by a failing-before/passing-after test
  • No regressions in the surrounding suite (baseline-compared against origin/main)
  • Change is scoped to the defect — no unrelated refactoring
  • Tested on Ubuntu 24.04

_env_line_defines_key() decides which .env lines the writers may rewrite or
drop. It matched on the `KEY=` prefix, but load_env() splits on the first
`=` and strips the name:

    key, _, value = line.partition('=')
    env_vars[key.strip()] = _parse_env_value(value)

so `OPENAI_API_KEY = sk-...` is a live assignment — the key resolves, the
provider works, and every UI shows it as set. The writers did not see it.

This is the same resurrection hole NousResearch#40041 fixed for `export KEY=`, still
open for the whitespace form:

- DELETE /api/env 404s ("not found in .env") while the credential stays
  active — a key the user revoked through the UI is never actually revoked
- PUT /api/env appends a SECOND line instead of replacing; a later delete
  removes the appended line and the original value silently comes back

Rotate-then-delete on a spaced line therefore restores exactly the key the
user rotated away from.

Match load_env()'s parse instead of prefix-matching, so the writers accept
precisely what the reader accepts: skip blank/comment/no-'=' lines, strip an
`export ` prefix, then compare the stripped name. Commented-out lines stay
untouched and `KEY_EXTRA=`/`MY_KEY=` still do not match `KEY`.

Verified against the real dashboard endpoints on a temp HERMES_HOME: the
spaced line is now removed, rotation replaces it in place with no duplicate,
and a parity check asserts the writer matches a line iff load_env() does.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard area/config Config system, migrations, profiles area/auth Authentication, OAuth, credential pools sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 19, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to merged #67213: that patch recognizes export KEY=, while this focused follow-up makes writer matching agree with the reader for KEY = value. Current main still has the whitespace gap, so this is related incremental work, not a duplicate.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks — the reported mismatch is present on current main. load_env() accepts a stripped assignment name after partitioning at = (hermes_cli/config.py:7647-7656), while _env_line_defines_key() still requires the literal KEY= form (hermes_cli/config.py:7890-7901). Both mutation paths use that helper (save_env_value at hermes_cli/config.py:7952-7955; remove_env_value at hermes_cli/config.py:8032), so the proposed parser alignment addresses the full stated lifecycle.

The added endpoint tests complement the existing export-prefix lifecycle coverage in tests/hermes_cli/test_env_export_line_lifecycle.py and exercise removal plus rotate-then-delete resurrection behavior. No blocking correctness or completeness issue found.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/config Config system, migrations, profiles comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants