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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,8 @@ jobs:
dimos/e2e_tests/test_publish_browser.py
dimos/e2e_tests/test_voice_browser.py
dimos/e2e_tests/test_stats_browser.py
dimos/e2e_tests/test_robot_picker_browser.py --no-cov
dimos/e2e_tests/test_robot_picker_browser.py
dimos/e2e_tests/test_relay_auth_browser.py --no-cov

tests:
if: |
Expand Down
4 changes: 3 additions & 1 deletion dimos/cli/commands/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@

import typer

from dimos.core.global_config import global_config
from dimos.core.global_config import SECRET_CONFIG_FIELDS, global_config


def show_config() -> None:
"""Show current config settings and their values."""
for field_name, value in global_config.model_dump().items():
if field_name in SECRET_CONFIG_FIELDS and value is not None:
value = "***"
typer.echo(f"{field_name}: {value}")


Expand Down
14 changes: 14 additions & 0 deletions dimos/cli/test_dimos.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ def test_global_config_flag_applies_before_subcommand():
global_config.update(transport=original)


def test_show_config_masks_secrets():
# A config dump gets pasted into chats and bug reports: keys print as ***.
original = global_config.relay_key
try:
result = CliRunner().invoke(
main, ["--relay-key", "robot-key-0123456789abcdef", "show-config"]
)
assert result.exit_code == 0, result.output
assert "relay_key: ***" in result.output
assert "robot-key-0123456789abcdef" not in result.output
finally:
global_config.update(relay_key=original)


def test_run_composition_leaves_blueprint_alone_when_relay_disabled() -> None:
class Config(ModuleConfig):
pass
Expand Down
7 changes: 7 additions & 0 deletions dimos/core/global_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
# LLM API keys itself.
ENV_FILE = None if "PYTEST_VERSION" in os.environ else ".env"

# Never expose these in config dumps or persist their CLI values in run metadata.
SECRET_CONFIG_FIELDS = frozenset({"dimos_api_key", "relay_key", "unitree_aes_128_key"})


def _get_all_numbers(s: str) -> list[float]:
return [float(x) for x in re.findall(r"-?\d+\.?\d*", s)]
Expand Down Expand Up @@ -138,6 +141,10 @@ class GlobalConfig(BaseSettings):
"""PEM CA bundle that signed the relay_url relay's certificate (mkcert, a
private CA); replaces the default trust stores. Unset for a relay with a
public certificate."""
relay_key: str | None = None
"""Key that identifies this robot to a relay started with --auth-file
(bound to its robot id there). Prefer RELAY_KEY in the environment or
.env over the --relay-key flag, which shows in the process list."""
dimos_cloud_url: str = "https://api.dimensional.org"
dimos_api_key: str | None = None
dimos_upload_codec: str = "lz4"
Expand Down
28 changes: 28 additions & 0 deletions dimos/core/run_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,33 @@

from dimos.constants import STATE_DIR
from dimos.core.coordination.process_lifecycle import kill_run_processes
from dimos.core.global_config import SECRET_CONFIG_FIELDS
from dimos.utils.logging_config import setup_logger

logger = setup_logger()

REGISTRY_DIR = STATE_DIR / "runs"


def _config_field_name(option: str) -> str:
return option.removeprefix("--").rsplit(".", 1)[-1].replace("-", "_")


def _without_secret_options(argv: list[str]) -> list[str]:
safe: list[str] = []
skip_value = False
for arg in argv:
if skip_value:
skip_value = False
continue
option, separator, _value = arg.partition("=")
if option.startswith("--") and _config_field_name(option) in SECRET_CONFIG_FIELDS:
skip_value = separator == ""
continue
safe.append(arg)
return safe


@dataclass
class RunEntry:
"""Metadata for a single DimOS run (daemon or foreground)."""
Expand All @@ -46,6 +66,14 @@ class RunEntry:
config_overrides: dict[str, object] = field(default_factory=dict)
original_argv: list[str] = field(default_factory=list)

def __post_init__(self) -> None:
self.config_overrides = {
key: value
for key, value in self.config_overrides.items()
if _config_field_name(key) not in SECRET_CONFIG_FIELDS
}
self.original_argv = _without_secret_options(self.original_argv)
Comment thread
paul-nechifor marked this conversation as resolved.

@property
def registry_path(self) -> Path:
return REGISTRY_DIR / f"{self.run_id}.json"
Expand Down
39 changes: 38 additions & 1 deletion dimos/core/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ def tmp_registry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
def _make_entry(
run_id: str = "20260306-120000-test",
pid: int | None = None,
config_overrides: dict[str, object] | None = None,
original_argv: list[str] | None = None,
) -> RunEntry:
return RunEntry(
run_id=run_id,
Expand All @@ -51,7 +53,8 @@ def _make_entry(
started_at="2026-03-06T12:00:00Z",
log_dir="/tmp/test-logs",
cli_args=["test"],
config_overrides={},
config_overrides=config_overrides or {},
original_argv=original_argv or [],
)


Expand Down Expand Up @@ -84,6 +87,40 @@ def test_remove_idempotent(self, tmp_registry: Path):
entry.remove()
entry.remove() # already gone — still fine

def test_secrets_are_not_persisted_in_restart_metadata(self, tmp_registry: Path) -> None:
secrets = {
"relay_key": "relay-key-0123456789abcdef",
"dimos_api_key": "api-key-0123456789abcdef",
"unitree_aes_128_key": "aes-key-0123456789abcdef",
}
entry = _make_entry(
config_overrides={"viewer": "none", **secrets},
original_argv=[
"dimos",
"--relay-key",
secrets["relay_key"],
"run",
"unitree-go2",
f"--g.dimos-api-key={secrets['dimos_api_key']}",
"--unitree-aes-128-key",
secrets["unitree_aes_128_key"],
"--g.relay-key=another-relay-key-0123456789abcdef",
"--viewer",
"none",
],
)

entry.save()

assert entry.config_overrides == {"viewer": "none"}
assert entry.original_argv == ["dimos", "run", "unitree-go2", "--viewer", "none"]
persisted = entry.registry_path.read_text()
for secret in (*secrets.values(), "another-relay-key-0123456789abcdef"):
assert secret not in persisted
loaded = RunEntry.load(entry.registry_path)
assert loaded.config_overrides == {"viewer": "none"}
assert loaded.original_argv == ["dimos", "run", "unitree-go2", "--viewer", "none"]


class TestGenerateRunId:
"""test_generate_run_id_format — timestamp + sanitized blueprint name."""
Expand Down
103 changes: 103 additions & 0 deletions dimos/e2e_tests/test_relay_auth_browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Copyright 2026 Dimensional Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Relay auth browser e2e (the T12d cockpit login, in CI).

A hand-started relay with --auth-file and one RelayBridgeModule attached
through relay_url with the robot's key: the hosted-relay shape, on loopback.
Nothing publishes data: the panel renders in its "waiting for data" state,
enough to prove that the token-bearing session adopted the manifest. The
page must show the token form (no token stored), connect and render the
panel once the token is typed, and return to the form on "log out".

Marked web_browser: excluded from the default suite (needs the
`browser-tests` dependency group, Playwright browsers, and built web dists).
Locally:
`uv run --group browser-tests pytest -m web_browser dimos/e2e_tests/test_relay_auth_browser.py`.
"""

from collections.abc import Iterator
import json

import pytest

from dimos.web.cockpit import Video, cockpit
from dimos.web.relay_bridge.e2e_support import stop_module
from dimos.web.relay_bridge.locate import find_web_dir
from dimos.web.relay_bridge.relay_process import RelayProcess, ensure_web_dist

pytest.importorskip("playwright")

from playwright.sync_api import Page, expect, sync_playwright

pytestmark = pytest.mark.web_browser

ROBOT_KEY = "robot-key-e2e-0123456789abcdef"
VIEWER_TOKEN = "viewer-token-e2e-0123456789abcdef"


@pytest.fixture(scope="module")
def auth_relay_url(tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
ensure_web_dist(find_web_dir())
auth_file = tmp_path_factory.mktemp("auth") / "auth.json"
auth_file.write_text(
json.dumps({"robots": {"go2-lab": ROBOT_KEY}, "viewers": {"tester": VIEWER_TOKEN}})
)
with RelayProcess(auth_file=auth_file) as ready:
(atom,) = cockpit(layout=Video("color_image")).blueprints
module = atom.module(
relay_url=ready.open_url,
relay_key=ROBOT_KEY,
open_browser=False,
web_build=False,
robot_id="go2-lab",
**atom.kwargs,
)
module.start() # returns only after hello/welcome: the key was accepted
try:
yield ready.open_url
finally:
stop_module(module)


@pytest.fixture
def chromium_page() -> Iterator[Page]:
with sync_playwright() as p:
browser = p.chromium.launch()
try:
yield browser.new_page()
finally:
browser.close()


def test_token_form_connects_and_logs_out(auth_relay_url: str, chromium_page: Page) -> None:
page = chromium_page
page.goto(auth_relay_url)
# No token stored: the relay rejects the session and the form shows why.
expect(page.get_by_test_id("token-message")).to_have_text(
"missing viewer token", timeout=120_000
)
expect(page.get_by_test_id("log-out")).to_have_count(0)

page.get_by_test_id("token-input").fill(VIEWER_TOKEN)
page.get_by_test_id("token-connect").click()
expect(page.get_by_test_id("status")).to_have_attribute(
"data-phase", "connected", timeout=60_000
)
expect(page.get_by_test_id("panel-p0")).to_be_visible(timeout=30_000)

page.get_by_test_id("log-out").click()
expect(page.get_by_test_id("token-message")).to_have_text(
"missing viewer token", timeout=60_000
)
2 changes: 2 additions & 0 deletions dimos/web/relay_bridge/locate.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def relay_run_cmd(
serve_dir: Path | None = None,
cert: Path | None = None,
key: Path | None = None,
auth_file: Path | None = None,
) -> list[str]:
"""Build the argv that runs the relay with the pinned config and least permissions."""
# Canonical paths: the relay realpath-checks served files against its
Expand All @@ -103,6 +104,7 @@ def relay_run_cmd(
("--serve-dir", serve_dir),
("--cert", cert),
("--key", key),
("--auth-file", auth_file),
)
if path is not None
]
Expand Down
11 changes: 10 additions & 1 deletion dimos/web/relay_bridge/module_test_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class FakeClient:

def __init__(self, hello_error: Exception | None = None) -> None:
self.hello_args: tuple[Any, Any] | None = None
self.hello_token: str | None = None
self.hello_error = hello_error
self.control_msgs: asyncio.Queue[Msg | DataFrame] = asyncio.Queue()
self.closed = asyncio.Event()
Expand All @@ -67,8 +68,16 @@ def __init__(self, hello_error: Exception | None = None) -> None:
self.control_frames: list[Msg] = []
self.close_count = 0

async def hello(self, timeout: float = 5.0, *, robot: Any = None, manifest: Any = None) -> None:
async def hello(
self,
timeout: float = 5.0,
*,
robot: Any = None,
manifest: Any = None,
token: str | None = None,
) -> None:
self.hello_args = (robot, manifest)
self.hello_token = token
if self.hello_error is not None:
raise self.hello_error

Expand Down
21 changes: 18 additions & 3 deletions dimos/web/relay_bridge/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@

logger = setup_logger()

# v6 (amended, T12d): hello gains an optional `token` (a robot key or viewer
# token for a relay started with --auth-file) and the relay answers
# auth_failed; no bump: an older peer omits the field and an auth-on relay
# rejects it, an auth-off relay ignores it.
# v6: /api/info.wtUrl is a WebTransport base URL; clients append their role
# path. v5 advertised the complete /viewer endpoint.
# v5: the robot hello leaves datagrams (and their ~1100 B budget) and rides
Expand Down Expand Up @@ -110,6 +114,9 @@
# the relay forwards its own per-robot token robot-ward.
MAX_REQUEST_ID_LEN = 64

# Bound for hello.token (a robot key or viewer token, see web/relay/auth.ts).
MAX_TOKEN_LEN = 256

# Reject absurd header lengths before allocating (mirrors protocol.ts).
MAX_HEADER_LEN = 65536

Expand Down Expand Up @@ -166,15 +173,21 @@ def _optional_reject_wire_null(value: Any, info: ValidationInfo) -> Any:
return value


# Optional wire scalars (teleop gen, pub clientTs, error requestId): absent is
# fine, never null on the wire (mirrors the absent-or-typed validators in
# protocol.ts).
# Optional wire scalars (teleop gen, pub clientTs, error requestId, hello
# token): absent is fine, never null on the wire (mirrors the absent-or-typed
# validators in protocol.ts).
_WireOptNumber = Annotated[int | float | None, BeforeValidator(_optional_reject_wire_null)]
_WireOptRequestId = Annotated[
str | None,
BeforeValidator(_optional_reject_wire_null),
Field(min_length=1, max_length=MAX_REQUEST_ID_LEN),
]
# The bound sits on the str member: a constrained `str | None` rejects an
# explicit None (the client passes token=None when there is no key).
_WireOptToken = Annotated[
Annotated[str, Field(max_length=MAX_TOKEN_LEN)] | None,
BeforeValidator(_optional_reject_wire_null),
]


class Hello(_WireModel):
Expand All @@ -184,6 +197,8 @@ class Hello(_WireModel):
# role=robot only: identity + channel manifest, registered by the relay.
robot: RobotInfo | None = None
manifest: RobotManifest | None = None
# Robot key or viewer token for a relay started with --auth-file.
token: _WireOptToken = None

@field_validator("robot", "manifest", mode="before")
@classmethod
Expand Down
Loading
Loading