diff --git a/dimos/cli/cloud.py b/dimos/cli/cloud.py index 20065efeff..b680f8e2cc 100644 --- a/dimos/cli/cloud.py +++ b/dimos/cli/cloud.py @@ -17,9 +17,9 @@ 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) on headless machines with no keyring +backend. `DIMOS_API_KEY` (via GlobalConfig) overrides any stored login. """ import json @@ -62,28 +62,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 +102,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 +115,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..e744ca057a 100644 --- a/dimos/constants.py +++ b/dimos/constants.py @@ -38,7 +38,7 @@ LOG_DIR = STATE_DIR / "logs" RECORDINGS_DIR = STATE_DIR / "recordings" -CREDENTIALS_PATH = CONFIG_DIR / "dimos" / "credentials.json" +CREDENTIALS_PATH = CONFIG_DIR / "dimos-credentials" """ Constants for shared memory