From f063c5b3ca969e8574f4ae49fb540e49944d4892 Mon Sep 17 00:00:00 2001 From: stash Date: Wed, 19 Aug 2026 05:54:51 -0700 Subject: [PATCH 1/2] fix(cli): credentials to flat ~/.config/dimos-credentials, plain text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The login PR nested credentials.json under ~/.config/dimos, turning the dimos-run config FILE path into a directory on any logged-in keyring-less machine (dimos run then dies with IsADirectoryError; reverse direction breaks login on machines with a legacy config file). Credentials are now a plain-text key in a flat 0600 sibling — no contested path, no JSON, no migration shims. Machines that logged in during the collision window just run dimos login again. --- dimos/cli/cloud.py | 31 +++++++++++++++---------------- dimos/cli/test_cloud.py | 12 ++++++------ dimos/constants.py | 5 ++++- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/dimos/cli/cloud.py b/dimos/cli/cloud.py index 20065efeff..76461a2940 100644 --- a/dimos/cli/cloud.py +++ b/dimos/cli/cloud.py @@ -17,9 +17,10 @@ Device-code flow (RFC 8628 shaped) against login.dimensional.org — built for robots: no browser or clipboard needed on this machine. The CLI prints an 8-character code, you approve it from any signed-in browser (laptop, phone), and the minted API key is -stored in the system keyring, falling back to a 0600 file (`CREDENTIALS_PATH`) on -headless machines with no keyring backend. `DIMOS_API_KEY` (via GlobalConfig) -overrides any stored login. +stored in the system keyring, falling back to a plain-text 0600 file +(`CREDENTIALS_PATH`, just the key — deliberately NOT under `~/.config/dimos`, +which is the `dimos run` config file) on headless machines with no keyring +backend. `DIMOS_API_KEY` (via GlobalConfig) overrides any stored login. """ import json @@ -62,28 +63,27 @@ def _keyring() -> ModuleType | None: return None -def _store(creds: dict[str, str]) -> str: - """Persist credentials; returns a human-readable location for the login message.""" - blob = json.dumps(creds) +def _store(key: str) -> str: + """Persist the API key; returns a human-readable location for the login message.""" if kr := _keyring(): - kr.set_password(_KEYRING_SERVICE, _KEYRING_USER, blob) + kr.set_password(_KEYRING_SERVICE, _KEYRING_USER, key) return "system keyring" CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True) # No keyring backend (typical on robots): owner-only file, the same convention # gh / aws / kubectl use for exactly this situation. fd = os.open(CREDENTIALS_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as f: - f.write(blob) + f.write(key + "\n") return str(CREDENTIALS_PATH) -def _load() -> dict[str, str] | None: +def _load() -> str | None: if kr := _keyring(): - if blob := kr.get_password(_KEYRING_SERVICE, _KEYRING_USER): - return cast("dict[str, str]", json.loads(blob)) + if key := kr.get_password(_KEYRING_SERVICE, _KEYRING_USER): + return cast("str", key) try: - return cast("dict[str, str]", json.loads(CREDENTIALS_PATH.read_text())) - except (OSError, ValueError): + return CREDENTIALS_PATH.read_text().strip() or None + except OSError: return None @@ -103,8 +103,7 @@ def api_key() -> str | None: """The credential for cloud calls: DIMOS_API_KEY first, then the stored login.""" if global_config.dimos_api_key: return global_config.dimos_api_key - creds = _load() - return creds.get("api_key") if creds else None + return _load() def login() -> None: @@ -117,7 +116,7 @@ def login() -> None: time.sleep(d["interval"]) r = _post("/auth/token", device_code=d["device_code"]) if r["status"] == "ok": - where = _store({"api_key": r["api_key"], "email": r["email"]}) + where = _store(r["api_key"]) typer.echo(f"Logged in as {r['email']} (key {r['key_id']}…, stored in {where})") return if r["status"] in ("denied", "expired"): diff --git a/dimos/cli/test_cloud.py b/dimos/cli/test_cloud.py index 6591c5d63e..ff0554a1cc 100644 --- a/dimos/cli/test_cloud.py +++ b/dimos/cli/test_cloud.py @@ -47,7 +47,7 @@ def delete_password(self, service: str, user: str) -> None: def filestore(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: """Force the headless path: no keyring backend, credentials in a temp file.""" monkeypatch.setattr(cloud, "_keyring", lambda: None) - cred = tmp_path / "credentials.json" + cred = tmp_path / "dimos-credentials" monkeypatch.setattr(cloud, "CREDENTIALS_PATH", cred) monkeypatch.setattr(global_config, "dimos_api_key", None) return cred @@ -78,7 +78,7 @@ def test_login_stores_key_owner_only(monkeypatch: pytest.MonkeyPatch, filestore: cloud.login() - assert json.loads(filestore.read_text())["api_key"] == "dimos_sk_x" + assert filestore.read_text().strip() == "dimos_sk_x" assert oct(filestore.stat().st_mode)[-3:] == "600" assert cloud.api_key() == "dimos_sk_x" @@ -110,7 +110,7 @@ def test_login_denied_exits(monkeypatch: pytest.MonkeyPatch, filestore: Path) -> def test_global_config_key_overrides_stored( monkeypatch: pytest.MonkeyPatch, filestore: Path ) -> None: - filestore.write_text(json.dumps({"api_key": "dimos_sk_stored"})) + filestore.write_text("dimos_sk_stored\n") monkeypatch.setattr(global_config, "dimos_api_key", "dimos_sk_env") assert cloud.api_key() == "dimos_sk_env" @@ -121,7 +121,7 @@ def test_keyring_store_load_forget(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa monkeypatch.setattr(cloud, "CREDENTIALS_PATH", tmp_path / "never-written.json") monkeypatch.setattr(global_config, "dimos_api_key", None) - assert cloud._store({"api_key": "dimos_sk_kr", "email": "e@x"}) == "system keyring" + assert cloud._store("dimos_sk_kr") == "system keyring" assert not (tmp_path / "never-written.json").exists() # keyring won; no file assert cloud.api_key() == "dimos_sk_kr" assert cloud._forget() is True @@ -131,7 +131,7 @@ def test_keyring_store_load_forget(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa def test_whoami_displays_account( monkeypatch: pytest.MonkeyPatch, filestore: Path, capsys: pytest.CaptureFixture[str] ) -> None: - filestore.write_text(json.dumps({"api_key": "dimos_sk_w"})) + filestore.write_text("dimos_sk_w\n") seen: dict[str, str] = {} def fake_urlopen(req: Any) -> io.BytesIO: @@ -145,7 +145,7 @@ def fake_urlopen(req: Any) -> io.BytesIO: def test_whoami_revoked_key_exits(monkeypatch: pytest.MonkeyPatch, filestore: Path) -> None: - filestore.write_text(json.dumps({"api_key": "dimos_sk_dead"})) + filestore.write_text("dimos_sk_dead\n") def fake_urlopen(req: Any) -> io.BytesIO: raise urllib.error.HTTPError(req.full_url, 401, "unauthorized", {}, None) # type: ignore[arg-type] diff --git a/dimos/constants.py b/dimos/constants.py index 96208932e0..d893a78f5e 100644 --- a/dimos/constants.py +++ b/dimos/constants.py @@ -38,7 +38,10 @@ LOG_DIR = STATE_DIR / "logs" RECORDINGS_DIR = STATE_DIR / "recordings" -CREDENTIALS_PATH = CONFIG_DIR / "dimos" / "credentials.json" +# Plain-text API key, 0600. Deliberately a flat sibling of the dimos config +# path, never inside it: the config path is shareable (dotfiles, bug reports, +# machine sync) and secrets must not ride along. +CREDENTIALS_PATH = CONFIG_DIR / "dimos-credentials" """ Constants for shared memory From 1f265690b3ddc98653b781206f531112c6676ec4 Mon Sep 17 00:00:00 2001 From: stash Date: Wed, 19 Aug 2026 06:07:07 -0700 Subject: [PATCH 2/2] drop editorializing comments --- dimos/cli/cloud.py | 3 +-- dimos/constants.py | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/dimos/cli/cloud.py b/dimos/cli/cloud.py index 76461a2940..b680f8e2cc 100644 --- a/dimos/cli/cloud.py +++ b/dimos/cli/cloud.py @@ -18,8 +18,7 @@ no browser or clipboard needed on this machine. The CLI prints an 8-character code, you approve it from any signed-in browser (laptop, phone), and the minted API key is stored in the system keyring, falling back to a plain-text 0600 file -(`CREDENTIALS_PATH`, just the key — deliberately NOT under `~/.config/dimos`, -which is the `dimos run` config file) on headless machines with no keyring +(`CREDENTIALS_PATH`, just the key) on headless machines with no keyring backend. `DIMOS_API_KEY` (via GlobalConfig) overrides any stored login. """ diff --git a/dimos/constants.py b/dimos/constants.py index d893a78f5e..e744ca057a 100644 --- a/dimos/constants.py +++ b/dimos/constants.py @@ -38,9 +38,6 @@ LOG_DIR = STATE_DIR / "logs" RECORDINGS_DIR = STATE_DIR / "recordings" -# Plain-text API key, 0600. Deliberately a flat sibling of the dimos config -# path, never inside it: the config path is shareable (dotfiles, bug reports, -# machine sync) and secrets must not ride along. CREDENTIALS_PATH = CONFIG_DIR / "dimos-credentials" """