Files
Zeyi (Rice) Fan 08b20956f2 fix(cli): honor isolated Omnigent state directories (#4822)
## Related issue

[OMNI-3489](https://linear.app/omnigent/issue/OMNI-3489/honor-omnidev-state-and-config-directories-in-omnigent-cli-paths)

## Summary

- Prevent `omnidev omnigent …` commands from leaking auth tokens, session logs, host daemon records, and native harness launch state into the developer's real `~/.omnigent` directory.
- Make runtime state honor `OMNIGENT_DATA_DIR` while configuration independently honors `OMNIGENT_CONFIG_HOME`; harness-specific native-state overrides still take precedence.
- Keep the real `HOME` and `XDG_*` environment intact so harness credentials and caches remain available, and update REPL E2E setup to seed its theme in the effective config without clobbering mock auth.

**ELI5:** omnidev already gives each development pod its own labeled storage boxes, but some Omnigent code still put files in the user's shared box. Those paths now use the pod's boxes without moving the user's home directory.

```text
omnidev omnigent
       |
       +-- OMNIGENT_DATA_DIR ------> tokens, logs, host/native state
       +-- OMNIGENT_CONFIG_HOME ---> config.yaml
       +-- HOME / XDG_* ------------> unchanged credentials and caches
```

## Test Plan

- `uv run --frozen pytest tests/frontends/sdk/test_user_config.py`
- `uv run --frozen pytest tests/test_native_state_legacy_dirs.py`
- `uv run --frozen pytest tests/host/test_cli_host.py::test_host_pid_path_honors_data_dir_at_import`
- `uv run --frozen pytest tests/e2e/omnigent/test_pexpect_harness.py`
- `uv run --frozen pytest tests/e2e/omnigent/test_repl_smoke.py::test_repl_smoke_single_prompt`
- `cargo test --manifest-path dev/omnidev/Cargo.toml omnigent_cmd::tests`
- `uv run --frozen ruff check omnigent/claude_native_state.py omnigent/cli.py omnigent/cli_auth.py omnigent/codex_native_state.py omnigent/opencode_native_state.py omnigent/repl/_session_log.py sdks/ui/omnigent_ui_sdk/terminal/_config.py tests/frontends/sdk/test_user_config.py tests/host/test_cli_host.py tests/test_native_state_legacy_dirs.py tests/e2e/omnigent/_pexpect_harness.py tests/e2e/omnigent/test_pexpect_harness.py`
- `cargo fmt --manifest-path dev/omnidev/Cargo.toml --check`

## Demo

N/A — non-visual CLI state-isolation fix.

## Type of change

- [x] Bug fix
- [ ] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [x] E2E tests added / updated
- [ ] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Regression tests cover pod environment wiring, data/config override precedence, HOME fallbacks, host pidfile placement, native harness state roots, and REPL startup with an isolated config home.

## Changelog

`omnidev omnigent` commands now keep runtime state and configuration inside their development pod.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
2026-08-15 00:00:14 +00:00

164 lines
5.6 KiB
Python

"""Persistent client-side state for ``omnigent codex`` sessions.
The native Codex wrapper records the cwd used to create a session so a
later ``omnigent codex --resume <conv_id>`` can launch Codex from the
same workspace. This state is intentionally client-side: local
filesystem paths belong to the user's machine and should not be stored
on the shared Omnigent server.
Layout (per conversation):
~/.omnigent/codex-native/<sha256(conv_id)[:32]>/launch.json
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from omnigent.process_logging import data_dir
_STATE_ROOT_ENV_VAR = "OMNIGENT_CODEX_NATIVE_STATE_DIR"
_logger = logging.getLogger(__name__)
_LAUNCH_FILE = "launch.json"
_ID_HASH_CHARS = 32
@dataclass(frozen=True)
class CodexNativeLaunchState:
"""
Persisted state about how a codex-native session was launched.
:param working_directory: Absolute filesystem path the wrapper
was invoked from when the session was created, e.g.
``"/home/me/repo"``.
"""
working_directory: str
def _codex_native_state_root() -> Path:
"""
Return the root directory for persistent codex-native state.
Honors :data:`_STATE_ROOT_ENV_VAR` for tests and advanced local
setups. Otherwise follows ``OMNIGENT_DATA_DIR``, falling back to
``~/.omnigent/codex-native``.
:returns: Absolute path to the state root.
"""
override = os.environ.get(_STATE_ROOT_ENV_VAR)
if override:
return Path(override)
return data_dir() / "codex-native"
def _state_dir_for_conversation_id(conversation_id: str) -> Path:
"""
Return the per-conversation persistent state directory.
Hashing the conversation id prevents path traversal if a server
ever returned an attacker-controlled id such as ``"../etc"``.
Sessions created before ids dropped the ``conv_`` prefix hashed the
prefixed string, so their directories live under the legacy digest; when
the bare-digest directory is absent, the legacy one is returned (never
renamed — files inside may embed their own absolute path).
:param conversation_id: Omnigent conversation id, bare 32-char hex
(a legacy ``conv_``-prefixed form is accepted and normalised).
:returns: Absolute directory path; not guaranteed to exist.
"""
bare = conversation_id.removeprefix("conv_")
root = _codex_native_state_root()
state_dir = root / hashlib.sha256(bare.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS]
if not state_dir.exists():
legacy = root / hashlib.sha256(f"conv_{bare}".encode()).hexdigest()[:_ID_HASH_CHARS]
if legacy.exists():
return legacy
return state_dir
def write_launch_state(conversation_id: str, working_directory: str) -> None:
"""
Persist a session's launch state at creation time.
Same-value writes are idempotent. Different-value writes are
refused and logged because changing the recorded cwd for an
existing session would make future resume checks incorrect.
:param conversation_id: Omnigent conversation id, e.g.
``"conv_abc123"``.
:param working_directory: Absolute launch cwd, e.g.
``"/home/me/repo"``.
:returns: None.
:raises ValueError: If *working_directory* is empty or relative.
"""
if not working_directory:
raise ValueError("working_directory must be a non-empty absolute path")
if not Path(working_directory).is_absolute():
raise ValueError("working_directory must be a non-empty absolute path")
state_dir = _state_dir_for_conversation_id(conversation_id)
existing = read_launch_state(conversation_id)
if existing is not None and existing.working_directory != working_directory:
_logger.warning(
"codex-native launch state mismatch for %s: existing=%r new=%r; "
"keeping existing value",
conversation_id,
existing.working_directory,
working_directory,
)
return
state_dir.mkdir(parents=True, exist_ok=True)
target = state_dir / _LAUNCH_FILE
payload = {
"conversation_id": conversation_id,
"working_directory": working_directory,
}
tmp = target.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8")
os.replace(tmp, target)
def read_launch_state(conversation_id: str) -> CodexNativeLaunchState | None:
"""
Load a session's launch state, or ``None`` if not recorded.
Missing, unreadable, or malformed state is treated as absent so
legacy and cross-machine resumes continue to behave as before.
:param conversation_id: Omnigent conversation id, e.g.
``"conv_abc123"``.
:returns: Parsed state, or ``None`` if missing / malformed.
"""
target = _state_dir_for_conversation_id(conversation_id) / _LAUNCH_FILE
try:
raw = target.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except OSError:
_logger.warning(
"codex-native launch state read failed for %s",
conversation_id,
exc_info=True,
)
return None
try:
payload = json.loads(raw)
except json.JSONDecodeError:
_logger.warning(
"codex-native launch state JSON is malformed for %s; ignoring",
conversation_id,
)
return None
if not isinstance(payload, dict):
return None
working_directory = payload.get("working_directory")
if not isinstance(working_directory, str) or not working_directory:
return None
return CodexNativeLaunchState(working_directory=working_directory)