fix(workspace): preserve remote POSIX paths across Session & streaming callsites (#7131) (#7168)

Closes the remote POSIX workspace path preservation gap across Session and streaming. Profile-aware runtime path resolution.

Co-authored-by: alfred-rootson <alfred.wojtasordzik@gmail.com>
This commit is contained in:
alfred-rootson authored and GitHub committed 2026-09-15 15:37:11 -07:00
1 parent 94fd2da830
commit b71c88f292
30 files changed
+2206 -153

No files matched your search

+18 -6
View File
@@ -713,10 +713,14 @@ def get_config_for_profile_home(profile_home: "Path | str | None") -> dict:
bypassing the thread-local resolver entirely. When ``profile_home`` matches bypassing the thread-local resolver entirely. When ``profile_home`` matches
the path the ambient resolver would pick (the common single-profile case), the path the ambient resolver would pick (the common single-profile case),
we return the cached ``get_config()`` to preserve in-memory overrides used we return the cached ``get_config()`` to preserve in-memory overrides used
by tests and runtime callers. Only when the session's profile home diverges by tests and runtime callers, and to honour an authoritative
from the ambient path do we read the session profile's file directly — a ``HERMES_CONFIG_PATH`` override. Only when the session's profile home
pure read with no global cache mutation, so it is race-free across diverges from the ambient path do we read the session profile's file
concurrent sessions on different profiles. directly — a pure read with no global cache mutation, so it is race-free
across concurrent sessions on different profiles. Divergent profiles stay
isolated: a nonexistent home returns ``{}`` and an existing home without a
``config.yaml`` yields defaults — neither ever falls back to the ambient
config (profiles-are-islands).
""" """
if not profile_home: if not profile_home:
return get_config() return get_config()
@@ -724,10 +728,18 @@ def get_config_for_profile_home(profile_home: "Path | str | None") -> dict:
target = Path(profile_home).expanduser() target = Path(profile_home).expanduser()
except Exception: except Exception:
return get_config() return get_config()
from api.workspace import _safe_resolve as _cfg_safe_resolve
# Canonicalize BOTH sides before every identity comparison (#7168 re-gate
# round 5): when HERMES_HOME (or the config parent) is a symlink alias,
# lexical equality fails and an authoritative HERMES_CONFIG_PATH inside
# the aliased home would be bypassed in favor of a direct — wrong — read.
target = _cfg_safe_resolve(target)
try: try:
from api.profiles import get_active_hermes_home from api.profiles import get_active_hermes_home
if Path(get_active_hermes_home()).expanduser() == target: if _cfg_safe_resolve(Path(get_active_hermes_home()).expanduser()) == target:
return get_config() return get_config()
except Exception: except Exception:
pass pass
@@ -737,7 +749,7 @@ def get_config_for_profile_home(profile_home: "Path | str | None") -> dict:
# whose directory doesn't physically exist yet (fresh install, monkeypatched # whose directory doesn't physically exist yet (fresh install, monkeypatched
# cfg) must still resolve through get_config(), not return {} (#4516 gate). # cfg) must still resolve through get_config(), not return {} (#4516 gate).
try: try:
if _get_config_path().parent == target: if _cfg_safe_resolve(_get_config_path().parent) == target:
return get_config() return get_config()
except Exception: except Exception:
pass pass
+2 -2
View File
@@ -584,7 +584,7 @@ def _run_gateway_runs_api_streaming(
try: try:
from api.streaming import _build_native_multimodal_message from api.streaming import _build_native_multimodal_message
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=active_provider, active_model=(model or ""), requested_provider=active_provider) message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=active_provider, active_model=(model or ""), requested_provider=active_provider, profile=getattr(session, "profile", None))
except Exception: except Exception:
logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True) logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True)
message_content = str(msg_text or "") message_content = str(msg_text or "")
@@ -1120,7 +1120,7 @@ def _run_gateway_chat_streaming(
try: try:
from api.streaming import _build_native_multimodal_message from api.streaming import _build_native_multimodal_message
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=(model_provider or ""), active_model=(model or ""), requested_provider=(model_provider or "")) message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=(model_provider or ""), active_model=(model or ""), requested_provider=(model_provider or ""), profile=getattr(s, "profile", None))
except Exception: except Exception:
logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True) logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True)
message_content = str(msg_text or "") message_content = str(msg_text or "")
+11 -6
View File
@@ -34,7 +34,7 @@ from api.config import (
LOCK, STREAMS, STREAMS_LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME, LOCK, STREAMS, STREAMS_LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME,
get_effective_default_model, _get_session_agent_lock, get_effective_default_model, _get_session_agent_lock,
) )
from api.workspace import get_last_workspace from api.workspace import get_last_workspace, _resolve_path
from api.usage import prompt_cache_hit_percent from api.usage import prompt_cache_hit_percent
from api.agent_sessions import ( from api.agent_sessions import (
_is_continuation_session, _is_continuation_session,
@@ -1264,7 +1264,8 @@ class Session:
**kwargs): **kwargs):
self.session_id = session_id or uuid.uuid4().hex[:12] self.session_id = session_id or uuid.uuid4().hex[:12]
self.title = title self.title = title
self.workspace = str(Path(workspace).expanduser().resolve()) self.profile = profile
self.workspace = str(_resolve_path(workspace, profile=profile))
# #6672: immutable snapshot of the workspace at session creation time. # #6672: immutable snapshot of the workspace at session creation time.
# s.workspace is updated on every turn when the user switches workspaces # s.workspace is updated on every turn when the user switches workspaces
# mid-session via the WebUI header dropdown; interpolating the live # mid-session via the WebUI header dropdown; interpolating the live
@@ -1276,7 +1277,7 @@ class Session:
# without a persisted created_workspace fall back to the workspace # without a persisted created_workspace fall back to the workspace
# recorded on disk, which is the best available approximation. # recorded on disk, which is the best available approximation.
self.created_workspace = ( self.created_workspace = (
str(Path(created_workspace).expanduser().resolve()) str(_resolve_path(created_workspace, profile=profile))
if created_workspace if created_workspace
else self.workspace else self.workspace
) )
@@ -5098,7 +5099,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
wt = worktree_info if isinstance(worktree_info, dict) else None wt = worktree_info if isinstance(worktree_info, dict) else None
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
s = Session( s = Session(
workspace=workspace_path or get_last_workspace(), workspace=workspace_path or get_last_workspace(profile=profile),
model=effective_model, model=effective_model,
model_provider=effective_model_provider, model_provider=effective_model_provider,
profile=profile, profile=profile,
@@ -5763,6 +5764,7 @@ def get_session_for_file_ops(sid: str):
workspace, recovered = resolve_implicit_workspace_with_recovery( workspace, recovered = resolve_implicit_workspace_with_recovery(
stored_workspace, stored_workspace,
get_last_workspace, get_last_workspace,
profile=session_profile or None,
) )
except ValueError: except ValueError:
# Preserve the existing file-handler behavior for non-missing trust or # Preserve the existing file-handler behavior for non-missing trust or
@@ -6797,7 +6799,7 @@ def import_cli_session(
s = Session( s = Session(
session_id=session_id, session_id=session_id,
title=title, title=title,
workspace=get_last_workspace(), workspace=get_last_workspace(profile=profile),
model=model, model=model,
messages=messages, messages=messages,
profile=profile, profile=profile,
@@ -7720,6 +7722,9 @@ def _load_cli_sessions_uncached(
_cli_workspace_cache: list = [None] # list-as-cell; None = not yet resolved _cli_workspace_cache: list = [None] # list-as-cell; None = not yet resolved
def _cli_workspace(): def _cli_workspace():
if _cli_workspace_cache[0] is None: if _cli_workspace_cache[0] is None:
try:
_cli_workspace_cache[0] = str(get_last_workspace(profile=_cli_profile))
except TypeError:
_cli_workspace_cache[0] = str(get_last_workspace()) _cli_workspace_cache[0] = str(get_last_workspace())
return _cli_workspace_cache[0] return _cli_workspace_cache[0]
@@ -7934,7 +7939,7 @@ def _load_cli_sessions_uncached(
cli_sessions.append({ cli_sessions.append({
'session_id': sid, 'session_id': sid,
'title': _display_title, 'title': _display_title,
'workspace': str(get_last_workspace()), 'workspace': str(get_last_workspace(profile=_cli_profile)),
'model': row['model'] or None, 'model': row['model'] or None,
'message_count': row['message_count'] or row['actual_message_count'] or 0, 'message_count': row['message_count'] or row['actual_message_count'] or 0,
'created_at': row['started_at'], 'created_at': row['started_at'],
+11 -7
View File
@@ -1711,19 +1711,22 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
default_workspace = None default_workspace = None
try: try:
from api.config import DEFAULT_WORKSPACE as _DW from api.config import DEFAULT_WORKSPACE as _DW
from api.workspace import _resolve_path, _remote_terminal_workspace_candidate
lw_file = home / 'webui_state' / 'last_workspace.txt' lw_file = home / 'webui_state' / 'last_workspace.txt'
if lw_file.exists(): if lw_file.exists():
_p = lw_file.read_text(encoding='utf-8').strip() _p = lw_file.read_text(encoding='utf-8').strip()
if _p: if _p:
_pp = Path(_p).expanduser() _pp = _resolve_path(_p, profile=name)
if _pp.is_dir(): remote_cand = _remote_terminal_workspace_candidate(_p, profile=name)
default_workspace = str(_pp.resolve()) if remote_cand is not None or _pp.is_dir():
default_workspace = str(_pp)
if default_workspace is None: if default_workspace is None:
for _key in ('workspace', 'default_workspace'): for _key in ('workspace', 'default_workspace'):
_v = cfg.get(_key) _v = cfg.get(_key)
if _v: if _v:
_pp = Path(str(_v)).expanduser().resolve() _pp = _resolve_path(str(_v), profile=name)
if _pp.is_dir(): remote_cand = _remote_terminal_workspace_candidate(str(_v), profile=name)
if remote_cand is not None or _pp.is_dir():
default_workspace = str(_pp) default_workspace = str(_pp)
break break
if default_workspace is None: if default_workspace is None:
@@ -1731,8 +1734,9 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
if isinstance(_tc, dict): if isinstance(_tc, dict):
_cwd = _tc.get('cwd', '') _cwd = _tc.get('cwd', '')
if _cwd and str(_cwd) not in ('.', ''): if _cwd and str(_cwd) not in ('.', ''):
_pp = Path(str(_cwd)).expanduser().resolve() _pp = _resolve_path(str(_cwd), profile=name)
if _pp.is_dir(): remote_cand = _remote_terminal_workspace_candidate(str(_cwd), profile=name)
if remote_cand is not None or _pp.is_dir():
default_workspace = str(_pp) default_workspace = str(_pp)
if default_workspace is None: if default_workspace is None:
default_workspace = str(_DW) default_workspace = str(_DW)
+146 -23
View File
@@ -10544,6 +10544,7 @@ from api.workspace import (
safe_resolve_ws, safe_resolve_ws,
raw_authorized_escape_target, raw_authorized_escape_target,
resolve_trusted_workspace, resolve_trusted_workspace,
_resolve_path,
resolve_implicit_workspace_with_recovery, resolve_implicit_workspace_with_recovery,
open_anchored_fd, open_anchored_fd,
open_anchored_create_fd, open_anchored_create_fd,
@@ -14160,22 +14161,39 @@ def handle_get(handler, parsed) -> bool:
return _handle_session_export(handler, parsed) return _handle_session_export(handler, parsed)
if parsed.path == "/api/workspaces": if parsed.path == "/api/workspaces":
from api.profiles import get_active_profile_name
active_profile = get_active_profile_name()
try:
wss = load_workspaces(profile=active_profile)
except TypeError:
wss = load_workspaces()
try:
lw = get_last_workspace(profile=active_profile)
except TypeError:
lw = get_last_workspace()
return j( return j(
handler, handler,
{ {
"workspaces": load_workspaces(), "workspaces": wss,
"last": get_last_workspace(), "last": lw,
"terminal_remote_backend": _terminal_remote_backend_enabled(), "terminal_remote_backend": _terminal_remote_backend_enabled(),
}, },
) )
if parsed.path == "/api/workspaces/suggest": if parsed.path == "/api/workspaces/suggest":
from api.profiles import get_active_profile_name
qs = parse_qs(parsed.query) qs = parse_qs(parsed.query)
prefix = qs.get("prefix", [""])[0] prefix = qs.get("prefix", [""])[0]
active_profile = get_active_profile_name()
try:
suggestions = list_workspace_suggestions(prefix, profile=active_profile)
except TypeError:
suggestions = list_workspace_suggestions(prefix)
return j( return j(
handler, handler,
{ {
"suggestions": list_workspace_suggestions(prefix), "suggestions": suggestions,
"prefix": prefix, "prefix": prefix,
}, },
) )
@@ -14626,6 +14644,9 @@ def handle_get(handler, parsed) -> bool:
# profile-scoped via the per-request hermes_profile cookie set in server.py. # profile-scoped via the per-request hermes_profile cookie set in server.py.
# Fail open: a resolution error must never 500 this boot-critical endpoint. # Fail open: a resolution error must never 500 this boot-critical endpoint.
try: try:
try:
_profile_default_workspace = get_profile_default_workspace(profile=active_profile_name)
except TypeError:
_profile_default_workspace = get_profile_default_workspace() _profile_default_workspace = get_profile_default_workspace()
except Exception: except Exception:
logger.debug("Failed to resolve profile default workspace for /api/profile/active", exc_info=True) logger.debug("Failed to resolve profile default workspace for /api/profile/active", exc_info=True)
@@ -14849,22 +14870,37 @@ def _validate_session_toolsets_shape(toolsets):
return toolsets return toolsets
def _resolve_new_session_workspace(body, visible_prev_session_id): def _resolve_new_session_workspace(body, visible_prev_session_id, profile=None):
"""Resolve a new-session workspace, recovering only verified inheritance.""" """Resolve a new-session workspace, recovering only verified inheritance."""
candidate = body.get("workspace") candidate = body.get("workspace")
if not candidate: if not candidate:
return None return None
def _rtw(value):
# Legacy test doubles may predate the profile kwarg.
try:
return resolve_trusted_workspace(value, profile=profile)
except TypeError:
return resolve_trusted_workspace(value)
if ( if (
body.get("workspace_inherited_from_prev_session") is not True body.get("workspace_inherited_from_prev_session") is not True
or not visible_prev_session_id or not visible_prev_session_id
): ):
return str(resolve_trusted_workspace(candidate)) return str(_rtw(candidate))
try: try:
previous_session = get_session(visible_prev_session_id, metadata_only=True) previous_session = get_session(visible_prev_session_id, metadata_only=True)
except KeyError: except KeyError:
return str(resolve_trusted_workspace(candidate)) return str(_rtw(candidate))
if str(getattr(previous_session, "workspace", None) or "") != str(candidate): if str(getattr(previous_session, "workspace", None) or "") != str(candidate):
return str(resolve_trusted_workspace(candidate)) return str(_rtw(candidate))
try:
workspace, _recovered = resolve_implicit_workspace_with_recovery(
candidate,
get_last_workspace,
profile=profile,
)
except TypeError:
workspace, _recovered = resolve_implicit_workspace_with_recovery( workspace, _recovered = resolve_implicit_workspace_with_recovery(
candidate, candidate,
get_last_workspace, get_last_workspace,
@@ -15210,7 +15246,9 @@ def handle_post(handler, parsed) -> bool:
): ):
workspace_prev_session_id = None workspace_prev_session_id = None
try: try:
workspace = _resolve_new_session_workspace(body, workspace_prev_session_id) workspace = _resolve_new_session_workspace(
body, workspace_prev_session_id, profile=body.get("profile") or None
)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
return bad(handler, str(e)) return bad(handler, str(e))
worktree_info = None worktree_info = None
@@ -15239,7 +15277,17 @@ def handle_post(handler, parsed) -> bool:
from api.worktrees import create_worktree_for_workspace from api.worktrees import create_worktree_for_workspace
base_workspace = workspace base_workspace = workspace
if not base_workspace: if not base_workspace:
base_workspace = str(resolve_trusted_workspace(get_last_workspace())) _new_profile = body.get("profile") or None
try:
_lw = get_last_workspace(profile=_new_profile)
except TypeError:
_lw = get_last_workspace()
try:
base_workspace = str(
resolve_trusted_workspace(_lw, profile=_new_profile)
)
except TypeError:
base_workspace = str(resolve_trusted_workspace(_lw))
worktree_info = create_worktree_for_workspace(base_workspace) worktree_info = create_worktree_for_workspace(base_workspace)
workspace = worktree_info["path"] workspace = worktree_info["path"]
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
@@ -15804,7 +15852,7 @@ def handle_post(handler, parsed) -> bool:
old_model = getattr(s, "model", None) old_model = getattr(s, "model", None)
old_provider = getattr(s, "model_provider", None) old_provider = getattr(s, "model_provider", None)
try: try:
new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace))) new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace), profile=getattr(s, "profile", None)))
except ValueError as e: except ValueError as e:
return bad(handler, str(e)) return bad(handler, str(e))
with _get_session_agent_lock(body["session_id"]): with _get_session_agent_lock(body["session_id"]):
@@ -15838,7 +15886,7 @@ def handle_post(handler, parsed) -> bool:
close_terminal(body["session_id"]) close_terminal(body["session_id"])
except Exception: except Exception:
logger.debug("Failed to close workspace terminal after workspace update") logger.debug("Failed to close workspace terminal after workspace update")
set_last_workspace(new_ws) set_last_workspace(new_ws, profile=getattr(s, "profile", None))
return j( return j(
handler, handler,
{"session": public_session_projection(s.compact() | {"messages": s.messages})}, {"session": public_session_projection(s.compact() | {"messages": s.messages})},
@@ -17026,14 +17074,16 @@ def handle_post(handler, parsed) -> bool:
if _arch_source_tag == "subagent" or _is_subagent_child_session_id(sid): if _arch_source_tag == "subagent" or _is_subagent_child_session_id(sid):
return bad(handler, "Subagent sessions cannot be archived from WebUI", 400) return bad(handler, "Subagent sessions cannot be archived from WebUI", 400)
if _is_messaging_session_record(cli_meta): if _is_messaging_session_record(cli_meta):
_arch_profile = cli_meta.get("profile") or None
s = Session( s = Session(
session_id=sid, session_id=sid,
title=cli_meta.get("title") or title_from(get_cli_session_messages(sid), "CLI Session"), title=cli_meta.get("title") or title_from(get_cli_session_messages(sid), "CLI Session"),
workspace=get_last_workspace(), workspace=get_last_workspace(profile=_arch_profile),
messages=[], messages=[],
model=cli_meta.get("model") or "unknown", model=cli_meta.get("model") or "unknown",
created_at=cli_meta.get("created_at"), created_at=cli_meta.get("created_at"),
updated_at=cli_meta.get("updated_at"), updated_at=cli_meta.get("updated_at"),
profile=_arch_profile,
) )
s.is_cli_session = is_cli_session_row(cli_meta) s.is_cli_session = is_cli_session_row(cli_meta)
s.source_tag = cli_meta.get("source_tag") s.source_tag = cli_meta.get("source_tag")
@@ -18019,11 +18069,22 @@ def _handle_list_dir(handler, parsed):
except Exception: except Exception:
return bad(handler, "Session not found", 404) return bad(handler, "Session not found", 404)
try: try:
_list_profile = getattr(webui_session, "profile", None)
if webui_session is None: if webui_session is None:
try:
workspace = resolve_trusted_workspace(workspace, profile=_list_profile)
except TypeError:
workspace = resolve_trusted_workspace(workspace) workspace = resolve_trusted_workspace(workspace)
recovered = False recovered = False
else: else:
stored_workspace = workspace stored_workspace = workspace
try:
workspace, recovered = resolve_implicit_workspace_with_recovery(
stored_workspace,
get_last_workspace,
profile=_list_profile,
)
except TypeError:
workspace, recovered = resolve_implicit_workspace_with_recovery( workspace, recovered = resolve_implicit_workspace_with_recovery(
stored_workspace, stored_workspace,
get_last_workspace, get_last_workspace,
@@ -19069,7 +19130,7 @@ def _handle_terminal_start(handler, body):
}, },
status=400, status=400,
) )
workspace = resolve_trusted_workspace(getattr(session, "workspace", "") or "") workspace = resolve_trusted_workspace(getattr(session, "workspace", "") or "", profile=getattr(session, "profile", None))
from api.terminal import start_terminal from api.terminal import start_terminal
term = start_terminal( term = start_terminal(
sid, sid,
@@ -22053,10 +22114,11 @@ def _memory_project_context_workspace(parsed) -> Path | None:
# fall through to Path("").resolve(), which returns the server's own # fall through to Path("").resolve(), which returns the server's own
# CWD and would surface the install's AGENTS.md/HERMES.md as if it # CWD and would surface the install's AGENTS.md/HERMES.md as if it
# were the user's project context. # were the user's project context.
ws = (get_session(sid).workspace or "").strip() session = get_session(sid)
ws = (session.workspace or "").strip()
if not ws: if not ws:
return None return None
return Path(ws).expanduser().resolve() return _resolve_path(ws, profile=getattr(session, "profile", None))
except Exception: except Exception:
return None return None
@@ -22064,7 +22126,7 @@ def _memory_project_context_workspace(parsed) -> Path | None:
if not raw_workspace: if not raw_workspace:
return None return None
try: try:
return Path(resolve_trusted_workspace(raw_workspace)).expanduser().resolve() return resolve_trusted_workspace(raw_workspace)
except Exception: except Exception:
logger.debug("Skipping project context for untrusted workspace %s", raw_workspace, exc_info=True) logger.debug("Skipping project context for untrusted workspace %s", raw_workspace, exc_info=True)
return None return None
@@ -22834,7 +22896,7 @@ def _start_regeneration_stream_locked(
save_attempted = True save_attempted = True
s.save() s.save()
accepted = True accepted = True
set_last_workspace(workspace) set_last_workspace(workspace, profile=getattr(s, "profile", None))
release_worker.set() release_worker.set()
except Exception as exc: except Exception as exc:
abort_worker.set() abort_worker.set()
@@ -23173,7 +23235,7 @@ def _start_chat_stream_for_session(
except Exception: except Exception:
logger.warning("Failed to append submitted turn journal event", exc_info=True) logger.warning("Failed to append submitted turn journal event", exc_info=True)
diag.stage("set_last_workspace") if diag else None diag.stage("set_last_workspace") if diag else None
set_last_workspace(workspace) set_last_workspace(workspace, profile=getattr(s, "profile", None))
diag.stage("stream_registration") if diag else None diag.stage("stream_registration") if diag else None
stream = create_stream_channel() stream = create_stream_channel()
register_stream_owner(stream_id, s.session_id) register_stream_owner(stream_id, s.session_id)
@@ -23886,7 +23948,7 @@ def _handle_goal_command(handler, body):
previous_goal_state = None previous_goal_state = None
if will_kickoff: if will_kickoff:
try: try:
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace, profile=getattr(s, "profile", None)))
except ValueError as e: except ValueError as e:
return bad(handler, str(e)) return bad(handler, str(e))
requested_model = body.get("model") or s.model requested_model = body.get("model") or s.model
@@ -23955,7 +24017,7 @@ def _handle_goal_command(handler, body):
if kickoff_prompt: if kickoff_prompt:
if workspace is None: if workspace is None:
try: try:
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace, profile=getattr(s, "profile", None)))
except ValueError as e: except ValueError as e:
return bad(handler, str(e)) return bad(handler, str(e))
if model is None: if model is None:
@@ -24336,10 +24398,21 @@ def _handle_chat_start(handler, body, diag=None):
def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str: def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str:
"""Recover stale implicit session workspaces without hiding explicit errors.""" """Recover stale implicit session workspaces without hiding explicit errors."""
_session_profile = getattr(s, "profile", None)
explicit = requested_workspace not in (None, "") explicit = requested_workspace not in (None, "")
if explicit: if explicit:
try:
return str(resolve_trusted_workspace(requested_workspace, profile=_session_profile))
except TypeError:
return str(resolve_trusted_workspace(requested_workspace)) return str(resolve_trusted_workspace(requested_workspace))
stored_workspace = getattr(s, "workspace", None) stored_workspace = getattr(s, "workspace", None)
try:
workspace, recovered = resolve_implicit_workspace_with_recovery(
stored_workspace,
get_last_workspace,
profile=_session_profile,
)
except TypeError:
workspace, recovered = resolve_implicit_workspace_with_recovery( workspace, recovered = resolve_implicit_workspace_with_recovery(
stored_workspace, stored_workspace,
get_last_workspace, get_last_workspace,
@@ -24356,8 +24429,19 @@ def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str:
def _resolve_chat_workspace_for_regeneration(s, requested_workspace) -> str: def _resolve_chat_workspace_for_regeneration(s, requested_workspace) -> str:
"""Resolve regeneration's workspace without persisting before start acceptance.""" """Resolve regeneration's workspace without persisting before start acceptance."""
_session_profile = getattr(s, "profile", None) or None
if requested_workspace not in (None, ""): if requested_workspace not in (None, ""):
try:
return str(resolve_trusted_workspace(requested_workspace, profile=_session_profile))
except TypeError:
return str(resolve_trusted_workspace(requested_workspace)) return str(resolve_trusted_workspace(requested_workspace))
try:
workspace, _recovered = resolve_implicit_workspace_with_recovery(
getattr(s, "workspace", None),
get_last_workspace,
profile=_session_profile,
)
except TypeError:
workspace, _recovered = resolve_implicit_workspace_with_recovery( workspace, _recovered = resolve_implicit_workspace_with_recovery(
getattr(s, "workspace", None), getattr(s, "workspace", None),
get_last_workspace, get_last_workspace,
@@ -24407,6 +24491,9 @@ def _handle_chat_sync(handler, body):
if not msg: if not msg:
return j(handler, {"error": "empty message"}, status=400) return j(handler, {"error": "empty message"}, status=400)
try: try:
try:
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace, profile=getattr(s, "profile", None)))
except TypeError:
workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace))
except ValueError as e: except ValueError as e:
return bad(handler, str(e)) return bad(handler, str(e))
@@ -25790,11 +25877,16 @@ def _handle_workspace_add(handler, body):
# macOS) so pytest's tmp_path_factory paths and other legit user-tmp dirs # macOS) so pytest's tmp_path_factory paths and other legit user-tmp dirs
# still register cleanly. # still register cleanly.
try: try:
candidate = Path(path_str).expanduser().resolve() from api.workspace import _remote_terminal_workspace_candidate, _resolve_path
from api.profiles import get_active_profile_name
active_profile = get_active_profile_name()
remote_candidate = _remote_terminal_workspace_candidate(path_str, profile=active_profile)
candidate = _resolve_path(path_str, profile=active_profile)
except (ValueError, OSError, RuntimeError) as e: except (ValueError, OSError, RuntimeError) as e:
# Invalid path (e.g. embedded null byte) — fail closed with a clean 400 # Invalid path (e.g. embedded null byte) — fail closed with a clean 400
# instead of letting .resolve() raise an uncaught 500. # instead of letting .resolve() raise an uncaught 500.
return bad(handler, f"Invalid path: {_sanitize_error(e)}") return bad(handler, f"Invalid path: {_sanitize_error(e)}")
if remote_candidate is None:
if _is_blocked_system_path(candidate): if _is_blocked_system_path(candidate):
# Home-directory carve-out, mirroring the validators # Home-directory carve-out, mirroring the validators
# (resolve_trusted_workspace / validate_workspace_to_add): a workspace # (resolve_trusted_workspace / validate_workspace_to_add): a workspace
@@ -25814,13 +25906,19 @@ def _handle_workspace_add(handler, body):
return bad(handler, f"Could not create directory: {_sanitize_error(e)}") return bad(handler, f"Could not create directory: {_sanitize_error(e)}")
# Full validation (exists, is_dir) — should pass now that dir exists # Full validation (exists, is_dir) — should pass now that dir exists
try: try:
p = validate_workspace_to_add(path_str) p = validate_workspace_to_add(path_str, profile=active_profile)
except ValueError as e: except ValueError as e:
return bad(handler, str(e)) return bad(handler, str(e))
try:
wss = load_workspaces(profile=active_profile)
except TypeError:
wss = load_workspaces() wss = load_workspaces()
if any(w["path"] == str(p) for w in wss): if any(w["path"] == str(p) for w in wss):
return bad(handler, "Workspace already in list") return bad(handler, "Workspace already in list")
wss.append({"path": str(p), "name": name or p.name}) wss.append({"path": str(p), "name": name or p.name})
try:
save_workspaces(wss, profile=active_profile)
except TypeError:
save_workspaces(wss) save_workspaces(wss)
return j(handler, {"ok": True, "workspaces": wss}) return j(handler, {"ok": True, "workspaces": wss})
@@ -25829,8 +25927,16 @@ def _handle_workspace_remove(handler, body):
path_str = body.get("path", "").strip() path_str = body.get("path", "").strip()
if not path_str: if not path_str:
return bad(handler, "path is required") return bad(handler, "path is required")
from api.profiles import get_active_profile_name
active_profile = get_active_profile_name()
try:
wss = load_workspaces(profile=active_profile)
except TypeError:
wss = load_workspaces() wss = load_workspaces()
wss = [w for w in wss if w["path"] != path_str] wss = [w for w in wss if w["path"] != path_str]
try:
save_workspaces(wss, profile=active_profile)
except TypeError:
save_workspaces(wss) save_workspaces(wss)
return j(handler, {"ok": True, "workspaces": wss}) return j(handler, {"ok": True, "workspaces": wss})
@@ -25840,6 +25946,11 @@ def _handle_workspace_rename(handler, body):
name = body.get("name", "").strip() name = body.get("name", "").strip()
if not path_str or not name: if not path_str or not name:
return bad(handler, "path and name are required") return bad(handler, "path and name are required")
from api.profiles import get_active_profile_name
active_profile = get_active_profile_name()
try:
wss = load_workspaces(profile=active_profile)
except TypeError:
wss = load_workspaces() wss = load_workspaces()
for w in wss: for w in wss:
if w["path"] == path_str: if w["path"] == path_str:
@@ -25847,6 +25958,9 @@ def _handle_workspace_rename(handler, body):
break break
else: else:
return bad(handler, "Workspace not found", 404) return bad(handler, "Workspace not found", 404)
try:
save_workspaces(wss, profile=active_profile)
except TypeError:
save_workspaces(wss) save_workspaces(wss)
return j(handler, {"ok": True, "workspaces": wss}) return j(handler, {"ok": True, "workspaces": wss})
@@ -25861,6 +25975,11 @@ def _handle_workspace_reorder(handler, body):
paths = body.get("paths", []) paths = body.get("paths", [])
if not paths or not isinstance(paths, list): if not paths or not isinstance(paths, list):
return bad(handler, "paths is required and must be a list") return bad(handler, "paths is required and must be a list")
from api.profiles import get_active_profile_name
active_profile = get_active_profile_name()
try:
wss = load_workspaces(profile=active_profile)
except TypeError:
wss = load_workspaces() wss = load_workspaces()
by_path = {w["path"]: w for w in wss} by_path = {w["path"]: w for w in wss}
# Build reordered list: given order first, then any omitted entries # Build reordered list: given order first, then any omitted entries
@@ -25875,6 +25994,10 @@ def _handle_workspace_reorder(handler, body):
for w in wss: for w in wss:
if w["path"] not in seen: if w["path"] not in seen:
reordered.append(w) reordered.append(w)
try:
save_workspaces(reordered, profile=active_profile)
except TypeError:
# Legacy signature (test doubles with single-arg lambdas, older forks).
save_workspaces(reordered) save_workspaces(reordered)
return j(handler, {"ok": True, "workspaces": reordered}) return j(handler, {"ok": True, "workspaces": reordered})
@@ -28391,7 +28514,7 @@ def _handle_session_import_cli(handler, body):
session_payload = { session_payload = {
"session_id": sid, "session_id": sid,
"title": title, "title": title,
"workspace": str(get_last_workspace()), "workspace": str(get_last_workspace(profile=profile)),
"model": model, "model": model,
"message_count": len(msgs), "message_count": len(msgs),
"created_at": created_at, "created_at": created_at,
+11 -7
View File
@@ -2556,7 +2556,7 @@ def _aiagent_import_error_detail() -> str:
lines.append(' Full troubleshooting: docs/troubleshooting.md ("AIAgent not available")') lines.append(' Full troubleshooting: docs/troubleshooting.md ("AIAgent not available")')
return "\n".join(lines) return "\n".join(lines)
from api.models import get_session, title_from from api.models import get_session, title_from
from api.workspace import set_last_workspace from api.workspace import _resolve_path
# Fields that are safe to send to LLM provider APIs. # Fields that are safe to send to LLM provider APIs.
# Everything else (attachments, timestamp, _ts, etc.) is display-only # Everything else (attachments, timestamp, _ts, etc.) is display-only
@@ -3385,7 +3385,7 @@ def _resolve_image_input_mode(cfg: dict, active_provider: str = "", active_model
return "native" return "native"
def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachments, workspace: str, *, cfg: dict = None, active_provider: str = "", active_model: str = "", requested_provider: str = ""): def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachments, workspace: str, *, cfg: dict = None, active_provider: str = "", active_model: str = "", requested_provider: str = "", profile: str | Path | None = None):
"""Build native multimodal content parts for current-turn image uploads. """Build native multimodal content parts for current-turn image uploads.
WebUI uploads files into the active workspace. For image files, pass the WebUI uploads files into the active workspace. For image files, pass the
@@ -3405,7 +3405,7 @@ def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachme
return workspace_ctx + msg_text return workspace_ctx + msg_text
parts = [{'type': 'text', 'text': workspace_ctx + msg_text}] parts = [{'type': 'text', 'text': workspace_ctx + msg_text}]
workspace_root = Path(workspace).expanduser().resolve() workspace_root = _resolve_path(workspace, profile=profile)
# Stage-361 maintainer fix (Opus SHOULD-FIX): chat uploads from #2319 now # Stage-361 maintainer fix (Opus SHOULD-FIX): chat uploads from #2319 now
# land in ~/.hermes/webui/attachments/<sid>/ (outside workspace_root by # land in ~/.hermes/webui/attachments/<sid>/ (outside workspace_root by
# design). The pre-existing `path.relative_to(workspace_root)` guard would # design). The pre-existing `path.relative_to(workspace_root)` guard would
@@ -9466,19 +9466,19 @@ def _run_agent_streaming(
# Resolved the same way as `s.workspace` below so the bound cwd and the # Resolved the same way as `s.workspace` below so the bound cwd and the
# session's own record cannot disagree. Guarded: a turn must not die # session's own record cannot disagree. Guarded: a turn must not die
# here because a workspace path is malformed. # here because a workspace path is malformed.
s = get_session(session_id)
try: try:
_turn_workspace_cwd = str(Path(workspace).expanduser().resolve()) _turn_workspace_cwd = str(_resolve_path(workspace, profile=getattr(s, 'profile', None)))
except Exception: except Exception:
_turn_workspace_cwd = "" _turn_workspace_cwd = ""
logger.debug("per-turn workspace cwd resolve failed", exc_info=True) logger.debug("per-turn workspace cwd resolve failed", exc_info=True)
_turn_session_identity_tokens = _set_turn_session_identity( _turn_session_identity_tokens = _set_turn_session_identity(
session_id, workspace=_turn_workspace_cwd session_id, workspace=_turn_workspace_cwd
) )
s = get_session(session_id)
_turn_pending_source = getattr(s, 'pending_user_source', None) or 'webui' _turn_pending_source = getattr(s, 'pending_user_source', None) or 'webui'
_active_turn_identity = _active_turn_authority(s, stream_id, msg_text) _active_turn_identity = _active_turn_authority(s, stream_id, msg_text)
update_active_run(stream_id, phase="running", session_id=session_id) update_active_run(stream_id, phase="running", session_id=session_id)
s.workspace = str(Path(workspace).expanduser().resolve()) s.workspace = _turn_workspace_cwd
_last_persisted_model = None _last_persisted_model = None
_last_persisted_provider = None _last_persisted_provider = None
_turn_owns_persisted_model = False _turn_owns_persisted_model = False
@@ -11006,7 +11006,7 @@ def _run_agent_streaming(
_agent_msg_text = msg_text _agent_msg_text = msg_text
if _process_notifications: if _process_notifications:
_agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip() _agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip()
user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg, active_provider=(resolved_provider or ""), active_model=(resolved_model or ""), requested_provider=(_session_requested_provider or "")) user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg, active_provider=(resolved_provider or ""), active_model=(resolved_model or ""), requested_provider=(_session_requested_provider or ""), profile=(getattr(s, "profile", None) or Path(_profile_home)))
_persistent_state_before = _persistent_state_snapshot(_profile_home) _persistent_state_before = _persistent_state_snapshot(_profile_home)
_run_conversation_kwargs = _build_run_conversation_kwargs( _run_conversation_kwargs = _build_run_conversation_kwargs(
agent.run_conversation, agent.run_conversation,
@@ -11059,6 +11059,10 @@ def _run_agent_streaming(
active_provider=(resolved_provider or ""), active_provider=(resolved_provider or ""),
active_model=(resolved_model or ""), active_model=(resolved_model or ""),
requested_provider=(_session_requested_provider or ""), requested_provider=(_session_requested_provider or ""),
# Legacy fallback wraps the home STRING in Path: string
# profiles are logical ids only (round-5 grammar gate),
# explicit homes must arrive as Path values.
profile=(getattr(s, "profile", None) or Path(_profile_home)),
) )
_run_conversation_kwargs["user_message"] = user_message _run_conversation_kwargs["user_message"] = user_message
_result_partial_pre_call_context = list(_previous_context_messages) _result_partial_pre_call_context = list(_previous_context_messages)
+6 -1
View File
@@ -635,7 +635,12 @@ def handle_workspace_upload(handler):
if _reject_invisible_session(handler, session): if _reject_invisible_session(handler, session):
return True return True
# Resolve workspace root from session # Resolve workspace root using the session profile, not the ambient request profile.
try:
workspace = resolve_trusted_workspace(
session.workspace, profile=getattr(session, "profile", None)
)
except TypeError:
workspace = resolve_trusted_workspace(session.workspace) workspace = resolve_trusted_workspace(session.workspace)
# Resolve target subdirectory within workspace # Resolve target subdirectory within workspace
+287 -65
View File
@@ -12,6 +12,7 @@ import json
import logging import logging
import os import os
import posixpath import posixpath
import re
import secrets import secrets
import shutil import shutil
import stat import stat
@@ -40,8 +41,14 @@ from api.subprocess_utils import windows_hide_flags
# ── Profile-aware path resolution ─────────────────────────────────────────── # ── Profile-aware path resolution ───────────────────────────────────────────
def _profile_state_dir() -> Path: # Logical profile-name grammar — mirrors api.profiles._PROFILE_ID_RE. Kept as
"""Return the webui_state directory for the active profile. # a local copy so workspace-layer validation does not import profiles at module
# load time (profiles may not be importable in every embedding context).
_PROFILE_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
def _profile_state_dir(profile: str | Path | None = None) -> Path:
"""Return the webui_state directory for the active or given profile.
For the default profile, returns the global STATE_DIR (respects For the default profile, returns the global STATE_DIR (respects
HERMES_WEBUI_STATE_DIR env var for test isolation). HERMES_WEBUI_STATE_DIR env var for test isolation).
@@ -49,6 +56,28 @@ def _profile_state_dir() -> Path:
""" """
try: try:
from api.profiles import get_active_profile_name, get_active_hermes_home from api.profiles import get_active_profile_name, get_active_hermes_home
if profile is not None:
# Literal-"default" STATE routing (#7168 re-gate round 7): the
# default profile's workspace state always lives in the global
# state files, even when isolated mode pins the default home at
# <base>/profiles/default. The round-6 resolver change made
# _resolve_profile_home_param("default") return that pinned home,
# so the canonical-home check below sent explicit
# profile="default" state reads/writes to {pinned}/webui_state/
# while ambient calls kept using the global dir — splitting saved
# workspaces between two authorities. Config and workspace PATH
# resolution still use the pinned home via
# _resolve_profile_home_param; only this state-file tier stays
# global for the logical string "default".
if isinstance(profile, str) and profile.strip() == 'default':
return _GLOBAL_WS_FILE.parent
profile_home = _resolve_profile_home_param(profile)
if not _is_default_profile_home(profile_home):
d = profile_home / 'webui_state'
d.mkdir(parents=True, exist_ok=True)
return d
return _GLOBAL_WS_FILE.parent
name = get_active_profile_name() name = get_active_profile_name()
if name and name != 'default': if name and name != 'default':
d = get_active_hermes_home() / 'webui_state' d = get_active_hermes_home() / 'webui_state'
@@ -59,14 +88,41 @@ def _profile_state_dir() -> Path:
return _GLOBAL_WS_FILE.parent return _GLOBAL_WS_FILE.parent
def _workspaces_file() -> Path: def _workspaces_file(profile: str | Path | None = None) -> Path:
"""Return the workspaces.json path for the active profile.""" """Return the workspaces.json path for the active or given profile."""
return _profile_state_dir() / 'workspaces.json' return _profile_state_dir(profile=profile) / 'workspaces.json'
def _last_workspace_file() -> Path: def _last_workspace_file(profile: str | Path | None = None) -> Path:
"""Return the last_workspace.txt path for the active profile.""" """Return the last_workspace.txt path for the active or given profile."""
return _profile_state_dir() / 'last_workspace.txt' return _profile_state_dir(profile=profile) / 'last_workspace.txt'
def _workspaces_file_for_profile(profile: str | Path | None = None) -> Path | None:
"""Profile-scoped workspaces.json path, or None for an INVALID profile.
``None`` is the fail-closed contract (#7168 re-gate round 4): a malformed
profile name must not be clamped onto the default/global state files, so
callers treat it as "no readable/writable profile-local state".
"""
try:
return _workspaces_file(profile=profile) if profile is not None else _workspaces_file()
except TypeError:
return _workspaces_file()
except ValueError:
logger.debug("Ignoring invalid profile name %r for workspaces file", profile)
return None
def _last_workspace_file_for_profile(profile: str | Path | None = None) -> Path | None:
"""Profile-scoped last_workspace.txt path, or None for an INVALID profile."""
try:
return _last_workspace_file(profile=profile) if profile is not None else _last_workspace_file()
except TypeError:
return _last_workspace_file()
except ValueError:
logger.debug("Ignoring invalid profile name %r for last-workspace file", profile)
return None
def _expanduser_path(path: str | Path) -> Path: def _expanduser_path(path: str | Path) -> Path:
@@ -101,8 +157,11 @@ def _expanduser_path(path: str | Path) -> Path:
return Path(raw) return Path(raw)
def _resolve_path(path: str | Path) -> Path: def _resolve_path(path: str | Path, profile: str | Path | None = None) -> Path:
"""Resolve *path* after env-aware home expansion, without raising.""" """Resolve *path* after env-aware home expansion, preserving remote POSIX paths without raising."""
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
if remote_candidate is not None:
return remote_candidate
return _safe_resolve(_expanduser_path(path)) return _safe_resolve(_expanduser_path(path))
@@ -148,12 +207,75 @@ def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool:
return backend not in ('', 'local') return backend not in ('', 'local')
def _remote_terminal_cwd() -> str | None: def _resolve_profile_home_param(profile: str | Path | None) -> Path:
"""Return target-side terminal cwd for remote profiles, without local stat().""" """Resolve a profile parameter (name string, directory path string, or Path) to a profile home Path.
Logical profile names are validated strictly (#7168 re-gate round 4): a
name that fails the profile-id grammar raises ValueError instead of being
silently clamped onto the default home — the old clamp let a malformed
name such as ``"bad name"`` read/write the DEFAULT profile's state.
Round 5 tightens the grammar gate: a STRING profile value is strictly a
logical profile id and is NEVER treated as a path-shaped home — the old
``"/" in raw`` branch resolved any slash-bearing string directly, so a
malformed value like ``"../evil"`` bypassed validation entirely and could
read/overwrite an arbitrary ``webui_state/last_workspace.txt``
(#7168 re-gate round 5, path traversal on the profile-isolation boundary).
Round 6 closes the last isolation hole in this resolver: the logical
string ``"default"`` used to short-circuit to ``_DEFAULT_HERMES_HOME``
before reaching ``get_hermes_home_for_profile()``, bypassing the
isolated-mode clamp in ``api.profiles._resolve_profile_home_for_name``
(#7168 re-gate round 6). In an isolated deployment pinned at
``<base>/profiles/default``, a session created with ``profile="default"``
therefore resolved workspace/config from the BASE root home instead of
the pinned one. The name now flows through the same delegated path as
every other logical id; literal-default routing to the global state
files is retained in ``_profile_state_dir``/``get_last_workspace`` via
canonical ``_is_default_profile_home`` identity.
An explicit home directory is expressed as a ``Path`` object (callers such
as streaming's legacy ``_profile_home`` fallback wrap their home strings
in ``Path``); Path values are honored and canonicalized so identity
comparisons never depend on lexical spelling (e.g. a symlink alias of the
default home must compare equal to it).
"""
if profile is None or str(profile).strip() == "":
from api.profiles import get_active_hermes_home
return get_active_hermes_home()
raw = str(profile).strip()
if isinstance(profile, Path):
return _safe_resolve(profile.expanduser())
# Strings are LOGICAL PROFILE IDS ONLY — no path-shaped strings, ever.
if not _PROFILE_NAME_RE.fullmatch(raw):
raise ValueError(f"invalid profile name: {raw!r}")
from api.profiles import get_hermes_home_for_profile
return _safe_resolve(get_hermes_home_for_profile(raw))
def _is_default_profile_home(profile_home: Path) -> bool:
"""Canonical identity check against the root/default Hermes home.
Compares resolved paths so a symlink alias of _DEFAULT_HERMES_HOME is
recognized as the default profile rather than treated as a foreign,
lexically-different directory (#7168 re-gate round 4).
"""
try: try:
from api.config import get_config from api.profiles import _DEFAULT_HERMES_HOME
return _safe_resolve(profile_home) == _safe_resolve(_DEFAULT_HERMES_HOME)
except Exception:
return False
def _remote_terminal_cwd(profile: str | Path | None = None) -> str | None:
"""Return target-side terminal cwd for a remote profile, without local stat()."""
try:
from api.config import get_config_for_profile_home
profile_home = _resolve_profile_home_param(profile)
terminal_cfg = get_config_for_profile_home(profile_home).get('terminal', {})
terminal_cfg = get_config().get('terminal', {})
if not _is_remote_terminal_backend(terminal_cfg): if not _is_remote_terminal_backend(terminal_cfg):
return None return None
cwd = str(terminal_cfg.get('cwd') or '').strip() cwd = str(terminal_cfg.get('cwd') or '').strip()
@@ -165,8 +287,8 @@ def _remote_terminal_cwd() -> str | None:
return None return None
def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None: def _remote_terminal_workspace_candidate(path: str | Path, profile: str | Path | None = None) -> Path | None:
"""Return a non-stat'ed target-side Path when it is under terminal.cwd. """Return a non-stat'ed target-side Path when it is under terminal.cwd for the given profile.
Remote workspace paths live on the target host (e.g., remote SSH/Docker Remote workspace paths live on the target host (e.g., remote SSH/Docker
backend). For valid target-side POSIX paths under ``terminal.cwd``, the backend). For valid target-side POSIX paths under ``terminal.cwd``, the
@@ -174,6 +296,9 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
local host-filesystem resolution (avoiding host-specific firmlink rewriting local host-filesystem resolution (avoiding host-specific firmlink rewriting
such as macOS synthetic ``/home`` -> ``/System/Volumes/Data/home``). such as macOS synthetic ``/home`` -> ``/System/Volumes/Data/home``).
""" """
try:
cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
except TypeError:
cwd = _remote_terminal_cwd() cwd = _remote_terminal_cwd()
if not cwd: if not cwd:
return None return None
@@ -192,8 +317,8 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
if posix_candidate == posix_base or _posix_is_within(posix_candidate, posix_base): if posix_candidate == posix_base or _posix_is_within(posix_candidate, posix_base):
return Path(normalized_raw) return Path(normalized_raw)
return None return None
candidate = _resolve_path(raw) candidate = _safe_resolve(_expanduser_path(raw))
base = _resolve_path(cwd) base = _safe_resolve(_expanduser_path(cwd))
if _is_blocked_workspace_path(candidate, raw) or _is_blocked_workspace_path(base, cwd): if _is_blocked_workspace_path(candidate, raw) or _is_blocked_workspace_path(base, cwd):
return None return None
if candidate == base or _is_within(candidate, base): if candidate == base or _is_within(candidate, base):
@@ -201,7 +326,7 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
return None return None
def _profile_default_workspace() -> str: def _profile_default_workspace(profile: str | Path | None = None) -> str:
"""Read the profile's default workspace from its config.yaml. """Read the profile's default workspace from its config.yaml.
Checks keys in priority order: Checks keys in priority order:
@@ -217,8 +342,9 @@ def _profile_default_workspace() -> str:
Falls back to the live DEFAULT_WORKSPACE from api.config. Falls back to the live DEFAULT_WORKSPACE from api.config.
""" """
try: try:
from api.config import get_config from api.config import get_config_for_profile_home
cfg = get_config() profile_home = _resolve_profile_home_param(profile)
cfg = get_config_for_profile_home(profile_home)
terminal_cfg = cfg.get('terminal', {}) terminal_cfg = cfg.get('terminal', {})
remote_terminal = _is_remote_terminal_backend(terminal_cfg) remote_terminal = _is_remote_terminal_backend(terminal_cfg)
# Explicit webui workspace keys first # Explicit webui workspace keys first
@@ -227,7 +353,7 @@ def _profile_default_workspace() -> str:
if ws: if ws:
if remote_terminal: if remote_terminal:
return str(ws).strip() return str(ws).strip()
p = _resolve_path(str(ws)) p = _resolve_path(str(ws), profile=profile)
if remote_terminal or p.is_dir(): if remote_terminal or p.is_dir():
return str(p) return str(p)
# Fall through to terminal.cwd — the agent's configured working directory # Fall through to terminal.cwd — the agent's configured working directory
@@ -236,7 +362,7 @@ def _profile_default_workspace() -> str:
if cwd and str(cwd) not in ('.', ''): if cwd and str(cwd) not in ('.', ''):
if remote_terminal: if remote_terminal:
return str(cwd).strip() return str(cwd).strip()
p = _resolve_path(str(cwd)) p = _resolve_path(str(cwd), profile=profile)
if remote_terminal or p.is_dir(): if remote_terminal or p.is_dir():
return str(p) return str(p)
except (ImportError, Exception): except (ImportError, Exception):
@@ -244,14 +370,14 @@ def _profile_default_workspace() -> str:
try: try:
from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE
return str(_resolve_path(_LIVE_DEFAULT_WORKSPACE)) return str(_resolve_path(_LIVE_DEFAULT_WORKSPACE, profile=profile))
except Exception: except Exception:
return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE)) return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE, profile=profile))
# ── Public API ────────────────────────────────────────────────────────────── # ── Public API ──────────────────────────────────────────────────────────────
def _clean_workspace_list(workspaces: list) -> list: def _clean_workspace_list(workspaces: list, profile: str | Path | None = None) -> list:
"""Sanitize a workspace list: """Sanitize a workspace list:
- Preserve target-side remote terminal workspace paths (SSH/Docker) without - Preserve target-side remote terminal workspace paths (SSH/Docker) without
resolving them against the local WebUI host filesystem. resolving them against the local WebUI host filesystem.
@@ -270,7 +396,7 @@ def _clean_workspace_list(workspaces: list) -> list:
name = w.get('name', '') name = w.get('name', '')
if not path: if not path:
continue continue
remote_cand = _remote_terminal_workspace_candidate(path) remote_cand = _remote_terminal_workspace_candidate(path, profile=profile)
if remote_cand is not None: if remote_cand is not None:
p = remote_cand p = remote_cand
else: else:
@@ -283,6 +409,13 @@ def _clean_workspace_list(workspaces: list) -> list:
# p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile # p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile
try: try:
from api.profiles import get_active_hermes_home from api.profiles import get_active_hermes_home
if profile is not None:
# Explicit profile wins: the list belongs to that profile,
# so "own" is defined by the profile parameter, never by the
# ambient home (loading profile A's list under ambient B must
# not silently drop A's own workspaces).
own_profile_dir = _resolve_profile_home_param(profile).resolve()
else:
own_profile_dir = get_active_hermes_home().resolve() own_profile_dir = get_active_hermes_home().resolve()
p.relative_to(own_profile_dir) p.relative_to(own_profile_dir)
# p is under our own profile dir — keep it # p is under our own profile dir — keep it
@@ -350,12 +483,12 @@ def _migrate_global_workspaces() -> list:
return [] return []
def load_workspaces() -> list: def load_workspaces(profile: str | Path | None = None) -> list:
ws_file = _workspaces_file() ws_file = _workspaces_file_for_profile(profile)
if ws_file.exists(): if ws_file is not None and ws_file.exists():
try: try:
raw = json.loads(ws_file.read_text(encoding='utf-8')) raw = json.loads(ws_file.read_text(encoding='utf-8'))
cleaned = _clean_workspace_list(raw) cleaned = _clean_workspace_list(raw, profile=profile)
if len(cleaned) != len(raw): if len(cleaned) != len(raw):
# Persist the cleaned version so stale entries don't keep reappearing # Persist the cleaned version so stale entries don't keep reappearing
try: try:
@@ -364,7 +497,7 @@ def load_workspaces() -> list:
) )
except Exception: except Exception:
logger.debug("Failed to persist cleaned workspace list") logger.debug("Failed to persist cleaned workspace list")
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}] return cleaned or [{'path': _profile_default_workspace(profile=profile), 'name': 'Home'}]
except Exception: except Exception:
logger.debug("Failed to load workspaces from %s", ws_file) logger.debug("Failed to load workspaces from %s", ws_file)
# No profile-local file yet. # No profile-local file yet.
@@ -372,6 +505,10 @@ def load_workspaces() -> list:
# For NAMED profiles: always start clean with just their own workspace. # For NAMED profiles: always start clean with just their own workspace.
try: try:
from api.profiles import get_active_profile_name from api.profiles import get_active_profile_name
if profile is not None:
profile_home = _resolve_profile_home_param(profile)
is_default = _is_default_profile_home(profile_home)
else:
is_default = get_active_profile_name() in ('default', None) is_default = get_active_profile_name() in ('default', None)
except ImportError: except ImportError:
is_default = True is_default = True
@@ -380,16 +517,20 @@ def load_workspaces() -> list:
if migrated: if migrated:
return migrated return migrated
# Fresh start: single entry from the profile's configured workspace, labeled "Home" # Fresh start: single entry from the profile's configured workspace, labeled "Home"
return [{'path': _profile_default_workspace(), 'name': 'Home'}] return [{'path': _profile_default_workspace(profile=profile), 'name': 'Home'}]
def save_workspaces(workspaces: list) -> None: def save_workspaces(workspaces: list, profile: str | Path | None = None) -> None:
ws_file = _workspaces_file() ws_file = _workspaces_file_for_profile(profile)
if ws_file is None:
# Fail-closed: an invalid profile name must not write any state file
# (it would land in the default profile's directory via the old clamp).
raise ValueError(f"cannot save workspaces for invalid profile {profile!r}")
ws_file.parent.mkdir(parents=True, exist_ok=True) ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8') ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
def get_profile_default_workspace() -> str: def get_profile_default_workspace(profile: str | Path | None = None) -> str:
"""Resolve the ACTIVE PROFILE's default workspace, never the global file. """Resolve the ACTIVE PROFILE's default workspace, never the global file.
Like get_last_workspace() but WITHOUT the global ``_GLOBAL_LW_FILE`` Like get_last_workspace() but WITHOUT the global ``_GLOBAL_LW_FILE``
@@ -404,31 +545,37 @@ def get_profile_default_workspace() -> str:
Priority: profile-scoped ``last_workspace.txt`` -> profile ``config.yaml`` Priority: profile-scoped ``last_workspace.txt`` -> profile ``config.yaml``
``workspace``/``default_workspace`` -> ``terminal.cwd`` -> process default. ``workspace``/``default_workspace`` -> ``terminal.cwd`` -> process default.
""" """
try:
remote_cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
except TypeError:
remote_cwd = _remote_terminal_cwd() remote_cwd = _remote_terminal_cwd()
def _valid(raw: str) -> str | None: def _valid(raw: str) -> str | None:
if not raw: if not raw:
return None return None
if remote_cwd: if remote_cwd:
if _remote_terminal_workspace_candidate(raw) is not None: if _remote_terminal_workspace_candidate(raw, profile=profile) is not None:
return raw return raw
return None return None
if Path(raw).is_dir(): if Path(raw).is_dir():
return raw return raw
return None return None
lw_file = _last_workspace_file() lw_file = _last_workspace_file_for_profile(profile)
if lw_file.exists(): if lw_file is not None and lw_file.exists():
try: try:
p = _valid(lw_file.read_text(encoding='utf-8').strip()) p = _valid(lw_file.read_text(encoding='utf-8').strip())
if p: if p:
return p return p
except Exception: except Exception:
logger.debug("Failed to read profile last workspace from %s", lw_file) logger.debug("Failed to read profile last workspace from %s", lw_file)
return _profile_default_workspace() return _profile_default_workspace(profile=profile)
def get_last_workspace() -> str: def get_last_workspace(profile: str | Path | None = None) -> str:
try:
remote_cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
except TypeError:
remote_cwd = _remote_terminal_cwd() remote_cwd = _remote_terminal_cwd()
def valid_last_workspace(raw: str) -> str | None: def valid_last_workspace(raw: str) -> str | None:
@@ -438,35 +585,58 @@ def get_last_workspace() -> str:
# For remote/SSH profiles, last_workspace is target-side state. Do # For remote/SSH profiles, last_workspace is target-side state. Do
# not accept stale server-local paths merely because they exist on # not accept stale server-local paths merely because they exist on
# the WebUI host; require the value to stay under terminal.cwd. # the WebUI host; require the value to stay under terminal.cwd.
if _remote_terminal_workspace_candidate(raw) is not None: if _remote_terminal_workspace_candidate(raw, profile=profile) is not None:
return raw return raw
return None return None
if Path(raw).is_dir(): if Path(raw).is_dir():
return raw return raw
return None return None
lw_file = _last_workspace_file() lw_file = _last_workspace_file_for_profile(profile)
if lw_file.exists(): if lw_file is not None and lw_file.exists():
try: try:
p = valid_last_workspace(lw_file.read_text(encoding='utf-8').strip()) p = valid_last_workspace(lw_file.read_text(encoding='utf-8').strip())
if p: if p:
return p return p
except Exception: except Exception:
logger.debug("Failed to read last workspace from %s", lw_file) logger.debug("Failed to read last workspace from %s", lw_file)
# Fallback: try global file # Fallback: try global file — but ONLY for the root/default profile. A named
if _GLOBAL_LW_FILE.exists(): # profile must never inherit another profile's last-workspace binding through
# the legacy global state (#7168 re-gate round 3). Identity is canonical:
# a symlink alias of the default home still counts as default, while a
# malformed profile name fails validation and is denied the fallback
# rather than being clamped onto the global file (#7168 re-gate round 4).
_global_fallback_allowed = True # ambient / no explicit profile: historical behavior
if profile is not None:
if str(profile).strip() == 'default':
_global_fallback_allowed = True
else:
try:
_global_fallback_allowed = _is_default_profile_home(
_resolve_profile_home_param(profile)
)
except Exception:
# Conservative default: an unresolvable explicit profile is NOT the
# root profile — deny the global fallback rather than leak.
_global_fallback_allowed = False
if _global_fallback_allowed and _GLOBAL_LW_FILE.exists():
try: try:
p = valid_last_workspace(_GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()) p = valid_last_workspace(_GLOBAL_LW_FILE.read_text(encoding='utf-8').strip())
if p: if p:
return p return p
except Exception: except Exception:
logger.debug("Failed to read global last workspace") logger.debug("Failed to read global last workspace")
return _profile_default_workspace() return _profile_default_workspace(profile=profile)
def set_last_workspace(path: str) -> None: def set_last_workspace(path: str, profile: str | Path | None = None) -> None:
try: try:
lw_file = _last_workspace_file() lw_file = _last_workspace_file_for_profile(profile)
if lw_file is None:
# Fail-closed: an invalid profile name must not write the default
# profile's last_workspace.txt (#7168 re-gate round 4).
logger.debug("Refusing to set last workspace for invalid profile %r", profile)
return
lw_file.parent.mkdir(parents=True, exist_ok=True) lw_file.parent.mkdir(parents=True, exist_ok=True)
lw_file.write_text(str(path), encoding='utf-8') lw_file.write_text(str(path), encoding='utf-8')
except Exception: except Exception:
@@ -683,7 +853,15 @@ def _is_within(path: Path, root: Path) -> bool:
return False return False
def _trusted_workspace_roots() -> list[Path]: def _trusted_workspace_roots(profile: str | Path | None = None) -> list[Path]:
"""Return the host directories workspace suggestions may traverse.
Saved-workspace roots follow the same trust rule as
:func:`resolve_trusted_workspace`: with an explicit *profile*, only
workspaces saved under THAT profile widen the boundary (plus the ambient
home / boot-default carve-outs); ``None`` keeps the historical ambient /
global saved-list behaviour.
"""
roots: list[Path] = [] roots: list[Path] = []
def add(candidate: str | Path | None) -> None: def add(candidate: str | Path | None) -> None:
@@ -702,24 +880,26 @@ def _trusted_workspace_roots() -> list[Path]:
add(_home_path()) add(_home_path())
add(_BOOT_DEFAULT_WORKSPACE) add(_BOOT_DEFAULT_WORKSPACE)
for w in load_workspaces(): for w in load_workspaces(profile=profile):
add(w.get("path")) add(w.get("path"))
roots.sort(key=lambda p: len(str(p))) roots.sort(key=lambda p: len(str(p)))
return roots return roots
def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]: def list_workspace_suggestions(
prefix: str = "", limit: int = 12, profile: str | Path | None = None
) -> list[str]:
"""Return workspace path suggestions under trusted roots only. """Return workspace path suggestions under trusted roots only.
Suggestions are limited to directories under one of: Suggestions are limited to directories under one of:
- Path.home() - Path.home()
- the boot default workspace - the boot default workspace
- already-saved workspace roots - already-saved workspace roots (scoped to *profile* when given)
Arbitrary system prefixes return an empty list rather than an error so the Arbitrary system prefixes return an empty list rather than an error so the
UI can safely autocomplete while the user types. UI can safely autocomplete while the user types.
""" """
roots = _trusted_workspace_roots() roots = _trusted_workspace_roots(profile=profile)
if not roots: if not roots:
return [] return []
@@ -815,7 +995,7 @@ def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
return suggestions[:limit] return suggestions[:limit]
def resolve_trusted_workspace(path: str | Path | None = None) -> Path: def resolve_trusted_workspace(path: str | Path | None = None, profile: str | Path | None = None) -> Path:
"""Resolve and validate a workspace path. """Resolve and validate a workspace path.
A path is trusted if it satisfies at least one of: A path is trusted if it satisfies at least one of:
@@ -837,12 +1017,12 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
trusted (it was validated at server startup). trusted (it was validated at server startup).
""" """
if path in (None, ""): if path in (None, ""):
return _resolve_path(_BOOT_DEFAULT_WORKSPACE) return _resolve_path(_BOOT_DEFAULT_WORKSPACE, profile) if profile is not None else _resolve_path(_BOOT_DEFAULT_WORKSPACE)
candidate = _resolve_path(path) candidate = _resolve_path(path, profile) if profile is not None else _resolve_path(path)
access_error = _workspace_access_error(candidate) access_error = _workspace_access_error(candidate)
remote_candidate = _remote_terminal_workspace_candidate(path) remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
if access_error: if access_error:
# For remote terminal profiles, workspace paths belong to the target # For remote terminal profiles, workspace paths belong to the target
# machine. Allow paths under terminal.cwd so session switching can # machine. Allow paths under terminal.cwd so session switching can
@@ -869,6 +1049,11 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
# (B) Trusted if already in the saved workspace list — covers non-home installs # (B) Trusted if already in the saved workspace list — covers non-home installs
try: try:
saved = load_workspaces(profile=profile)
saved_paths = {_resolve_path(w["path"], profile) for w in saved if w.get("path")}
if candidate in saved_paths:
return candidate
except TypeError:
saved = load_workspaces() saved = load_workspaces()
saved_paths = {_resolve_path(w["path"]) for w in saved if w.get("path")} saved_paths = {_resolve_path(w["path"]) for w in saved if w.get("path")}
if candidate in saved_paths: if candidate in saved_paths:
@@ -898,15 +1083,22 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
def resolve_implicit_workspace_with_recovery( def resolve_implicit_workspace_with_recovery(
candidate: str | Path | None, candidate: str | Path | None,
fallback: str | Path | None | Callable[[], str | Path | None], fallback: str | Path | None | Callable[[], str | Path | None],
profile: str | Path | None = None,
) -> tuple[Path, bool]: ) -> tuple[Path, bool]:
"""Resolve an implicit workspace, recovering only a genuinely missing path. """Resolve an implicit workspace, recovering only a genuinely missing path.
The fallback still passes through :func:`resolve_trusted_workspace`. Existing The fallback still passes through :func:`resolve_trusted_workspace`. Existing
but untrusted, inaccessible, or non-directory candidates are not recovery but untrusted, inaccessible, or non-directory candidates are not recovery
cases: their original validation error is preserved so fallback cannot widen cases: their original validation error is preserved so fallback cannot widen
the workspace trust boundary. the workspace trust boundary. When *profile* is given, both the trust
resolution and the recovery fallback are scoped to that profile.
""" """
try: try:
if profile is not None:
try:
return resolve_trusted_workspace(candidate, profile=profile), False
except TypeError:
pass
return resolve_trusted_workspace(candidate), False return resolve_trusted_workspace(candidate), False
except ValueError as original_error: except ValueError as original_error:
if candidate in (None, ""): if candidate in (None, ""):
@@ -917,8 +1109,16 @@ def resolve_implicit_workspace_with_recovery(
# never prove target-side deletion. Config-read uncertainty also fails # never prove target-side deletion. Config-read uncertainty also fails
# closed by preserving the original validation error. # closed by preserving the original validation error.
try: try:
from api.config import get_config from api.config import get_config, get_config_for_profile_home
if profile is not None:
# Classify the backend from THIS profile's own config, never the
# ambient one: a remote profile without terminal.cwd must still be
# recognized as remote when loaded under a different ambient home.
terminal_cfg = get_config_for_profile_home(
_resolve_profile_home_param(profile)
).get("terminal", {})
else:
terminal_cfg = get_config().get("terminal", {}) terminal_cfg = get_config().get("terminal", {})
except Exception: except Exception:
logger.debug("Failed to classify terminal backend for workspace recovery", exc_info=True) logger.debug("Failed to classify terminal backend for workspace recovery", exc_info=True)
@@ -926,10 +1126,32 @@ def resolve_implicit_workspace_with_recovery(
if _is_remote_terminal_backend(terminal_cfg): if _is_remote_terminal_backend(terminal_cfg):
raise original_error from None raise original_error from None
try: try:
local_candidate = _resolve_path(candidate) local_candidate = (
_resolve_path(candidate, profile=profile)
if profile is not None
else _resolve_path(candidate)
)
local_candidate.stat() local_candidate.stat()
except FileNotFoundError: except FileNotFoundError:
fallback_value = fallback() if callable(fallback) else fallback def _profile_bound_fallback():
if profile is not None and callable(fallback):
# Profile-bound getter FIRST (#7168 re-gate round 3): the
# production call sites pass profile-aware getters such as
# get_last_workspace, so binding the explicit profile must
# take precedence over any zero-argument compatibility call,
# which would read ambient/global state.
try:
return fallback(profile)
except TypeError:
pass
return fallback() if callable(fallback) else fallback
fallback_value = _profile_bound_fallback()
if profile is not None:
try:
return resolve_trusted_workspace(fallback_value, profile=profile), True
except TypeError:
pass
return resolve_trusted_workspace(fallback_value), True return resolve_trusted_workspace(fallback_value), True
except (OSError, RuntimeError, ValueError): except (OSError, RuntimeError, ValueError):
raise original_error from None raise original_error from None
@@ -957,7 +1179,7 @@ def _strip_surrounding_quotes(path: str) -> str:
return s return s
def validate_workspace_to_add(path: str) -> Path: def validate_workspace_to_add(path: str, profile: str | Path | None = None) -> Path:
"""Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace). """Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
When a user explicitly adds a new workspace path, we trust their intent — they When a user explicitly adds a new workspace path, we trust their intent — they
@@ -972,10 +1194,10 @@ def validate_workspace_to_add(path: str) -> Path:
and users routinely paste those into the Add Space input. and users routinely paste those into the Add Space input.
""" """
path = _strip_surrounding_quotes(path) path = _strip_surrounding_quotes(path)
candidate = _resolve_path(path) candidate = _resolve_path(path, profile) if profile is not None else _resolve_path(path)
access_error = _workspace_access_error(candidate) access_error = _workspace_access_error(candidate)
remote_candidate = _remote_terminal_workspace_candidate(path) remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
if access_error: if access_error:
# Remote terminal profiles validate workspace existence on the target # Remote terminal profiles validate workspace existence on the target
# machine, not on the WebUI server. Permit target-side paths under # machine, not on the WebUI server. Permit target-side paths under
+4
View File
@@ -23,6 +23,10 @@ contributor guidance; it does not change runtime behavior or CI gates.
## Runtime, durability, and state contracts ## Runtime, durability, and state contracts
- [`docs/remote-workspaces.md`](remote-workspaces.md):
architecture contract for remote terminal workspaces (SSH/Docker), target-side
POSIX path preservation against macOS synthetic firmlink expansion, and
per-profile isolation boundaries.
- [`docs/rfcs/webui-run-state-consistency-contract.md`](rfcs/webui-run-state-consistency-contract.md): - [`docs/rfcs/webui-run-state-consistency-contract.md`](rfcs/webui-run-state-consistency-contract.md):
proposed consistency rules for current WebUI streaming, recovery, replay, proposed consistency rules for current WebUI streaming, recovery, replay,
model-context reconstruction, compression, UI scene/cache, and sidebar metadata model-context reconstruction, compression, UI scene/cache, and sidebar metadata
+29
View File
@@ -0,0 +1,29 @@
# Remote Terminal Workspaces
Architecture contract and path-resolution semantics for remote terminal profiles (SSH, Docker) in Hermes WebUI.
---
## 1. Overview
When a Hermes profile is configured with a remote terminal backend (e.g. `terminal.backend: "ssh"` or `"docker"`), its working directory (`terminal.cwd`) lives on the remote target host rather than the local WebUI host filesystem.
On hosts such as macOS, local path resolution via `Path.resolve()` or `os.path.realpath()` expands synthetic firmlinks (e.g. rewriting `/home/<user>` to `/System/Volumes/Data/home/<user>`). Because the target-side path does not exist on the local macOS server, unconstrained local resolution causes runtime validation failures and session corruption.
---
## 2. Path Resolution Contract
1. **Target-side POSIX Path Preservation:**
- Any valid POSIX path at or beneath a remote profile's configured `terminal.cwd` is preserved verbatim as a `Path` object without calling host-local `Path.resolve()`.
- Traversal escapes (`..`), null bytes (`\0`), and blocked system roots (`/etc`, `/usr`, `/var`, `/sys`, `/proc`, `/dev`) continue to be strictly rejected.
2. **Profile Isolation & Boundary Enforcement:**
- Remote path recognition is **strictly scoped to the target/active profile**.
- If an active profile has a local backend, target-side remote paths belonging to inactive named profiles are **not** treated as remote for the active local profile.
- For local profiles, workspace addition (`/api/workspaces/add`) enforces host-local directory existence and permissions.
- For remote profiles, workspace addition validates against the profile's remote `terminal.cwd` and skips host-local directory creation (`mkdir`).
3. **Session & Streaming Lifecycle:**
- `Session.__init__` and `Session.load` normalize session workspace paths through `_resolve_path(..., profile=profile)`, preserving target-side paths for remote profiles and preventing corruption of `session.workspace` or `session.created_workspace`.
- Streaming execution (`_run_agent_streaming`) and multimodal asset root resolution preserve the remote session workspace when updating runtime run state.
+38
View File
@@ -231,6 +231,44 @@ def _reset_password_hash_cache():
_invalidate_password_hash_cache() _invalidate_password_hash_cache()
def _strip_leaked_webui_password_env() -> None:
"""Remove a leaked HERMES_WEBUI_PASSWORD between tests (#7168 review).
bootstrap.py runs _load_repo_dotenv() at import time, which copies values
from the developer's real repo .env straight into os.environ. When any
test imports bootstrap mid-session (e.g. tests/test_bootstrap_foreground.py
via its import_bootstrap fixture), a local .env containing
HERMES_WEBUI_PASSWORD leaks into the process environment OUTSIDE
monkeypatch's undo scope. Every later test then sees is_auth_enabled()
True and no-handler cookie helpers raise spurious
"build_profile_cookie requires a request handler" errors — exactly the
#5588 failure shape, but sourced from the repo .env instead of the hash
cache. Tests that legitimately enable auth set the var themselves AFTER
this strip; an intentionally-empty value ("") is preserved so
ctl.sh-style override semantics keep working.
HERMES_COMMAND gets the same treatment (#7168 re-gate round 7): a local
.env carrying HERMES_COMMAND leaks past bootstrap imports and redirects
gateway_restart._resolve_hermes_command() away from its mocked
shutil.which result, failing every later active-profile-restart test
with a machine-specific CLI path. Upstream code has no
HERMES_COMMAND override, so stripping a leaked value restores exact
upstream semantics.
"""
if os.environ.get("HERMES_WEBUI_PASSWORD") == "":
pass # intentional empty override preserved for the password var
else:
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
os.environ.pop("HERMES_COMMAND", None)
@pytest.fixture(autouse=True)
def _strip_leaked_webui_password():
_strip_leaked_webui_password_env()
yield
_strip_leaked_webui_password_env()
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _invalidate_providers_cache(): def _invalidate_providers_cache():
"""Clear the /api/providers TTL cache around every test (#6010). """Clear the /api/providers TTL cache around every test (#6010).
+1 -1
View File
@@ -854,7 +854,7 @@ def test_stream_admission_uses_one_gateway_ownership_snapshot(monkeypatch, gatew
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda _sid: None) monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda _sid: None)
monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False) monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False)
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", prepare) monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", prepare)
monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace, **_kw: None)
monkeypatch.setattr(routes.threading, "Thread", FakeThread) monkeypatch.setattr(routes.threading, "Thread", FakeThread)
monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {}) monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {})
+84
View File
@@ -197,3 +197,87 @@ class TestBootstrapStructure:
assert "_load_repo_dotenv" in bootstrap_src, ( assert "_load_repo_dotenv" in bootstrap_src, (
"bootstrap.py must load .env so direct invocation matches start.sh behaviour" "bootstrap.py must load .env so direct invocation matches start.sh behaviour"
) )
class TestLeakedWebuiPasswordIsolation:
"""#7168 review: a local repo .env containing HERMES_WEBUI_PASSWORD leaks
into os.environ when any test imports bootstrap (import-time
_load_repo_dotenv() runs OUTSIDE monkeypatch's undo scope). The conftest
autouse guard strips the leaked var around every test so later tests
don't see is_auth_enabled()==True (the #5588 failure shape)."""
def _load_guard(self):
import importlib.util
cpath = Path(__file__).resolve().parent / "conftest.py"
spec = importlib.util.spec_from_file_location("_test_conftest", cpath)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod._strip_leaked_webui_password_env
def test_conftest_guard_strips_leaked_password(self):
strip = self._load_guard()
os.environ["HERMES_WEBUI_PASSWORD"] = "leaked-from-repo-dotenv"
try:
strip()
assert "HERMES_WEBUI_PASSWORD" not in os.environ
finally:
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
def test_conftest_guard_preserves_intentional_empty_override(self):
"""ctl.sh-style override semantics: an explicitly empty value means
'keep auth off' and must survive the strip."""
strip = self._load_guard()
sentinel = object()
os.environ["HERMES_WEBUI_PASSWORD"] = ""
try:
strip()
assert os.environ.get("HERMES_WEBUI_PASSWORD", sentinel) == ""
finally:
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
class TestLeakedHermesCommandIsolation:
"""#7168 re-gate round 7: a local repo .env carrying HERMES_COMMAND leaks
into os.environ via bootstrap import-time _load_repo_dotenv() and
redirects gateway_restart._resolve_hermes_command() away from its mocked
shutil.which result — every later active-profile-restart test then fails
on a machine-specific CLI path. The autouse conftest guard strips the
leaked var around every test; upstream code never reads HERMES_COMMAND,
so stripping restores exact upstream semantics."""
def _load_guard(self):
import importlib.util
cpath = Path(__file__).resolve().parent / "conftest.py"
spec = importlib.util.spec_from_file_location("_test_conftest_hc", cpath)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod._strip_leaked_webui_password_env
def test_conftest_guard_strips_leaked_hermes_command(self):
strip = self._load_guard()
os.environ["HERMES_COMMAND"] = "/machine/local/hermes-gateway-wrapper"
try:
strip()
assert "HERMES_COMMAND" not in os.environ
finally:
os.environ.pop("HERMES_COMMAND", None)
def test_conftest_guard_still_strips_password_when_command_leaks(self):
"""Both leaked vars are stripped in one pass (password branch must
not be short-circuited by the HERMES_COMMAND handling)."""
strip = self._load_guard()
os.environ["HERMES_WEBUI_PASSWORD"] = "leaked-from-repo-dotenv"
os.environ["HERMES_COMMAND"] = "/machine/local/hermes-gateway-wrapper"
try:
strip()
assert "HERMES_WEBUI_PASSWORD" not in os.environ
assert "HERMES_COMMAND" not in os.environ
finally:
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
os.environ.pop("HERMES_COMMAND", None)
+1 -1
View File
@@ -200,7 +200,7 @@ def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, t
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload) monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid, profile=None: messages if _sid == sid else []) monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid, profile=None: messages if _sid == sid else [])
monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [meta]) monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [meta])
monkeypatch.setattr(routes, "get_last_workspace", lambda: tmp_path / "workspace") monkeypatch.setattr(routes, "get_last_workspace", lambda profile=None: tmp_path / "workspace")
monkeypatch.setattr(routes, "import_cli_session", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("read-only import must not persist"))) monkeypatch.setattr(routes, "import_cli_session", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("read-only import must not persist")))
response = routes._handle_session_import_cli(object(), {"session_id": sid}) response = routes._handle_session_import_cli(object(), {"session_id": sid})
+1 -1
View File
@@ -93,7 +93,7 @@ def test_start_chat_stream_response_includes_provisional_title(tmp_path, monkeyp
import api.routes as routes import api.routes as routes
monkeypatch.setattr(Session, "save", lambda self, *a, **k: None) monkeypatch.setattr(Session, "save", lambda self, *a, **k: None)
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) monkeypatch.setattr(routes, "create_stream_channel", lambda: object())
monkeypatch.setattr(routes, "_run_agent_streaming", lambda *a, **k: None) monkeypatch.setattr(routes, "_run_agent_streaming", lambda *a, **k: None)
+6 -6
View File
@@ -397,7 +397,7 @@ def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
monkeypatch.setattr( monkeypatch.setattr(
routes, routes,
"webui_gateway_chat_enabled", "webui_gateway_chat_enabled",
@@ -519,7 +519,7 @@ def test_goal_endpoint_adapter_keeps_full_set_text_and_legacy_payload_status(mon
monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal") monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal")
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
monkeypatch.setattr( monkeypatch.setattr(
routes, routes,
"_resolve_compatible_session_model_state", "_resolve_compatible_session_model_state",
@@ -732,7 +732,7 @@ def test_goal_kickoff_forwards_explicit_model_pick_to_resolver(monkeypatch, tmp_
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
monkeypatch.setattr(routes, "get_config", lambda: {}) monkeypatch.setattr(routes, "get_config", lambda: {})
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve) monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve)
@@ -809,7 +809,7 @@ def test_goal_kickoff_defaults_explicit_model_pick_false(monkeypatch, tmp_path):
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
monkeypatch.setattr(routes, "get_config", lambda: {}) monkeypatch.setattr(routes, "get_config", lambda: {})
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve) monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve)
@@ -901,7 +901,7 @@ def test_goal_kickoff_stamps_explicit_pick_signature(monkeypatch, tmp_path):
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession())
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
monkeypatch.setattr(routes, "get_config", lambda: {}) monkeypatch.setattr(routes, "get_config", lambda: {})
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve) monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve)
@@ -973,7 +973,7 @@ def test_goal_kickoff_does_not_stamp_signature_without_explicit_pick(monkeypatch
return model, provider, False return model, provider, False
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path)
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
monkeypatch.setattr(routes, "get_config", lambda: {}) monkeypatch.setattr(routes, "get_config", lambda: {})
monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve) monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve)
@@ -748,7 +748,7 @@ def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkey
monkeypatch.setattr(routes, "title_from", models.title_from) monkeypatch.setattr(routes, "title_from", models.title_from)
monkeypatch.setattr(config, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) monkeypatch.setattr(config, "get_config", lambda: {"model": "test-model", "provider": "test-provider"})
monkeypatch.setattr(routes, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) monkeypatch.setattr(routes, "get_config", lambda: {"model": "test-model", "provider": "test-provider"})
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path)
monkeypatch.setattr(routes, "load_settings", lambda: {}) monkeypatch.setattr(routes, "load_settings", lambda: {})
monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: []) monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: [])
@@ -241,7 +241,7 @@ def test_load_cli_sessions_uncached_pushes_specific_source_into_state_db_scan(mo
monkeypatch.setattr(models, "get_claude_code_sessions", lambda: claude_calls.append(True) or []) monkeypatch.setattr(models, "get_claude_code_sessions", lambda: claude_calls.append(True) or [])
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows)
monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path)
monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False)
monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id") monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id")
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
@@ -283,7 +283,7 @@ def test_cron_source_filter_uses_cron_rescue_limit(monkeypatch, tmp_path):
] ]
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows)
monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path)
monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False)
monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id") monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id")
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
+2 -1
View File
@@ -22,7 +22,8 @@ class TestWorkspaceReorderEndpoint:
{"path": "/home/user/b", "name": "Beta"}, {"path": "/home/user/b", "name": "Beta"},
{"path": "/home/user/c", "name": "Gamma"}, {"path": "/home/user/c", "name": "Gamma"},
] ]
mock_save.side_effect = lambda wss: wss # Accept the profile-scoped save signature (save_workspaces(wss, profile=...)).
mock_save.side_effect = lambda wss, **kwargs: wss
handler = _make_handler() handler = _make_handler()
_handle_workspace_reorder(handler, { _handle_workspace_reorder(handler, {
"paths": ["/home/user/c", "/home/user/a", "/home/user/b"] "paths": ["/home/user/c", "/home/user/a", "/home/user/b"]
@@ -193,7 +193,7 @@ def test_locked_postacceptance_workspace_exception_does_not_restore_turn(monkeyp
monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue())
monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {"turn_id": "turn-6611"}) monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {"turn_id": "turn-6611"})
monkeypatch.setattr(Session, "save", lambda *_args, **_kwargs: None) monkeypatch.setattr(Session, "save", lambda *_args, **_kwargs: None)
monkeypatch.setattr(routes, "set_last_workspace", lambda *_args: (_ for _ in ()).throw(RuntimeError("workspace failed"))) monkeypatch.setattr(routes, "set_last_workspace", lambda *_args, **_kw: (_ for _ in ()).throw(RuntimeError("workspace failed")))
class FakeThread: class FakeThread:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -435,7 +435,7 @@ def test_issue6751_sync_chat_agent_receives_original_api_content_bytes(monkeypat
monkeypatch.setattr(routes, "title_from", models.title_from) monkeypatch.setattr(routes, "title_from", models.title_from)
monkeypatch.setattr(config, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) monkeypatch.setattr(config, "get_config", lambda: {"model": "test-model", "provider": "test-provider"})
monkeypatch.setattr(routes, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) monkeypatch.setattr(routes, "get_config", lambda: {"model": "test-model", "provider": "test-provider"})
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path)
monkeypatch.setattr(routes, "load_settings", lambda: {}) monkeypatch.setattr(routes, "load_settings", lambda: {})
monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: []) monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: [])
@@ -526,7 +526,7 @@ def test_issue6751_json_import_strips_internal_aliases_before_persistence(monkey
monkeypatch.setattr(models, "SESSION_INDEX_FILE", state_dir / "session_index.json") monkeypatch.setattr(models, "SESSION_INDEX_FILE", state_dir / "session_index.json")
monkeypatch.setattr(models, "SESSIONS", sessions) monkeypatch.setattr(models, "SESSIONS", sessions)
monkeypatch.setattr(routes, "SESSIONS", sessions) monkeypatch.setattr(routes, "SESSIONS", sessions)
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path)
monkeypatch.setattr(routes, "get_active_profile_name", lambda: "default") monkeypatch.setattr(routes, "get_active_profile_name", lambda: "default")
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *args, **kwargs: None) monkeypatch.setattr(routes, "publish_session_list_changed", lambda *args, **kwargs: None)
monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False}) monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False})
@@ -625,7 +625,7 @@ def test_issue6751_json_import_nested_tool_calls_are_removed_at_agent_boundary(
monkeypatch.setattr(models, "SESSIONS", sessions) monkeypatch.setattr(models, "SESSIONS", sessions)
monkeypatch.setattr(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json") monkeypatch.setattr(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json")
monkeypatch.setattr(routes, "SESSIONS", sessions) monkeypatch.setattr(routes, "SESSIONS", sessions)
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path)
monkeypatch.setattr(routes, "get_active_profile_name", lambda: "default") monkeypatch.setattr(routes, "get_active_profile_name", lambda: "default")
monkeypatch.setattr(routes, "publish_session_list_changed", lambda *args, **kwargs: None) monkeypatch.setattr(routes, "publish_session_list_changed", lambda *args, **kwargs: None)
monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False}) monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False})
@@ -784,7 +784,7 @@ def test_issue6751_json_import_rejects_non_list_session_tool_calls(monkeypatch,
monkeypatch.setattr(models, "SESSIONS", sessions) monkeypatch.setattr(models, "SESSIONS", sessions)
monkeypatch.setattr(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json") monkeypatch.setattr(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json")
monkeypatch.setattr(routes, "SESSIONS", sessions) monkeypatch.setattr(routes, "SESSIONS", sessions)
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path)
monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False}) monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False})
captured = {} captured = {}
@@ -0,0 +1,294 @@
"""Re-gate round 6 (#7168): the logical "default" name must honor isolated-mode clamping.
Maintainer round-6 re-gate CORE (one remaining instance of the isolation
class): ``_resolve_profile_home_param("default")`` short-circuited to
``_DEFAULT_HERMES_HOME`` BEFORE delegating to
``api.profiles.get_hermes_home_for_profile()``, so it never reached the
isolated-mode clamp in ``_resolve_profile_home_for_name`` (which pins every
lookup to ``_INITIAL_HERMES_HOME`` when ``HERMES_WEBUI_ISOLATED_PROFILE`` is
enabled). In an isolated deployment pinned at ``<base>/profiles/default``, a
session created with ``profile="default"`` therefore used the BASE root
home's workspace + config instead of the pinned one.
Fix under test: the literal shortcut is gone — the logical string ``"default"``
flows through ``get_hermes_home_for_profile()`` like every other id, so the
clamp applies. Literal-default routing to the global state files is RETAINED
(canonical ``_is_default_profile_home`` identity), asserted by the fallback
tests below.
"""
import pytest
import api.workspace as workspace
from api import profiles
@pytest.fixture
def isolated_default_pinned(tmp_path, monkeypatch):
"""Isolated-mode deployment pinned at <base>/profiles/default.
Mirrors the gate reproduction: HERMES_WEBUI_ISOLATED_PROFILE=1 with a
profile-shaped _INITIAL_HERMES_HOME whose directory NAME is literally
'default' (the exact shape where base and pin diverge). Base home gets a
DISTINCT config/workspace marker so any leak of the root home is visible.
"""
base = tmp_path / ".hermes"
pinned = base / "profiles" / "default"
pinned.mkdir(parents=True)
(pinned / "webui_state").mkdir()
# Distinct base-vs-pinned configs: only the PINNED one carries the marker.
(pinned / "config.yaml").write_text(
"workspace: /srv/pinned-workspace\n", encoding="utf-8"
)
monkeypatch.setenv("HERMES_WEBUI_ISOLATED_PROFILE", "1")
monkeypatch.setattr(profiles, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "1")
monkeypatch.setattr(profiles, "_INITIAL_HERMES_HOME", str(pinned))
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base)
monkeypatch.setattr(profiles, "_LIST_PROFILES_CACHE", None)
# Hermetic w.r.t. the runner's real state dir / remote terminal config.
monkeypatch.setattr(workspace, "_remote_terminal_cwd", lambda profile=None: None)
return {"base": base, "pinned": pinned, "tmp": tmp_path}
class TestRound6DefaultHonorsIsolationClamp:
"""The logical "default" name resolves through the clamped delegated path."""
def test_resolver_pins_default_in_isolated_mode(self, isolated_default_pinned):
env = isolated_default_pinned
assert profiles._is_isolated_profile_mode() is True
assert (
profiles.get_hermes_home_for_profile("default") == env["pinned"]
) # pre-existing correct behavior (the clamp)
got = workspace._resolve_profile_home_param("default")
assert got == env["pinned"].resolve(), (
f"_resolve_profile_home_param('default') must reach the isolated-mode "
f"clamp and return the PINNED home {env['pinned']}, got {got}"
)
assert got != env["base"], "must not resolve to the base/root home"
def test_get_last_workspace_uses_global_state_not_base(self, isolated_default_pinned, monkeypatch):
"""Round 7 contract: 'default' STATE lives in the GLOBAL files only.
Round-6 follow-up: once the resolver pins config/path resolution at
the isolated home, the canonical-home check in _profile_state_dir
split explicit profile="default" state I/O onto {pinned}/webui_state/
while ambient calls kept the global dir. Round 7 restores ONE state
authority: literal "default" reads/writes the global files, so this
test now asserts the global binding WINS and the BASE home's
webui_state is never consulted.
"""
env = isolated_default_pinned
# The authoritative GLOBAL binding...
global_ws = env["tmp"] / "srv" / "global-ws"
global_ws.mkdir(parents=True)
global_lw = env["tmp"] / "global-state"
global_lw.mkdir(parents=True)
# Both global state files must move TOGETHER: in production
# _GLOBAL_WS_FILE and _GLOBAL_LW_FILE share one state dir.
monkeypatch.setattr(workspace, "_GLOBAL_WS_FILE", global_lw / "workspaces.json")
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt")
(global_lw / "last_workspace.txt").write_text(str(global_ws), encoding="utf-8")
# ...and a POISONED base binding that must NOT win. Point the base
# home's own webui_state at a foreign path to prove no base read.
poisoned_ws = env["tmp"] / "srv" / "poisoned-base-ws"
poisoned_ws.mkdir(parents=True)
base_state = env["base"] / "webui_state"
base_state.mkdir(parents=True)
(base_state / "last_workspace.txt").write_text(str(poisoned_ws), encoding="utf-8")
got = workspace.get_last_workspace(profile="default")
assert str(got) == str(global_ws.resolve()), (
f"profile='default' in isolated mode must read the GLOBAL state "
f"authority ({global_lw / 'last_workspace.txt'}), got {got!r}"
)
def test_get_last_workspace_global_fallback_only_when_canonically_default(
self, isolated_default_pinned, monkeypatch
):
"""No profile-local state + non-pinned layout → legacy global fallback.
Literal-default routing to _GLOBAL_LW_FILE is retained OUTSIDE isolated
mode: when the resolved default home IS canonically the default home,
get_last_workspace(profile="default") still reads the global file
(historical behavior, re-gate round 3 contract).
"""
env = isolated_default_pinned
# Disable isolation so 'default' resolves canonically to the base home.
monkeypatch.delenv("HERMES_WEBUI_ISOLATED_PROFILE", raising=False)
monkeypatch.setattr(profiles, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "")
monkeypatch.setattr(profiles, "_INITIAL_HERMES_HOME", str(env["base"]))
global_lw = env["tmp"] / "global-state"
global_lw.mkdir(parents=True)
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt")
shared_ws = env["tmp"] / "srv" / "shared-ws"
shared_ws.mkdir(parents=True)
(global_lw / "last_workspace.txt").write_text(str(shared_ws), encoding="utf-8")
# The conftest autouse cleanup fixture writes TEST_STATE_DIR /
# last_workspace.txt at every teardown; for a canonically-default
# profile that IS the profile-local tier and would shadow the global
# file under test. Pin the profile-local tier to an absent path so the
# GLOBAL fallback is what actually gets exercised.
monkeypatch.setattr(
workspace,
"_last_workspace_file_for_profile",
lambda p=None: env["tmp"] / "absent-webui_state" / "last_workspace.txt",
)
got = workspace.get_last_workspace(profile="default")
assert str(got) == str(shared_ws), (
"outside isolated mode, profile='default' keeps the legacy global "
"fallback (canonical identity retained after the round-6 fix)"
)
def test_new_session_binds_global_state_workspace(self, isolated_default_pinned, monkeypatch):
"""Session creation with profile='default' honors the global state
authority and never leaks onto the base home (round-7 contract)."""
import api.models as models
env = isolated_default_pinned
bound_ws = env["tmp"] / "srv" / "bound-session-ws"
bound_ws.mkdir(parents=True)
global_lw = env["tmp"] / "global-state"
global_lw.mkdir(parents=True)
# Both global state files must move TOGETHER: in production
# _GLOBAL_WS_FILE and _GLOBAL_LW_FILE share one state dir.
monkeypatch.setattr(workspace, "_GLOBAL_WS_FILE", global_lw / "workspaces.json")
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt")
(global_lw / "last_workspace.txt").write_text(str(bound_ws), encoding="utf-8")
# POISONED base-home binding must not win.
poisoned_ws = env["tmp"] / "srv" / "poisoned-base-ws"
poisoned_ws.mkdir(parents=True)
base_state = env["base"] / "webui_state"
base_state.mkdir(parents=True)
(base_state / "last_workspace.txt").write_text(str(poisoned_ws), encoding="utf-8")
s = models.new_session(profile="default")
assert s.profile == "default"
assert str(s.workspace) == str(bound_ws.resolve()), (
f"new_session(profile='default') in isolated mode must bind the "
f"GLOBAL state authority's workspace, got {s.workspace!r}"
)
assert str(s.workspace) != str(poisoned_ws.resolve()), (
"must never leak onto the BASE home's webui_state binding"
)
def test_session_config_reads_pinned_config(
self, isolated_default_pinned, monkeypatch
):
"""Config for the resolved 'default' home comes from the PINNED home.
In isolated mode the pinned home IS the active home, so
get_config_for_profile_home takes the ambient-match branch — which is
exactly the authority we must verify: the ambient resolver (and its
HERMES_CONFIG_PATH) must be anchored at the PINNED home, never at the
base/root one. Point the authoritative config INSIDE the pinned home;
had the resolver leaked to the base home, this config would not win.
"""
from api import config as cfg_mod
from api.config import get_config_for_profile_home
env = isolated_default_pinned
monkeypatch.setenv("HERMES_CONFIG_PATH", str(env["pinned"] / "config.yaml"))
cfg_mod.reload_config()
home = workspace._resolve_profile_home_param("default")
assert home == env["pinned"].resolve()
got = get_config_for_profile_home(home)
assert got.get("workspace") == "/srv/pinned-workspace", (
"config for the resolved 'default' home must be anchored at the "
"PINNED profile's home/config, never the base root's"
)
# The BASE root has no config.yaml — a leak onto the base home would
# have produced the defaults-only dict (no 'workspace' key).
class TestRound7DefaultStateRouting:
"""Explicit and ambient "default" STATE I/O share ONE authority (round 7).
Round-6 CORE follow-up: routing the literal "default" name through the
delegated resolver made the canonical-home check in
``_profile_state_dir`` send EXPLICIT ``profile="default"`` state
reads/writes to ``{pinned}/webui_state/`` while AMBIENT calls kept using
the global state dir — splitting saved workspaces between two
authorities and hiding the pre-upgrade list. Fix under test:
literal-"default" STATE routing stays on the global files, while config
and workspace PATH resolution keep the pinned home.
"""
def _seed_global_state(self, env, monkeypatch):
"""Point both global state files at an isolated tmp dir."""
g = env["tmp"] / "global-state"
g.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(workspace, "_GLOBAL_WS_FILE", g / "workspaces.json")
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", g / "last_workspace.txt")
return g
def test_explicit_default_state_dir_is_global(self, isolated_default_pinned, monkeypatch):
env = isolated_default_pinned
g = self._seed_global_state(env, monkeypatch)
assert workspace._profile_state_dir(profile="default") == g, (
"explicit profile='default' must route STATE to the global dir "
"(same authority as ambient), not {pinned}/webui_state/"
)
assert workspace._workspaces_file("default") == g / "workspaces.json"
assert workspace._last_workspace_file("default") == g / "last_workspace.txt"
def test_explicit_and_ambient_share_workspaces_authority(
self, isolated_default_pinned, monkeypatch
):
import json
env = isolated_default_pinned
g = self._seed_global_state(env, monkeypatch)
# Pre-upgrade saved list lives ONLY in the global file.
pre_dir = env["tmp"] / "srv" / "pre-upgrade"
pre_dir.mkdir(parents=True)
pre = [{"path": str(pre_dir), "name": "Pre"}]
(g / "workspaces.json").write_text(
json.dumps(pre, ensure_ascii=False), encoding="utf-8"
)
# Explicit profile="default" reads the pre-upgrade list...
assert workspace.load_workspaces(profile="default") == pre
# ...and explicit writes land in the SAME global file.
added = env["tmp"] / "srv" / "added"
added.mkdir(parents=True)
updated = pre + [{"path": str(added), "name": "Added"}]
workspace.save_workspaces(updated, profile="default")
assert json.loads((g / "workspaces.json").read_text(encoding="utf-8")) == updated
# Ambient calls see the identical authority — no split.
assert workspace._profile_state_dir(None) == g
assert workspace.load_workspaces() == updated
# No divergent webui_state authority may appear under the pinned home.
assert not (env["pinned"] / "webui_state" / "workspaces.json").exists()
assert not (env["pinned"] / "webui_state" / "last_workspace.txt").exists()
def test_last_workspace_round_trip_shared_explicit_ambient(
self, isolated_default_pinned, monkeypatch
):
env = isolated_default_pinned
g = self._seed_global_state(env, monkeypatch)
ws = env["tmp"] / "srv" / "bound-ws"
ws.mkdir(parents=True)
workspace.set_last_workspace(str(ws), profile="default")
assert (g / "last_workspace.txt").exists(), (
"set_last_workspace('default') must write the GLOBAL file"
)
assert not (env["pinned"] / "webui_state" / "last_workspace.txt").exists()
assert workspace.get_last_workspace(profile="default") == str(ws)
# Ambient (isolated mode's active profile is literally "default")
# resolves to the same binding.
assert workspace.get_last_workspace() == str(ws)
+2 -2
View File
@@ -696,7 +696,7 @@ def test_chat_start_retags_empty_session_to_request_profile(monkeypatch, tmp_pat
"_resolve_compatible_session_model_state", "_resolve_compatible_session_model_state",
lambda model, provider, **_: (model, provider, False), lambda model, provider, **_: (model, provider, False),
) )
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) monkeypatch.setattr(routes, "create_stream_channel", lambda: object())
started_threads = [] started_threads = []
@@ -768,7 +768,7 @@ def test_chat_start_does_not_retag_non_empty_session(monkeypatch, tmp_path):
"_resolve_compatible_session_model_state", "_resolve_compatible_session_model_state",
lambda model, provider, **_: (model, provider, False), lambda model, provider, **_: (model, provider, False),
) )
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) monkeypatch.setattr(routes, "create_stream_channel", lambda: object())
class FakeThread: class FakeThread:
+2 -2
View File
@@ -1059,9 +1059,9 @@ def test_issue1734_chat_start_persists_repaired_codex_provider(monkeypatch):
"_resolve_chat_workspace_with_recovery", "_resolve_chat_workspace_with_recovery",
lambda current, _requested: current.workspace, lambda current, _requested: current.workspace,
) )
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: value) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: value)
monkeypatch.setattr(routes, "_get_session_agent_lock", lambda sid: contextlib.nullcontext()) monkeypatch.setattr(routes, "_get_session_agent_lock", lambda sid: contextlib.nullcontext())
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) monkeypatch.setattr(routes, "create_stream_channel", lambda: object())
monkeypatch.setattr(routes.threading, "Thread", FakeThread) monkeypatch.setattr(routes.threading, "Thread", FakeThread)
@@ -537,7 +537,7 @@ def test_handle_chat_sync_passes_result_turn_authority_to_settlement(tmp_path, m
monkeypatch.setattr(routes, "get_session", models.get_session) monkeypatch.setattr(routes, "get_session", models.get_session)
monkeypatch.setattr(routes, "title_from", models.title_from) monkeypatch.setattr(routes, "title_from", models.title_from)
monkeypatch.setattr(routes, "get_config", lambda: {"model": "m", "provider": "p"}) monkeypatch.setattr(routes, "get_config", lambda: {"model": "m", "provider": "p"})
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path)
monkeypatch.setattr(routes, "load_settings", lambda: {}) monkeypatch.setattr(routes, "load_settings", lambda: {})
monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: []) monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: [])
monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_k: None) monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_k: None)
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -117,7 +117,7 @@ def test_workspace_suggest_preserves_tilde_prefix(monkeypatch, tmp_path):
child = home / "Projects" child = home / "Projects"
child.mkdir(parents=True) child.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()]) monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda *a, **kw: [home.resolve()])
suggestions = workspace.list_workspace_suggestions("~/") suggestions = workspace.list_workspace_suggestions("~/")
@@ -132,7 +132,7 @@ def test_workspace_suggest_preserves_tilde_prefix_for_partial_child(monkeypatch,
child = home / "Projects" child = home / "Projects"
child.mkdir(parents=True) child.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()]) monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda *a, **kw: [home.resolve()])
suggestions = workspace.list_workspace_suggestions("~/Pro") suggestions = workspace.list_workspace_suggestions("~/Pro")
@@ -152,7 +152,7 @@ def test_workspace_suggest_expands_tilde_when_home_is_symlink(monkeypatch, tmp_p
pytest.skip(f"symlinks are not available in this environment: {exc}") pytest.skip(f"symlinks are not available in this environment: {exc}")
monkeypatch.setenv("HOME", str(link_home)) monkeypatch.setenv("HOME", str(link_home))
monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [actual_home.resolve()]) monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda *a, **kw: [actual_home.resolve()])
suggestions = workspace.list_workspace_suggestions("~/Doc") suggestions = workspace.list_workspace_suggestions("~/Doc")
@@ -166,7 +166,7 @@ def test_workspace_suggest_keeps_absolute_prefix_absolute(monkeypatch, tmp_path)
child = home / "Projects" child = home / "Projects"
child.mkdir(parents=True) child.mkdir(parents=True)
monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()]) monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda *a, **kw: [home.resolve()])
suggestions = workspace.list_workspace_suggestions(str(home) + "/Pro") suggestions = workspace.list_workspace_suggestions(str(home) + "/Pro")
+4 -4
View File
@@ -134,7 +134,7 @@ def test_chat_start_rechecks_active_stream_under_session_lock(monkeypatch, tmp_p
monkeypatch.setattr(routes, "_get_session_agent_lock", lambda sid: MutatingSessionLock()) monkeypatch.setattr(routes, "_get_session_agent_lock", lambda sid: MutatingSessionLock())
monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})()) monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})())
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue())
monkeypatch.setattr(routes.threading, "Thread", NoopThread) monkeypatch.setattr(routes.threading, "Thread", NoopThread)
@@ -199,7 +199,7 @@ def test_chat_start_blocks_same_session_active_run_after_cancel_clears_stream_id
return None return None
monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})()) monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})())
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue())
monkeypatch.setattr(routes.threading, "Thread", NoopThread) monkeypatch.setattr(routes.threading, "Thread", NoopThread)
@@ -257,7 +257,7 @@ def test_chat_start_allows_same_session_after_active_run_unregisters(monkeypatch
return None return None
monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})()) monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})())
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue())
monkeypatch.setattr(routes.threading, "Thread", NoopThread) monkeypatch.setattr(routes.threading, "Thread", NoopThread)
@@ -332,7 +332,7 @@ def test_chat_start_not_permanently_blocked_by_stale_active_run(monkeypatch, tmp
return None return None
monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})()) monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})())
monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None)
monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue())
monkeypatch.setattr(routes.threading, "Thread", NoopThread) monkeypatch.setattr(routes.threading, "Thread", NoopThread)
+2 -2
View File
@@ -108,7 +108,7 @@ def test_webhook_rows_get_webhook_project_id(monkeypatch, tmp_path):
db.write_text("", encoding="utf-8") db.write_text("", encoding="utf-8")
monkeypatch.setattr(models, "read_importable_agent_session_rows", lambda *_a, **_kw: [_agent_row()]) monkeypatch.setattr(models, "read_importable_agent_session_rows", lambda *_a, **_kw: [_agent_row()])
monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path)
monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id") monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id")
monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False) monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False)
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
@@ -137,7 +137,7 @@ def test_webhook_second_pass_keeps_older_project_rows_available(monkeypatch, tmp
return [] return []
monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows)
monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path)
monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id") monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id")
monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False) monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False)
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
+1 -1
View File
@@ -136,7 +136,7 @@ def _configure_direct_office_upload(monkeypatch, tmp_path):
monkeypatch.setattr(upload, "get_session", lambda _sid: session) monkeypatch.setattr(upload, "get_session", lambda _sid: session)
monkeypatch.setattr(upload, "_reject_invisible_session", lambda *_args: False) monkeypatch.setattr(upload, "_reject_invisible_session", lambda *_args: False)
monkeypatch.setattr(upload, "resolve_trusted_workspace", lambda path: path) monkeypatch.setattr(upload, "resolve_trusted_workspace", lambda path, **_kw: path)
return upload, office_documents, workspace return upload, office_documents, workspace