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:
1 parent
94fd2da830
commit
b71c88f292
30 files changed
+2206
-153
No files matched your search
+18
-6
@@ -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
|
||||
the path the ambient resolver would pick (the common single-profile case),
|
||||
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
|
||||
from the ambient path do we read the session profile's file directly — a
|
||||
pure read with no global cache mutation, so it is race-free across
|
||||
concurrent sessions on different profiles.
|
||||
by tests and runtime callers, and to honour an authoritative
|
||||
``HERMES_CONFIG_PATH`` override. Only when the session's profile home
|
||||
diverges from the ambient path do we read the session profile's file
|
||||
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:
|
||||
return get_config()
|
||||
@@ -724,10 +728,18 @@ def get_config_for_profile_home(profile_home: "Path | str | None") -> dict:
|
||||
target = Path(profile_home).expanduser()
|
||||
except Exception:
|
||||
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:
|
||||
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()
|
||||
except Exception:
|
||||
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
|
||||
# cfg) must still resolve through get_config(), not return {} (#4516 gate).
|
||||
try:
|
||||
if _get_config_path().parent == target:
|
||||
if _cfg_safe_resolve(_get_config_path().parent) == target:
|
||||
return get_config()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+2
-2
@@ -584,7 +584,7 @@ def _run_gateway_runs_api_streaming(
|
||||
try:
|
||||
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:
|
||||
logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
@@ -1120,7 +1120,7 @@ def _run_gateway_chat_streaming(
|
||||
try:
|
||||
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:
|
||||
logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
|
||||
+11
-6
@@ -34,7 +34,7 @@ from api.config import (
|
||||
LOCK, STREAMS, STREAMS_LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME,
|
||||
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.agent_sessions import (
|
||||
_is_continuation_session,
|
||||
@@ -1264,7 +1264,8 @@ class Session:
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
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.
|
||||
# s.workspace is updated on every turn when the user switches workspaces
|
||||
# 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
|
||||
# recorded on disk, which is the best available approximation.
|
||||
self.created_workspace = (
|
||||
str(Path(created_workspace).expanduser().resolve())
|
||||
str(_resolve_path(created_workspace, profile=profile))
|
||||
if created_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
|
||||
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
|
||||
s = Session(
|
||||
workspace=workspace_path or get_last_workspace(),
|
||||
workspace=workspace_path or get_last_workspace(profile=profile),
|
||||
model=effective_model,
|
||||
model_provider=effective_model_provider,
|
||||
profile=profile,
|
||||
@@ -5763,6 +5764,7 @@ def get_session_for_file_ops(sid: str):
|
||||
workspace, recovered = resolve_implicit_workspace_with_recovery(
|
||||
stored_workspace,
|
||||
get_last_workspace,
|
||||
profile=session_profile or None,
|
||||
)
|
||||
except ValueError:
|
||||
# Preserve the existing file-handler behavior for non-missing trust or
|
||||
@@ -6797,7 +6799,7 @@ def import_cli_session(
|
||||
s = Session(
|
||||
session_id=session_id,
|
||||
title=title,
|
||||
workspace=get_last_workspace(),
|
||||
workspace=get_last_workspace(profile=profile),
|
||||
model=model,
|
||||
messages=messages,
|
||||
profile=profile,
|
||||
@@ -7720,6 +7722,9 @@ def _load_cli_sessions_uncached(
|
||||
_cli_workspace_cache: list = [None] # list-as-cell; None = not yet resolved
|
||||
def _cli_workspace():
|
||||
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())
|
||||
return _cli_workspace_cache[0]
|
||||
|
||||
@@ -7934,7 +7939,7 @@ def _load_cli_sessions_uncached(
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': _display_title,
|
||||
'workspace': str(get_last_workspace()),
|
||||
'workspace': str(get_last_workspace(profile=_cli_profile)),
|
||||
'model': row['model'] or None,
|
||||
'message_count': row['message_count'] or row['actual_message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
|
||||
+11
-7
@@ -1711,19 +1711,22 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
default_workspace = None
|
||||
try:
|
||||
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'
|
||||
if lw_file.exists():
|
||||
_p = lw_file.read_text(encoding='utf-8').strip()
|
||||
if _p:
|
||||
_pp = Path(_p).expanduser()
|
||||
if _pp.is_dir():
|
||||
default_workspace = str(_pp.resolve())
|
||||
_pp = _resolve_path(_p, profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(_p, profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
if default_workspace is None:
|
||||
for _key in ('workspace', 'default_workspace'):
|
||||
_v = cfg.get(_key)
|
||||
if _v:
|
||||
_pp = Path(str(_v)).expanduser().resolve()
|
||||
if _pp.is_dir():
|
||||
_pp = _resolve_path(str(_v), profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(str(_v), profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
break
|
||||
if default_workspace is None:
|
||||
@@ -1731,8 +1734,9 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
if isinstance(_tc, dict):
|
||||
_cwd = _tc.get('cwd', '')
|
||||
if _cwd and str(_cwd) not in ('.', ''):
|
||||
_pp = Path(str(_cwd)).expanduser().resolve()
|
||||
if _pp.is_dir():
|
||||
_pp = _resolve_path(str(_cwd), profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(str(_cwd), profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
if default_workspace is None:
|
||||
default_workspace = str(_DW)
|
||||
|
||||
+146
-23
@@ -10544,6 +10544,7 @@ from api.workspace import (
|
||||
safe_resolve_ws,
|
||||
raw_authorized_escape_target,
|
||||
resolve_trusted_workspace,
|
||||
_resolve_path,
|
||||
resolve_implicit_workspace_with_recovery,
|
||||
open_anchored_fd,
|
||||
open_anchored_create_fd,
|
||||
@@ -14160,22 +14161,39 @@ def handle_get(handler, parsed) -> bool:
|
||||
return _handle_session_export(handler, parsed)
|
||||
|
||||
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(
|
||||
handler,
|
||||
{
|
||||
"workspaces": load_workspaces(),
|
||||
"last": get_last_workspace(),
|
||||
"workspaces": wss,
|
||||
"last": lw,
|
||||
"terminal_remote_backend": _terminal_remote_backend_enabled(),
|
||||
},
|
||||
)
|
||||
|
||||
if parsed.path == "/api/workspaces/suggest":
|
||||
from api.profiles import get_active_profile_name
|
||||
|
||||
qs = parse_qs(parsed.query)
|
||||
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(
|
||||
handler,
|
||||
{
|
||||
"suggestions": list_workspace_suggestions(prefix),
|
||||
"suggestions": suggestions,
|
||||
"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.
|
||||
# Fail open: a resolution error must never 500 this boot-critical endpoint.
|
||||
try:
|
||||
try:
|
||||
_profile_default_workspace = get_profile_default_workspace(profile=active_profile_name)
|
||||
except TypeError:
|
||||
_profile_default_workspace = get_profile_default_workspace()
|
||||
except Exception:
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
candidate = body.get("workspace")
|
||||
if not candidate:
|
||||
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 (
|
||||
body.get("workspace_inherited_from_prev_session") is not True
|
||||
or not visible_prev_session_id
|
||||
):
|
||||
return str(resolve_trusted_workspace(candidate))
|
||||
return str(_rtw(candidate))
|
||||
try:
|
||||
previous_session = get_session(visible_prev_session_id, metadata_only=True)
|
||||
except KeyError:
|
||||
return str(resolve_trusted_workspace(candidate))
|
||||
return str(_rtw(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(
|
||||
candidate,
|
||||
get_last_workspace,
|
||||
@@ -15210,7 +15246,9 @@ def handle_post(handler, parsed) -> bool:
|
||||
):
|
||||
workspace_prev_session_id = None
|
||||
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:
|
||||
return bad(handler, str(e))
|
||||
worktree_info = None
|
||||
@@ -15239,7 +15277,17 @@ def handle_post(handler, parsed) -> bool:
|
||||
from api.worktrees import create_worktree_for_workspace
|
||||
base_workspace = 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)
|
||||
workspace = worktree_info["path"]
|
||||
except (TypeError, ValueError) as e:
|
||||
@@ -15804,7 +15852,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
old_model = getattr(s, "model", None)
|
||||
old_provider = getattr(s, "model_provider", None)
|
||||
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:
|
||||
return bad(handler, str(e))
|
||||
with _get_session_agent_lock(body["session_id"]):
|
||||
@@ -15838,7 +15886,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
close_terminal(body["session_id"])
|
||||
except Exception:
|
||||
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(
|
||||
handler,
|
||||
{"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):
|
||||
return bad(handler, "Subagent sessions cannot be archived from WebUI", 400)
|
||||
if _is_messaging_session_record(cli_meta):
|
||||
_arch_profile = cli_meta.get("profile") or None
|
||||
s = Session(
|
||||
session_id=sid,
|
||||
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=[],
|
||||
model=cli_meta.get("model") or "unknown",
|
||||
created_at=cli_meta.get("created_at"),
|
||||
updated_at=cli_meta.get("updated_at"),
|
||||
profile=_arch_profile,
|
||||
)
|
||||
s.is_cli_session = is_cli_session_row(cli_meta)
|
||||
s.source_tag = cli_meta.get("source_tag")
|
||||
@@ -18019,11 +18069,22 @@ def _handle_list_dir(handler, parsed):
|
||||
except Exception:
|
||||
return bad(handler, "Session not found", 404)
|
||||
try:
|
||||
_list_profile = getattr(webui_session, "profile", None)
|
||||
if webui_session is None:
|
||||
try:
|
||||
workspace = resolve_trusted_workspace(workspace, profile=_list_profile)
|
||||
except TypeError:
|
||||
workspace = resolve_trusted_workspace(workspace)
|
||||
recovered = False
|
||||
else:
|
||||
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(
|
||||
stored_workspace,
|
||||
get_last_workspace,
|
||||
@@ -19069,7 +19130,7 @@ def _handle_terminal_start(handler, body):
|
||||
},
|
||||
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
|
||||
term = start_terminal(
|
||||
sid,
|
||||
@@ -22053,10 +22114,11 @@ def _memory_project_context_workspace(parsed) -> Path | None:
|
||||
# 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
|
||||
# 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:
|
||||
return None
|
||||
return Path(ws).expanduser().resolve()
|
||||
return _resolve_path(ws, profile=getattr(session, "profile", None))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -22064,7 +22126,7 @@ def _memory_project_context_workspace(parsed) -> Path | None:
|
||||
if not raw_workspace:
|
||||
return None
|
||||
try:
|
||||
return Path(resolve_trusted_workspace(raw_workspace)).expanduser().resolve()
|
||||
return resolve_trusted_workspace(raw_workspace)
|
||||
except Exception:
|
||||
logger.debug("Skipping project context for untrusted workspace %s", raw_workspace, exc_info=True)
|
||||
return None
|
||||
@@ -22834,7 +22896,7 @@ def _start_regeneration_stream_locked(
|
||||
save_attempted = True
|
||||
s.save()
|
||||
accepted = True
|
||||
set_last_workspace(workspace)
|
||||
set_last_workspace(workspace, profile=getattr(s, "profile", None))
|
||||
release_worker.set()
|
||||
except Exception as exc:
|
||||
abort_worker.set()
|
||||
@@ -23173,7 +23235,7 @@ def _start_chat_stream_for_session(
|
||||
except Exception:
|
||||
logger.warning("Failed to append submitted turn journal event", exc_info=True)
|
||||
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
|
||||
stream = create_stream_channel()
|
||||
register_stream_owner(stream_id, s.session_id)
|
||||
@@ -23886,7 +23948,7 @@ def _handle_goal_command(handler, body):
|
||||
previous_goal_state = None
|
||||
if will_kickoff:
|
||||
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:
|
||||
return bad(handler, str(e))
|
||||
requested_model = body.get("model") or s.model
|
||||
@@ -23955,7 +24017,7 @@ def _handle_goal_command(handler, body):
|
||||
if kickoff_prompt:
|
||||
if workspace is None:
|
||||
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:
|
||||
return bad(handler, str(e))
|
||||
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:
|
||||
"""Recover stale implicit session workspaces without hiding explicit errors."""
|
||||
_session_profile = getattr(s, "profile", None)
|
||||
explicit = requested_workspace not in (None, "")
|
||||
if explicit:
|
||||
try:
|
||||
return str(resolve_trusted_workspace(requested_workspace, profile=_session_profile))
|
||||
except TypeError:
|
||||
return str(resolve_trusted_workspace(requested_workspace))
|
||||
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(
|
||||
stored_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:
|
||||
"""Resolve regeneration's workspace without persisting before start acceptance."""
|
||||
_session_profile = getattr(s, "profile", None) or 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))
|
||||
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(
|
||||
getattr(s, "workspace", None),
|
||||
get_last_workspace,
|
||||
@@ -24407,6 +24491,9 @@ def _handle_chat_sync(handler, body):
|
||||
if not msg:
|
||||
return j(handler, {"error": "empty message"}, status=400)
|
||||
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))
|
||||
except ValueError as 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
|
||||
# still register cleanly.
|
||||
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:
|
||||
# Invalid path (e.g. embedded null byte) — fail closed with a clean 400
|
||||
# instead of letting .resolve() raise an uncaught 500.
|
||||
return bad(handler, f"Invalid path: {_sanitize_error(e)}")
|
||||
if remote_candidate is None:
|
||||
if _is_blocked_system_path(candidate):
|
||||
# Home-directory carve-out, mirroring the validators
|
||||
# (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)}")
|
||||
# Full validation (exists, is_dir) — should pass now that dir exists
|
||||
try:
|
||||
p = validate_workspace_to_add(path_str)
|
||||
p = validate_workspace_to_add(path_str, profile=active_profile)
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
try:
|
||||
wss = load_workspaces(profile=active_profile)
|
||||
except TypeError:
|
||||
wss = load_workspaces()
|
||||
if any(w["path"] == str(p) for w in wss):
|
||||
return bad(handler, "Workspace already in list")
|
||||
wss.append({"path": str(p), "name": name or p.name})
|
||||
try:
|
||||
save_workspaces(wss, profile=active_profile)
|
||||
except TypeError:
|
||||
save_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()
|
||||
if not path_str:
|
||||
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 = [w for w in wss if w["path"] != path_str]
|
||||
try:
|
||||
save_workspaces(wss, profile=active_profile)
|
||||
except TypeError:
|
||||
save_workspaces(wss)
|
||||
return j(handler, {"ok": True, "workspaces": wss})
|
||||
|
||||
@@ -25840,6 +25946,11 @@ def _handle_workspace_rename(handler, body):
|
||||
name = body.get("name", "").strip()
|
||||
if not path_str or not name:
|
||||
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()
|
||||
for w in wss:
|
||||
if w["path"] == path_str:
|
||||
@@ -25847,6 +25958,9 @@ def _handle_workspace_rename(handler, body):
|
||||
break
|
||||
else:
|
||||
return bad(handler, "Workspace not found", 404)
|
||||
try:
|
||||
save_workspaces(wss, profile=active_profile)
|
||||
except TypeError:
|
||||
save_workspaces(wss)
|
||||
return j(handler, {"ok": True, "workspaces": wss})
|
||||
|
||||
@@ -25861,6 +25975,11 @@ def _handle_workspace_reorder(handler, body):
|
||||
paths = body.get("paths", [])
|
||||
if not paths or not isinstance(paths, 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()
|
||||
by_path = {w["path"]: w for w in wss}
|
||||
# Build reordered list: given order first, then any omitted entries
|
||||
@@ -25875,6 +25994,10 @@ def _handle_workspace_reorder(handler, body):
|
||||
for w in wss:
|
||||
if w["path"] not in seen:
|
||||
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)
|
||||
return j(handler, {"ok": True, "workspaces": reordered})
|
||||
|
||||
@@ -28391,7 +28514,7 @@ def _handle_session_import_cli(handler, body):
|
||||
session_payload = {
|
||||
"session_id": sid,
|
||||
"title": title,
|
||||
"workspace": str(get_last_workspace()),
|
||||
"workspace": str(get_last_workspace(profile=profile)),
|
||||
"model": model,
|
||||
"message_count": len(msgs),
|
||||
"created_at": created_at,
|
||||
|
||||
+11
-7
@@ -2556,7 +2556,7 @@ def _aiagent_import_error_detail() -> str:
|
||||
lines.append(' Full troubleshooting: docs/troubleshooting.md ("AIAgent not available")')
|
||||
return "\n".join(lines)
|
||||
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.
|
||||
# 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"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
# land in ~/.hermes/webui/attachments/<sid>/ (outside workspace_root by
|
||||
# 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
|
||||
# session's own record cannot disagree. Guarded: a turn must not die
|
||||
# here because a workspace path is malformed.
|
||||
s = get_session(session_id)
|
||||
try:
|
||||
_turn_workspace_cwd = str(Path(workspace).expanduser().resolve())
|
||||
_turn_workspace_cwd = str(_resolve_path(workspace, profile=getattr(s, 'profile', None)))
|
||||
except Exception:
|
||||
_turn_workspace_cwd = ""
|
||||
logger.debug("per-turn workspace cwd resolve failed", exc_info=True)
|
||||
_turn_session_identity_tokens = _set_turn_session_identity(
|
||||
session_id, workspace=_turn_workspace_cwd
|
||||
)
|
||||
s = get_session(session_id)
|
||||
_turn_pending_source = getattr(s, 'pending_user_source', None) or 'webui'
|
||||
_active_turn_identity = _active_turn_authority(s, stream_id, msg_text)
|
||||
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_provider = None
|
||||
_turn_owns_persisted_model = False
|
||||
@@ -11006,7 +11006,7 @@ def _run_agent_streaming(
|
||||
_agent_msg_text = msg_text
|
||||
if _process_notifications:
|
||||
_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)
|
||||
_run_conversation_kwargs = _build_run_conversation_kwargs(
|
||||
agent.run_conversation,
|
||||
@@ -11059,6 +11059,10 @@ def _run_agent_streaming(
|
||||
active_provider=(resolved_provider or ""),
|
||||
active_model=(resolved_model 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
|
||||
_result_partial_pre_call_context = list(_previous_context_messages)
|
||||
|
||||
+6
-1
@@ -635,7 +635,12 @@ def handle_workspace_upload(handler):
|
||||
if _reject_invisible_session(handler, session):
|
||||
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)
|
||||
|
||||
# Resolve target subdirectory within workspace
|
||||
|
||||
+287
-65
@@ -12,6 +12,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
@@ -40,8 +41,14 @@ from api.subprocess_utils import windows_hide_flags
|
||||
|
||||
# ── Profile-aware path resolution ───────────────────────────────────────────
|
||||
|
||||
def _profile_state_dir() -> Path:
|
||||
"""Return the webui_state directory for the active profile.
|
||||
# Logical profile-name grammar — mirrors api.profiles._PROFILE_ID_RE. Kept as
|
||||
# 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
|
||||
HERMES_WEBUI_STATE_DIR env var for test isolation).
|
||||
@@ -49,6 +56,28 @@ def _profile_state_dir() -> Path:
|
||||
"""
|
||||
try:
|
||||
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()
|
||||
if name and name != 'default':
|
||||
d = get_active_hermes_home() / 'webui_state'
|
||||
@@ -59,14 +88,41 @@ def _profile_state_dir() -> Path:
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
|
||||
def _workspaces_file() -> Path:
|
||||
"""Return the workspaces.json path for the active profile."""
|
||||
return _profile_state_dir() / 'workspaces.json'
|
||||
def _workspaces_file(profile: str | Path | None = None) -> Path:
|
||||
"""Return the workspaces.json path for the active or given profile."""
|
||||
return _profile_state_dir(profile=profile) / 'workspaces.json'
|
||||
|
||||
|
||||
def _last_workspace_file() -> Path:
|
||||
"""Return the last_workspace.txt path for the active profile."""
|
||||
return _profile_state_dir() / 'last_workspace.txt'
|
||||
def _last_workspace_file(profile: str | Path | None = None) -> Path:
|
||||
"""Return the last_workspace.txt path for the active or given profile."""
|
||||
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:
|
||||
@@ -101,8 +157,11 @@ def _expanduser_path(path: str | Path) -> Path:
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _resolve_path(path: str | Path) -> Path:
|
||||
"""Resolve *path* after env-aware home expansion, without raising."""
|
||||
def _resolve_path(path: str | Path, profile: str | Path | None = None) -> Path:
|
||||
"""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))
|
||||
|
||||
|
||||
@@ -148,12 +207,75 @@ def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool:
|
||||
return backend not in ('', 'local')
|
||||
|
||||
|
||||
def _remote_terminal_cwd() -> str | None:
|
||||
"""Return target-side terminal cwd for remote profiles, without local stat()."""
|
||||
def _resolve_profile_home_param(profile: str | Path | None) -> Path:
|
||||
"""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:
|
||||
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):
|
||||
return None
|
||||
cwd = str(terminal_cfg.get('cwd') or '').strip()
|
||||
@@ -165,8 +287,8 @@ def _remote_terminal_cwd() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
|
||||
"""Return a non-stat'ed target-side Path when it is under terminal.cwd.
|
||||
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 for the given profile.
|
||||
|
||||
Remote workspace paths live on the target host (e.g., remote SSH/Docker
|
||||
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
|
||||
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()
|
||||
if not cwd:
|
||||
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):
|
||||
return Path(normalized_raw)
|
||||
return None
|
||||
candidate = _resolve_path(raw)
|
||||
base = _resolve_path(cwd)
|
||||
candidate = _safe_resolve(_expanduser_path(raw))
|
||||
base = _safe_resolve(_expanduser_path(cwd))
|
||||
if _is_blocked_workspace_path(candidate, raw) or _is_blocked_workspace_path(base, cwd):
|
||||
return None
|
||||
if candidate == base or _is_within(candidate, base):
|
||||
@@ -201,7 +326,7 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | 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.
|
||||
|
||||
Checks keys in priority order:
|
||||
@@ -217,8 +342,9 @@ def _profile_default_workspace() -> str:
|
||||
Falls back to the live DEFAULT_WORKSPACE from api.config.
|
||||
"""
|
||||
try:
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
from api.config import get_config_for_profile_home
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
cfg = get_config_for_profile_home(profile_home)
|
||||
terminal_cfg = cfg.get('terminal', {})
|
||||
remote_terminal = _is_remote_terminal_backend(terminal_cfg)
|
||||
# Explicit webui workspace keys first
|
||||
@@ -227,7 +353,7 @@ def _profile_default_workspace() -> str:
|
||||
if ws:
|
||||
if remote_terminal:
|
||||
return str(ws).strip()
|
||||
p = _resolve_path(str(ws))
|
||||
p = _resolve_path(str(ws), profile=profile)
|
||||
if remote_terminal or p.is_dir():
|
||||
return str(p)
|
||||
# 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 remote_terminal:
|
||||
return str(cwd).strip()
|
||||
p = _resolve_path(str(cwd))
|
||||
p = _resolve_path(str(cwd), profile=profile)
|
||||
if remote_terminal or p.is_dir():
|
||||
return str(p)
|
||||
except (ImportError, Exception):
|
||||
@@ -244,14 +370,14 @@ def _profile_default_workspace() -> str:
|
||||
try:
|
||||
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:
|
||||
return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE))
|
||||
return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE, profile=profile))
|
||||
|
||||
|
||||
# ── 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:
|
||||
- Preserve target-side remote terminal workspace paths (SSH/Docker) without
|
||||
resolving them against the local WebUI host filesystem.
|
||||
@@ -270,7 +396,7 @@ def _clean_workspace_list(workspaces: list) -> list:
|
||||
name = w.get('name', '')
|
||||
if not path:
|
||||
continue
|
||||
remote_cand = _remote_terminal_workspace_candidate(path)
|
||||
remote_cand = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if remote_cand is not None:
|
||||
p = remote_cand
|
||||
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
|
||||
try:
|
||||
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()
|
||||
p.relative_to(own_profile_dir)
|
||||
# p is under our own profile dir — keep it
|
||||
@@ -350,12 +483,12 @@ def _migrate_global_workspaces() -> list:
|
||||
return []
|
||||
|
||||
|
||||
def load_workspaces() -> list:
|
||||
ws_file = _workspaces_file()
|
||||
if ws_file.exists():
|
||||
def load_workspaces(profile: str | Path | None = None) -> list:
|
||||
ws_file = _workspaces_file_for_profile(profile)
|
||||
if ws_file is not None and ws_file.exists():
|
||||
try:
|
||||
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):
|
||||
# Persist the cleaned version so stale entries don't keep reappearing
|
||||
try:
|
||||
@@ -364,7 +497,7 @@ def load_workspaces() -> list:
|
||||
)
|
||||
except Exception:
|
||||
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:
|
||||
logger.debug("Failed to load workspaces from %s", ws_file)
|
||||
# No profile-local file yet.
|
||||
@@ -372,6 +505,10 @@ def load_workspaces() -> list:
|
||||
# For NAMED profiles: always start clean with just their own workspace.
|
||||
try:
|
||||
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)
|
||||
except ImportError:
|
||||
is_default = True
|
||||
@@ -380,16 +517,20 @@ def load_workspaces() -> list:
|
||||
if migrated:
|
||||
return migrated
|
||||
# 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:
|
||||
ws_file = _workspaces_file()
|
||||
def save_workspaces(workspaces: list, profile: str | Path | None = None) -> None:
|
||||
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.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.
|
||||
|
||||
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``
|
||||
``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()
|
||||
|
||||
def _valid(raw: str) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
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 None
|
||||
if Path(raw).is_dir():
|
||||
return raw
|
||||
return None
|
||||
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
lw_file = _last_workspace_file_for_profile(profile)
|
||||
if lw_file is not None and lw_file.exists():
|
||||
try:
|
||||
p = _valid(lw_file.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
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()
|
||||
|
||||
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
|
||||
# not accept stale server-local paths merely because they exist on
|
||||
# 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 None
|
||||
if Path(raw).is_dir():
|
||||
return raw
|
||||
return None
|
||||
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
lw_file = _last_workspace_file_for_profile(profile)
|
||||
if lw_file is not None and lw_file.exists():
|
||||
try:
|
||||
p = valid_last_workspace(lw_file.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
logger.debug("Failed to read last workspace from %s", lw_file)
|
||||
# Fallback: try global file
|
||||
if _GLOBAL_LW_FILE.exists():
|
||||
# Fallback: try global file — but ONLY for the root/default profile. A named
|
||||
# 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:
|
||||
p = valid_last_workspace(_GLOBAL_LW_FILE.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
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:
|
||||
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.write_text(str(path), encoding='utf-8')
|
||||
except Exception:
|
||||
@@ -683,7 +853,15 @@ def _is_within(path: Path, root: Path) -> bool:
|
||||
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] = []
|
||||
|
||||
def add(candidate: str | Path | None) -> None:
|
||||
@@ -702,24 +880,26 @@ def _trusted_workspace_roots() -> list[Path]:
|
||||
|
||||
add(_home_path())
|
||||
add(_BOOT_DEFAULT_WORKSPACE)
|
||||
for w in load_workspaces():
|
||||
for w in load_workspaces(profile=profile):
|
||||
add(w.get("path"))
|
||||
roots.sort(key=lambda p: len(str(p)))
|
||||
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.
|
||||
|
||||
Suggestions are limited to directories under one of:
|
||||
- Path.home()
|
||||
- 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
|
||||
UI can safely autocomplete while the user types.
|
||||
"""
|
||||
roots = _trusted_workspace_roots()
|
||||
roots = _trusted_workspace_roots(profile=profile)
|
||||
if not roots:
|
||||
return []
|
||||
|
||||
@@ -815,7 +995,7 @@ def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
|
||||
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.
|
||||
|
||||
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).
|
||||
"""
|
||||
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)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if access_error:
|
||||
# For remote terminal profiles, workspace paths belong to the target
|
||||
# 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
|
||||
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_paths = {_resolve_path(w["path"]) for w in saved if w.get("path")}
|
||||
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(
|
||||
candidate: str | Path | None,
|
||||
fallback: str | Path | None | Callable[[], str | Path | None],
|
||||
profile: str | Path | None = None,
|
||||
) -> tuple[Path, bool]:
|
||||
"""Resolve an implicit workspace, recovering only a genuinely missing path.
|
||||
|
||||
The fallback still passes through :func:`resolve_trusted_workspace`. Existing
|
||||
but untrusted, inaccessible, or non-directory candidates are not recovery
|
||||
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:
|
||||
if profile is not None:
|
||||
try:
|
||||
return resolve_trusted_workspace(candidate, profile=profile), False
|
||||
except TypeError:
|
||||
pass
|
||||
return resolve_trusted_workspace(candidate), False
|
||||
except ValueError as original_error:
|
||||
if candidate in (None, ""):
|
||||
@@ -917,8 +1109,16 @@ def resolve_implicit_workspace_with_recovery(
|
||||
# never prove target-side deletion. Config-read uncertainty also fails
|
||||
# closed by preserving the original validation error.
|
||||
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", {})
|
||||
except Exception:
|
||||
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):
|
||||
raise original_error from None
|
||||
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()
|
||||
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
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise original_error from None
|
||||
@@ -957,7 +1179,7 @@ def _strip_surrounding_quotes(path: str) -> str:
|
||||
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).
|
||||
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if access_error:
|
||||
# Remote terminal profiles validate workspace existence on the target
|
||||
# machine, not on the WebUI server. Permit target-side paths under
|
||||
|
||||
@@ -23,6 +23,10 @@ contributor guidance; it does not change runtime behavior or CI gates.
|
||||
|
||||
## 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):
|
||||
proposed consistency rules for current WebUI streaming, recovery, replay,
|
||||
model-context reconstruction, compression, UI scene/cache, and sidebar metadata
|
||||
|
||||
@@ -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.
|
||||
@@ -231,6 +231,44 @@ def _reset_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)
|
||||
def _invalidate_providers_cache():
|
||||
"""Clear the /api/providers TTL cache around every test (#6010).
|
||||
|
||||
@@ -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, "_is_hidden_empty_session", lambda _session: False)
|
||||
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(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {})
|
||||
|
||||
|
||||
@@ -197,3 +197,87 @@ class TestBootstrapStructure:
|
||||
assert "_load_repo_dotenv" in bootstrap_src, (
|
||||
"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)
|
||||
@@ -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, "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_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")))
|
||||
|
||||
response = routes._handle_session_import_cli(object(), {"session_id": sid})
|
||||
|
||||
@@ -93,7 +93,7 @@ def test_start_chat_stream_response_includes_provisional_title(tmp_path, monkeyp
|
||||
import api.routes as routes
|
||||
|
||||
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, "_run_agent_streaming", lambda *a, **k: None)
|
||||
|
||||
|
||||
@@ -397,7 +397,7 @@ def test_goal_endpoint_sets_goal_and_starts_kickoff_stream(
|
||||
|
||||
monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
||||
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",
|
||||
@@ -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.setattr(webui_goals, "GoalManager", FakeGoalManager)
|
||||
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,
|
||||
"_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(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, "get_config", lambda: {})
|
||||
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(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, "get_config", lambda: {})
|
||||
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(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, "get_config", lambda: {})
|
||||
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
|
||||
|
||||
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, "get_config", lambda: {})
|
||||
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(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, "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, "_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, "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, "ensure_cron_project", lambda **_: "cron-project-id")
|
||||
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, "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, "ensure_cron_project", lambda **_: "cron-project-id")
|
||||
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
|
||||
|
||||
@@ -22,7 +22,8 @@ class TestWorkspaceReorderEndpoint:
|
||||
{"path": "/home/user/b", "name": "Beta"},
|
||||
{"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()
|
||||
_handle_workspace_reorder(handler, {
|
||||
"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(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {"turn_id": "turn-6611"})
|
||||
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:
|
||||
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(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, "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, "_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, "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, "publish_session_list_changed", lambda *args, **kwargs: None)
|
||||
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(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json")
|
||||
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, "publish_session_list_changed", lambda *args, **kwargs: None)
|
||||
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(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json")
|
||||
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})
|
||||
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)
|
||||
@@ -696,7 +696,7 @@ def test_chat_start_retags_empty_session_to_request_profile(monkeypatch, tmp_pat
|
||||
"_resolve_compatible_session_model_state",
|
||||
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())
|
||||
|
||||
started_threads = []
|
||||
@@ -768,7 +768,7 @@ def test_chat_start_does_not_retag_non_empty_session(monkeypatch, tmp_path):
|
||||
"_resolve_compatible_session_model_state",
|
||||
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())
|
||||
|
||||
class FakeThread:
|
||||
|
||||
@@ -1059,9 +1059,9 @@ def test_issue1734_chat_start_persists_repaired_codex_provider(monkeypatch):
|
||||
"_resolve_chat_workspace_with_recovery",
|
||||
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, "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.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, "title_from", models.title_from)
|
||||
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, "_resolve_cli_toolsets", lambda: [])
|
||||
monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_k: None)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -117,7 +117,7 @@ def test_workspace_suggest_preserves_tilde_prefix(monkeypatch, tmp_path):
|
||||
child = home / "Projects"
|
||||
child.mkdir(parents=True)
|
||||
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("~/")
|
||||
|
||||
@@ -132,7 +132,7 @@ def test_workspace_suggest_preserves_tilde_prefix_for_partial_child(monkeypatch,
|
||||
child = home / "Projects"
|
||||
child.mkdir(parents=True)
|
||||
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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
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")
|
||||
|
||||
@@ -166,7 +166,7 @@ def test_workspace_suggest_keeps_absolute_prefix_absolute(monkeypatch, tmp_path)
|
||||
child = home / "Projects"
|
||||
child.mkdir(parents=True)
|
||||
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")
|
||||
|
||||
|
||||
@@ -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.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.threading, "Thread", NoopThread)
|
||||
|
||||
@@ -199,7 +199,7 @@ def test_chat_start_blocks_same_session_active_run_after_cancel_clears_stream_id
|
||||
return None
|
||||
|
||||
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.threading, "Thread", NoopThread)
|
||||
|
||||
@@ -257,7 +257,7 @@ def test_chat_start_allows_same_session_after_active_run_unregisters(monkeypatch
|
||||
return None
|
||||
|
||||
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.threading, "Thread", NoopThread)
|
||||
|
||||
@@ -332,7 +332,7 @@ def test_chat_start_not_permanently_blocked_by_stale_active_run(monkeypatch, tmp
|
||||
return None
|
||||
|
||||
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.threading, "Thread", NoopThread)
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ def test_webhook_rows_get_webhook_project_id(monkeypatch, tmp_path):
|
||||
db.write_text("", encoding="utf-8")
|
||||
|
||||
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_webhook_project", lambda: "webhook-project-id", raising=False)
|
||||
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 []
|
||||
|
||||
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_webhook_project", lambda: "webhook-project-id", raising=False)
|
||||
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
|
||||
|
||||
@@ -136,7 +136,7 @@ def _configure_direct_office_upload(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setattr(upload, "get_session", lambda _sid: session)
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user