Summary
Starting agent-server with OH_PERSISTENCE_DIR=/tmp/foo doesn't actually isolate state — the materialized <persistence_dir>/settings.json is silently seeded with values pulled from ~/.openhands/profiles/, which OH_PERSISTENCE_DIR has no influence over.
The result: callers (e.g. agent-canvas CI / dev / test rigs) can't get a known-empty agent-server, and stale LLM endpoints from the host user's profiles leak into supposedly-isolated instances.
Repro
rm -rf /tmp/fresh-state && mkdir -p /tmp/fresh-state
env -i \
PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" HOME="$HOME" \
PYTHONUTF8=1 \
OH_PERSISTENCE_DIR=/tmp/fresh-state \
OH_SESSION_API_KEYS_0="$(openssl rand -hex 32)" \
uvx --from openhands-agent-server==1.28.1 \
--with openhands-sdk==1.28.1 \
--with openhands-tools==1.28.1 \
--with openhands-workspace==1.28.1 \
agent-server --host 127.0.0.1 --port 8050 &
sleep 8
curl -sS -H "X-Session-API-Key: $OH_SESSION_API_KEYS_0" \
http://127.0.0.1:8050/api/settings \
| jq '.agent_settings.llm | {model, base_url, api_key}'
Expected
A brand-new OH_PERSISTENCE_DIR should yield default values:
{ "model": "gpt-5.5", "base_url": null, "api_key": null }
Actual
agent_settings.llm is populated from whichever LLM profile happens to live in ~/.openhands/profiles/ — values that have nothing to do with the configured persistence dir:
{
"model": "openai/Qwen3.6-35B-A3B-GGUF",
"base_url": "https://statusquo-amd-lemonade.ngrok-free.dev/v1",
"api_key": null
}
/tmp/fresh-state/settings.json is then written with those leaked values.
Root cause
LLMProfileStore and AgentProfileStore default their base_dir to Path.home() / ".openhands" / ...:
openhands-sdk/openhands/sdk/llm/llm_profile_store.py:26
_DEFAULT_PROFILE_DIR: Final[Path] = Path.home() / ".openhands" / "profiles"
openhands-sdk/openhands/sdk/profiles/agent_profile_store.py:28
_DEFAULT_PROFILE_DIR: Final[Path] = Path.home() / ".openhands" / "agent-profiles"
The stores accept a base_dir override, but all 21 construction sites inside openhands-agent-server use the no-arg form LLMProfileStore() / AgentProfileStore(). So they never honor OH_PERSISTENCE_DIR:
$ grep -rn 'LLMProfileStore()\|AgentProfileStore()' openhands-agent-server --include='*.py' | wc -l
21
Compare to FileSettingsStore / FileSecretsStore / FileWorkspacesStore, which go through _get_persistence_dir() (which does read OH_PERSISTENCE_DIR) — openhands-agent-server/openhands/agent_server/persistence/store.py:693.
Suggested fix
Introduce profile-store accessors next to get_settings_store() / get_secrets_store() in persistence/store.py:
_llm_profile_store: LLMProfileStore | None = None
_agent_profile_store: AgentProfileStore | None = None
def get_llm_profile_store(config: Config | None = None) -> LLMProfileStore:
global _llm_profile_store
if _llm_profile_store is None:
with _store_lock:
if _llm_profile_store is None:
_llm_profile_store = LLMProfileStore(
base_dir=_get_persistence_dir(config) / "profiles",
)
return _llm_profile_store
def get_agent_profile_store(config: Config | None = None) -> AgentProfileStore:
# symmetrical ...
Then replace the 21 default-constructed call sites with the accessor. Extend reset_stores() to clear both.
Add a regression test that asserts:
- with
OH_PERSISTENCE_DIR=$TMP set, LLMProfileStore.base_dir == $TMP/profiles
agent_settings.llm.model after server start matches the default, not whatever lives in ~/.openhands/profiles/.
Context
Discovered while testing agent-canvas PR OpenHands/agent-canvas#1389. For the onboarding-flow QA we needed a known-empty agent-server; the leak hid the actual onboarding state-machine bug we were trying to test.
A duplicate Linear ticket was filed at AGE-1894 before realizing code bugs should be filed on GitHub — cancelling that.
This issue was filed by an AI agent (OpenHands) on behalf of @gneubig.
Summary
Starting
agent-serverwithOH_PERSISTENCE_DIR=/tmp/foodoesn't actually isolate state — the materialized<persistence_dir>/settings.jsonis silently seeded with values pulled from~/.openhands/profiles/, whichOH_PERSISTENCE_DIRhas no influence over.The result: callers (e.g.
agent-canvasCI / dev / test rigs) can't get a known-empty agent-server, and stale LLM endpoints from the host user's profiles leak into supposedly-isolated instances.Repro
Expected
A brand-new
OH_PERSISTENCE_DIRshould yield default values:{ "model": "gpt-5.5", "base_url": null, "api_key": null }Actual
agent_settings.llmis populated from whichever LLM profile happens to live in~/.openhands/profiles/— values that have nothing to do with the configured persistence dir:{ "model": "openai/Qwen3.6-35B-A3B-GGUF", "base_url": "https://statusquo-amd-lemonade.ngrok-free.dev/v1", "api_key": null }/tmp/fresh-state/settings.jsonis then written with those leaked values.Root cause
LLMProfileStoreandAgentProfileStoredefault theirbase_dirtoPath.home() / ".openhands" / ...:openhands-sdk/openhands/sdk/llm/llm_profile_store.py:26openhands-sdk/openhands/sdk/profiles/agent_profile_store.py:28The stores accept a
base_diroverride, but all 21 construction sites insideopenhands-agent-serveruse the no-arg formLLMProfileStore()/AgentProfileStore(). So they never honorOH_PERSISTENCE_DIR:Compare to
FileSettingsStore/FileSecretsStore/FileWorkspacesStore, which go through_get_persistence_dir()(which does readOH_PERSISTENCE_DIR) —openhands-agent-server/openhands/agent_server/persistence/store.py:693.Suggested fix
Introduce profile-store accessors next to
get_settings_store()/get_secrets_store()inpersistence/store.py:Then replace the 21 default-constructed call sites with the accessor. Extend
reset_stores()to clear both.Add a regression test that asserts:
OH_PERSISTENCE_DIR=$TMPset,LLMProfileStore.base_dir == $TMP/profilesagent_settings.llm.modelafter server start matches the default, not whatever lives in~/.openhands/profiles/.Context
Discovered while testing
agent-canvasPR OpenHands/agent-canvas#1389. For the onboarding-flow QA we needed a known-empty agent-server; the leak hid the actual onboarding state-machine bug we were trying to test.A duplicate Linear ticket was filed at AGE-1894 before realizing code bugs should be filed on GitHub — cancelling that.
This issue was filed by an AI agent (OpenHands) on behalf of @gneubig.