fix(tui): one bound-profile backend for every workspace-cwd decision

Follow-up to the #105749 + #123903 salvage:

- A named profile without terminal.backend is local. Both contributor
  helpers fell back to the LAUNCH profile's backend, so a local profile
  opened from an ssh launch had its terminal.cwd treated as remote.
- _bound_terminal_backend() is the single resolver (create, completion,
  workspace.move, display heal, settle-follow, terminal tool). The
  terminal tool now reads it too, so an ssh profile under a local launch
  keeps /home/kali instead of the display heal persisting /home.
- _declared_remote_profile_cwd() only honours a profile that itself
  declares backend: ssh; the placeholder set is gone (the ~/absolute
  shape check already rejects ".", "auto", "cwd").
- One loader for a named profile's terminal section
  (_profile_terminal_cfg), shared with _profile_configured_cwd.
- Eager row at session.create and "row cwd = explicit" on hydrate apply
  to remote sessions only; local project drafts stay lazy and keep
  settle-following, as on main.
- config.get project forwards the pinned profile (and marks a picked
  path explicit) so the desktop gets the remote dir back; the renderer
  keeps adopting the server's normalized cwd for local users (WSL
  translation, abspath) instead of bypassing it.
- Dropped #123903's cwd_explicit-decides-intent change in session.create:
  it made every local desktop new chat in a project lose its workspace
  and AGENTS.md.
This commit is contained in:
kshitijk4poor
2026-09-26 20:09:25 +05:30
committed by kshitij
parent f94688995d
commit fc589b6898
6 changed files with 62 additions and 105 deletions

View File

@@ -74,10 +74,8 @@ describe('startWorkspaceSession', () => {
await second.promise
await Promise.resolve()
// The explicitly chosen path is authoritative over the server-normalized cwd (a remote/ssh project
// dir is dropped to the launch cwd by host-side normalization); only the branch is adopted from the probe.
expect($newChatWorkspaceTarget.get()).toBe('/workspace-b')
expect($currentCwd.get()).toBe('/workspace-b')
expect($newChatWorkspaceTarget.get()).toBe('/normalized-b')
expect($currentCwd.get()).toBe('/normalized-b')
expect($currentBranch.get()).toBe('main')
})

View File

@@ -61,17 +61,19 @@ export function startWorkspaceSession({
const workspaceGeneration = $newChatWorkspaceTargetGeneration.get()
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
void requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
cwd: target,
// The project's profile decides its terminal backend: an ssh project dir is not on this host, and
// resolving it under the launch profile would normalize it away to the launch cwd.
...(profile ? { profile } : {})
})
.then(info => {
if ($newChatWorkspaceTargetGeneration.get() !== workspaceGeneration || activeSessionIdRef.current) {
return
}
// An explicitly chosen project path is authoritative (like the backend's explicit_cwd): a remote/ssh
// project dir does not exist on the gateway host, so config.get's host-side normalization drops it to
// the launch cwd (/opt/hermes). Keep the user's path; only adopt the server cwd for the path-less
// fallback. Branch still comes from the probe.
const resolved = explicitTarget || info.cwd || target
const resolved = info.cwd || target
setCurrentCwd(resolved)
setNewChatWorkspaceTarget(resolved)

View File

@@ -191,7 +191,9 @@ def _cfg_get_provider(params):
def _cfg_get_project(params):
raw = str(params.get("cwd", "") or (_load_cfg().get("terminal") or {}).get("cwd", "") or "").strip()
cwd = _completion_cwd({"cwd": raw} if raw else {})
# A picked path is explicit (the profile's terminal.cwd must not replace it); the profile picks the backend,
# so a remote project dir is kept instead of being dropped to the launch cwd by the host isdir check.
cwd = _completion_cwd({"cwd": raw, "cwd_explicit": bool(params.get("cwd")), "profile": params.get("profile")})
return {"cwd": cwd, "branch": git_probe.branch(cwd)}

View File

@@ -377,7 +377,7 @@ def _create_session(rid, params: dict, *, copy_parent_history: bool = False) ->
raw_cwd = _str_param(params, "cwd") # unguarded, as on BASE: only the path check is best-effort
# The BOUND profile's backend (multiplex hasn't rebound HERMES_HOME yet, so the process-global
# _effective_terminal_backend() would read the launch profile - usually local).
session_backend = _profile_terminal_backend(profile_home) or _effective_terminal_backend()
session_backend = _bound_terminal_backend(profile_home)
with contextlib.suppress(Exception):
# A non-local backend's cwd lives inside the target environment, so a LOCAL isdir gate would
# drop the remote project path and _terminal_task_cwd_with_source would fall to `~`. Mark it
@@ -437,12 +437,10 @@ def _create_session(rid, params: dict, *, copy_parent_history: bool = False) ->
_seed_branch_row(_sessions[sid], key, parent_session_id, history, source, profile_home)
elif history:
_seed_row(_sessions[sid])
elif _sessions[sid].get("explicit_cwd"):
# A session opened INTO a project (explicit cwd) is explicit intent, not an abandoned draft, so the
# "no eager row" rule above does not apply: persist the row now with its project cwd. Otherwise the
# per-profile gateway process that runs the first turn mints the row itself (AIAgent INSERT-OR-IGNORE)
# with cwd=None -- the sidebar then drops the session to Home and the terminal falls back to the
# profile's ~ dir. Gated on explicit_cwd so plain launches still stay row-less (no "Untitled" litter).
elif explicit_cwd and session_backend != "local":
# A remote project session persists its row now: the per-profile gateway that runs the first turn mints
# the row itself (AIAgent INSERT-OR-IGNORE) with cwd=None, and the sidebar then drops it to Home. Local
# project drafts stay lazy — their cwd reaches the row on the first prompt (no "Untitled" litter).
_ensure_session_db_row(_sessions[sid])
# Return immediately so Ink can paint; the AIAgent builds right after the flush.
_schedule_agent_build(sid)
@@ -1019,9 +1017,7 @@ def _(rid, params: dict) -> dict:
# A non-local (ssh/docker) profile's workspace lives inside the target environment: the local isdir
# gate would reject a valid remote project dir. Read the BOUND profile's backend (not the launch
# process env) and, when non-local, trust the path raw - mirroring _completion_cwd / _set_session_cwd.
is_local = _profile_terminal_backend(_profile_home(params.get("profile"))) or _effective_terminal_backend()
is_local = is_local == "local"
if is_local:
if _bound_terminal_backend(_profile_home(params.get("profile"))) == "local":
if not os.path.isdir(resolved):
return _err(rid, 4017, f"working directory does not exist: {raw}")
target_cwd = resolved

View File

@@ -619,34 +619,33 @@ def _profile_configured_cwd(profile_home: Path | None) -> str | None:
env var (issue #40334). Returns an absolute, existing directory, or None for placeholders / missing /
invalid paths.
"""
return _configured_cwd_from_cfg({"terminal": _profile_terminal_cfg(profile_home)}) if profile_home else None
def _profile_terminal_cfg(profile_home: Path | str | None) -> dict:
"""A named profile's ``terminal:`` section from ITS config.yaml ({} for the launch profile / fail-open)."""
if profile_home is None:
return None
return {}
with contextlib.suppress(Exception):
from hermes_cli.config_effective import load_user_config_effective
p = Path(profile_home) / "config.yaml"
return _configured_cwd_from_cfg(load_user_config_effective(p)) if p.exists() else None
return None
cfg = load_user_config_effective(p) if p.exists() else {}
terminal_cfg = cfg.get("terminal") if isinstance(cfg, dict) else None
return terminal_cfg if isinstance(terminal_cfg, dict) else {}
return {}
def _profile_terminal_backend(profile_home: Path | None) -> str | None:
"""A non-launch profile's ``terminal.backend`` from ITS config.yaml (fail-open → None).
def _profile_terminal_backend(profile_home: Path | str | None) -> str | None:
"""A named profile's ``terminal.backend`` from ITS config.yaml; None for the launch profile.
Same reason as :func:`_profile_configured_cwd`: at ``session.create`` the multiplex gateway has NOT yet
rebound HERMES_HOME to the target profile, so ``_effective_terminal_backend()`` reads the LAUNCH profile
(usually ``local``). A session bound to an ``ssh``/``docker`` profile then loses the non-local cwd
exemption and its remote workspace is dropped to the launch dir. Read the bound profile's own backend.
rebound HERMES_HOME to the target profile, so ``_effective_terminal_backend()`` reads the LAUNCH profile.
A missing key is ``local`` (the default) — never the launch profile's backend, which would make a local
profile's paths look remote whenever the app was launched from an ssh profile.
"""
if profile_home is None:
return None
with contextlib.suppress(Exception):
from hermes_cli.config_effective import load_user_config_effective
p = Path(profile_home) / "config.yaml"
if p.exists():
cfg = load_user_config_effective(p)
terminal_cfg = cfg.get("terminal") if isinstance(cfg, dict) else None
if isinstance(terminal_cfg, dict):
return str(terminal_cfg.get("backend") or "").strip().lower() or None
return None
return str(_profile_terminal_cfg(profile_home).get("backend") or "").strip().lower() or "local"
def _launch_configured_cwd() -> str | None:
@@ -2599,11 +2598,11 @@ def _hydrate_session_cwd(sid: str, key: str, session_db, profile_home: str | Non
if row and row.get("cwd"):
with _sessions_lock:
if sid in _sessions:
# A persisted row cwd is the session's authoritative workspace (a project session, or a
# settled dir), not a launch artifact: mark it explicit so the ssh/remote terminal uses it
# instead of falling back to the profile's ~ (session_workdir._terminal_task_cwd_with_source).
_sessions[sid]["cwd"] = row["cwd"]
_sessions[sid]["explicit_cwd"] = True
# A remote session's stored cwd is its workspace: explicit, so the ssh/docker terminal uses
# it instead of the profile's ~. Local rows keep settle-following as before.
if not _session_is_local_backend(_sessions[sid]):
_sessions[sid]["explicit_cwd"] = True
elif hasattr(db, "update_session_cwd"):
try:
_persist_session_cwd_and_schedule_git_meta(_sessions[sid], _sessions[sid]["cwd"], db=db)

View File

@@ -34,10 +34,8 @@ def _completion_cwd(params: dict | None = None) -> str:
profile_cwd = _profile_configured_cwd(profile_home)
if profile_cwd:
return profile_cwd
# SSH cwd usually does not exist on the desktop host, so the isdir
# check above drops it and the launch profile's workspace wins.
remote_cwd = _declared_remote_profile_cwd(profile_home)
if remote_cwd:
# An ssh profile's terminal.cwd is not on this host, so the isdir check above drops it.
if remote_cwd := _declared_remote_profile_cwd(profile_home):
return remote_cwd
# A session bound to another profile resolves its workspace from THAT profile's config before the launch profile's
# env var; the dashboard's in-memory gateway does NOT inherit the PTY child's bridged TERMINAL_CWD, so a configured
@@ -48,20 +46,14 @@ def _completion_cwd(params: dict | None = None) -> str:
# The BOUND profile's backend, not the launch profile's: under multiplex HERMES_HOME is not yet rebound at
# session.create, so the process-global _effective_terminal_backend() would misread an ssh/docker profile as
# local and drop its remote cwd to getcwd().
backend = _profile_terminal_backend(_profile_home(params.get("profile"))) or _effective_terminal_backend()
# A non-local backend's cwd lives inside the target environment, not on the host: pass it raw (as
# _terminal_task_cwd_with_source does) instead of the local isdir gate that would drop it to getcwd().
if _bound_terminal_backend(_profile_home(params.get("profile"))) != "local":
return str(raw)
with contextlib.suppress(Exception):
resolved = os.path.abspath(os.path.expanduser(str(raw)))
# A non-local backend's cwd lives inside the target environment, not on the host: mirror the
# exemption _terminal_task_cwd_with_source already has (:58/:65) and pass it raw, skipping the
# local isdir gate that would otherwise drop the remote project path to getcwd().
if backend != "local":
return resolved
if os.path.isdir(resolved):
return resolved
if not params.get("cwd_explicit"):
remote_cwd = _declared_remote_profile_cwd(_profile_home(params.get("profile")))
if remote_cwd:
return remote_cwd
return os.getcwd()
@@ -74,46 +66,23 @@ def _workdir_terminal_cfg(key: str) -> str:
return ""
_REMOTE_CWD_PLACEHOLDERS = {".", "./", "auto", "cwd"}
def _profile_terminal_section(profile_home) -> dict:
"""``terminal:`` from a profile's own config.yaml, or {}."""
if not profile_home:
return {}
with contextlib.suppress(Exception):
from pathlib import Path
from hermes_cli.config_effective import load_user_config_effective
path = Path(profile_home) / "config.yaml"
if not path.is_file():
return {}
cfg = load_user_config_effective(path)
terminal = cfg.get("terminal") if isinstance(cfg, dict) else None
return terminal if isinstance(terminal, dict) else {}
return {}
def _bound_terminal_backend(profile_home) -> str:
"""Terminal backend of the profile a session/RPC is bound to: a named profile's own config, else the process's."""
return _profile_terminal_backend(Path(profile_home) if profile_home else None) or _effective_terminal_backend()
def _declared_remote_profile_cwd(profile_home) -> str | None:
"""A non-local profile's ``terminal.cwd``, kept when the path is not on this host.
"""A named ssh profile's own ``terminal.cwd`` (``~``, ``~/…`` or absolute), unchecked against this host.
``_profile_configured_cwd`` requires ``os.path.isdir``. An SSH working
directory lives on the remote, so that check drops it and the desktop
keeps using the launch profile's ``TERMINAL_CWD``.
``_profile_configured_cwd`` requires ``os.path.isdir``; an ssh working directory lives on the remote, so that
check drops it and the launch profile's ``TERMINAL_CWD`` wins. Only a profile that itself declares
``backend: ssh`` qualifies — placeholders (``.``/``auto``/``cwd``) fail the shape check.
"""
terminal = _profile_terminal_section(profile_home)
if not terminal:
return None
backend = str(terminal.get("backend") or "").strip().lower() or _effective_terminal_backend()
if not backend or backend == "local":
terminal = _profile_terminal_cfg(profile_home)
if str(terminal.get("backend") or "").strip().lower() != "ssh":
return None
raw = str(terminal.get("cwd") or "").strip()
if not raw or raw in _REMOTE_CWD_PLACEHOLDERS:
return None
if raw == "~" or raw.startswith("~/") or os.path.isabs(raw):
return raw
return None
return raw if raw == "~" or raw.startswith("~/") or os.path.isabs(raw) else None
def _terminal_task_cwd(session: dict | None) -> str:
@@ -126,17 +95,14 @@ def _terminal_task_cwd_with_source(session: dict | None) -> tuple[str, str]:
"""``(cwd, source)``: ``"session"`` for THIS session's workspace (``explicit_cwd``/tracked dir), ``"process"`` for
the global ``TERMINAL_CWD``/``terminal.cwd`` fallback — under per-session docker isolation that is a PREVIOUS
session's launch artifact, so terminal_tool refuses it as a bind-mount source."""
backend = _effective_terminal_backend()
backend = _bound_terminal_backend((session or {}).get("profile_home"))
if backend != "local":
# THIS session's explicit workspace beats the LAST session's env var.
if session and session.get("explicit_cwd") and session.get("cwd"):
return str(session["cwd"]), "session"
# Process TERMINAL_CWD is the launch profile. A named SSH profile's
# terminal.cwd is on the remote and must not lose to that env var.
if backend == "ssh":
remote_cwd = _declared_remote_profile_cwd((session or {}).get("profile_home"))
if remote_cwd:
return remote_cwd, "session"
# Process TERMINAL_CWD is the launch profile's; a named ssh profile's own terminal.cwd wins over it.
if remote_cwd := _declared_remote_profile_cwd((session or {}).get("profile_home")):
return remote_cwd, "session"
raw = os.environ.get("TERMINAL_CWD", "").strip() or _workdir_terminal_cfg("cwd")
if raw and raw not in {".", "auto", "cwd"}:
return raw, "process"
@@ -202,15 +168,9 @@ def _session_is_local_backend(session: dict | None) -> bool:
for a session actually bound to an ssh/docker profile - which then heals its remote cwd to a host
ancestor (/home) and persists that. Read the BOUND profile's backend first (profile_home), falling
back to the process env only when the session carries no profile."""
profile_home = session.get("profile_home") if session else None
if profile_home:
backend = _profile_terminal_backend(Path(profile_home))
if backend:
return backend == "local"
# Fallback must read env OR config (like _terminal_task_cwd_with_source's _effective_terminal_backend), not
# env alone: a per-profile gateway (hermes -p felix) sets terminal.backend=ssh in config but leaves TERMINAL_ENV
# unset, so the env-only check reported "local" and healed a live remote cwd (/home/felix/... -> /home).
return _effective_terminal_backend() == "local"
# The process fallback reads env OR config: a per-profile gateway (hermes -p felix) sets terminal.backend=ssh
# in config but leaves TERMINAL_ENV unset, so an env-only check would heal a live remote cwd to /home.
return _bound_terminal_backend(session.get("profile_home") if session else None) == "local"
def _effective_terminal_backend() -> str: