Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 14 additions & 16 deletions dimos/cli/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +81 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Legacy JSON logins send a JSON document as the bearer token

Users authenticated by the preceding implementation have {"api_key":"...","email":"..."} stored in the legacy file or keyring. _load() now returns that whole object as a string, so api_key() passes it through and whoami() sends Bearer {"api_key":...} instead of the API key. Existing logged-in users will therefore be rejected until they manually log in again. Decode the legacy JSON shape when it contains a string api_key (optionally rewriting it in the new format), while preserving new plain-text credentials.

Artifacts

Isolated legacy JSON credential reproduction script

  • The authored no-network harness loads both parent and current cloud modules, exercises file and keyring legacy values, and records the Authorization header, with the takeaway that the regression is directly executable.

Before-change legacy JSON credential behavior

  • The parent-revision run shows both legacy file and keyring credentials are decoded to `dimos_sk_old` and sent as `Bearer dimos_sk_old`, with the takeaway that the prior behavior authenticated correctly.

Current-change legacy JSON credential behavior

  • The current-revision run shows both legacy file and keyring values are returned and sent as the full JSON blob, with the takeaway that existing users' cloud authentication regresses.

View artifacts

T-Rex Ran code and verified through T-Rex

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 A focused local HTTP-server check exercised load → apikey → whoami with both simulated...

  • Bug
    • A focused local HTTP-server check exercised load → apikey → whoami with both simulated keyring storage and the fallback credential file. A plain token produced Authorization: Bearer current-token, while the legacy value {"apikey":"legacy-secret","email":"legacy@example.test"} produced Authorization: Bearer {"apikey":"legacy-secret","email":"legacy@example.test"} in both storage modes. This confirms that existing JSON-formatted logins cannot authenticate because the complete document, rather than its apikey, is used as the bearer token.
  • Cause
    • T-Rex reproduced this while running the changed behavior, but it did not return a separate root-cause sentence.
  • Fix
    • Update the changed code so this failing path is handled, then rerun the same T-Rex check to confirm it passes.
Artifacts

Focused legacy JSON credential end-to-end test source

  • This exact temporary test source loads the repository's cloud module, simulates keyring and file credentials, and captures the local server Authorization header; the takeaway is that it exercises the reported path without changing repository code.

Baseline scalar credential execution output

  • This executed baseline run records `_load`, `api_key`, and the captured Authorization header for a normal scalar token in both storage modes; the takeaway is that the expected `Bearer current-token` path works.

Legacy JSON credential execution output

  • This executed run records `_load`, `api_key`, and the local server's captured Authorization header for legacy JSON in keyring and file storage; the takeaway is that both send the whole JSON object as the bearer token.

View artifacts

T-Rex Ran code and verified through T-Rex

except OSError:
return None


Expand All @@ -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:
Expand All @@ -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"):
Expand Down
12 changes: 6 additions & 6 deletions dimos/cli/test_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion dimos/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading