From fbdbca332c5fd604b2786dab4ceb76bc2fdfc894 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 18 Jul 2026 14:30:39 +0800 Subject: [PATCH 01/82] Live Stream: hydrate ID-linked historical tool turns --- .../stable-assistant-turn-anchor-phase0.md | 2 +- static/assistant_turn_anchors.js | 46 ++++ static/ui.js | 187 +++++++++++++ ...sue6220_id_linked_tool_anchor_hydration.py | 259 ++++++++++++++++++ 4 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue6220_id_linked_tool_anchor_hydration.py diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index ceea2023..3984bbd5 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -310,7 +310,7 @@ must not compete with an anchor-owned turn. | --- | --- | --- | --- | | Settled Compact Worklog activity | `_renderSettledAnchorSceneForMessage()` renders `activity_scene_v1` rows before the final answer. | Legacy `S.toolCalls`, `tool_calls`, `_partial_tool_calls`, and raw `content[].tool_use` rebuilds. | `anchorOwnedAssistantRawIdxs` excludes the anchor-owned turn from message metadata scans, fallback source collection, tool buckets, thinking buckets, and worklog-source mirroring. | | Settled Transparent Stream activity | `_renderSettledAnchorSceneTransparentForMessage()` renders the same scene rows as transparent event rows or inline prose before the final answer segment. | `_transparentStreamOrderedParts()` rebuilds raw `content[]` order for historical messages without an anchor scene. | `_transparentStreamOrderedParts()` returns `null` when `message._anchor_activity_scene` exists, leaving the dedicated anchor renderer in charge. | -| Historical / non-anchor transcripts | No anchor owner is available. | Raw `content[]`, persisted `session.tool_calls`, role=`tool` rows, and partial tool-call snapshots continue to recover visible tool/prose history. | None. These paths remain until replay/runtime-journal coverage proves the same transcript shapes hydrate through anchors. | +| Historical / non-anchor transcripts | Complete, user-bounded transcripts with unique top-level `assistant.tool_calls[].id` declarations, exactly one matching role=`tool` `tool_call_id` result per declaration, and a later final assistant answer hydrate an in-memory `activity_scene_v1` through the Anchor module adapter before settled rendering. | Raw `content[]`, persisted `session.tool_calls`, role=`tool` rows, and partial tool-call snapshots continue to recover ambiguous, incomplete, or richer historical shapes. | The ID-linked shape exits fallback ownership only after strict turn-local validation; missing or duplicate IDs/results, cross-turn matches, process prose/reasoning, and other tool schemas fail closed to the legacy renderer. | | Live reattach / session switch | `renderLiveAnchorActivityScene()` consumes the projected live scene, and session switch first attempts runtime-journal anchor scene restore. | Saved live DOM snapshots and `INFLIGHT` tool replay. | Snapshot fallback only runs when no usable live anchor scene can be rendered. | This matrix is the current settled-render contract, backed by runtime-journal diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index 17c69ba0..d0794279 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -1048,6 +1048,51 @@ }); } + function projectAssistantTurnAnchorHistoricalTranscriptScene(input, options){ + const item=(input&&typeof input==='object')?input:{}; + const sessionId=_cleanString(_own(item,'session_id')); + const turnId=_cleanString(_own(item,'turn_id')); + const localId=_cleanString(_own(item,'local_id')); + const activityEvents=Array.isArray(_own(item,'activity_events'))?_own(item,'activity_events'):[]; + const settledMessage=_own(item,'settled_message'); + if(!sessionId||!turnId||!localId||!activityEvents.length + ||!settledMessage||typeof settledMessage!=='object') return null; + const runId=_cleanString(_own(item,'run_id'))||null; + const streamId=_cleanString(_own(item,'stream_id'))||null; + const sourceMessageRefs=Array.isArray(_own(item,'source_message_refs')) + ?_own(item,'source_message_refs').map(_cleanString).filter(Boolean) + :[]; + const registry=createAssistantTurnAnchorRegistry({ + session_id:sessionId, + turn_id:turnId, + run_id:runId, + stream_id:streamId, + local_id:localId, + source_message_refs:sourceMessageRefs, + }); + const context={ + session_id:sessionId, + turn_id:turnId, + run_id:runId, + stream_id:streamId, + }; + for(const event of activityEvents){ + const result=applyAssistantTurnAnchorSourceEvent(registry,event,context); + if(!result.applied||result.normalized.classification!=='activity') return null; + } + const settledPayload={...settledMessage,role:_cleanString(_own(settledMessage,'role'))||'assistant'}; + const settled=applyAssistantTurnAnchorSourceEvent(registry,{ + source_type:'settled_message', + seq:activityEvents.length+1, + local_id:localId, + payload:settledPayload, + },context); + if(!settled.applied) return null; + const opts=(options&&typeof options==='object')?options:{}; + const requestedMode=_cleanString(_own(opts,'mode'))||_cleanString(_own(item,'mode')); + return projectAssistantTurnAnchorActivityScene(registry,{mode:requestedMode}); + } + const ACTIVITY_RECONCILIATION_DEFAULT_FIELDS=Object.freeze([ 'kind', 'role', @@ -1648,6 +1693,7 @@ createAssistantTurnAnchorShadowSnapshot, projectAssistantTurnAnchorSettledMessageFinalAnswer, projectAssistantTurnAnchorActivityScene, + projectAssistantTurnAnchorHistoricalTranscriptScene, reconcileAssistantTurnAnchorActivityScene, createAssistantTurnAnchorRendererSnapshot, reconcileAssistantTurnAnchorRendererSnapshot, diff --git a/static/ui.js b/static/ui.js index 37c49249..6d93dd52 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14560,6 +14560,189 @@ function _toolArgsSnapshot(args, limit){ return out; } +function _idLinkedHistoricalMessageText(message){ + if(!message||typeof message!=='object') return ''; + const content=message.content; + if(typeof content==='string') return content.trim(); + if(!Array.isArray(content)) return ''; + return content.map(part=>{ + if(typeof part==='string') return part; + if(!part||typeof part!=='object') return ''; + if(part.type!=='text'&&part.type!=='input_text'&&part.type!=='output_text') return ''; + return String(part.text||part.content||''); + }).join('').trim(); +} + +function _idLinkedHistoricalMessageRef(message, rawIdx){ + if(message&&typeof message==='object'){ + for(const key of ['message_id','id','local_id']){ + const value=message[key]; + if(typeof value==='string'&&value.trim()) return value.trim(); + if(typeof value==='number'&&Number.isFinite(value)) return String(value); + } + } + return `raw_idx:${rawIdx}`; +} + +function _idLinkedHistoricalToolArguments(toolCall){ + if(!toolCall||typeof toolCall!=='object') return null; + const fn=toolCall.function; + if(!fn||typeof fn!=='object'||Array.isArray(fn)) return null; + const raw=fn.arguments; + if(raw===undefined||raw===null||raw==='') return {}; + if(raw&&typeof raw==='object'&&!Array.isArray(raw)) return raw; + if(typeof raw!=='string') return null; + try{ + const parsed=JSON.parse(raw); + return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?parsed:null; + }catch(e){ + return null; + } +} + +function _idLinkedHistoricalToolResultRaw(message){ + if(!message||typeof message!=='object') return ''; + const content=message.content; + if(typeof content==='string') return content; + if(content===undefined||content===null) return ''; + try{return JSON.stringify(content);} + catch(e){return String(content||'');} +} + +// Claim legacy settled ownership only when the transcript itself proves a +// complete, user-bounded declaration/result/final-answer chain. +function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ + const list=Array.isArray(messages)?messages:[]; + const start=Math.max(0,Number(turnStart)||0); + const end=Math.min(list.length,Math.max(start,Number(turnEnd)||0)); + const opts=options&&typeof options==='object'?options:{}; + const sessionId=String(opts.sessionId||opts.session_id||'').trim(); + const api=(typeof window!=='undefined')?window.HermesAssistantTurnAnchors:null; + if(!sessionId||!api||typeof api.projectAssistantTurnAnchorHistoricalTranscriptScene!=='function') return null; + + const declarations=[]; + const declarationIds=new Set(); + const declarationRefs=[]; + const visibleAssistantIndexes=[]; + const assistantIndexes=[]; + const resultsById=new Map(); + for(let rawIdx=start;rawIdxpart&&typeof part==='object'&&part.type==='tool_use')) return null; + const toolCalls=Array.isArray(message.tool_calls)?message.tool_calls:[]; + if(toolCalls.length&&visibleText) return null; + if(!toolCalls.length) continue; + const messageRef=_idLinkedHistoricalMessageRef(message,rawIdx); + if(!declarationRefs.includes(messageRef)) declarationRefs.push(messageRef); + for(const toolCall of toolCalls){ + const callId=String(toolCall&&toolCall.id||'').trim(); + const fn=toolCall&&toolCall.function; + const name=String(fn&&fn.name||'').trim(); + const args=_idLinkedHistoricalToolArguments(toolCall); + if(!callId||!name||args===null||declarationIds.has(callId)) return null; + declarationIds.add(callId); + declarations.push({callId,name,args,rawIdx,messageRef}); + } + continue; + } + if(message.role!=='tool') continue; + const callId=String(message.tool_call_id||'').trim(); + if(!callId||!declarationIds.has(callId)) return null; + const matches=resultsById.get(callId)||[]; + matches.push({message,rawIdx}); + resultsById.set(callId,matches); + } + + if(!declarations.length||visibleAssistantIndexes.length!==1) return null; + const ownerIndex=visibleAssistantIndexes[0]; + if(ownerIndex!==assistantIndexes[assistantIndexes.length-1]) return null; + const owner=list[ownerIndex]; + if(Array.isArray(owner.tool_calls)&&owner.tool_calls.length) return null; + const ownerRef=_idLinkedHistoricalMessageRef(owner,ownerIndex); + for(const declaration of declarations){ + const matches=resultsById.get(declaration.callId)||[]; + if(matches.length!==1||matches[0].rawIdx<=declaration.rawIdx||matches[0].rawIdx>=ownerIndex) return null; + } + if(resultsById.size!==declarations.length) return null; + + const sourceRefs=declarationRefs.concat(ownerRef).filter((value,index,array)=>array.indexOf(value)===index); + const turnId=['historical',sessionId,declarationRefs[0],ownerRef].join(':'); + const activityEvents=[]; + for(let index=0;index{ + if(turnStart<0||turnEnd<=turnStart+1) return; + const hydratedTurn=_idLinkedHistoricalTurnScene(list,turnStart,turnEnd,options); + if(!hydratedTurn) return; + const owner=list[hydratedTurn.ownerIndex]; + try{owner._anchor_activity_scene=hydratedTurn.scene;} + catch(e){return;} + if(owner._anchor_activity_scene===hydratedTurn.scene) hydrated+=1; + }; + for(let rawIdx=0;rawIdx +``role=tool.tool_call_id`` turn may move from the legacy settled renderer into +an Assistant Turn Anchor scene. Ambiguous or richer historical shapes stay on +the compatibility path. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +ANCHOR_JS = ROOT / "static" / "assistant_turn_anchors.js" +NODE = shutil.which("node") + + +_EXTRACT_FUNC_JS = r""" +function extractFunc(name){ + const start = src.indexOf('function ' + name); + if(start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for(let i=params; i dict: + assert NODE, "node is required for Anchor hydration behavior tests" + script = f""" +const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'ui.js'))}, 'utf8'); +{_EXTRACT_FUNC_JS} + +const sandbox = {{console}}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +vm.createContext(sandbox); +vm.runInContext( + fs.readFileSync({json.dumps(str(ANCHOR_JS))}, 'utf8'), + sandbox, + {{filename:'assistant_turn_anchors.js'}} +); +sandbox._redactToolTargetLabel = value => String(value); + +for(const name of [ + '_clipCliToolSnippet', + '_cliToolResultText', + '_cliLooksLikePatchDiff', + '_cliToolResultSnippet', + '_prefixedCliDiffLines', + '_firstOwnedValue', + '_cliPatchSnippetFromArgs', + '_cliToolCardSnippet', + '_toolArgsSnapshot', + '_assistantReasoningPayloadText', + '_idLinkedHistoricalMessageText', + '_idLinkedHistoricalMessageRef', + '_idLinkedHistoricalToolArguments', + '_idLinkedHistoricalToolResultRaw', + '_idLinkedHistoricalTurnScene', + '_hydrateIdLinkedHistoricalToolScenes', +]){{ + vm.runInContext(extractFunc(name), sandbox, {{filename:'ui.js:' + name}}); +}} + +const messages = {json.dumps(messages)}; +sandbox.messages = messages; +const first = vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(messages, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox +); +const afterFirst = JSON.stringify(messages); +const second = {str(repeat).lower()} + ? vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(messages, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox + ) + : null; +const owners = messages + .map((message, index) => message && message._anchor_activity_scene ? {{index, scene:message._anchor_activity_scene}} : null) + .filter(Boolean); +console.log(JSON.stringify({{first, second, unchanged:afterFirst===JSON.stringify(messages), owners}})); +""" + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _linked_turn(call_id: str = "call-1") -> list[dict]: + return [ + {"role": "user", "content": "Inspect the workspace"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": "terminal", + "arguments": '{"command":"pwd"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": call_id, + "content": '{"output":"/workspace"}', + }, + {"role": "assistant", "content": "The workspace is `/workspace`."}, + ] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_tool_turn_hydrates_one_anchor_scene_idempotently(): + data = _run_hydration(_linked_turn(), repeat=True) + + assert data["first"] == 1 + assert data["second"] == 0 + assert data["unchanged"] is True + assert [owner["index"] for owner in data["owners"]] == [3] + + scene = data["owners"][0]["scene"] + assert scene["version"] == "activity_scene_v1" + assert scene["final_answer"] == "The workspace is `/workspace`." + assert scene["final_message_ref"] == "raw_idx:3" + assert scene["identity"]["session_id"] == "sid-6220" + assert scene["identity"]["source_message_refs"] == ["raw_idx:1", "raw_idx:3"] + assert len(scene["activity_rows"]) == 1 + + row = scene["activity_rows"][0] + assert row["role"] == "tool" + assert row["source_event_type"] == "tool_complete" + assert row["tool_call_id"] == "call-1" + assert row["tool"]["name"] == "terminal" + assert row["tool"]["args"] == {"command": "pwd"} + assert row["tool"]["snippet"] == "/workspace" + assert row["tool"]["done"] is True + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +@pytest.mark.parametrize( + "messages", + [ + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call-1", "function": {"name": "one", "arguments": "{}"}}, + {"id": "call-1", "function": {"name": "two", "arguments": "{}"}}, + ]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "first"}, + {"role": "tool", "tool_call_id": "call-1", "content": "second"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": "I will inspect it first.", "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "reasoning": "Need to inspect it.", "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "user", "content": "What happened?"}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "tool-1", "name": "terminal", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": "ok"}]}, + {"role": "assistant", "content": "Done"}, + ], + ], +) +def test_ambiguous_or_richer_historical_tool_shapes_keep_legacy_ownership(messages): + data = _run_hydration(messages) + + assert data["first"] == 0 + assert data["owners"] == [] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_reused_ids_in_adjacent_complete_turns_remain_turn_scoped(): + data = _run_hydration(_linked_turn("call-reused") + _linked_turn("call-reused")) + + assert data["first"] == 2 + assert [owner["index"] for owner in data["owners"]] == [3, 7] + assert [owner["scene"]["activity_rows"][0]["tool_call_id"] for owner in data["owners"]] == ["call-reused", "call-reused"] + assert data["owners"][0]["scene"]["identity"]["turn_id"] != data["owners"][1]["scene"]["identity"]["turn_id"] + + +def test_render_messages_hydrates_before_visibility_and_fallback_ownership_scan(): + start = UI_JS.index("function renderMessages(options)") + body = UI_JS[start : UI_JS.index("\nfunction ", start + 1)] + + hydrate = "_hydrateIdLinkedHistoricalToolScenes(S.messages" + visible = "const visWithIdx=_getVisibleMessagesWithIdx();" + fallback = "const anchorOwnedAssistantRawIdxs=new Set();" + + assert hydrate in body + assert body.index(hydrate) < body.index(visible) < body.index(fallback) + assert "if(!S.busy" in body[: body.index(visible)] From 45eff68955d84e43a44de5aa16800f8fba0fa9a2 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 18 Jul 2026 17:32:51 +0800 Subject: [PATCH 02/82] test: cover ordered multi-tool anchor hydration --- ...sue6220_id_linked_tool_anchor_hydration.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/test_issue6220_id_linked_tool_anchor_hydration.py b/tests/test_issue6220_id_linked_tool_anchor_hydration.py index 45e5c18b..2b32510b 100644 --- a/tests/test_issue6220_id_linked_tool_anchor_hydration.py +++ b/tests/test_issue6220_id_linked_tool_anchor_hydration.py @@ -171,6 +171,47 @@ def test_id_linked_historical_tool_turn_hydrates_one_anchor_scene_idempotently() assert row["tool"]["done"] is True +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_multi_tool_turn_preserves_declaration_order(): + messages = [ + {"role": "user", "content": "Inspect both files"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-first", + "type": "function", + "function": { + "name": "terminal", + "arguments": '{"command":"pwd"}', + }, + }, + { + "id": "call-second", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path":"README.md"}', + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call-second", "content": "readme body"}, + {"role": "tool", "tool_call_id": "call-first", "content": "/workspace"}, + {"role": "assistant", "content": "Both checks completed."}, + ] + + data = _run_hydration(messages, repeat=True) + + assert data["first"] == 1 + assert data["second"] == 0 + rows = data["owners"][0]["scene"]["activity_rows"] + assert [row["tool_call_id"] for row in rows] == ["call-first", "call-second"] + assert [row["tool"]["name"] for row in rows] == ["terminal", "read_file"] + assert [row["tool"]["snippet"] for row in rows] == ["/workspace", "readme body"] + + @pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") @pytest.mark.parametrize( "messages", @@ -227,6 +268,23 @@ def test_id_linked_historical_tool_turn_hydrates_one_anchor_scene_idempotently() {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": "ok"}]}, {"role": "assistant", "content": "Done"}, ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{not-json"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}], + "_partial_tool_calls": [{"name": "terminal"}], + }, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], ], ) def test_ambiguous_or_richer_historical_tool_shapes_keep_legacy_ownership(messages): From 828b244031a5552ad13ada0c835957b9b54c90a6 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:35:04 +0200 Subject: [PATCH 03/82] Atomic config.yaml writes to survive mid-write crashes config.yaml and profile config.yaml were persisted with a plain Path.write_text(), which truncates the target before writing. A crash (or exception) after the truncate but before the full payload was flushed left the live config truncated/corrupt, so the next agent/WebUI start failed to parse it (availability regression). Extract a shared api.paths._atomic_write_text() helper (tempfile in the same dir -> write -> flush + os.fsync -> os.replace; unlink tmp on error), mirroring the existing .env / cost-snapshot atomic pattern in api.providers, and apply it to _save_yaml_config_file (api.config) and the two profile model-config writers (api.profiles). On any mid-write failure os.replace never runs, so the original file stays byte-for-byte intact. Preserve the target's permissions: tempfile.mkstemp() hard-codes 0600 and os.replace carries the temp file's mode onto the target, so without an explicit chmod every save would silently tighten a group/other- readable config.yaml (the homelab install ships 0644, profiles 0664) down to owner-only. Copy the existing file's mode before the replace, falling back to the umask-adjusted 0666 for a new file. config.yaml holds no secrets, so that tightening would be a regression, not hardening (unlike .env, which stays 0600 in api.providers). settings.json is intentionally left untouched here. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/config.py | 3 +- api/paths.py | 165 +++++++ api/profiles.py | 5 +- tests/test_5774b_atomic_config_writes.py | 537 +++++++++++++++++++++++ 4 files changed, 707 insertions(+), 3 deletions(-) create mode 100644 tests/test_5774b_atomic_config_writes.py diff --git a/api/config.py b/api/config.py index 79a47994..c2e19bf8 100644 --- a/api/config.py +++ b/api/config.py @@ -761,7 +761,8 @@ def _save_yaml_config_file(config_path: Path, config_data: dict) -> None: raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text( + _paths._atomic_write_text( + config_path, _yaml.safe_dump(_config_for_yaml_save(config_data), sort_keys=False, allow_unicode=True), encoding="utf-8", ) diff --git a/api/paths.py b/api/paths.py index ea00f848..2fad3194 100644 --- a/api/paths.py +++ b/api/paths.py @@ -5,12 +5,177 @@ that need the default Hermes home can import them without triggering config's larger startup side effects. """ +import errno import os +import stat +import tempfile from pathlib import Path HOME = Path.home() +def _probe_umask() -> int: + """Return the current process umask. + + ``os.umask`` has no read-only form: it always sets and returns the previous + value, so we set-then-restore. This is a process-wide syscall, so the + two-call dance is unsafe once request threads are running (another thread + creating a file in the tiny window would see umask 0). We therefore call this + exactly once, at import, while the module is still single-threaded, and cache + the derived default below. + """ + umask = os.umask(0) + os.umask(umask) + return umask + + +# umask-adjusted 0666 — the mode a plain ``open(..., "w")`` would produce for a +# brand-new file. Computed once at import (single-threaded) to avoid probing the +# process-wide umask on every new-file write; see ``_probe_umask``. +_NEW_FILE_MODE = 0o666 & ~_probe_umask() + + +def _fsync_directory(directory: Path) -> None: + """Persist a completed rename on filesystems that support directory fsync.""" + if os.name == "nt": + return + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + try: + fd = os.open(directory, flags) + except PermissionError: + return + try: + try: + os.fsync(fd) + except OSError as exc: + if exc.errno not in {errno.EINVAL, errno.ENOTSUP}: + raise + finally: + os.close(fd) + + +def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None: + """Atomically replace *path* with *text*. + + Writes to a temp file in the same directory, flushes + ``os.fsync``, then + ``os.replace``s it into place, so a crash (or exception) mid-write can never + truncate an existing file — the old contents stay intact until the rename + commits the new ones in one step. Mirrors the tempfile+fsync+os.replace + pattern already used for ``.env`` and cost snapshots in ``api.providers``; + extracted here so ``config.yaml`` / profile ``config.yaml`` writers can share + it without pulling in env-file-specific logic. + + Permissions and ownership are preserved: ``os.replace`` carries the temp + file's metadata onto the target, and ``tempfile.mkstemp`` starts with the + writer's owner/group and mode ``0600``. Without correcting those values, + every rewrite could silently reset a shared config's group and tighten a + group/other-readable ``config.yaml`` (commonly ``0644``/``0664``) down to + owner-only. Existing files copy uid/gid and mode onto the temp descriptor; + new files use ``_NEW_FILE_MODE`` (the umask-adjusted ``0666`` a normal + ``open(..., "w")`` would have produced). + (Unlike ``.env``, ``config.yaml`` holds no secrets and is not meant to be + forced to ``0600``.) + + Symlinks keep the same follow-through semantics as ``Path.write_text``: + writing ``config.yaml`` through a symlink updates the referent instead of + replacing the link itself with a regular file. A target change observed + before commit is rejected rather than updating a stale referent. + + The temp+rename dance needs WRITE PERMISSION ON THE DIRECTORY, which the + plain ``Path.write_text`` it replaced did not: hardened deployments + (``HERMES_CONFIG_PATH`` pointing into a root-owned, read-only dir with a + writable ``config.yaml`` inside) would otherwise lose the ability to save + settings at all. When temp creation is denied, or the original uid/gid + cannot be transferred to the temp inode, we fall back to a guarded + descriptor write only if the target is still the regular-file inode + inspected above. This gives up crash-atomicity only where atomic replace + with preserved metadata is impossible. Other ``PermissionError``s still + propagate. Successful renames fsync the parent directory where supported. + + The caller is responsible for ensuring ``path.parent`` exists. + """ + path = Path(path) + symlink_target = path.resolve(strict=False) if path.is_symlink() else None + write_path = symlink_target or path + try: + existing_stat = os.stat(write_path) + except FileNotFoundError: + existing_stat = None + mode = stat.S_IMODE(existing_stat.st_mode) if existing_stat else _NEW_FILE_MODE + + def _verify_symlink_target() -> None: + if symlink_target is not None and path.resolve(strict=False) != symlink_target: + raise RuntimeError("config symlink target changed during atomic write") + + def _write_in_place() -> None: + if existing_stat is None or not stat.S_ISREG(existing_stat.st_mode): + raise PermissionError(f"Cannot replace config path: {write_path}") + _verify_symlink_target() + fallback_fd = os.open(write_path, os.O_WRONLY) + owns_fallback_fd = True + try: + opened_stat = os.fstat(fallback_fd) + if (opened_stat.st_dev, opened_stat.st_ino) != ( + existing_stat.st_dev, + existing_stat.st_ino, + ): + raise PermissionError("config target changed before fallback write") + _verify_symlink_target() + os.ftruncate(fallback_fd, 0) + fallback_file = os.fdopen(fallback_fd, "w", encoding=encoding) + owns_fallback_fd = False + with fallback_file: + fallback_file.write(text) + fallback_file.flush() + os.fsync(fallback_file.fileno()) + finally: + if owns_fallback_fd: + os.close(fallback_fd) + + try: + fd, tmp = tempfile.mkstemp( + dir=str(write_path.parent), prefix=f".{write_path.name}_", suffix=".tmp" + ) + except PermissionError: + _write_in_place() + return + owns_fd = True + try: + if existing_stat is not None and hasattr(os, "fchown"): + try: + os.fchown(fd, existing_stat.st_uid, existing_stat.st_gid) + except (PermissionError, NotImplementedError): + os.close(fd) + owns_fd = False + os.unlink(tmp) + _write_in_place() + return + if hasattr(os, "fchmod"): + os.fchmod(fd, mode) + else: + os.chmod(tmp, mode) + f = os.fdopen(fd, "w", encoding=encoding) + owns_fd = False + with f: + f.write(text) + f.flush() + os.fsync(f.fileno()) + _verify_symlink_target() + os.replace(tmp, write_path) + _fsync_directory(write_path.parent) + except BaseException: + if owns_fd: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(tmp) + except OSError: + pass + raise + + def _hermes_home_has_webui_state(base: Path) -> bool: """Return True when *base* holds real WebUI state under its ``webui/`` dir. diff --git a/api/profiles.py b/api/profiles.py index 7f047673..fcc79b0c 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -21,6 +21,7 @@ from typing import Optional import yaml +from api.paths import _atomic_write_text from api.session_events import publish_session_list_changed logger = logging.getLogger(__name__) @@ -2262,7 +2263,7 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: if base_url: model_section['base_url'] = base_url cfg['model'] = model_section - config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8') + _atomic_write_text(config_path, _yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8') def _clean_profile_config_value(value: Optional[str], field: str) -> Optional[str]: @@ -2401,7 +2402,7 @@ def _write_model_defaults_to_config( if model_provider: model_section['provider'] = model_provider cfg['model'] = model_section - config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8') + _atomic_write_text(config_path, _yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8') def create_profile_api(name: str, clone_from: str = None, diff --git a/tests/test_5774b_atomic_config_writes.py b/tests/test_5774b_atomic_config_writes.py new file mode 100644 index 00000000..30755700 --- /dev/null +++ b/tests/test_5774b_atomic_config_writes.py @@ -0,0 +1,537 @@ +"""Regression tests for atomic config.yaml / profile config.yaml writes. + +Pre-fix behaviour: ``_save_yaml_config_file`` (api.config) and the profile +model-config writers (api.profiles) persisted YAML via a plain +``Path.write_text``. A crash — or any exception — after ``open(..., "w")`` +truncated the target file but before the full payload was flushed left the +live ``config.yaml`` truncated / corrupt, so the next agent or WebUI start +would fail to parse it (an availability regression, not a disclosure one). + +Fix: a shared ``api.paths._atomic_write_text`` helper writes to a temp file in +the same directory, ``fsync``s, then ``os.replace``s it into place. Because +``os.replace`` is atomic, a failure at any point before the rename commits +leaves the ORIGINAL file byte-for-byte intact, and a success swaps in the new +contents in a single step. + +These tests pin the helper and all three config.yaml callers across success, +fault, metadata, symlink, permission, and concurrency paths so a future +refactor cannot silently reintroduce the truncating plain-write. +""" + +import errno +import os +import stat +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +from api.paths import _atomic_write_text + + +def test_atomic_write_replaces_contents(tmp_path: Path) -> None: + target = tmp_path / "config.yaml" + target.write_text("model:\n default: old\n", encoding="utf-8") + + _atomic_write_text(target, "model:\n default: new\n") + + assert target.read_text(encoding="utf-8") == "model:\n default: new\n" + # No temp files left lying around after a clean write. + assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"] + + +def test_atomic_write_creates_new_file(tmp_path: Path) -> None: + target = tmp_path / "config.yaml" + assert not target.exists() + + _atomic_write_text(target, "created: true\n") + + assert target.read_text(encoding="utf-8") == "created: true\n" + + +def test_atomic_write_preserves_existing_permissions(tmp_path: Path) -> None: + """Rewriting a 0644/0664 config.yaml must not tighten it to 0600. + + ``tempfile.mkstemp`` hard-codes 0600 and ``os.replace`` carries the temp + file's mode onto the target, so without an explicit chmod every save would + silently strip group/other read from a world-readable ``config.yaml`` (the + live homelab install ships 0644, profiles 0664). config.yaml holds no + secrets, so that tightening is a real regression, not a hardening. + """ + target = tmp_path / "config.yaml" + target.write_text("model:\n default: old\n", encoding="utf-8") + os.chmod(target, 0o644) + + _atomic_write_text(target, "model:\n default: new\n") + + assert target.read_text(encoding="utf-8") == "model:\n default: new\n" + assert (os.stat(target).st_mode & 0o777) == 0o644 + + # A 0664 (group-writable profile config) survives its mode too. + os.chmod(target, 0o664) + _atomic_write_text(target, "model:\n default: newer\n") + assert (os.stat(target).st_mode & 0o777) == 0o664 + + # Preserve special permission bits too; replacing the inode must not + # silently discard an administrator's setgid policy on a shared config. + os.chmod(target, 0o2664) + _atomic_write_text(target, "model:\n default: newest\n") + assert stat.S_IMODE(os.stat(target).st_mode) == 0o2664 + + +@pytest.mark.skipif(not hasattr(os, "chown"), reason="POSIX ownership semantics") +def test_atomic_write_preserves_existing_group(tmp_path: Path) -> None: + """Replacing the inode must not silently reset an existing config's group.""" + supplementary_groups = [gid for gid in os.getgroups() if gid != os.getegid()] + if not supplementary_groups: + pytest.skip("requires a supplementary group distinct from the effective gid") + + target = tmp_path / "config.yaml" + target.write_text("model:\n default: old\n", encoding="utf-8") + expected_gid = supplementary_groups[0] + os.chown(target, -1, expected_gid) + + _atomic_write_text(target, "model:\n default: new\n") + + assert os.stat(target).st_gid == expected_gid + + +def test_atomic_write_fsyncs_file_and_parent_directory( + tmp_path: Path, monkeypatch +) -> None: + """Durability requires syncing both payload bytes and the committed rename.""" + target = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + synced_types: list[str] = [] + real_fsync = os.fsync + + def _recording_fsync(fd: int) -> None: + mode = os.fstat(fd).st_mode + synced_types.append("directory" if stat.S_ISDIR(mode) else "file") + real_fsync(fd) + + monkeypatch.setattr(os, "fsync", _recording_fsync) + + _atomic_write_text(target, "new: true\n") + + assert synced_types == ["file", "directory"] + + +def test_fdopen_failure_closes_temp_descriptor(tmp_path: Path, monkeypatch) -> None: + """A wrapper-construction failure must not leak the mkstemp descriptor.""" + target = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + captured_fd: list[int] = [] + real_mkstemp = __import__("tempfile").mkstemp + + def _capturing_mkstemp(*args, **kwargs): + fd, name = real_mkstemp(*args, **kwargs) + captured_fd.append(fd) + return fd, name + + def _failing_fdopen(*_args, **_kwargs): + raise OSError("simulated fdopen failure") + + from api import paths + + monkeypatch.setattr(paths.tempfile, "mkstemp", _capturing_mkstemp) + monkeypatch.setattr(os, "fdopen", _failing_fdopen) + + with pytest.raises(OSError, match="simulated fdopen failure"): + _atomic_write_text(target, "new: true\n") + + assert len(captured_fd) == 1 + with pytest.raises(OSError) as exc_info: + os.fstat(captured_fd[0]) + assert exc_info.value.errno == errno.EBADF + assert target.read_text(encoding="utf-8") == "old: true\n" + + +def test_concurrent_writers_expose_only_complete_versions(tmp_path: Path) -> None: + """Concurrent saves may be last-writer-wins, but never partial or mixed.""" + target = tmp_path / "config.yaml" + original = b"model:\n default: original\n" + payloads = [ + f"model:\n default: writer-{index}\n padding: {'x' * 200_000}\n".encode() + for index in range(8) + ] + target.write_bytes(original) + valid_versions = {original, *payloads} + invalid_versions: list[bytes] = [] + reading = threading.Event() + reading.set() + + def _observe() -> None: + while reading.is_set(): + observed = target.read_bytes() + if observed not in valid_versions: + invalid_versions.append(observed) + return + time.sleep(0.001) + + observer = threading.Thread(target=_observe) + observer.start() + try: + with ThreadPoolExecutor(max_workers=len(payloads)) as pool: + list(pool.map(lambda payload: _atomic_write_text(target, payload.decode()), payloads)) + finally: + reading.clear() + observer.join() + + assert invalid_versions == [] + assert target.read_bytes() in payloads + assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"] + + +def test_new_file_uses_cached_umask_mode(tmp_path: Path, monkeypatch) -> None: + """A freshly created file uses _NEW_FILE_MODE (umask-adjusted 0666), not 0600. + + The mode is cached at import (the process umask is read once while the module + is single-threaded), so this pins the cached value rather than probing umask + per call. 0o644 = 0o666 & ~0o022, the common server umask. + """ + from api import paths + + monkeypatch.setattr(paths, "_NEW_FILE_MODE", 0o644) + target = tmp_path / "config.yaml" + assert not target.exists() + + _atomic_write_text(target, "created: true\n") + + assert target.exists() + assert (os.stat(target).st_mode & 0o777) == 0o644 + + +def test_probe_umask_is_read_once_at_import() -> None: + """_NEW_FILE_MODE is a plausible umask-derived file mode, cached at import.""" + from api import paths + + # A file mode: no execute/setuid bits beyond rw for the three classes, and + # never more permissive than 0o666 (umask only ever clears bits). + assert 0 <= paths._NEW_FILE_MODE <= 0o666 + assert paths._NEW_FILE_MODE & 0o111 == 0 # no execute bits on a data file + + +def test_atomic_write_follows_config_symlink(tmp_path: Path) -> None: + """Writing through a config.yaml symlink updates the target, not the link. + + ``Path.write_text`` follows symlinks. The atomic rewrite must preserve that + contract because ``HERMES_CONFIG_PATH`` and profile config paths may point at + a shared config via symlink; replacing the symlink itself would silently + sever the user's chosen config location. + """ + target_dir = tmp_path / "target" + link_dir = tmp_path / "link" + target_dir.mkdir() + link_dir.mkdir() + target = target_dir / "config.yaml" + link = link_dir / "config.yaml" + target.write_text("model:\n default: old\n", encoding="utf-8") + os.chmod(target, 0o644) + link.symlink_to(target) + + _atomic_write_text(link, "model:\n default: new\n") + + assert link.is_symlink() + assert os.readlink(link) == str(target) + assert target.read_text(encoding="utf-8") == "model:\n default: new\n" + assert link.read_text(encoding="utf-8") == "model:\n default: new\n" + assert (os.stat(target).st_mode & 0o777) == 0o644 + assert [p.name for p in link_dir.iterdir()] == ["config.yaml"] + + +def test_symlink_retarget_during_write_aborts_without_touching_either_target( + tmp_path: Path, monkeypatch +) -> None: + """A concurrent symlink retarget must not commit to the stale referent.""" + old_target = tmp_path / "old.yaml" + new_target = tmp_path / "new.yaml" + link = tmp_path / "config.yaml" + old_target.write_text("old-target: true\n", encoding="utf-8") + new_target.write_text("new-target: true\n", encoding="utf-8") + link.symlink_to(old_target) + real_fsync = os.fsync + retargeted = False + + def _retarget_after_payload_sync(fd: int) -> None: + nonlocal retargeted + real_fsync(fd) + if not retargeted and stat.S_ISREG(os.fstat(fd).st_mode): + link.unlink() + link.symlink_to(new_target) + retargeted = True + + monkeypatch.setattr(os, "fsync", _retarget_after_payload_sync) + + with pytest.raises(RuntimeError, match="symlink target changed"): + _atomic_write_text(link, "replacement: true\n") + + assert link.resolve() == new_target + assert old_target.read_text(encoding="utf-8") == "old-target: true\n" + assert new_target.read_text(encoding="utf-8") == "new-target: true\n" + assert sorted(p.name for p in tmp_path.iterdir()) == [ + "config.yaml", + "new.yaml", + "old.yaml", + ] + + +def test_partial_temp_write_failure_leaves_old_file_intact( + tmp_path: Path, monkeypatch +) -> None: + """A failure after writing some temp bytes must preserve valid old YAML.""" + target = tmp_path / "config.yaml" + original = "model:\n default: keep-me\n" + target.write_text(original, encoding="utf-8") + + class _PartialWriter: + def __init__(self, fd: int): + self.fd = fd + + def __enter__(self): + return self + + def __exit__(self, *_args): + os.close(self.fd) + + def write(self, value: str) -> None: + os.write(self.fd, value[:8].encode("utf-8")) + raise OSError("simulated partial temp write") + + def _partial_fdopen(fd: int, *_args, **_kwargs): + return _PartialWriter(fd) + + monkeypatch.setattr(os, "fdopen", _partial_fdopen) + + with pytest.raises(OSError, match="simulated partial temp write"): + _atomic_write_text(target, "model:\n default: replacement\n") + + assert target.read_text(encoding="utf-8") == original + assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"] + + +def test_encoding_and_newline_bytes_match_path_write_text(tmp_path: Path) -> None: + """The atomic helper preserves the prior writer's encoding/newline contract.""" + target = tmp_path / "config.yaml" + expected = tmp_path / "expected.yaml" + text = "greeting: Grüß dich\r\nnext: line\n" + expected.write_text(text, encoding="utf-16") + + _atomic_write_text(target, text, encoding="utf-16") + + assert target.read_bytes() == expected.read_bytes() + + +def test_symlink_chain_updates_final_referent(tmp_path: Path) -> None: + target = tmp_path / "real.yaml" + middle = tmp_path / "middle.yaml" + outer = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + middle.symlink_to(target) + outer.symlink_to(middle) + + _atomic_write_text(outer, "new: true\n") + + assert outer.is_symlink() + assert middle.is_symlink() + assert target.read_text(encoding="utf-8") == "new: true\n" + + +def test_failed_write_leaves_old_file_intact(tmp_path: Path, monkeypatch) -> None: + """A crash at the os.replace step must not touch the original file.""" + target = tmp_path / "config.yaml" + original = "model:\n default: keep-me\n" + target.write_text(original, encoding="utf-8") + + boom = RuntimeError("simulated crash mid-write") + + def _failing_replace(src, dst): + raise boom + + monkeypatch.setattr(os, "replace", _failing_replace) + + with pytest.raises(RuntimeError, match="simulated crash mid-write"): + _atomic_write_text(target, "model:\n default: half-written\n") + + # Original config survives untouched — the whole point of the fix. + assert target.read_text(encoding="utf-8") == original + # And the temp file was cleaned up rather than left as debris. + leftovers = [p.name for p in tmp_path.iterdir() if p.name != "config.yaml"] + assert leftovers == [] + + +def test_failed_write_through_symlink_leaves_link_and_target_intact( + tmp_path: Path, monkeypatch +) -> None: + """A failed symlink write must not replace the symlink or truncate target.""" + target_dir = tmp_path / "target" + link_dir = tmp_path / "link" + target_dir.mkdir() + link_dir.mkdir() + target = target_dir / "config.yaml" + link = link_dir / "config.yaml" + original = "model:\n default: keep-me\n" + target.write_text(original, encoding="utf-8") + link.symlink_to(target) + + boom = RuntimeError("simulated crash mid-write") + + def _failing_replace(src, dst): + raise boom + + monkeypatch.setattr(os, "replace", _failing_replace) + + with pytest.raises(RuntimeError, match="simulated crash mid-write"): + _atomic_write_text(link, "model:\n default: half-written\n") + + assert link.is_symlink() + assert os.readlink(link) == str(target) + assert target.read_text(encoding="utf-8") == original + assert [p.name for p in link_dir.iterdir()] == ["config.yaml"] + assert [p.name for p in target_dir.iterdir()] == ["config.yaml"] + + +@pytest.mark.parametrize("writer", ["main", "profile_endpoint", "profile_defaults"]) +def test_each_config_writer_preserves_old_bytes_when_replace_fails( + tmp_path: Path, monkeypatch, writer: str +) -> None: + """Pin every config.yaml writer to the shared atomic failure contract.""" + original = b"model:\n default: keep-me\n" + target = tmp_path / "config.yaml" + target.write_bytes(original) + + def _failing_replace(_src, _dst): + raise OSError("simulated replace failure") + + monkeypatch.setattr(os, "replace", _failing_replace) + + with pytest.raises(OSError, match="simulated replace failure"): + if writer == "main": + from api import config + + config._save_yaml_config_file(target, {"model": {"default": "new"}}) + else: + from api import profiles + + if writer == "profile_endpoint": + profiles._write_endpoint_to_config(tmp_path, base_url="https://example.invalid") + else: + profiles._write_model_defaults_to_config( + tmp_path, default_model="replacement-model" + ) + + assert target.read_bytes() == original + + +_ROOT_SKIP = pytest.mark.skipif( + os.geteuid() == 0, reason="root bypasses directory permission bits" +) + + +@_ROOT_SKIP +def test_readonly_parent_with_writable_file_falls_back_to_write_through( + tmp_path: Path, +) -> None: + """A locked-down config dir with a writable config.yaml must still save. + + Hardened deployments (documented ``HERMES_CONFIG_PATH`` layouts) keep the + containing directory read-only while leaving ``config.yaml`` itself + writable. The old ``Path.write_text`` only needed file write permission; + ``mkstemp(dir=parent)`` needs DIRECTORY write permission, so without the + fallback every settings/onboarding/profile save would start failing there. + """ + cfg_dir = tmp_path / "locked" + cfg_dir.mkdir() + target = cfg_dir / "config.yaml" + target.write_text("model:\n default: old\n", encoding="utf-8") + os.chmod(target, 0o644) + os.chmod(cfg_dir, 0o555) + try: + _atomic_write_text(target, "model:\n default: new\n") + + assert target.read_text(encoding="utf-8") == "model:\n default: new\n" + # In-place write-through keeps the file's mode and leaves no debris. + assert (os.stat(target).st_mode & 0o777) == 0o644 + assert [p.name for p in cfg_dir.iterdir()] == ["config.yaml"] + finally: + os.chmod(cfg_dir, 0o755) + + +@_ROOT_SKIP +def test_writable_unreadable_parent_still_commits_atomically(tmp_path: Path) -> None: + """A best-effort directory fsync must not reject a writable 0333 parent.""" + cfg_dir = tmp_path / "cfg" + cfg_dir.mkdir() + target = cfg_dir / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + os.chmod(cfg_dir, 0o333) + try: + _atomic_write_text(target, "new: true\n") + assert target.read_text(encoding="utf-8") == "new: true\n" + finally: + os.chmod(cfg_dir, 0o755) + + +def test_fallback_rejects_target_swap_before_open(tmp_path: Path, monkeypatch) -> None: + """The non-atomic fallback must not overwrite a swapped-in unrelated inode.""" + target = tmp_path / "config.yaml" + victim = tmp_path / "victim.yaml" + target.write_text("original: true\n", encoding="utf-8") + victim.write_text("victim: keep\n", encoding="utf-8") + + from api import paths + + def _swap_then_deny(*_args, **_kwargs): + target.unlink() + target.symlink_to(victim) + raise PermissionError("simulated non-writable parent") + + monkeypatch.setattr(paths.tempfile, "mkstemp", _swap_then_deny) + + with pytest.raises(PermissionError, match="changed before fallback"): + _atomic_write_text(target, "victim: overwritten\n") + + assert target.is_symlink() + assert victim.read_text(encoding="utf-8") == "victim: keep\n" + + +@_ROOT_SKIP +def test_readonly_parent_without_writable_target_still_raises( + tmp_path: Path, +) -> None: + """The fallback is scoped to existing writable files, not a broad catch. + + With no target file to write through (or an unwritable one), swallowing + the ``PermissionError`` would turn a genuinely misconfigured deployment + into a silent no-op save — the error must keep propagating. + """ + cfg_dir = tmp_path / "locked" + cfg_dir.mkdir() + missing = cfg_dir / "config.yaml" + os.chmod(cfg_dir, 0o555) + try: + with pytest.raises(PermissionError): + _atomic_write_text(missing, "model:\n default: new\n") + assert not missing.exists() + finally: + os.chmod(cfg_dir, 0o755) + + +@_ROOT_SKIP +def test_readonly_parent_with_unwritable_file_still_raises(tmp_path: Path) -> None: + cfg_dir = tmp_path / "locked" + cfg_dir.mkdir() + target = cfg_dir / "config.yaml" + original = "model:\n default: keep-me\n" + target.write_text(original, encoding="utf-8") + os.chmod(target, 0o444) + os.chmod(cfg_dir, 0o555) + try: + with pytest.raises(PermissionError): + _atomic_write_text(target, "model:\n default: new\n") + assert target.read_text(encoding="utf-8") == original + finally: + os.chmod(cfg_dir, 0o755) + os.chmod(target, 0o644) From d5904283ab38cad03a6166c24120d62f5eaf4e9d Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:24:19 +0200 Subject: [PATCH 04/82] Cover onboarding with atomic config writes --- api/onboarding.py | 4 +- tests/test_5774b_atomic_config_writes.py | 59 +++++++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/api/onboarding.py b/api/onboarding.py index 7af2910a..cfff9ea2 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -28,6 +28,7 @@ from api.config import ( save_settings, verify_hermes_imports, ) +from api.paths import _atomic_write_text from api.providers import _write_env_file # shared impl with _ENV_LOCK (#1164) from api.workspace import get_last_workspace, load_workspaces @@ -251,7 +252,8 @@ def _save_yaml_config(config_path: Path, config: dict) -> None: raise RuntimeError("PyYAML is required to write Hermes config.yaml") from exc config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text( + _atomic_write_text( + config_path, _yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8", ) diff --git a/tests/test_5774b_atomic_config_writes.py b/tests/test_5774b_atomic_config_writes.py index 30755700..a4200a3a 100644 --- a/tests/test_5774b_atomic_config_writes.py +++ b/tests/test_5774b_atomic_config_writes.py @@ -98,6 +98,52 @@ def test_atomic_write_preserves_existing_group(tmp_path: Path) -> None: assert os.stat(target).st_gid == expected_gid +@pytest.mark.skipif(not hasattr(os, "fchown"), reason="POSIX ownership semantics") +def test_fchown_denial_falls_back_without_temp_debris( + tmp_path: Path, monkeypatch +) -> None: + """A writer unable to transfer ownership must keep the old inode contract.""" + target = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + + def _deny_fchown(*_args) -> None: + raise PermissionError("simulated ownership denial") + + monkeypatch.setattr(os, "fchown", _deny_fchown) + + _atomic_write_text(target, "new: true\n") + + assert target.read_text(encoding="utf-8") == "new: true\n" + assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"] + + +def test_atomic_write_without_posix_fd_metadata_helpers( + tmp_path: Path, monkeypatch +) -> None: + """Windows-like platforms still use same-directory atomic replacement.""" + target = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + replace_calls: list[tuple[Path, Path]] = [] + real_replace = os.replace + + monkeypatch.delattr(os, "fchown", raising=False) + monkeypatch.delattr(os, "fchmod", raising=False) + + def _recording_replace(src, dst) -> None: + replace_calls.append((Path(src), Path(dst))) + real_replace(src, dst) + + monkeypatch.setattr(os, "replace", _recording_replace) + + _atomic_write_text(target, "new: true\n") + + assert target.read_text(encoding="utf-8") == "new: true\n" + assert len(replace_calls) == 1 + assert replace_calls[0][0].parent == target.parent + assert replace_calls[0][1] == target + assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"] + + def test_atomic_write_fsyncs_file_and_parent_directory( tmp_path: Path, monkeypatch ) -> None: @@ -393,7 +439,9 @@ def test_failed_write_through_symlink_leaves_link_and_target_intact( assert [p.name for p in target_dir.iterdir()] == ["config.yaml"] -@pytest.mark.parametrize("writer", ["main", "profile_endpoint", "profile_defaults"]) +@pytest.mark.parametrize( + "writer", ["main", "onboarding", "profile_endpoint", "profile_defaults"] +) def test_each_config_writer_preserves_old_bytes_when_replace_fails( tmp_path: Path, monkeypatch, writer: str ) -> None: @@ -412,6 +460,12 @@ def test_each_config_writer_preserves_old_bytes_when_replace_fails( from api import config config._save_yaml_config_file(target, {"model": {"default": "new"}}) + elif writer == "onboarding": + from api import onboarding + + onboarding._save_yaml_config( + target, {"model": {"default": "replacement-model"}} + ) else: from api import profiles @@ -426,7 +480,8 @@ def test_each_config_writer_preserves_old_bytes_when_replace_fails( _ROOT_SKIP = pytest.mark.skipif( - os.geteuid() == 0, reason="root bypasses directory permission bits" + getattr(os, "geteuid", lambda: -1)() == 0, + reason="root bypasses directory permission bits", ) From c7ce0db4fa83c5981d4d74e6c906465ccf3d2279 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:26:30 +0200 Subject: [PATCH 05/82] Handle unsupported ownership transfer in atomic writes --- api/paths.py | 13 ++++++++++++- tests/test_5774b_atomic_config_writes.py | 11 +++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/api/paths.py b/api/paths.py index 2fad3194..7254db26 100644 --- a/api/paths.py +++ b/api/paths.py @@ -144,7 +144,18 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non if existing_stat is not None and hasattr(os, "fchown"): try: os.fchown(fd, existing_stat.st_uid, existing_stat.st_gid) - except (PermissionError, NotImplementedError): + except (OSError, NotImplementedError) as exc: + unsupported_ownership_errnos = { + errno.EINVAL, + errno.ENOSYS, + errno.ENOTSUP, + } + if ( + isinstance(exc, OSError) + and not isinstance(exc, PermissionError) + and exc.errno not in unsupported_ownership_errnos + ): + raise os.close(fd) owns_fd = False os.unlink(tmp) diff --git a/tests/test_5774b_atomic_config_writes.py b/tests/test_5774b_atomic_config_writes.py index a4200a3a..bb0f06d5 100644 --- a/tests/test_5774b_atomic_config_writes.py +++ b/tests/test_5774b_atomic_config_writes.py @@ -99,15 +99,22 @@ def test_atomic_write_preserves_existing_group(tmp_path: Path) -> None: @pytest.mark.skipif(not hasattr(os, "fchown"), reason="POSIX ownership semantics") +@pytest.mark.parametrize( + "failure", + [ + PermissionError("simulated ownership denial"), + OSError(errno.EINVAL, "simulated unsupported ownership transfer"), + ], +) def test_fchown_denial_falls_back_without_temp_debris( - tmp_path: Path, monkeypatch + tmp_path: Path, monkeypatch, failure: OSError ) -> None: """A writer unable to transfer ownership must keep the old inode contract.""" target = tmp_path / "config.yaml" target.write_text("old: true\n", encoding="utf-8") def _deny_fchown(*_args) -> None: - raise PermissionError("simulated ownership denial") + raise failure monkeypatch.setattr(os, "fchown", _deny_fchown) From 297535bfba36ad6dae042c39060f12ae11de8607 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:42:03 +0200 Subject: [PATCH 06/82] Preserve hard-linked configs during atomic writes --- api/paths.py | 75 +++++++++++------- tests/test_5774b_atomic_config_writes.py | 96 +++++++++++++++++++++++- 2 files changed, 141 insertions(+), 30 deletions(-) diff --git a/api/paths.py b/api/paths.py index 7254db26..e553a6de 100644 --- a/api/paths.py +++ b/api/paths.py @@ -9,6 +9,7 @@ import errno import os import stat import tempfile +import threading from pathlib import Path HOME = Path.home() @@ -34,6 +35,12 @@ def _probe_umask() -> int: # process-wide umask on every new-file write; see ``_probe_umask``. _NEW_FILE_MODE = 0o666 & ~_probe_umask() +# In-place compatibility writes cannot use rename isolation. Serialize every +# such write in this process so concurrent WebUI saves cannot interleave their +# truncate/write/fsync sequences. A single lock avoids a permanent per-path +# lock cache for arbitrary profile/config locations. +_IN_PLACE_WRITE_LOCK = threading.RLock() + def _fsync_directory(directory: Path) -> None: """Persist a completed rename on filesystems that support directory fsync.""" @@ -76,10 +83,11 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non (Unlike ``.env``, ``config.yaml`` holds no secrets and is not meant to be forced to ``0600``.) - Symlinks keep the same follow-through semantics as ``Path.write_text``: - writing ``config.yaml`` through a symlink updates the referent instead of - replacing the link itself with a regular file. A target change observed - before commit is rejected rather than updating a stale referent. + Symlinks and hard links keep the same follow-through semantics as + ``Path.write_text``: writing ``config.yaml`` through a symlink updates the + referent, while writing an inode with multiple hard links updates every + alias rather than severing the selected path. A symlink target change + observed before commit is rejected rather than updating a stale referent. The temp+rename dance needs WRITE PERMISSION ON THE DIRECTORY, which the plain ``Path.write_text`` it replaced did not: hardened deployments @@ -88,9 +96,13 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non settings at all. When temp creation is denied, or the original uid/gid cannot be transferred to the temp inode, we fall back to a guarded descriptor write only if the target is still the regular-file inode - inspected above. This gives up crash-atomicity only where atomic replace - with preserved metadata is impossible. Other ``PermissionError``s still - propagate. Successful renames fsync the parent directory where supported. + inspected above. Hard-linked files use that same path because replacing + their directory entry would break ``Path.write_text`` compatibility. These + in-place writes are serialized across process-local WebUI writers. They give + up crash-atomicity (and raw concurrent readers may observe the write in + progress) only where atomic replace with preserved semantics is impossible. + Other ``PermissionError``s still propagate. Successful renames fsync the + parent directory where supported. The caller is responsible for ensuring ``path.parent`` exists. """ @@ -108,29 +120,34 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non raise RuntimeError("config symlink target changed during atomic write") def _write_in_place() -> None: - if existing_stat is None or not stat.S_ISREG(existing_stat.st_mode): - raise PermissionError(f"Cannot replace config path: {write_path}") - _verify_symlink_target() - fallback_fd = os.open(write_path, os.O_WRONLY) - owns_fallback_fd = True - try: - opened_stat = os.fstat(fallback_fd) - if (opened_stat.st_dev, opened_stat.st_ino) != ( - existing_stat.st_dev, - existing_stat.st_ino, - ): - raise PermissionError("config target changed before fallback write") + with _IN_PLACE_WRITE_LOCK: + if existing_stat is None or not stat.S_ISREG(existing_stat.st_mode): + raise PermissionError(f"Cannot replace config path: {write_path}") _verify_symlink_target() - os.ftruncate(fallback_fd, 0) - fallback_file = os.fdopen(fallback_fd, "w", encoding=encoding) - owns_fallback_fd = False - with fallback_file: - fallback_file.write(text) - fallback_file.flush() - os.fsync(fallback_file.fileno()) - finally: - if owns_fallback_fd: - os.close(fallback_fd) + fallback_fd = os.open(write_path, os.O_WRONLY) + owns_fallback_fd = True + try: + opened_stat = os.fstat(fallback_fd) + if (opened_stat.st_dev, opened_stat.st_ino) != ( + existing_stat.st_dev, + existing_stat.st_ino, + ): + raise PermissionError("config target changed before fallback write") + _verify_symlink_target() + os.ftruncate(fallback_fd, 0) + fallback_file = os.fdopen(fallback_fd, "w", encoding=encoding) + owns_fallback_fd = False + with fallback_file: + fallback_file.write(text) + fallback_file.flush() + os.fsync(fallback_file.fileno()) + finally: + if owns_fallback_fd: + os.close(fallback_fd) + + if existing_stat is not None and existing_stat.st_nlink > 1: + _write_in_place() + return try: fd, tmp = tempfile.mkstemp( diff --git a/tests/test_5774b_atomic_config_writes.py b/tests/test_5774b_atomic_config_writes.py index bb0f06d5..996014d6 100644 --- a/tests/test_5774b_atomic_config_writes.py +++ b/tests/test_5774b_atomic_config_writes.py @@ -13,7 +13,7 @@ the same directory, ``fsync``s, then ``os.replace``s it into place. Because leaves the ORIGINAL file byte-for-byte intact, and a success swaps in the new contents in a single step. -These tests pin the helper and all three config.yaml callers across success, +These tests pin the helper and all four config.yaml callers across success, fault, metadata, symlink, permission, and concurrency paths so a future refactor cannot silently reintroduce the truncating plain-write. """ @@ -238,6 +238,83 @@ def test_concurrent_writers_expose_only_complete_versions(tmp_path: Path) -> Non assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"] +def test_forced_fallback_serializes_writers_and_syncs_only_complete_versions( + tmp_path: Path, monkeypatch +) -> None: + """The non-atomic compatibility path must not interleave WebUI writers.""" + target = tmp_path / "config.yaml" + payloads = [ + f"writer: {index}\npadding: {'x' * 20_000}\n".encode() + for index in range(6) + ] + valid_versions = set(payloads) + observed_at_sync: list[bytes] = [] + active_writers = 0 + max_active_writers = 0 + state_lock = threading.Lock() + real_fdopen = os.fdopen + real_fsync = os.fsync + + from api import paths + + def _force_fallback(*_args, **_kwargs): + raise PermissionError("simulated non-writable parent") + + class _SlowWriter: + def __init__(self, wrapped): + self._wrapped = wrapped + + def __enter__(self): + nonlocal active_writers, max_active_writers + self._wrapped.__enter__() + with state_lock: + active_writers += 1 + max_active_writers = max(max_active_writers, active_writers) + return self + + def __exit__(self, *args): + nonlocal active_writers + try: + return self._wrapped.__exit__(*args) + finally: + with state_lock: + active_writers -= 1 + + def write(self, value: str) -> None: + midpoint = len(value) // 2 + self._wrapped.write(value[:midpoint]) + self._wrapped.flush() + time.sleep(0.01) + self._wrapped.write(value[midpoint:]) + + def flush(self) -> None: + self._wrapped.flush() + + def fileno(self) -> int: + return self._wrapped.fileno() + + def _slow_fdopen(fd: int, *args, **kwargs): + return _SlowWriter(real_fdopen(fd, *args, **kwargs)) + + def _observing_fsync(fd: int) -> None: + if stat.S_ISREG(os.fstat(fd).st_mode): + observed_at_sync.append(target.read_bytes()) + real_fsync(fd) + + target.write_bytes(payloads[0]) + monkeypatch.setattr(paths.tempfile, "mkstemp", _force_fallback) + monkeypatch.setattr(os, "fdopen", _slow_fdopen) + monkeypatch.setattr(os, "fsync", _observing_fsync) + + with ThreadPoolExecutor(max_workers=len(payloads)) as pool: + list(pool.map(lambda payload: _atomic_write_text(target, payload.decode()), payloads)) + + assert max_active_writers == 1 + assert len(observed_at_sync) == len(payloads) + assert all(observed in valid_versions for observed in observed_at_sync) + assert target.read_bytes() in valid_versions + + def test_new_file_uses_cached_umask_mode(tmp_path: Path, monkeypatch) -> None: """A freshly created file uses _NEW_FILE_MODE (umask-adjusted 0666), not 0600. @@ -295,6 +372,23 @@ def test_atomic_write_follows_config_symlink(tmp_path: Path) -> None: assert [p.name for p in link_dir.iterdir()] == ["config.yaml"] +def test_atomic_write_preserves_hard_link_aliases(tmp_path: Path) -> None: + """Path.write_text updates a shared inode instead of severing hard links.""" + target = tmp_path / "config.yaml" + alias = tmp_path / "shared-config.yaml" + target.write_text("model:\n default: old\n", encoding="utf-8") + os.link(target, alias) + inode = os.stat(target).st_ino + + _atomic_write_text(target, "model:\n default: new\n") + + assert target.read_text(encoding="utf-8") == "model:\n default: new\n" + assert alias.read_text(encoding="utf-8") == "model:\n default: new\n" + assert os.stat(target).st_ino == inode + assert os.stat(alias).st_ino == inode + assert os.stat(target).st_nlink == 2 + + def test_symlink_retarget_during_write_aborts_without_touching_either_target( tmp_path: Path, monkeypatch ) -> None: From 27064338027022b0e5161c5b525c321595540b7b Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:48:32 +0200 Subject: [PATCH 07/82] fix: preserve config extended attributes --- api/paths.py | 39 +++++++-- tests/test_5774b_atomic_config_writes.py | 104 +++++++++++++++++++++++ 2 files changed, 135 insertions(+), 8 deletions(-) diff --git a/api/paths.py b/api/paths.py index e553a6de..4ec733d9 100644 --- a/api/paths.py +++ b/api/paths.py @@ -61,6 +61,24 @@ def _fsync_directory(directory: Path) -> None: os.close(fd) +def _has_extended_attributes(path: Path) -> bool: + """Return whether *path* has xattrs that an inode replacement would discard.""" + listxattr = getattr(os, "listxattr", None) + if listxattr is None: + return False + try: + return bool(listxattr(path)) + except OSError as exc: + unsupported_errnos = { + errno.ENOSYS, + errno.ENOTSUP, + getattr(errno, "EOPNOTSUPP", errno.ENOTSUP), + } + if exc.errno in unsupported_errnos: + return False + raise + + def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None: """Atomically replace *path* with *text*. @@ -96,13 +114,14 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non settings at all. When temp creation is denied, or the original uid/gid cannot be transferred to the temp inode, we fall back to a guarded descriptor write only if the target is still the regular-file inode - inspected above. Hard-linked files use that same path because replacing - their directory entry would break ``Path.write_text`` compatibility. These - in-place writes are serialized across process-local WebUI writers. They give - up crash-atomicity (and raw concurrent readers may observe the write in - progress) only where atomic replace with preserved semantics is impossible. - Other ``PermissionError``s still propagate. Successful renames fsync the - parent directory where supported. + inspected above. Hard-linked files and files with extended attributes use + that same path because replacing their directory entry would respectively + break ``Path.write_text`` compatibility or discard metadata such as POSIX + ACLs. These in-place writes are serialized across process-local WebUI + writers. They give up crash-atomicity (and raw concurrent readers may + observe the write in progress) only where atomic replace with preserved + semantics is impossible. Other ``PermissionError``s still propagate. + Successful renames fsync the parent directory where supported. The caller is responsible for ensuring ``path.parent`` exists. """ @@ -145,7 +164,11 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non if owns_fallback_fd: os.close(fallback_fd) - if existing_stat is not None and existing_stat.st_nlink > 1: + if ( + existing_stat is not None + and stat.S_ISREG(existing_stat.st_mode) + and (existing_stat.st_nlink > 1 or _has_extended_attributes(write_path)) + ): _write_in_place() return diff --git a/tests/test_5774b_atomic_config_writes.py b/tests/test_5774b_atomic_config_writes.py index 996014d6..d47d36b0 100644 --- a/tests/test_5774b_atomic_config_writes.py +++ b/tests/test_5774b_atomic_config_writes.py @@ -20,7 +20,9 @@ refactor cannot silently reintroduce the truncating plain-write. import errno import os +import shutil import stat +import subprocess import threading import time from concurrent.futures import ThreadPoolExecutor @@ -81,6 +83,108 @@ def test_atomic_write_preserves_existing_permissions(tmp_path: Path) -> None: assert stat.S_IMODE(os.stat(target).st_mode) == 0o2664 +@pytest.mark.skipif( + not all(hasattr(os, name) for name in ("getxattr", "listxattr", "setxattr")), + reason="extended attributes are unavailable on this platform", +) +def test_atomic_write_preserves_existing_user_xattr(tmp_path: Path) -> None: + """Replacing config contents must not discard administrator metadata.""" + target = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + attribute = "user.hermes_review" + value = b"preserve-me" + try: + os.setxattr(target, attribute, value) + except OSError as exc: + if exc.errno in {errno.ENOSYS, errno.ENOTSUP, errno.EOPNOTSUPP}: + pytest.skip("test filesystem does not support user extended attributes") + raise + + _atomic_write_text(target, "new: true\n") + + assert attribute in os.listxattr(target) + assert os.getxattr(target, attribute) == value + + +@pytest.mark.skipif(not hasattr(os, "listxattr"), reason="xattr probe unavailable") +def test_unsupported_xattr_probe_keeps_atomic_replace( + tmp_path: Path, monkeypatch +) -> None: + """Filesystems without xattr support retain the normal atomic path.""" + target = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + replace_calls = 0 + real_replace = os.replace + + def _unsupported_xattrs(_path) -> list[str]: + raise OSError(errno.ENOTSUP, "xattrs unsupported") + + def _recording_replace(src, dst) -> None: + nonlocal replace_calls + replace_calls += 1 + real_replace(src, dst) + + monkeypatch.setattr(os, "listxattr", _unsupported_xattrs) + monkeypatch.setattr(os, "replace", _recording_replace) + + _atomic_write_text(target, "new: true\n") + + assert target.read_text(encoding="utf-8") == "new: true\n" + assert replace_calls == 1 + + +@pytest.mark.skipif(not hasattr(os, "listxattr"), reason="xattr probe unavailable") +def test_xattr_probe_failure_does_not_silently_drop_metadata( + tmp_path: Path, monkeypatch +) -> None: + """Unexpected metadata-read failures abort before touching the old file.""" + target = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + + def _denied_xattrs(_path) -> list[str]: + raise PermissionError("xattr access denied") + + monkeypatch.setattr(os, "listxattr", _denied_xattrs) + + with pytest.raises(PermissionError, match="xattr access denied"): + _atomic_write_text(target, "new: true\n") + + assert target.read_text(encoding="utf-8") == "old: true\n" + assert [p.name for p in tmp_path.iterdir()] == ["config.yaml"] + + +@pytest.mark.skipif( + not shutil.which("getfacl") or not shutil.which("setfacl"), + reason="getfacl/setfacl are unavailable", +) +def test_atomic_write_preserves_existing_posix_acl(tmp_path: Path) -> None: + """POSIX access ACLs, commonly stored as xattrs, survive a rewrite.""" + target = tmp_path / "config.yaml" + target.write_text("old: true\n", encoding="utf-8") + subprocess.run( + ["setfacl", "-m", "u:12345:r--", str(target)], + check=True, + capture_output=True, + text=True, + ) + expected_acl = subprocess.run( + ["getfacl", "-cp", str(target)], + check=True, + capture_output=True, + text=True, + ).stdout + + _atomic_write_text(target, "new: true\n") + + actual_acl = subprocess.run( + ["getfacl", "-cp", str(target)], + check=True, + capture_output=True, + text=True, + ).stdout + assert actual_acl == expected_acl + + @pytest.mark.skipif(not hasattr(os, "chown"), reason="POSIX ownership semantics") def test_atomic_write_preserves_existing_group(tmp_path: Path) -> None: """Replacing the inode must not silently reset an existing config's group.""" From 83bd7125bd8428febcdcc6b08ab108bca7da89e4 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:18:06 +0200 Subject: [PATCH 08/82] fix: avoid racy umask probe for config writes --- api/paths.py | 50 ++++++++++++------------ tests/test_5774b_atomic_config_writes.py | 50 +++++++++++++----------- 2 files changed, 54 insertions(+), 46 deletions(-) diff --git a/api/paths.py b/api/paths.py index 4ec733d9..7d167b10 100644 --- a/api/paths.py +++ b/api/paths.py @@ -7,6 +7,7 @@ larger startup side effects. import errno import os +import secrets import stat import tempfile import threading @@ -15,25 +16,28 @@ from pathlib import Path HOME = Path.home() -def _probe_umask() -> int: - """Return the current process umask. +def _create_atomic_temp_file(write_path: Path, *, existing: bool) -> tuple[int, str]: + """Create a same-directory temp file without reading the process umask. - ``os.umask`` has no read-only form: it always sets and returns the previous - value, so we set-then-restore. This is a process-wide syscall, so the - two-call dance is unsafe once request threads are running (another thread - creating a file in the tiny window would see umask 0). We therefore call this - exactly once, at import, while the module is still single-threaded, and cache - the derived default below. + ``tempfile.mkstemp`` deliberately creates files as ``0600``. For a brand-new + config, use ``os.open(..., 0666)`` instead so the kernel applies the active + process umask as it would for ``Path.write_text``. Reading umask by calling + ``os.umask`` is unsafe because that syscall mutates process-wide state, and + imports may happen after the threaded server has started. """ - umask = os.umask(0) - os.umask(umask) - return umask + if existing: + return tempfile.mkstemp( + dir=str(write_path.parent), prefix=f".{write_path.name}_", suffix=".tmp" + ) - -# umask-adjusted 0666 — the mode a plain ``open(..., "w")`` would produce for a -# brand-new file. Computed once at import (single-threaded) to avoid probing the -# process-wide umask on every new-file write; see ``_probe_umask``. -_NEW_FILE_MODE = 0o666 & ~_probe_umask() + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL + for _ in range(100): + tmp = write_path.parent / f".{write_path.name}_{secrets.token_hex(16)}.tmp" + try: + return os.open(tmp, flags, 0o666), str(tmp) + except FileExistsError: + continue + raise FileExistsError("unable to allocate unique config temporary file") # In-place compatibility writes cannot use rename isolation. Serialize every # such write in this process so concurrent WebUI saves cannot interleave their @@ -96,8 +100,8 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non every rewrite could silently reset a shared config's group and tighten a group/other-readable ``config.yaml`` (commonly ``0644``/``0664``) down to owner-only. Existing files copy uid/gid and mode onto the temp descriptor; - new files use ``_NEW_FILE_MODE`` (the umask-adjusted ``0666`` a normal - ``open(..., "w")`` would have produced). + new files use the active process umask, exactly as a normal + ``open(..., "w")`` would. (Unlike ``.env``, ``config.yaml`` holds no secrets and is not meant to be forced to ``0600``.) @@ -132,7 +136,7 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non existing_stat = os.stat(write_path) except FileNotFoundError: existing_stat = None - mode = stat.S_IMODE(existing_stat.st_mode) if existing_stat else _NEW_FILE_MODE + mode = stat.S_IMODE(existing_stat.st_mode) if existing_stat else None def _verify_symlink_target() -> None: if symlink_target is not None and path.resolve(strict=False) != symlink_target: @@ -173,9 +177,7 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non return try: - fd, tmp = tempfile.mkstemp( - dir=str(write_path.parent), prefix=f".{write_path.name}_", suffix=".tmp" - ) + fd, tmp = _create_atomic_temp_file(write_path, existing=existing_stat is not None) except PermissionError: _write_in_place() return @@ -201,9 +203,9 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non os.unlink(tmp) _write_in_place() return - if hasattr(os, "fchmod"): + if mode is not None and hasattr(os, "fchmod"): os.fchmod(fd, mode) - else: + elif mode is not None: os.chmod(tmp, mode) f = os.fdopen(fd, "w", encoding=encoding) owns_fd = False diff --git a/tests/test_5774b_atomic_config_writes.py b/tests/test_5774b_atomic_config_writes.py index d47d36b0..bfe2d48b 100644 --- a/tests/test_5774b_atomic_config_writes.py +++ b/tests/test_5774b_atomic_config_writes.py @@ -23,6 +23,7 @@ import os import shutil import stat import subprocess +import sys import threading import time from concurrent.futures import ThreadPoolExecutor @@ -419,33 +420,38 @@ def test_forced_fallback_serializes_writers_and_syncs_only_complete_versions( assert target.read_bytes() in valid_versions -def test_new_file_uses_cached_umask_mode(tmp_path: Path, monkeypatch) -> None: - """A freshly created file uses _NEW_FILE_MODE (umask-adjusted 0666), not 0600. +def test_new_files_follow_the_current_umask_without_a_global_probe(tmp_path: Path) -> None: + """New config files use the kernel's current umask without mutating it. - The mode is cached at import (the process umask is read once while the module - is single-threaded), so this pins the cached value rather than probing umask - per call. 0o644 = 0o666 & ~0o022, the common server umask. + Importing this helper can occur on a request thread, so caching a mode by + calling ``os.umask`` at import time is still racy. A subprocess isolates the + process-global umask and proves two writes after import follow two different + active masks. """ - from api import paths + script = """ +import os +import stat +import sys +from pathlib import Path +from api.paths import _atomic_write_text - monkeypatch.setattr(paths, "_NEW_FILE_MODE", 0o644) - target = tmp_path / "config.yaml" - assert not target.exists() +base = Path(sys.argv[1]) +os.umask(0o077) +_atomic_write_text(base / 'private.yaml', 'private: true\\n') +os.umask(0o022) +_atomic_write_text(base / 'shared.yaml', 'shared: true\\n') +print(oct(stat.S_IMODE(os.stat(base / 'private.yaml').st_mode))) +print(oct(stat.S_IMODE(os.stat(base / 'shared.yaml').st_mode))) +""" - _atomic_write_text(target, "created: true\n") + result = subprocess.run( + [sys.executable, "-c", script, str(tmp_path)], + check=True, + capture_output=True, + text=True, + ) - assert target.exists() - assert (os.stat(target).st_mode & 0o777) == 0o644 - - -def test_probe_umask_is_read_once_at_import() -> None: - """_NEW_FILE_MODE is a plausible umask-derived file mode, cached at import.""" - from api import paths - - # A file mode: no execute/setuid bits beyond rw for the three classes, and - # never more permissive than 0o666 (umask only ever clears bits). - assert 0 <= paths._NEW_FILE_MODE <= 0o666 - assert paths._NEW_FILE_MODE & 0o111 == 0 # no execute bits on a data file + assert result.stdout.splitlines() == ["0o600", "0o644"] def test_atomic_write_follows_config_symlink(tmp_path: Path) -> None: From 6f10e4d48b706c6384014f01e608d4f9bcfb8408 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:32:47 +0200 Subject: [PATCH 09/82] fix(ctl): detect foreign/supervised WebUI instances instead of double-starting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctl.sh start only guarded against launchd (macOS). On Linux, with a systemd-supervised WebUI serving the port and a stale PID file, stop reported 'stopped', start spawned a bootstrap that died ~2s later on server.py's 'already responding' check — after the 0.15s aliveness gate had already printed 'Started' and recorded the doomed PID. Killing the foreign server by hand then put its supervisor's auto-restart into a race with ctl.sh's start, ending in a permanent RestartSec crash loop. - start: refuse when anything answers HTTP(S) on the target port (any response bytes, matching server.py's abort semantics — a 404 squatter still dooms our server), and when the hermes-webui systemd unit is active on our port or mid-auto-restart (activating). Port scoping mirrors the launchd #3291 over-block fix; overrides: HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT / _ALLOW_PORT_CONFLICT, unit name via HERMES_WEBUI_SYSTEMD_UNIT. - start: watch the child through a startup grace window (HERMES_WEBUI_START_GRACE, default 3s) — report failure and clean the PID file when it dies during startup; break early once /health answers. - status/stop: when ctl.sh owns no PID but the port answers, say 'running (not managed by ctl.sh)' with listener diagnostics instead of 'stopped', and never touch the foreign process. - _pid_listens_on_port: ss fallback for Linux hosts without lsof. Co-Authored-By: Claude Fable 5 --- ctl.sh | 212 +++++++++++++- tests/test_ctl_foreign_server_guard.py | 367 +++++++++++++++++++++++++ tests/test_ctl_script.py | 5 + 3 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 tests/test_ctl_foreign_server_guard.py diff --git a/ctl.sh b/ctl.sh index 2e827aad..e062356f 100755 --- a/ctl.sh +++ b/ctl.sh @@ -390,6 +390,20 @@ _pid_listens_on_port() { fi return 1 # PID is alive but NOT listening on that port → no conflict fi + # Linux hosts (where the systemd guard runs) often lack lsof but ship ss. + if command -v ss >/dev/null 2>&1; then + local ss_out rows + if ! ss_out="$(ss -tlnp 2>/dev/null)"; then + return 2 + fi + rows="$(printf '%s\n' "${ss_out}" | awk -v p="${port}" '$4 ~ (":" p "$")')" + [[ -z "${rows}" ]] && return 1 # nothing listens on that port at all + printf '%s\n' "${rows}" | grep -q "pid=${pid}," && return 0 + # Listener rows exist; when they carry pid= attribution and none is ours, + # some OTHER process owns the port — not a conflict for this pid. + printf '%s\n' "${rows}" | grep -q "pid=" && return 1 + return 2 # listener present but unattributable + fi return 2 # can't determine } @@ -424,6 +438,105 @@ _launchd_webui_pid() { esac } +_probe_target_host() { + # A bind-all host (0.0.0.0 / ::) is not a connectable probe target; probe + # loopback instead (mirrors server.py _abort_if_already_serving). + case "${1:-}" in + 0.0.0.0 | '' | ::) printf '127.0.0.1' ;; + *) printf '%s' "$1" ;; + esac +} + +_port_answers_http() { + # True when ANYTHING answers an HTTP(S) request on host:port — mirrors + # server.py's _abort_if_already_serving, which treats any response bytes + # (including an error status from a foreign app squatting the port) as a + # conflict. Deliberately broader than hermes_webui_probe_health, which + # requires a 200 from /health. + local host="$1" port="$2" url rc scheme + for scheme in http https; do + url="${scheme}://${host}:${port}/health" + if command -v curl >/dev/null 2>&1; then + # No -f: an HTTP error status is still a responder. -k: a self-signed + # cert is still a responder. + curl -sS -o /dev/null -k --max-time 2 "${url}" 2>/dev/null + rc=$? + [[ ${rc} -eq 0 ]] && return 0 + elif command -v wget >/dev/null 2>&1; then + wget -qO /dev/null --no-check-certificate "--timeout=2" --tries=1 "${url}" 2>/dev/null + rc=$? + # 0 = OK; 8 = server answered with an error status — both are responders. + [[ ${rc} -eq 0 || ${rc} -eq 8 ]] && return 0 + else + return 1 + fi + done + return 1 +} + +_port_listener_diag() { + # Best-effort one-liner describing what listens on TCP port $1. Used in + # error/status messages only — never fails the caller. + local port="$1" line="" + if command -v ss >/dev/null 2>&1; then + line="$(ss -tlnp 2>/dev/null | awk -v p="${port}" '$4 ~ (":" p "$") {print; exit}')" + fi + if [[ -z "${line}" ]] && command -v lsof >/dev/null 2>&1; then + line="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print; exit}')" + fi + [[ -n "${line}" ]] && printf '%s' "${line}" +} + +_systemd_webui_conflict() { + # Linux analog of _launchd_webui_pid: echo a short conflict descriptor and + # return 0 when a systemd unit (default hermes-webui.service, override via + # HERMES_WEBUI_SYSTEMD_UNIT) effectively owns the instance we are about to + # start. Two conflict shapes: + # - ActiveState=active and the unit's MainPID listens on our port. + # - ActiveState=activating/reloading (Restart= backoff between attempts): + # the port is briefly silent, but the unit WILL respawn and re-bind; a + # ctl.sh-started server would trap it in a permanent crash loop + # (each systemd attempt aborts on the "already responding" check, 5s + # later the next one dies the same way — observed in the field). + # Port scoping mirrors the launchd #3291 fix: when the unit's port cannot + # be determined, only the default port is guarded, so alternate-port test + # instances are never wrongly blocked. + [[ "${HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT:-0}" == "1" ]] && return 1 + command -v systemctl >/dev/null 2>&1 || return 1 + local unit="${HERMES_WEBUI_SYSTEMD_UNIT:-hermes-webui.service}" + [[ -n "${unit}" ]] || return 1 + local want_port="${CTL_PORT:-${HERMES_WEBUI_PORT:-8787}}" + local scope state main_pid + for scope in --system --user; do + state="$(systemctl "${scope}" show -p ActiveState --value "${unit}" 2>/dev/null)" || continue + case "${state}" in + active) + main_pid="$(systemctl "${scope}" show -p MainPID --value "${unit}" 2>/dev/null)" + [[ "${main_pid}" =~ ^[0-9]+$ ]] || main_pid=0 + if (( main_pid > 0 )); then + _pid_listens_on_port "${main_pid}" "${want_port}" + case "$?" in + 0) printf 'unit %s is active (MainPID %s listens on port %s)' "${unit}" "${main_pid}" "${want_port}"; return 0 ;; + 1) continue ;; # active on a different port → no conflict + esac + fi + # Port ownership unknown: guard only the default port (see #3291). + if [[ "${want_port}" == "8787" ]]; then + printf 'unit %s is active' "${unit}" + return 0 + fi + ;; + activating | reloading) + if [[ "${want_port}" == "8787" ]]; then + printf 'unit %s is %s (auto-restart pending)' "${unit}" "${state}" + return 0 + fi + ;; + esac + done + return 1 +} + start_cmd() { ensure_home _load_repo_dotenv_preserving_env @@ -446,6 +559,28 @@ start_cmd() { echo "[ctl] Use launchctl kickstart -k gui/$(id -u)/${HERMES_WEBUI_LAUNCHD_LABEL:-${DEFAULT_LAUNCHD_LABEL}} or disable the launchd job before using ctl.sh start." >&2 return 2 fi + local systemd_conflict + if systemd_conflict="$(_systemd_webui_conflict 2>/dev/null)"; then + echo "[ctl] Refusing to start a second Hermes WebUI: systemd ${systemd_conflict}." >&2 + echo "[ctl] Manage that instance with systemctl instead (e.g. sudo systemctl restart ${HERMES_WEBUI_SYSTEMD_UNIT:-hermes-webui.service}), or disable the unit before using ctl.sh start. Set HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT=1 to override." >&2 + return 2 + fi + # Generic duplicate guard: whatever supervises it, if a live server already + # answers on the target host:port, a second one is doomed — server.py aborts + # on its "already responding" startup check a few seconds in, AFTER ctl.sh's + # old 0.15s aliveness check had reported success and recorded a PID file + # that goes stale on exit. Refuse up front instead. + local probe_host + probe_host="$(_probe_target_host "${CTL_HOST}")" + if [[ "${HERMES_WEBUI_CTL_ALLOW_PORT_CONFLICT:-0}" != "1" ]] \ + && _port_answers_http "${probe_host}" "${CTL_PORT}"; then + echo "[ctl] Refusing to start: a live server is already responding on ${probe_host}:${CTL_PORT}." >&2 + local listener_diag + listener_diag="$(_port_listener_diag "${CTL_PORT}")" + [[ -n "${listener_diag}" ]] && echo "[ctl] listener: ${listener_diag}" >&2 + echo "[ctl] Stop that instance first (its own supervisor may restart it — check systemctl/launchctl)." >&2 + return 2 + fi _clear_stale_pid >/dev/null 2>&1 || true local python_exe pid @@ -461,7 +596,30 @@ start_cmd() { printf '%s\n' "${pid}" > "${PID_FILE}" _write_state "${pid}" "${CTL_HOST}" "${CTL_PORT}" "${python_exe}" - sleep 0.15 + # Watch the child through its startup window instead of a single 0.15s + # aliveness check. A server that dies during startup (bad venv, import + # error, port stolen between guard and bind) exits after ~1-3s — after the + # old check had already printed success and left a stale PID file behind. + # Break early as soon as /health answers; report failure the moment the + # process dies. HERMES_WEBUI_START_GRACE (integer seconds, default 3) + # bounds the wait for servers that need longer before /health responds — + # on timeout we keep the optimistic legacy behavior and say so. + local grace="${HERMES_WEBUI_START_GRACE:-3}" + [[ "${grace}" =~ ^[0-9]+$ ]] || grace=3 + local grace_steps=$(( grace * 4 )) step=0 healthy=0 + while (( step < grace_steps )); do + if ! _is_alive "${pid}"; then + echo "[ctl] Hermes WebUI failed to stay running. Log: ${LOG_FILE}" >&2 + rm -f "${PID_FILE}" "${STATE_FILE}" + return 1 + fi + if hermes_webui_probe_health "${probe_host}" "${CTL_PORT}" "/health" 1 >/dev/null 2>&1; then + healthy=1 + break + fi + sleep 0.25 + step=$(( step + 1 )) + done if ! _is_alive "${pid}"; then echo "[ctl] Hermes WebUI failed to stay running. Log: ${LOG_FILE}" >&2 rm -f "${PID_FILE}" "${STATE_FILE}" @@ -470,6 +628,28 @@ start_cmd() { echo "[ctl] Started Hermes WebUI (PID ${pid})" echo "[ctl] Bound: ${CTL_HOST}:${CTL_PORT}" echo "[ctl] Log: ${LOG_FILE}" + if (( ! healthy )); then + echo "[ctl] Note: /health did not respond within ${grace}s; check '$(basename "$0") status' shortly." + fi +} + +_warn_if_unmanaged_instance_serving() { + # After stop concluded "nothing to do", check whether a server is STILL + # answering on the configured port. Silently reporting "stopped" while a + # foreign (e.g. systemd-supervised) instance keeps serving is how operators + # end up starting doomed duplicates. + _load_state_if_present + local host="${HOST:-${HERMES_WEBUI_HOST:-127.0.0.1}}" + local port="${PORT:-${HERMES_WEBUI_PORT:-8787}}" + local probe_host + probe_host="$(_probe_target_host "${host}")" + if _port_answers_http "${probe_host}" "${port}"; then + echo "[ctl] Warning: an instance NOT managed by ctl.sh is still serving ${probe_host}:${port} — not touching it." >&2 + local listener_diag + listener_diag="$(_port_listener_diag "${port}")" + [[ -n "${listener_diag}" ]] && echo "[ctl] listener: ${listener_diag}" >&2 + echo "[ctl] If it is supervised (systemd/launchd), stop or restart it there instead." >&2 + fi } stop_cmd() { @@ -478,11 +658,13 @@ stop_cmd() { if ! pid="$(_pid_from_file 2>/dev/null)"; then echo "[ctl] Hermes WebUI is stopped" rm -f "${PID_FILE}" "${STATE_FILE}" + _warn_if_unmanaged_instance_serving return 0 fi if ! _is_alive "${pid}" || ! _is_owned_webui_pid "${pid}"; then _clear_stale_pid + _warn_if_unmanaged_instance_serving return 0 fi @@ -551,11 +733,29 @@ status_cmd() { echo " Health: ${health}" else [[ -f "${PID_FILE}" ]] && _clear_stale_pid >/dev/null 2>&1 || true - echo "● hermes-webui — stopped" - echo " PID: -" - echo " Bound: ${host}:${port}" - echo " Log: ${log_path}" - echo " Health: not checked" + local probe_host + probe_host="$(_probe_target_host "${host}")" + if _port_answers_http "${probe_host}" "${port}"; then + # Something serves the port but ctl.sh does not own it (systemd unit, + # launchd job, manual run). Saying "stopped" here is what leads + # operators to start a doomed duplicate. + health="$(_health_line "${probe_host}" "${port}")" + echo "● hermes-webui — running (not managed by ctl.sh)" + local listener_diag + listener_diag="$(_port_listener_diag "${port}")" + echo " PID: -" + [[ -n "${listener_diag}" ]] && echo " Listener: ${listener_diag}" + echo " Bound: ${host}:${port}" + echo " Log: ${log_path}" + echo " Health: ${health}" + echo " Note: manage it via its own supervisor (systemctl/launchctl) or the process directly." + else + echo "● hermes-webui — stopped" + echo " PID: -" + echo " Bound: ${host}:${port}" + echo " Log: ${log_path}" + echo " Health: not checked" + fi fi } diff --git a/tests/test_ctl_foreign_server_guard.py b/tests/test_ctl_foreign_server_guard.py new file mode 100644 index 00000000..c0d05e5b --- /dev/null +++ b/tests/test_ctl_foreign_server_guard.py @@ -0,0 +1,367 @@ +"""ctl.sh must not double-start against a foreign/supervised WebUI instance. + +Field failure this pins down: a systemd-supervised WebUI (Restart=always) was +serving :8787 while ctl.sh's PID file was stale. `ctl.sh stop` said "stopped", +`ctl.sh restart` spawned a bootstrap that died ~2s later on server.py's +"already responding" startup check — AFTER ctl.sh's old 0.15s aliveness gate +had printed "Started" and recorded the doomed PID. Killing the foreign server +by hand then put systemd's auto-restart into a race with ctl.sh's own start, +ending in a permanent 5s crash loop (each systemd attempt aborting against the +ctl.sh-started server). + +Guards under test: +- start refuses when anything already answers HTTP(S) on the target port. +- start refuses when the hermes-webui systemd unit is active on our port or + mid-auto-restart (activating), instead of racing its next respawn. +- start reports failure (and cleans the PID file) when the spawned server dies + during the startup window instead of printing success after 0.15s. +- status/stop surface a running unmanaged instance instead of claiming + "stopped" and never touch a process ctl.sh does not own. +""" + +import os +import socket +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest + +from tests.test_ctl_script import ( + bash_path, + run_ctl, + write_fake_python, +) + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _start_dummy_http_server(port: int, status: int = 200) -> subprocess.Popen: + """A minimal HTTP server answering every request with `status`.""" + code = textwrap.dedent( + f""" + from http.server import BaseHTTPRequestHandler, HTTPServer + + class H(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response({status}) + body = b'{{"status": "ok", "sessions": 0, "active_streams": 0}}' + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + HTTPServer(("127.0.0.1", {port}), H).serve_forever() + """ + ) + proc = subprocess.Popen([sys.executable, "-c", code]) + deadline = time.time() + 5 + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return proc + except OSError: + time.sleep(0.05) + proc.terminate() + raise AssertionError("dummy HTTP server did not come up") + + +def _stop_proc(proc: subprocess.Popen) -> None: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + + +def _write_fake_systemctl(fake_bin: Path, active_state: str, main_pid: int = 0) -> None: + """Fake systemctl answering `show -p ActiveState|MainPID --value `.""" + systemctl = fake_bin / "systemctl" + systemctl.write_text( + textwrap.dedent( + f""" + #!/usr/bin/env bash + for arg in "$@"; do + case "${{arg}}" in + *ActiveState*) echo "{active_state}"; exit 0 ;; + *MainPID*) echo "{main_pid}"; exit 0 ;; + esac + done + exit 0 + """ + ).lstrip(), + encoding="utf-8", + ) + systemctl.chmod(0o755) + + +def _write_fake_port_tools(fake_bin: Path, pid_listens: bool) -> None: + """Fake lsof/ss so _pid_listens_on_port resolves deterministically.""" + lsof = fake_bin / "lsof" + lsof.write_text( + "#!/usr/bin/env bash\n" + ("exit 0\n" if pid_listens else "exit 1\n"), + encoding="utf-8", + ) + lsof.chmod(0o755) + # _pid_listens_on_port prefers lsof; keep a fake ss anyway so the shim + # works on hosts without lsof, where the ss branch runs instead. + ss = fake_bin / "ss" + if pid_listens: + ss.write_text( + '#!/usr/bin/env bash\n' + 'echo "LISTEN 0 64 0.0.0.0:8787 0.0.0.0:* users:((\\"python\\",pid=4242,fd=7))"\n', + encoding="utf-8", + ) + else: + ss.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + ss.chmod(0o755) + + +def _guard_env(fake_bin: Path | None = None, **extra: str) -> dict[str, str]: + env = { + "HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1", + } + if fake_bin is not None: + env["PATH"] = f"{bash_path(fake_bin)}{os.pathsep}{os.environ.get('PATH', '')}" + env.update(extra) + return env + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_start_refuses_when_port_already_serving(tmp_path): + port = _free_port() + server = _start_dummy_http_server(port) + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 2, combined + assert "already responding" in combined + assert not (tmp_path / ".hermes" / "webui.pid").exists() + finally: + _stop_proc(server) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_start_refuses_even_when_squatter_answers_http_errors(tmp_path): + """server.py treats ANY response bytes as a conflict; the guard must match + (a 404 from a foreign app squatting the port still dooms our server).""" + port = _free_port() + server = _start_dummy_http_server(port, status=404) + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 2, combined + assert "already responding" in combined + finally: + _stop_proc(server) + + +@pytest.mark.skipif(sys.platform == "win32", reason="systemd is a Linux/POSIX path") +def test_start_refuses_when_systemd_unit_is_auto_restarting(tmp_path): + """ActiveState=activating means the unit is between Restart= attempts: the + port is briefly silent, but starting now traps the unit in a crash loop.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_systemctl(fake_bin, "activating") + + result = run_ctl( + tmp_path, + "start", + env=_guard_env(fake_bin), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 2, combined + assert "activating" in combined + assert "systemctl" in combined + assert not (tmp_path / ".hermes" / "webui.pid").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="systemd is a Linux/POSIX path") +def test_start_refuses_when_systemd_unit_active_on_our_port(tmp_path): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_systemctl(fake_bin, "active", main_pid=4242) + _write_fake_port_tools(fake_bin, pid_listens=True) + + result = run_ctl( + tmp_path, + "start", + env=_guard_env(fake_bin), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 2, combined + assert "4242" in combined + assert not (tmp_path / ".hermes" / "webui.pid").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="systemd is a Linux/POSIX path") +def test_start_allows_alternate_port_while_systemd_unit_auto_restarts(tmp_path): + """Mirror of the launchd #3291 over-block fix: an auto-restarting default + unit must not block a test instance on a different port.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_systemctl(fake_bin, "activating") + + fake_python = tmp_path / "fake-python" + fake_log = tmp_path / "fake-python.log" + write_fake_python(fake_python) + + port = _free_port() + started_pid = None + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + fake_bin, + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_PYTHON=str(fake_python), + FAKE_PYTHON_LOG=str(fake_log), + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert "Refusing to start" not in combined, combined + assert result.returncode == 0, combined + pid_file = tmp_path / ".hermes" / "webui.pid" + if pid_file.exists(): + started_pid = int(pid_file.read_text().strip()) + finally: + if started_pid: + try: + os.kill(started_pid, 9) + except ProcessLookupError: + pass + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_start_proceeds_when_systemd_unit_inactive(tmp_path): + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_systemctl(fake_bin, "inactive") + + fake_python = tmp_path / "fake-python" + fake_log = tmp_path / "fake-python.log" + write_fake_python(fake_python) + + port = _free_port() + started_pid = None + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + fake_bin, + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_PYTHON=str(fake_python), + FAKE_PYTHON_LOG=str(fake_log), + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert "Refusing to start" not in combined, combined + assert result.returncode == 0, combined + started_pid = int((tmp_path / ".hermes" / "webui.pid").read_text().strip()) + finally: + if started_pid: + try: + os.kill(started_pid, 9) + except ProcessLookupError: + pass + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_start_reports_failure_when_server_dies_during_startup(tmp_path): + """A server that exits ~0.5s in (import error, stolen port) must yield a + failure and no stale PID file — not '[ctl] Started'.""" + dying_python = tmp_path / "dying-python" + dying_python.write_text( + "#!/usr/bin/env bash\nsleep 0.5\nexit 1\n", + encoding="utf-8", + ) + dying_python.chmod(0o755) + + port = _free_port() + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_PYTHON=str(dying_python), + HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 1, combined + assert "failed to stay running" in combined + assert "Started Hermes WebUI" not in combined + assert not (tmp_path / ".hermes" / "webui.pid").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_status_reports_unmanaged_running_instance(tmp_path): + port = _free_port() + server = _start_dummy_http_server(port) + try: + result = run_ctl( + tmp_path, + "status", + env=_guard_env(HERMES_WEBUI_PORT=str(port)), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, combined + assert "running (not managed by ctl.sh)" in combined + assert "stopped" not in combined + finally: + _stop_proc(server) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_stop_warns_about_unmanaged_instance_and_leaves_it_alone(tmp_path): + port = _free_port() + server = _start_dummy_http_server(port) + try: + result = run_ctl( + tmp_path, + "stop", + env=_guard_env(HERMES_WEBUI_PORT=str(port)), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, combined + assert "NOT managed by ctl.sh" in combined + assert server.poll() is None, "ctl.sh stop must not kill a foreign server" + # The foreign server must still answer after stop returned. + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + finally: + _stop_proc(server) diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index 27ba3267..31e1bd44 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -269,6 +269,11 @@ def test_start_can_ignore_repo_dotenv_for_authoritative_test_env(tmp_path): "HERMES_WEBUI_PYTHON": str(fake_python), "FAKE_PYTHON_LOG": str(fake_log), "HERMES_WEBUI_CTL_ALLOW_LAUNCHD_CONFLICT": "1", + # This test exercises dotenv precedence on the DEFAULT port; keep + # it hermetic on developer machines where a real WebUI (systemd + # unit or manual run) is serving 8787. + "HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT": "1", + "HERMES_WEBUI_CTL_ALLOW_PORT_CONFLICT": "1", }, repo_root=repo_root, ) From 1272ccb4eb572a579d92690b3a013f6f9c30bf86 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:58:35 +0200 Subject: [PATCH 10/82] fix(ctl): harden the foreign-instance guards per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from the Greptile review on #5944: - Bracket IPv6 literals in the probe target ('::1' -> '[::1]') so the URL-based responder checks don't silently miss a running instance. - Force direct connections in the local ownership probes: --noproxy '*' (curl) / --no-proxy (wget) in _port_answers_http, and neutralized proxy env around the startup-grace health probe — a configured http(s)_proxy would report the proxy instead of the port. - Clamp HERMES_WEBUI_START_GRACE=0 to the default: a zero window would skip startup monitoring entirely and restore the stale-PID behavior the window exists to prevent. - stop: warn about an unmanaged instance BEFORE deleting the state file — it carries the saved host/port binding the probe needs when the instance was started off-default. Co-Authored-By: Claude Fable 5 --- ctl.sh | 31 ++++-- tests/test_ctl_foreign_server_guard.py | 147 +++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 9 deletions(-) diff --git a/ctl.sh b/ctl.sh index e062356f..f693aaee 100755 --- a/ctl.sh +++ b/ctl.sh @@ -440,10 +440,15 @@ _launchd_webui_pid() { _probe_target_host() { # A bind-all host (0.0.0.0 / ::) is not a connectable probe target; probe - # loopback instead (mirrors server.py _abort_if_already_serving). - case "${1:-}" in + # loopback instead (mirrors server.py _abort_if_already_serving). IPv6 + # literals must be bracketed for URL interpolation ("http://[::1]:8787"), + # or curl/wget reject the URL and an existing instance is missed. + local host="${1:-}" + case "${host}" in 0.0.0.0 | '' | ::) printf '127.0.0.1' ;; - *) printf '%s' "$1" ;; + \[*\]) printf '%s' "${host}" ;; + *:*) printf '[%s]' "${host}" ;; + *) printf '%s' "${host}" ;; esac } @@ -458,12 +463,14 @@ _port_answers_http() { url="${scheme}://${host}:${port}/health" if command -v curl >/dev/null 2>&1; then # No -f: an HTTP error status is still a responder. -k: a self-signed - # cert is still a responder. - curl -sS -o /dev/null -k --max-time 2 "${url}" 2>/dev/null + # cert is still a responder. --noproxy: this is a LOCAL ownership + # check — routed through an http(s)_proxy it would report the proxy, + # not the port (false block or, with a dead proxy, a missed conflict). + curl -sS -o /dev/null -k --noproxy '*' --max-time 2 "${url}" 2>/dev/null rc=$? [[ ${rc} -eq 0 ]] && return 0 elif command -v wget >/dev/null 2>&1; then - wget -qO /dev/null --no-check-certificate "--timeout=2" --tries=1 "${url}" 2>/dev/null + wget -qO /dev/null --no-check-certificate --no-proxy "--timeout=2" --tries=1 "${url}" 2>/dev/null rc=$? # 0 = OK; 8 = server answered with an error status — both are responders. [[ ${rc} -eq 0 || ${rc} -eq 8 ]] && return 0 @@ -606,6 +613,9 @@ start_cmd() { # on timeout we keep the optimistic legacy behavior and say so. local grace="${HERMES_WEBUI_START_GRACE:-3}" [[ "${grace}" =~ ^[0-9]+$ ]] || grace=3 + # 0 would skip startup monitoring entirely and restore the stale-PID + # behavior this window exists to prevent; treat it like any invalid value. + (( grace > 0 )) || grace=3 local grace_steps=$(( grace * 4 )) step=0 healthy=0 while (( step < grace_steps )); do if ! _is_alive "${pid}"; then @@ -613,7 +623,8 @@ start_cmd() { rm -f "${PID_FILE}" "${STATE_FILE}" return 1 fi - if hermes_webui_probe_health "${probe_host}" "${CTL_PORT}" "/health" 1 >/dev/null 2>&1; then + if http_proxy='' https_proxy='' HTTP_PROXY='' HTTPS_PROXY='' \ + hermes_webui_probe_health "${probe_host}" "${CTL_PORT}" "/health" 1 >/dev/null 2>&1; then healthy=1 break fi @@ -657,14 +668,16 @@ stop_cmd() { local pid if ! pid="$(_pid_from_file 2>/dev/null)"; then echo "[ctl] Hermes WebUI is stopped" - rm -f "${PID_FILE}" "${STATE_FILE}" + # Warn BEFORE deleting the state file: it carries the saved host/port + # binding the probe needs when the instance was started off-default. _warn_if_unmanaged_instance_serving + rm -f "${PID_FILE}" "${STATE_FILE}" return 0 fi if ! _is_alive "${pid}" || ! _is_owned_webui_pid "${pid}"; then - _clear_stale_pid _warn_if_unmanaged_instance_serving + _clear_stale_pid return 0 fi diff --git a/tests/test_ctl_foreign_server_guard.py b/tests/test_ctl_foreign_server_guard.py index c0d05e5b..6fe402c8 100644 --- a/tests/test_ctl_foreign_server_guard.py +++ b/tests/test_ctl_foreign_server_guard.py @@ -365,3 +365,150 @@ def test_stop_warns_about_unmanaged_instance_and_leaves_it_alone(tmp_path): pass finally: _stop_proc(server) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_port_guard_ignores_http_proxy_env(tmp_path): + """The ownership probe must force a direct connection: a configured + http_proxy would report the proxy, not the local port (Greptile P1 on + #5944) — a dead proxy hides an occupied port and start proceeds doomed.""" + port = _free_port() + server = _start_dummy_http_server(port) + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1", + # Dead proxy: any probe routed through it sees no responder. + http_proxy="http://127.0.0.1:1", + https_proxy="http://127.0.0.1:1", + HTTP_PROXY="http://127.0.0.1:1", + HTTPS_PROXY="http://127.0.0.1:1", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 2, combined + assert "already responding" in combined + finally: + _stop_proc(server) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_ipv6_host_probe_builds_bracketed_url(tmp_path): + """HERMES_WEBUI_HOST='::1' must probe http://[::1]:port — the unbracketed + literal is rejected by curl/wget and the running instance is missed.""" + if not socket.has_ipv6: + pytest.skip("no IPv6 support") + port = _free_port() + code = textwrap.dedent( + f""" + import socket + from http.server import BaseHTTPRequestHandler, HTTPServer + + class V6Server(HTTPServer): + address_family = socket.AF_INET6 + + class H(BaseHTTPRequestHandler): + def do_GET(self): + body = b'{{"status": "ok"}}' + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + V6Server(("::1", {port}), H).serve_forever() + """ + ) + try: + server = subprocess.Popen([sys.executable, "-c", code]) + except OSError: + pytest.skip("cannot bind ::1") + try: + deadline = time.time() + 5 + up = False + while time.time() < deadline: + if server.poll() is not None: + pytest.skip("IPv6 loopback unavailable") + try: + with socket.create_connection(("::1", port), timeout=0.2): + up = True + break + except OSError: + time.sleep(0.05) + assert up, "IPv6 dummy server did not come up" + + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + HERMES_WEBUI_HOST="::1", + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 2, combined + assert "already responding" in combined + finally: + _stop_proc(server) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_zero_start_grace_still_monitors_startup(tmp_path): + """HERMES_WEBUI_START_GRACE=0 must not disable the startup watch — that + would restore the exact stale-PID-on-early-death behavior being fixed.""" + dying_python = tmp_path / "dying-python" + dying_python.write_text( + "#!/usr/bin/env bash\nsleep 0.5\nexit 1\n", + encoding="utf-8", + ) + dying_python.chmod(0o755) + + port = _free_port() + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_PYTHON=str(dying_python), + HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1", + HERMES_WEBUI_START_GRACE="0", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 1, combined + assert "failed to stay running" in combined + assert not (tmp_path / ".hermes" / "webui.pid").exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_stop_warns_using_saved_binding_from_state_file(tmp_path): + """With no PID file but a saved off-default binding in the state file, + stop must probe THAT binding (before deleting the file) and still warn + about the unmanaged instance.""" + port = _free_port() + server = _start_dummy_http_server(port) + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "webui.ctl.env").write_text( + f"PID=999999\nHOST=127.0.0.1\nPORT={port}\n", + encoding="utf-8", + ) + try: + # No HERMES_WEBUI_PORT in the environment: the probe target must come + # from the saved state file. + result = run_ctl(tmp_path, "stop", env=_guard_env(), timeout=15) + combined = result.stdout + result.stderr + assert result.returncode == 0, combined + assert "NOT managed by ctl.sh" in combined + assert server.poll() is None + finally: + _stop_proc(server) From ee7af858845695c370f60789b0ad62eea450248e Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:34:24 +0200 Subject: [PATCH 11/82] fix(ctl): keep listener diagnostics best-effort --- ctl.sh | 5 +++- tests/test_ctl_foreign_server_guard.py | 34 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/ctl.sh b/ctl.sh index f693aaee..fe26185f 100755 --- a/ctl.sh +++ b/ctl.sh @@ -491,7 +491,10 @@ _port_listener_diag() { if [[ -z "${line}" ]] && command -v lsof >/dev/null 2>&1; then line="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print; exit}')" fi - [[ -n "${line}" ]] && printf '%s' "${line}" + if [[ -n "${line}" ]]; then + printf '%s' "${line}" + fi + return 0 } _systemd_webui_conflict() { diff --git a/tests/test_ctl_foreign_server_guard.py b/tests/test_ctl_foreign_server_guard.py index 6fe402c8..b095cf05 100644 --- a/tests/test_ctl_foreign_server_guard.py +++ b/tests/test_ctl_foreign_server_guard.py @@ -345,6 +345,40 @@ def test_status_reports_unmanaged_running_instance(tmp_path): _stop_proc(server) +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +@pytest.mark.parametrize( + ("command", "expected_output"), + [ + ("status", "running (not managed by ctl.sh)"), + ("stop", "NOT managed by ctl.sh"), + ], +) +def test_unmanaged_instance_commands_survive_missing_listener_diagnostics( + tmp_path, command, expected_output +): + """Best-effort listener diagnostics must not abort status/stop under set -e + when ss/lsof are unavailable or return no matching listener row.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_port_tools(fake_bin, pid_listens=False) + port = _free_port() + server = _start_dummy_http_server(port) + try: + result = run_ctl( + tmp_path, + command, + env=_guard_env(fake_bin, HERMES_WEBUI_PORT=str(port)), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, combined + assert expected_output in combined + assert "Listener:" not in combined + assert server.poll() is None, "ctl.sh must leave a foreign server untouched" + finally: + _stop_proc(server) + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") def test_stop_warns_about_unmanaged_instance_and_leaves_it_alone(tmp_path): port = _free_port() From 7ab9f6ee5c1caa8eeba5a38564a75446cf242d46 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:39:37 +0200 Subject: [PATCH 12/82] fix: bypass proxies in ctl startup health probe --- ctl.sh | 3 +- scripts/lib/health_probe.sh | 46 ++++++++++++------ tests/test_ctl_foreign_server_guard.py | 67 ++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/ctl.sh b/ctl.sh index fe26185f..95164be8 100755 --- a/ctl.sh +++ b/ctl.sh @@ -626,8 +626,7 @@ start_cmd() { rm -f "${PID_FILE}" "${STATE_FILE}" return 1 fi - if http_proxy='' https_proxy='' HTTP_PROXY='' HTTPS_PROXY='' \ - hermes_webui_probe_health "${probe_host}" "${CTL_PORT}" "/health" 1 >/dev/null 2>&1; then + if hermes_webui_probe_health "${probe_host}" "${CTL_PORT}" "/health" 1 direct >/dev/null 2>&1; then healthy=1 break fi diff --git a/scripts/lib/health_probe.sh b/scripts/lib/health_probe.sh index 89141231..477adbaa 100644 --- a/scripts/lib/health_probe.sh +++ b/scripts/lib/health_probe.sh @@ -58,31 +58,49 @@ _hermes_webui_warn_self_signed() { "$1" >&2 } -# _hermes_webui_http_get +# _hermes_webui_http_get [direct] # mode: "insecure" disables certificate verification, anything else verifies. +# direct: "direct" bypasses every configured proxy. # Prints the response body to stdout; returns the underlying client exit code. _hermes_webui_http_get() { - local url="$1" max_time="$2" mode="$3" + local url="$1" max_time="$2" mode="$3" direct="${4:-}" if command -v curl >/dev/null 2>&1; then if [[ "${mode}" == "insecure" ]]; then - curl -fsS -k --max-time "${max_time}" "${url}" 2>/dev/null + if [[ "${direct}" == "direct" ]]; then + curl -fsS -k --noproxy '*' --max-time "${max_time}" "${url}" 2>/dev/null + else + curl -fsS -k --max-time "${max_time}" "${url}" 2>/dev/null + fi else - curl -fsS --max-time "${max_time}" "${url}" 2>/dev/null + if [[ "${direct}" == "direct" ]]; then + curl -fsS --noproxy '*' --max-time "${max_time}" "${url}" 2>/dev/null + else + curl -fsS --max-time "${max_time}" "${url}" 2>/dev/null + fi fi return $? elif command -v wget >/dev/null 2>&1; then if [[ "${mode}" == "insecure" ]]; then - wget -qO- --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + if [[ "${direct}" == "direct" ]]; then + wget -qO- --no-proxy --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + else + wget -qO- --no-check-certificate "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + fi else - wget -qO- "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + if [[ "${direct}" == "direct" ]]; then + wget -qO- --no-proxy "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + else + wget -qO- "--timeout=${max_time}" --tries=1 "${url}" 2>/dev/null + fi fi return $? fi return 127 } -# hermes_webui_probe_health [path] [max_time] -# Prints the response body to stdout on success; warnings go to stderr. +# hermes_webui_probe_health [path] [max_time] [direct] +# Pass "direct" to bypass every configured proxy for all probe attempts. +# Prints the response body to stdout; warnings go to stderr. # Returns 0 if the server answered, 1 otherwise. # # Side effect: sets the global _HERMES_WEBUI_PROBE_SCHEME to the scheme that @@ -91,14 +109,14 @@ _hermes_webui_http_get() { # back to plain HTTP when the cert/key are unloadable — so the configured # scheme can be https:// while the live server speaks http://. hermes_webui_probe_health() { - local host="$1" port="$2" path="${3:-/health}" max_time="${4:-2}" + local host="$1" port="$2" path="${3:-/health}" max_time="${4:-2}" direct="${5:-}" local scheme body scheme="$(hermes_webui_probe_scheme)" local http_url="http://${host}:${port}${path}" if [[ "${scheme}" == "http" ]]; then - if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "")"; then + if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "" "${direct}")"; then _HERMES_WEBUI_PROBE_SCHEME="http" printf '%s' "${body}" return 0 @@ -111,20 +129,20 @@ hermes_webui_probe_health() { if _hermes_webui_truthy "${HERMES_WEBUI_TLS_INSECURE_PROBE:-}"; then # Explicit opt-in: skip verification, stay silent by contract. - if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure")"; then + if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure" "${direct}")"; then _HERMES_WEBUI_PROBE_SCHEME="https" printf '%s' "${body}" return 0 fi else # 1) Verified HTTPS. - if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "")"; then + if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "" "${direct}")"; then _HERMES_WEBUI_PROBE_SCHEME="https" printf '%s' "${body}" return 0 fi # 2) Self-signed fallback: verification failed, retry unverified + warn. - if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure")"; then + if body="$(_hermes_webui_http_get "${https_url}" "${max_time}" "insecure" "${direct}")"; then _hermes_webui_warn_self_signed "${https_url}" _HERMES_WEBUI_PROBE_SCHEME="https" printf '%s' "${body}" @@ -133,7 +151,7 @@ hermes_webui_probe_health() { fi # 3) server.py may have fallen back to plain HTTP (cert/key unloadable). - if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "")"; then + if body="$(_hermes_webui_http_get "${http_url}" "${max_time}" "" "${direct}")"; then _HERMES_WEBUI_PROBE_SCHEME="http" printf '%s' "${body}" return 0 diff --git a/tests/test_ctl_foreign_server_guard.py b/tests/test_ctl_foreign_server_guard.py index b095cf05..3a24ff8e 100644 --- a/tests/test_ctl_foreign_server_guard.py +++ b/tests/test_ctl_foreign_server_guard.py @@ -430,6 +430,73 @@ def test_port_guard_ignores_http_proxy_env(tmp_path): _stop_proc(server) +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_start_health_probe_ignores_all_proxy_env(tmp_path): + """The startup watch must reach its own local health server directly. + + Unlike the preflight ownership guard above, this drives the post-launch + health loop: a dead ALL_PROXY must not make ctl wait out its grace period + and print the misleading health-timeout note. + """ + port = _free_port() + health_server = tmp_path / "health-server" + health_server.write_text( + textwrap.dedent( + """ + #!/usr/bin/env python3 + import sys + from http.server import BaseHTTPRequestHandler, HTTPServer + + class HealthHandler(BaseHTTPRequestHandler): + def do_GET(self): + body = b'{"status": "ok"}' + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + HTTPServer(("127.0.0.1", int(sys.argv[-1])), HealthHandler).serve_forever() + """ + ).lstrip(), + encoding="utf-8", + ) + health_server.chmod(0o755) + + started_pid = None + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_PYTHON=str(health_server), + HERMES_WEBUI_CTL_ALLOW_SYSTEMD_CONFLICT="1", + # A live local server must still win when both proxy spellings + # route clients to a dead endpoint. + ALL_PROXY="http://127.0.0.1:1", + all_proxy="http://127.0.0.1:1", + NO_PROXY="", + no_proxy="", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + pid_file = tmp_path / ".hermes" / "webui.pid" + if pid_file.exists(): + started_pid = int(pid_file.read_text().strip()) + assert result.returncode == 0, combined + assert "/health did not respond" not in combined + finally: + if started_pid: + try: + os.kill(started_pid, 9) + except ProcessLookupError: + pass + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") def test_ipv6_host_probe_builds_bracketed_url(tmp_path): """HERMES_WEBUI_HOST='::1' must probe http://[::1]:port — the unbracketed From 5d3b76bd13aae341fa2c9635a33a4793f5c5e25d Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:43:05 +0200 Subject: [PATCH 13/82] test: allow startup monitor cleanup in dotenv test --- tests/test_ctl_script.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index 31e1bd44..ebfe6fbd 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -354,6 +354,9 @@ def test_start_loads_dotenv_double_quoted_port_with_trailing_comment(tmp_path): }, repo_root=repo_root, load_dotenv=True, + # The legitimate three-second startup monitor plus suite cleanup can + # exceed run_ctl's narrow default budget on a loaded test worker. + timeout=15, ) assert result.returncode == 0, result.stderr + result.stdout From 96bb78b1c16bb9b08b320f80f6037b8c7545c047 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:46:23 +0200 Subject: [PATCH 14/82] test: give ctl start fixtures startup grace --- tests/test_ctl_script.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_ctl_script.py b/tests/test_ctl_script.py index ebfe6fbd..47b3b437 100644 --- a/tests/test_ctl_script.py +++ b/tests/test_ctl_script.py @@ -27,7 +27,7 @@ def run_ctl( home: Path, *args: str, env: dict[str, str] | None = None, - timeout: float = 5.0, + timeout: float = 15.0, repo_root: Path = REPO_ROOT, load_dotenv: bool = False, ): @@ -354,9 +354,6 @@ def test_start_loads_dotenv_double_quoted_port_with_trailing_comment(tmp_path): }, repo_root=repo_root, load_dotenv=True, - # The legitimate three-second startup monitor plus suite cleanup can - # exceed run_ctl's narrow default budget on a loaded test worker. - timeout=15, ) assert result.returncode == 0, result.stderr + result.stdout From 0dc16dcce8abdc251ee4d5e9d7a223043b1582f1 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:32:56 +0200 Subject: [PATCH 15/82] fix(config): restore read-only-target protection for atomic writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding 2 (10.07 gate): a deliberately locked config (0444) in a writable directory was silently overwritten — atomic replace creates a fresh temp inode and renames over the read-only file, defeating the old in-place Path.write_text PermissionError contract. Probe the existing target with a non-truncating O_WRONLY open before any replacement work and let the PermissionError propagate. The probe fstat()s the fd it actually opened and hands that stat to the rest of the write, so a concurrent writer replacing the inode between stat and probe refreshes the metadata instead of failing (keeps concurrent-writer semantics). Regression: writable parent + 0444 target now raises and keeps the original bytes and mode. Co-Authored-By: Claude Fable 5 --- api/paths.py | 42 ++++++++++++++++++++---- tests/test_5774b_atomic_config_writes.py | 20 +++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/api/paths.py b/api/paths.py index 7d167b10..358352d9 100644 --- a/api/paths.py +++ b/api/paths.py @@ -83,6 +83,28 @@ def _has_extended_attributes(path: Path) -> bool: raise +def _require_writable_target(write_path: Path) -> os.stat_result | None: + """Reject writes to a read-only target before any replacement work. + + ``os.replace`` happily swaps a fresh inode over a ``0444`` file when the + directory is writable, which would silently defeat a deliberately locked + config. The old in-place ``Path.write_text`` raised ``PermissionError`` + there; preserve that contract with a non-truncating ``O_WRONLY`` probe, + letting the failure propagate. Returns the ``fstat`` of the inode the + probe actually opened (so the caller's metadata describes the verified + file even if a concurrent writer replaced it since its own ``stat``), or + ``None`` if the target vanished concurrently. + """ + try: + probe_fd = os.open(write_path, os.O_WRONLY) + except FileNotFoundError: + return None + try: + return os.fstat(probe_fd) + finally: + os.close(probe_fd) + + def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> None: """Atomically replace *path* with *text*. @@ -168,13 +190,19 @@ def _atomic_write_text(path: Path, text: str, *, encoding: str = "utf-8") -> Non if owns_fallback_fd: os.close(fallback_fd) - if ( - existing_stat is not None - and stat.S_ISREG(existing_stat.st_mode) - and (existing_stat.st_nlink > 1 or _has_extended_attributes(write_path)) - ): - _write_in_place() - return + if existing_stat is not None and stat.S_ISREG(existing_stat.st_mode): + probed_stat = _require_writable_target(write_path) + if probed_stat is not None and stat.S_ISREG(probed_stat.st_mode): + existing_stat = probed_stat + mode = stat.S_IMODE(existing_stat.st_mode) + else: + existing_stat = probed_stat + mode = None + if existing_stat is not None and ( + existing_stat.st_nlink > 1 or _has_extended_attributes(write_path) + ): + _write_in_place() + return try: fd, tmp = _create_atomic_temp_file(write_path, existing=existing_stat is not None) diff --git a/tests/test_5774b_atomic_config_writes.py b/tests/test_5774b_atomic_config_writes.py index bfe2d48b..179320ac 100644 --- a/tests/test_5774b_atomic_config_writes.py +++ b/tests/test_5774b_atomic_config_writes.py @@ -786,6 +786,26 @@ def test_readonly_parent_without_writable_target_still_raises( @_ROOT_SKIP +def test_writable_parent_with_readonly_file_raises_and_keeps_bytes(tmp_path: Path) -> None: + """A deliberately locked config (0444) in a writable directory must keep + rejecting writes: without the target-writability probe, atomic replace + creates a fresh temp inode and renames over the read-only file.""" + cfg_dir = tmp_path / "open-dir" + cfg_dir.mkdir() + target = cfg_dir / "config.yaml" + original = "model:\n default: keep-me\n" + target.write_text(original, encoding="utf-8") + os.chmod(target, 0o444) + os.chmod(cfg_dir, 0o755) + try: + with pytest.raises(PermissionError): + _atomic_write_text(target, "model:\n default: new\n") + assert target.read_text(encoding="utf-8") == original + assert stat.S_IMODE(os.stat(target).st_mode) == 0o444 + finally: + os.chmod(target, 0o644) + + def test_readonly_parent_with_unwritable_file_still_raises(tmp_path: Path) -> None: cfg_dir = tmp_path / "locked" cfg_dir.mkdir() From a248aa52de157abc11f3995b627759bb2a2cb4cd Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:47:03 +0200 Subject: [PATCH 16/82] fix(ctl): close all three 12.07 re-gate findings on the systemd guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. inherit_errexit: the listener-diagnostic assignments in _port_listener_diag abort status/stop when errexit is inherited into command substitutions (shopt inherit_errexit or a BASHOPTS env from the invoking shell). Guard both assignments with || true; regression test runs status/stop under BASHOPTS=inherit_errexit. 2. PID/port AND: _pid_listens_on_port called lsof -p PID -iTCP:PORT without -a, which OR-combines the selectors — any socket of the PID or any listener on the port matched, so an active unit could be blamed for a port its MainPID does not listen on. Add -a; the new fake lsof mimics real OR/AND semantics so a missing -a fails the test. 3. Binding-aware collision: an active/activating unit whose ownership could not be attributed via MainPID was assumed to own port 8787 unconditionally. Resolve the unit's configured binding first (HERMES_WEBUI_PORT from Environment=, then --port from ExecStart=) and refuse only on actual overlap; the default-port guard remains solely for undeterminable bindings (#3291 semantics). Tests cover the reverse alternate-port case (unit on 9999, start on 8787 proceeds) and the overlap case (unit on the requested port refuses). Co-Authored-By: Claude Fable 5 --- ctl.sh | 51 ++++++- tests/test_ctl_foreign_server_guard.py | 189 ++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 9 deletions(-) diff --git a/ctl.sh b/ctl.sh index 95164be8..f4a1a426 100755 --- a/ctl.sh +++ b/ctl.sh @@ -385,7 +385,7 @@ _pid_listens_on_port() { local pid="$1" port="$2" [[ "${pid}" =~ ^[0-9]+$ && "${port}" =~ ^[0-9]+$ ]] || return 2 if command -v lsof >/dev/null 2>&1; then - if lsof -nP -p "${pid}" -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then + if lsof -nP -a -p "${pid}" -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then return 0 # PID is listening on that port → real conflict fi return 1 # PID is alive but NOT listening on that port → no conflict @@ -485,11 +485,14 @@ _port_listener_diag() { # Best-effort one-liner describing what listens on TCP port $1. Used in # error/status messages only — never fails the caller. local port="$1" line="" + # The || true keeps the assignments non-failing when errexit is inherited + # into command substitutions (shopt inherit_errexit, or a BASHOPTS env from + # the invoking shell) — without it a no-match awk/ss aborts status/stop. if command -v ss >/dev/null 2>&1; then - line="$(ss -tlnp 2>/dev/null | awk -v p="${port}" '$4 ~ (":" p "$") {print; exit}')" + line="$(ss -tlnp 2>/dev/null | awk -v p="${port}" '$4 ~ (":" p "$") {print; exit}')" || true fi if [[ -z "${line}" ]] && command -v lsof >/dev/null 2>&1; then - line="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print; exit}')" + line="$(lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print; exit}')" || true fi if [[ -n "${line}" ]]; then printf '%s' "${line}" @@ -497,6 +500,25 @@ _port_listener_diag() { return 0 } +_systemd_unit_effective_port() { + # Best-effort resolution of the port a systemd unit is configured to bind: + # HERMES_WEBUI_PORT in its Environment=, then an explicit --port on its + # ExecStart=. Prints nothing when the binding cannot be determined so the + # caller can fall back to the conservative default-port guard. + local scope="$1" unit="$2" env_block="" exec_block="" port="" + env_block="$(systemctl "${scope}" show -p Environment --value "${unit}" 2>/dev/null)" || true + if [[ "${env_block}" =~ HERMES_WEBUI_PORT=([0-9]+) ]]; then + port="${BASH_REMATCH[1]}" + fi + if [[ -z "${port}" ]]; then + exec_block="$(systemctl "${scope}" show -p ExecStart --value "${unit}" 2>/dev/null)" || true + if [[ "${exec_block}" =~ --port[=[:space:]]([0-9]+) ]]; then + port="${BASH_REMATCH[1]}" + fi + fi + printf '%s' "${port}" +} + _systemd_webui_conflict() { # Linux analog of _launchd_webui_pid: echo a short conflict descriptor and # return 0 when a systemd unit (default hermes-webui.service, override via @@ -516,7 +538,7 @@ _systemd_webui_conflict() { local unit="${HERMES_WEBUI_SYSTEMD_UNIT:-hermes-webui.service}" [[ -n "${unit}" ]] || return 1 local want_port="${CTL_PORT:-${HERMES_WEBUI_PORT:-8787}}" - local scope state main_pid + local scope state main_pid unit_port for scope in --system --user; do state="$(systemctl "${scope}" show -p ActiveState --value "${unit}" 2>/dev/null)" || continue case "${state}" in @@ -530,14 +552,29 @@ _systemd_webui_conflict() { 1) continue ;; # active on a different port → no conflict esac fi - # Port ownership unknown: guard only the default port (see #3291). - if [[ "${want_port}" == "8787" ]]; then + # Port ownership unknown via PID: resolve the unit's CONFIGURED + # binding and refuse only on actual overlap. Only when the binding + # cannot be determined fall back to guarding the default port + # (see #3291) so alternate-port instances are never wrongly blocked. + unit_port="$(_systemd_unit_effective_port "${scope}" "${unit}")" || true + if [[ -n "${unit_port}" ]]; then + if [[ "${unit_port}" == "${want_port}" ]]; then + printf 'unit %s is active (configured for port %s)' "${unit}" "${unit_port}" + return 0 + fi + elif [[ "${want_port}" == "8787" ]]; then printf 'unit %s is active' "${unit}" return 0 fi ;; activating | reloading) - if [[ "${want_port}" == "8787" ]]; then + unit_port="$(_systemd_unit_effective_port "${scope}" "${unit}")" || true + if [[ -n "${unit_port}" ]]; then + if [[ "${unit_port}" == "${want_port}" ]]; then + printf 'unit %s is %s (auto-restart pending on port %s)' "${unit}" "${state}" "${unit_port}" + return 0 + fi + elif [[ "${want_port}" == "8787" ]]; then printf 'unit %s is %s (auto-restart pending)' "${unit}" "${state}" return 0 fi diff --git a/tests/test_ctl_foreign_server_guard.py b/tests/test_ctl_foreign_server_guard.py index 3a24ff8e..fd1f219c 100644 --- a/tests/test_ctl_foreign_server_guard.py +++ b/tests/test_ctl_foreign_server_guard.py @@ -83,8 +83,15 @@ def _stop_proc(proc: subprocess.Popen) -> None: proc.kill() -def _write_fake_systemctl(fake_bin: Path, active_state: str, main_pid: int = 0) -> None: - """Fake systemctl answering `show -p ActiveState|MainPID --value `.""" +def _write_fake_systemctl( + fake_bin: Path, + active_state: str, + main_pid: int = 0, + environment: str = "", + exec_start: str = "", +) -> None: + """Fake systemctl answering `show -p --value ` for the + ActiveState/MainPID/Environment/ExecStart properties the guard inspects.""" systemctl = fake_bin / "systemctl" systemctl.write_text( textwrap.dedent( @@ -94,6 +101,8 @@ def _write_fake_systemctl(fake_bin: Path, active_state: str, main_pid: int = 0) case "${{arg}}" in *ActiveState*) echo "{active_state}"; exit 0 ;; *MainPID*) echo "{main_pid}"; exit 0 ;; + *Environment*) echo "{environment}"; exit 0 ;; + *ExecStart*) echo "{exec_start}"; exit 0 ;; esac done exit 0 @@ -104,6 +113,32 @@ def _write_fake_systemctl(fake_bin: Path, active_state: str, main_pid: int = 0) systemctl.chmod(0o755) +def _write_fake_lsof_with_real_and_semantics(fake_bin: Path) -> None: + """Fake lsof mimicking real -a semantics: without -a, `-p PID -iTCP:PORT` + OR-combines its selectors (matching whenever EITHER hits); with -a they + AND. Exit codes come from FAKE_LSOF_OR_EXIT / FAKE_LSOF_AND_EXIT so a test + can model 'the pid is alive with sockets, but nothing of it listens on the + requested port' — the case a missing -a falsely reports as a conflict.""" + lsof = fake_bin / "lsof" + lsof.write_text( + textwrap.dedent( + """ + #!/usr/bin/env bash + has_a=0 + for arg in "$@"; do + [[ "${arg}" == "-a" ]] && has_a=1 + done + if [[ "${has_a}" == 1 ]]; then + exit "${FAKE_LSOF_AND_EXIT:-1}" + fi + exit "${FAKE_LSOF_OR_EXIT:-0}" + """ + ).lstrip(), + encoding="utf-8", + ) + lsof.chmod(0o755) + + def _write_fake_port_tools(fake_bin: Path, pid_listens: bool) -> None: """Fake lsof/ss so _pid_listens_on_port resolves deterministically.""" lsof = fake_bin / "lsof" @@ -261,6 +296,156 @@ def test_start_allows_alternate_port_while_systemd_unit_auto_restarts(tmp_path): pass +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_systemd_pid_port_check_requires_and_semantics(tmp_path): + """lsof without -a OR-combines -p/-i, so an active unit whose MainPID has + sockets but does NOT listen on the requested port must not be mistaken + for a conflict (12.07 re-gate finding 2).""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_systemctl(fake_bin, "active", main_pid=4242) + _write_fake_lsof_with_real_and_semantics(fake_bin) + + fake_python = tmp_path / "fake-python" + fake_log = tmp_path / "fake-python.log" + write_fake_python(fake_python) + + port = _free_port() + started_pid = None + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + fake_bin, + HERMES_WEBUI_PORT=str(port), + HERMES_WEBUI_PYTHON=str(fake_python), + FAKE_PYTHON_LOG=str(fake_log), + FAKE_LSOF_OR_EXIT="0", + FAKE_LSOF_AND_EXIT="1", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert "Refusing to start" not in combined, combined + assert result.returncode == 0, combined + pid_file = tmp_path / ".hermes" / "webui.pid" + if pid_file.exists(): + started_pid = int(pid_file.read_text().strip()) + finally: + if started_pid: + try: + os.kill(started_pid, 9) + except ProcessLookupError: + pass + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_start_on_default_port_allowed_when_unit_configured_elsewhere(tmp_path): + """Reverse alternate-port case (12.07 re-gate finding 3): a unit whose + CONFIGURED binding is a non-default port must not block a start on 8787 + just because its ownership cannot be attributed via MainPID.""" + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", 8787)) == 0: + pytest.skip("default port 8787 already occupied on this host") + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_systemctl( + fake_bin, "activating", environment="HERMES_WEBUI_PORT=9999" + ) + + fake_python = tmp_path / "fake-python" + fake_log = tmp_path / "fake-python.log" + write_fake_python(fake_python) + + started_pid = None + try: + result = run_ctl( + tmp_path, + "start", + env=_guard_env( + fake_bin, + HERMES_WEBUI_PORT="8787", + HERMES_WEBUI_PYTHON=str(fake_python), + FAKE_PYTHON_LOG=str(fake_log), + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert "Refusing to start" not in combined, combined + assert result.returncode == 0, combined + pid_file = tmp_path / ".hermes" / "webui.pid" + if pid_file.exists(): + started_pid = int(pid_file.read_text().strip()) + finally: + if started_pid: + try: + os.kill(started_pid, 9) + except ProcessLookupError: + pass + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +def test_start_refuses_when_unit_configured_for_requested_alternate_port(tmp_path): + """Counterpart of the reverse case: when the unit's configured binding + matches the requested (non-default) port, the conflict must be reported.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + port = _free_port() + _write_fake_systemctl( + fake_bin, "activating", environment=f"HERMES_WEBUI_PORT={port}" + ) + + result = run_ctl( + tmp_path, + "start", + env=_guard_env(fake_bin, HERMES_WEBUI_PORT=str(port)), + timeout=15, + ) + combined = result.stdout + result.stderr + assert "Refusing to start" in combined, combined + assert result.returncode != 0, combined + assert str(port) in combined + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") +@pytest.mark.parametrize( + ("command", "expected_output"), + [ + ("status", "running (not managed by ctl.sh)"), + ("stop", "NOT managed by ctl.sh"), + ], +) +def test_unmanaged_instance_commands_survive_inherit_errexit( + tmp_path, command, expected_output +): + """12.07 re-gate finding 1: with errexit inherited into command + substitutions (BASHOPTS=inherit_errexit from the invoking shell), the + no-match listener diagnostics must still not abort status/stop.""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_fake_port_tools(fake_bin, pid_listens=False) + port = _free_port() + server = _start_dummy_http_server(port) + try: + result = run_ctl( + tmp_path, + command, + env=_guard_env( + fake_bin, + HERMES_WEBUI_PORT=str(port), + BASHOPTS="inherit_errexit", + ), + timeout=15, + ) + combined = result.stdout + result.stderr + assert result.returncode == 0, combined + assert expected_output in combined + assert server.poll() is None, "ctl.sh must leave a foreign server untouched" + finally: + _stop_proc(server) + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX daemon guards") def test_start_proceeds_when_systemd_unit_inactive(tmp_path): fake_bin = tmp_path / "bin" From fd48f26162130ab8b1a21b8f36c871ea2a4c237e Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 19 Jul 2026 05:16:06 +0800 Subject: [PATCH 17/82] Fail closed on historical anchor hydration throws --- static/ui.js | 4 +++- ...issue6220_id_linked_tool_anchor_hydration.py | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/static/ui.js b/static/ui.js index 3f696474..6b7c45c1 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14808,7 +14808,9 @@ function _hydrateIdLinkedHistoricalToolScenes(messages, options){ let hydrated=0; const hydrateTurn=(turnEnd)=>{ if(turnStart<0||turnEnd<=turnStart+1) return; - const hydratedTurn=_idLinkedHistoricalTurnScene(list,turnStart,turnEnd,options); + let hydratedTurn; + try{hydratedTurn=_idLinkedHistoricalTurnScene(list,turnStart,turnEnd,options);} + catch(e){return;} if(!hydratedTurn) return; const owner=list[hydratedTurn.ownerIndex]; try{owner._anchor_activity_scene=hydratedTurn.scene;} diff --git a/tests/test_issue6220_id_linked_tool_anchor_hydration.py b/tests/test_issue6220_id_linked_tool_anchor_hydration.py index 2b32510b..42c4b460 100644 --- a/tests/test_issue6220_id_linked_tool_anchor_hydration.py +++ b/tests/test_issue6220_id_linked_tool_anchor_hydration.py @@ -49,7 +49,7 @@ function extractFunc(name){ """.strip() -def _run_hydration(messages: list[dict], *, repeat: bool = False) -> dict: +def _run_hydration(messages: list[dict], *, repeat: bool = False, throw_helper: bool = False) -> dict: assert NODE, "node is required for Anchor hydration behavior tests" script = f""" const fs = require('fs'); @@ -89,6 +89,13 @@ for(const name of [ vm.runInContext(extractFunc(name), sandbox, {{filename:'ui.js:' + name}}); }} +if({str(throw_helper).lower()}){{ + vm.runInContext( + "_toolArgsSnapshot = function(){{ throw new Error('synthetic event build failure'); }}", + sandbox + ); +}} + const messages = {json.dumps(messages)}; sandbox.messages = messages; const first = vm.runInContext( @@ -294,6 +301,14 @@ def test_ambiguous_or_richer_historical_tool_shapes_keep_legacy_ownership(messag assert data["owners"] == [] +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_event_building_helper_exceptions_keep_legacy_ownership(): + data = _run_hydration(_linked_turn(), throw_helper=True) + + assert data["first"] == 0 + assert data["owners"] == [] + + @pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") def test_reused_ids_in_adjacent_complete_turns_remain_turn_scoped(): data = _run_hydration(_linked_turn("call-reused") + _linked_turn("call-reused")) From 02b2b0aac18a38fd38500ab33c73345ea1eefd6d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 19 Jul 2026 21:10:06 -0700 Subject: [PATCH 18/82] Release: Live Stream stable Anchor run identity (#6201, @franksong2702) (#6346) * fix: preserve live anchor run identity * Validate envelope run ids before snapshot cursor use * docs(changelog): stamp #6201 stable Anchor run identity --------- Co-authored-by: Frank Song Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/routes.py | 74 ++++- static/messages.js | 15 +- tests/test_live_anchor_stable_run_identity.py | 304 ++++++++++++++++++ 4 files changed, 378 insertions(+), 17 deletions(-) create mode 100644 tests/test_live_anchor_stable_run_identity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1942f4e..4ea42aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Fixed +- **Live Stream: Assistant Turn Anchors now carry a stable run identity through the settled scene.** The activity-scene projection now threads a stable `run_id` (independent of the transport `stream_id`) through Anchor scenes and client hydration, so a settled/reloaded Worklog keeps a consistent identity and can't mis-own or duplicate rows if a producer ever emits `run_id != stream_id`. Behavior-preserving for current writers (which still emit `run_id == stream_id`) — verified byte-identical live/settle/reload render, with malformed/conflicting identities falling back to the transport identity. Thanks @franksong2702. (#6201, #3400) + - **Content search no longer evicts your open sessions from the cache.** A content search scans many sessions, and each scan used to promote/cold-load sessions into the in-memory LRU — evicting the sessions you actually have open. Search now reads through a scan-only accessor that keeps the same disk-freshness, journal-recovery, and state.db reconciliation (so recent content stays searchable) but doesn't disturb the working-set cache. Thanks @ai-ag2026. (#6084, #6083) - **Watching the same terminal from two tabs no longer splits the output stream.** Terminal output is now broadcast to every subscribed viewer instead of draining from one shared queue, so a second tab (or a reconnect) sees the full stream rather than a random half. SSE responses carry event `id:`s and a reconnecting viewer replays only events after its `Last-Event-ID` cursor (fresh viewers still get the full bounded backlog). Thanks @ai-ag2026. (#5836) diff --git a/api/routes.py b/api/routes.py index 4d0998f5..fd57db10 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3132,6 +3132,38 @@ def _run_journal_snapshot_merge_args(existing, incoming): return merged, changed +def _run_journal_envelope_run_id_result(event: dict) -> tuple[str | None, bool]: + raw_run_id = event.get("run_id") + if raw_run_id is None: + return None, False + if not isinstance(raw_run_id, str): + return None, True + run_id = raw_run_id.strip() + if not run_id: + return None, True + raw_event_id = event.get("event_id") + event_id = str(raw_event_id or "").strip() + if event_id: + event_run_id, event_seq = _shared_parse_run_journal_event_id(event_id) + if event_run_id and event_seq is not None and event_run_id != run_id: + return None, True + return run_id, False + + +def _run_journal_snapshot_event_id_for_run( + event: dict, + run_id: str, + event_seq: int, +) -> str | None: + raw_event_id = event.get("event_id") + event_id = str(raw_event_id or "").strip() + if event_id: + event_run_id, parsed_seq = _shared_parse_run_journal_event_id(event_id) + if event_run_id == run_id and parsed_seq is not None: + return event_id + return f"{run_id}:{event_seq}" if event_seq else None + + def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | None: stream_id = str(stream_id or "").strip() if not stream_id: @@ -3152,6 +3184,22 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | events = [event for event in (journal.get("events") or []) if isinstance(event, dict)] if not events: return None + event_run_ids: set[str] = set() + malformed_envelope_run_id = False + for event in events: + event_run_id, event_run_id_malformed = _run_journal_envelope_run_id_result(event) + if event_run_id is not None: + event_run_ids.add(event_run_id) + if event_run_id_malformed: + malformed_envelope_run_id = True + # The event envelope is the durable identity authority. Older summaries + # are keyed by the transport id, so only use that fallback when the journal + # does not provide one unambiguous run id. + run_id = ( + next(iter(event_run_ids)) + if not malformed_envelope_run_id and len(event_run_ids) == 1 + else str(summary.get("run_id") or stream_id).strip() + ) assistant_text = "" reasoning_text = "" @@ -3346,7 +3394,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "source_event_type": "token", "event_id": None, "local_id": local_id, - "run_id": stream_id, + "run_id": run_id, "stream_id": stream_id, "seq": None, "status": status, @@ -3354,7 +3402,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "identity": { "event_id": None, "local_id": local_id, - "run_id": stream_id, + "run_id": run_id, "stream_id": stream_id, "seq": None, }, @@ -3389,7 +3437,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "source_event_type": "reasoning", "event_id": None, "local_id": local_id, - "run_id": stream_id, + "run_id": run_id, "stream_id": stream_id, "seq": None, "status": status, @@ -3397,7 +3445,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "identity": { "event_id": None, "local_id": local_id, - "run_id": stream_id, + "run_id": run_id, "stream_id": stream_id, "seq": None, }, @@ -3466,7 +3514,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "source_event_type": "tool_complete" if call.get("done") else "tool", "event_id": None, "local_id": tool_id or row_id, - "run_id": stream_id, + "run_id": run_id, "stream_id": stream_id, "seq": None, "status": status, @@ -3474,7 +3522,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "identity": { "event_id": None, "local_id": tool_id or row_id, - "run_id": stream_id, + "run_id": run_id, "stream_id": stream_id, "seq": None, }, @@ -3588,7 +3636,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "source_event_type": "runtime_journal_snapshot", "event_id": None, "local_id": f"lifecycle:{stream_id}:running", - "run_id": stream_id, + "run_id": run_id, "stream_id": stream_id, "seq": None, "status": "running", @@ -3596,7 +3644,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "identity": { "event_id": None, "local_id": f"lifecycle:{stream_id}:running", - "run_id": stream_id, + "run_id": run_id, "stream_id": stream_id, "seq": None, }, @@ -3626,9 +3674,11 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | event_last_seq = 0 if event_last_seq >= summary_last_seq: last_seq = event_last_seq - last_event_id = events[-1].get("event_id") or ( - f"{stream_id}:{event_last_seq}" if event_last_seq else summary.get("last_event_id") - ) + last_event_id = _run_journal_snapshot_event_id_for_run( + events[-1], + run_id, + event_last_seq, + ) or summary.get("last_event_id") else: last_seq = summary_last_seq last_event_id = summary.get("last_event_id") or events[-1].get("event_id") @@ -3656,7 +3706,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict | "identity": { "session_id": session_id, "stream_id": stream_id, - "run_id": stream_id, + "run_id": run_id, "source_message_refs": [], }, "lifecycle": { diff --git a/static/messages.js b/static/messages.js index cb6ce5cf..58993eba 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3992,8 +3992,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _hydrateAnchorRegistryFromActivityScene(scene){ if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return false; if(!scene||scene.version!=='activity_scene_v1'||!Array.isArray(scene.activity_rows)||!scene.activity_rows.length) return false; + const sceneIdentity=(scene.identity&&typeof scene.identity==='object')?scene.identity:{}; + const sceneStreamId=sceneIdentity.stream_id||streamId; + const sceneRunId=sceneIdentity.run_id||sceneStreamId; const sceneKey=[ - scene.identity&&scene.identity.stream_id||streamId||'', + sceneRunId||'', + sceneStreamId||'', scene.activity_rows.length, scene.activity_rows.map(row=>row&&row.row_id||row&&row.local_id||'').join('|'), ].join(':'); @@ -4022,22 +4026,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ payload.activitySegmentSeq=payload.activitySegmentSeq||row.group.activity_segment_seq; payload.activityBurstId=payload.activityBurstId||row.group.activity_burst_id; } + const rowIdentity=(row.identity&&typeof row.identity==='object')?row.identity:{}; const sourceEvent={ ...payload, source_event_type:sourceType, - local_id:row.local_id||row.row_id||`snapshot:${streamId}:${i}`, + local_id:row.local_id||row.row_id||`snapshot:${sceneStreamId}:${i}`, event_id:row.event_id||null, seq:row.seq??undefined, status:row.status||undefined, - stream_id:row.stream_id||streamId, - run_id:row.run_id||streamId, + stream_id:row.stream_id||rowIdentity.stream_id||sceneStreamId, + run_id:row.run_id||rowIdentity.run_id||sceneRunId, // Carry the row's persisted creation timestamp through hydration so the // worklog event timestamp (#5700/#5739) survives a settled-snapshot rebuild // (payload may not carry created_at even when the row does). (#5739 gate.) created_at:payload.created_at??row.created_at??undefined, }; try{ - _anchorApi.applyAssistantTurnAnchorSourceEvent(_anchorRegistry,sourceEvent,{session_id:activeSid,stream_id:streamId,run_id:streamId}); + _anchorApi.applyAssistantTurnAnchorSourceEvent(_anchorRegistry,sourceEvent,{session_id:activeSid,stream_id:sceneStreamId,run_id:sceneRunId}); }catch(err){ if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){ _anchorShadowWarned=true; diff --git a/tests/test_live_anchor_stable_run_identity.py b/tests/test_live_anchor_stable_run_identity.py new file mode 100644 index 00000000..e9df7b1d --- /dev/null +++ b/tests/test_live_anchor_stable_run_identity.py @@ -0,0 +1,304 @@ +"""Stable run identity regressions for live Anchor scene projection/hydration.""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +NODE = shutil.which("node") + + +def test_runtime_journal_snapshot_preserves_run_id_separately_from_stream_id( + monkeypatch, +): + from api import routes + + session_id = "session-stable-run" + run_id = "run-stable-1" + stream_id = "stream-transport-1" + events = [ + { + "event": "token", + "seq": 1, + "event_id": f"{run_id}:1", + "run_id": run_id, + "created_at": 1.0, + "payload": {"text": "working"}, + }, + { + "event": "reasoning", + "seq": 2, + "event_id": f"{run_id}:2", + "run_id": run_id, + "created_at": 2.0, + "payload": {"text": "checking"}, + }, + { + "event": "tool", + "seq": 3, + "event_id": f"{run_id}:3", + "run_id": run_id, + "created_at": 3.0, + "payload": {"name": "terminal", "tid": "call-1"}, + }, + { + "event": "tool_complete", + "seq": 4, + "event_id": f"{run_id}:4", + "run_id": run_id, + "created_at": 4.0, + "payload": {"name": "terminal", "tid": "call-1", "preview": "ok"}, + }, + ] + + monkeypatch.setattr( + routes, + "find_run_summary", + lambda lookup_id: { + "session_id": session_id, + # The current summary is keyed by the legacy lookup id, while the + # durable event envelope already carries the stable run identity. + "run_id": stream_id, + "stream_id": stream_id, + "last_seq": 4, + "last_event_id": f"{run_id}:4", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda loaded_session_id, lookup_id: {"events": events}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + scene = snapshot["anchor_activity_scene"] + + assert snapshot["stream_id"] == stream_id + assert scene["identity"]["run_id"] == run_id + assert scene["identity"]["stream_id"] == stream_id + assert scene["activity_rows"] + assert [row["role"] for row in scene["activity_rows"]] == [ + "prose", + "thinking", + "tool", + ] + assert {row["run_id"] for row in scene["activity_rows"]} == {run_id} + assert {row["stream_id"] for row in scene["activity_rows"]} == {stream_id} + assert {row["identity"]["run_id"] for row in scene["activity_rows"]} == {run_id} + assert {row["identity"]["stream_id"] for row in scene["activity_rows"]} == { + stream_id + } + + +def test_runtime_journal_lifecycle_shell_preserves_stable_run_id(monkeypatch): + from api import routes + + run_id = "run-stable-lifecycle" + stream_id = "stream-transport-lifecycle" + event = { + "event": "metering", + "seq": 1, + "event_id": f"{run_id}:1", + "run_id": run_id, + "payload": {}, + } + monkeypatch.setattr( + routes, + "find_run_summary", + lambda lookup_id: { + "session_id": "session-stable-lifecycle", + "run_id": stream_id, + "stream_id": stream_id, + "last_seq": 1, + "last_event_id": f"{run_id}:1", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda loaded_session_id, lookup_id: {"events": [event]}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + scene = snapshot["anchor_activity_scene"] + row = scene["activity_rows"][0] + + assert row["role"] == "lifecycle" + assert row["run_id"] == run_id + assert row["stream_id"] == stream_id + assert row["identity"]["run_id"] == run_id + assert row["identity"]["stream_id"] == stream_id + + +@pytest.mark.parametrize( + ("event_run_id", "event_id"), + [ + ({"bad": "id"}, None), + ("run-conflicting-envelope", "run-conflicting-event-id:1"), + ], +) +def test_runtime_journal_malformed_envelope_run_id_falls_back_to_transport_cursor( + monkeypatch, + event_run_id, + event_id, +): + from api import routes + + stream_id = "stream-current-1" + event = { + "event": "token", + "seq": 1, + "run_id": event_run_id, + "payload": {"text": "hello"}, + } + if event_id is not None: + event["event_id"] = event_id + + monkeypatch.setattr( + routes, + "find_run_summary", + lambda lookup_id: { + "session_id": "session-malformed-envelope", + "run_id": stream_id, + "stream_id": stream_id, + "last_seq": 1, + "last_event_id": f"{stream_id}:1", + }, + ) + monkeypatch.setattr( + routes, + "read_run_events", + lambda loaded_session_id, lookup_id: {"events": [event]}, + ) + + snapshot = routes._run_journal_live_snapshot(stream_id) + scene = snapshot["anchor_activity_scene"] + row = scene["activity_rows"][0] + + assert scene["identity"]["run_id"] == stream_id + assert row["run_id"] == stream_id + assert row["identity"]["run_id"] == stream_id + assert snapshot["last_event_id"] == f"{stream_id}:1" + assert ( + routes._parse_run_journal_after_seq( + {"after_event_id": [snapshot["last_event_id"]]}, + stream_id, + ) + == 1 + ) + + +@pytest.mark.skipif( + NODE is None, reason="node is required for browser hydration regression coverage" +) +def test_browser_scene_hydration_prefers_stable_run_id_with_legacy_stream_fallback(): + messages_path = ROOT / "static" / "messages.js" + script = f""" +const fs=require('fs'); +const src=fs.readFileSync({json.dumps(str(messages_path))},'utf8'); +function extractFunc(name){{ + const start=src.indexOf('function '+name); + if(start===-1) throw new Error(name+' not found'); + const params=src.indexOf('(',start); + let depth=0,close=-1; + for(let i=params;i Date: Sun, 19 Jul 2026 21:15:33 -0700 Subject: [PATCH 19/82] Release: fix Kanban column scrolling on mobile (#6306, @jpalazz2) (#6347) * fix: Fix kanban scrolling in mobile viewports Scrolling vertically (particularly in expanded view) in kanban on a mobile viewport is difficult. The columns have overscroll disabled, meaning tap and drag will only scroll within the column and will not continue to the next section. On desktop it's much easier to get the mouse outside the column div, on mobile you have to deliberately try to tap very close to the edge of the viewport. Disabling that behavior makes the experience much better Author: Joe Palazzolo * docs(changelog): stamp #6306 mobile Kanban column scroll fix --------- Co-authored-by: Joe Palazzolo Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 ++ static/style.css | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea42aeb..19999e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Fixed +- **Kanban board columns are now scrollable on mobile.** On a phone, a touch-drag inside a Kanban column was trapped in the column's nested scroller, so you couldn't scroll the board. The mobile layout now lets the column overscroll chain to the page/section, restoring natural scrolling; desktop and the pull-to-refresh behavior are unchanged. Thanks @jpalazz2. (#6306) + - **Live Stream: Assistant Turn Anchors now carry a stable run identity through the settled scene.** The activity-scene projection now threads a stable `run_id` (independent of the transport `stream_id`) through Anchor scenes and client hydration, so a settled/reloaded Worklog keeps a consistent identity and can't mis-own or duplicate rows if a producer ever emits `run_id != stream_id`. Behavior-preserving for current writers (which still emit `run_id == stream_id`) — verified byte-identical live/settle/reload render, with malformed/conflicting identities falling back to the transport identity. Thanks @franksong2702. (#6201, #3400) - **Content search no longer evicts your open sessions from the cache.** A content search scans many sessions, and each scan used to promote/cold-load sessions into the in-memory LRU — evicting the sessions you actually have open. Search now reads through a scan-only accessor that keeps the same disk-freshness, journal-recovery, and state.db reconciliation (so recent content stays searchable) but doesn't disturb the working-set cache. Thanks @ai-ag2026. (#6084, #6083) diff --git a/static/style.css b/static/style.css index 16bfd387..06f203da 100644 --- a/static/style.css +++ b/static/style.css @@ -7024,6 +7024,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-modal-overlay{padding:12px;} .kanban-modal{padding:16px 16px 14px;border-radius:14px;} .kanban-modal-row-inline{flex-direction:column;gap:0;} + .kanban-column-body{overscroll-behavior:auto;} } /* ── Logs panel (#1455) ───────────────────────────────────────────────────── */ From dd554c1788948ca5892f549575daf741aa6a8850 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 19 Jul 2026 21:56:51 -0700 Subject: [PATCH 20/82] Release: Live Stream Anchor side-effects projection (#6204, @franksong2702) (#6348) * fix: preserve anchor-owned side effects * test: prove invisible anchor outcomes do not repaint * docs(changelog): stamp #6204 Anchor side-effects projection --------- Co-authored-by: Frank Song Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + .../stable-assistant-turn-anchor-phase0.md | 7 + static/assistant_turn_anchors.js | 10 ++ static/messages.js | 16 +- ...st_issue3820_chat_activity_display_mode.py | 6 +- ...t_stable_assistant_turn_anchor_registry.py | 165 ++++++++++++++++++ 6 files changed, 201 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19999e3b..15a83735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Fixed +- **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400) + - **Kanban board columns are now scrollable on mobile.** On a phone, a touch-drag inside a Kanban column was trapped in the column's nested scroller, so you couldn't scroll the board. The mobile layout now lets the column overscroll chain to the page/section, restoring natural scrolling; desktop and the pull-to-refresh behavior are unchanged. Thanks @jpalazz2. (#6306) - **Live Stream: Assistant Turn Anchors now carry a stable run identity through the settled scene.** The activity-scene projection now threads a stable `run_id` (independent of the transport `stream_id`) through Anchor scenes and client hydration, so a settled/reloaded Worklog keeps a consistent identity and can't mis-own or duplicate rows if a producer ever emits `run_id != stream_id`. Behavior-preserving for current writers (which still emit `run_id == stream_id`) — verified byte-identical live/settle/reload render, with malformed/conflicting identities falling back to the transport identity. Thanks @franksong2702. (#6201, #3400) diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index ceea2023..ab4a90a6 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -54,6 +54,13 @@ when that slice landed; they do not override the current coverage summary above. `_anchor_activity_scene` snapshot so the folded activity summary can appear above the final answer. This handoff does not claim Transparent Stream wiring or durable hard-reload scene persistence. +- The first RFC Phase 6 ownership slice routes the production `state_saved` SSE + into the active Assistant Turn Anchor and carries `artifacts` and + `side_effects` through `activity_scene_v1` settlement persistence and reload + hydration. These owned outcomes remain off the readable Worklog by default; + memory/skill saves still surface through their existing toast and natural UI. + Production artifact-reference derivation and run-journal reconstruction of + side effects remain follow-up work. ## State Layers diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index 17c69ba0..a237b7af 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -1030,6 +1030,8 @@ final_message_ref:null, terminal_state:null, activity_rows:Object.freeze([]), + artifacts:Object.freeze([]), + side_effects:Object.freeze([]), }); } const rows=(Array.isArray(anchor.activity_events)?anchor.activity_events:[]) @@ -1045,6 +1047,14 @@ final_message_ref:typeof content.final_message_ref==='string'?content.final_message_ref:null, terminal_state:_cleanString(_own(lifecycle,'terminal_state'))||null, activity_rows:Object.freeze(rows), + artifacts:Object.freeze((Array.isArray(anchor.artifacts)?anchor.artifacts:[]).map(event=>Object.freeze({ + ..._copyObject(event), + payload:Object.freeze(_copyObject(_own(event,'payload'))), + }))), + side_effects:Object.freeze((Array.isArray(anchor.side_effects)?anchor.side_effects:[]).map(event=>Object.freeze({ + ..._copyObject(event), + payload:Object.freeze(_copyObject(_own(event,'payload'))), + }))), }); } diff --git a/static/messages.js b/static/messages.js index 58993eba..9f1532f1 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2649,7 +2649,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _scheduleAnchorRegistryCleanup(600000); // Applying an event and painting it are separate outcomes. Reasoning uses the // optional holder to decide whether a temporary visible fallback is needed. - function _applyToAnchor(sourceEventType, rawEventData, sseEvent, renderOutcome){ + function _applyToAnchor(sourceEventType, rawEventData, sseEvent, renderOutcome, options={}){ if(renderOutcome&&typeof renderOutcome==='object') renderOutcome.rendered=false; if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return null; const raw=(rawEventData&&typeof rawEventData==='object')?rawEventData:{}; @@ -2674,7 +2674,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ sourceEvent, {session_id:activeSid,stream_id:streamId} ); - const rendered=_renderAnchorLiveScene(); + const rendered=options&&options.render===false?false:_renderAnchorLiveScene(); if(renderOutcome&&typeof renderOutcome==='object') renderOutcome.rendered=rendered; return result; }catch(err){ @@ -3694,6 +3694,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } return false; } + function _anchorSceneHasOwnedOutcomes(scene){ + return !!( + (Array.isArray(scene&&scene.artifacts)&&scene.artifacts.length) + || (Array.isArray(scene&&scene.side_effects)&&scene.side_effects.length) + ); + } function _attachProjectedAnchorSceneToLastAssistant(messages){ if(!_anchorRegistry||!Array.isArray(messages)) return false; let lastAsst=null; @@ -3709,9 +3715,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!lastAsst) return false; const projectedScene=_projectLiveAnchorActivityScene(); const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene); - if(scene&&Array.isArray(scene.activity_rows)&&scene.activity_rows.length){ + const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene); + if(scene&&Array.isArray(scene.activity_rows)&&(scene.activity_rows.length||hasOwnedOutcomes)){ const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene); - const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity'; + const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity'||hasOwnedOutcomes; if(!shouldPersistScene) return false; lastAsst._anchor_stream_id=streamId; lastAsst._anchor_activity_scene=scene; @@ -5586,6 +5593,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ try{ d=JSON.parse(e.data||'{}'); }catch(_){} if((d.session_id||activeSid)!==activeSid) return; if(!S.session||S.session.session_id!==activeSid) return; + _applyToAnchor('state_saved',d,e,null,{render:false}); _showPersistentStateToast(d.kind, d.name||'', {created:String(d.action||'').toLowerCase()==='created'}); }); diff --git a/tests/test_issue3820_chat_activity_display_mode.py b/tests/test_issue3820_chat_activity_display_mode.py index a5842b4f..935b544a 100644 --- a/tests/test_issue3820_chat_activity_display_mode.py +++ b/tests/test_issue3820_chat_activity_display_mode.py @@ -322,7 +322,11 @@ def test_chat_activity_display_mode_settled_hide_all_scene_persists_without_work return_index = block.index("return hasWorklogRows;") assert "const hasWorklogRows=_anchorSceneHasWorklogWorthyRows(scene);" in block - assert "const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity';" in block + assert "const hasOwnedOutcomes=_anchorSceneHasOwnedOutcomes(scene);" in block + assert ( + "const shouldPersistScene=hasWorklogRows||scene.mode==='hide_all_activity'||hasOwnedOutcomes;" + in block + ) assert persist_index < return_index assert "return true;" not in block diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index 4566baf8..a1d8fdc0 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -47,6 +47,34 @@ def _function_body(src: str, name: str) -> str: raise AssertionError(f"{name} body did not close") +def _function_source(src: str, name: str) -> str: + start = src.find(f"function {name}") + assert start != -1, f"{name} not found" + params = src.find("(", start) + assert params != -1, f"{name} parameters not found" + depth = 0 + close = -1 + for idx in range(params, len(src)): + if src[idx] == "(": + depth += 1 + elif src[idx] == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} parameters did not close" + brace = src.find("{", close) + depth = 0 + for idx in range(brace, len(src)): + if src[idx] == "{": + depth += 1 + elif src[idx] == "}": + depth -= 1 + if depth == 0: + return src[start : idx + 1] + raise AssertionError(f"{name} body did not close") + + def _registry_snapshot() -> dict: assert NODE, "node is required for assistant_turn_anchors.js registry tests" script = f""" @@ -83,6 +111,7 @@ api.applyAssistantTurnAnchorSourceEvent(registry, {{ console.log(JSON.stringify({{ version:api.version, registry, + scene:api.projectAssistantTurnAnchorActivityScene(registry,{{mode:'compact_worklog'}}), isolated, results:results.map((item)=>({{applied:item.applied, reason:item.reason}})), }})); @@ -723,6 +752,140 @@ def test_registry_routes_activity_artifacts_side_effects_metadata_and_transport( assert anchor["transport_events"][0]["source_event_type"] == "stream_end" +def test_live_state_saved_listener_attaches_side_effect_to_anchor(): + body = _event_listener_body(_read(MESSAGES_JS), "state_saved") + + assert "_applyToAnchor('state_saved',d,e,null,{render:false});" in body + + +def test_invisible_anchor_outcome_applies_without_repainting_live_scene(): + assert NODE, "node is required for assistant-turn ownership tests" + apply_source = _function_source(_read(MESSAGES_JS), "_applyToAnchor") + script = f""" +const activeSid='sid-owned-outcome'; +const streamId='stream-owned-outcome'; +const _anchorRegistry={{anchor:{{}}}}; +const applied=[]; +const _anchorApi={{ + applyAssistantTurnAnchorSourceEvent(registry,event,context){{ + applied.push({{registry,event,context}}); + return {{applied:true}}; + }}, +}}; +let _anchorShadowWarned=false; +let _assistantSegmentSeq=3; +let _currentActivityBurstId=4; +let renderCount=0; +function _renderAnchorLiveScene(){{ renderCount+=1; return true; }} +{apply_source} +const renderOutcome={{}}; +const result=_applyToAnchor( + 'state_saved', + {{kind:'memory',name:'session-state'}}, + {{lastEventId:'run-owned-outcome:7'}}, + renderOutcome, + {{render:false}} +); +console.log(JSON.stringify({{result,applied,renderCount,renderOutcome}})); +""" + result = subprocess.run( + [NODE, "-e", script], text=True, capture_output=True, check=False + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["result"] == {"applied": True} + assert data["renderCount"] == 0 + assert data["renderOutcome"] == {"rendered": False} + assert data["applied"][0]["event"]["source_event_type"] == "state_saved" + assert data["applied"][0]["event"]["event_id"] == "run-owned-outcome:7" + assert data["applied"][0]["context"] == { + "session_id": "sid-owned-outcome", + "stream_id": "stream-owned-outcome", + } + + +def test_side_effect_owner_survives_scene_settlement_and_reload(): + from api import routes + + data = _registry_snapshot() + scene = routes._sanitize_anchor_activity_scene(data["scene"]) + messages = [ + {"role": "user", "content": "save this"}, + {"role": "assistant", "content": "final answer"}, + ] + message_ref = routes._assistant_anchor_scene_message_ref(messages[1]) + records = { + message_ref: { + "version": "anchor_activity_scene_record_v1", + "message_index": 1, + "message_ref": message_ref, + "stream_id": "stream-1", + "scene": scene, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records) + settled_scene = hydrated[1]["_anchor_activity_scene"] + + assert settled_scene["artifacts"] == scene["artifacts"] + assert settled_scene["artifacts"][0]["payload"] == { + "kind": "workspace_file", + "path": "answer.txt", + } + assert settled_scene["side_effects"] == scene["side_effects"] + assert settled_scene["side_effects"][0]["source_event_type"] == "state_saved" + assert settled_scene["side_effects"][0]["payload"] == { + "kind": "memory", + "name": "session-state", + } + + +def test_side_effect_only_scene_is_persisted_without_creating_a_worklog(): + assert NODE, "node is required for assistant-turn ownership tests" + messages_js = _read(MESSAGES_JS) + has_owned_outcomes = _function_body(messages_js, "_anchorSceneHasOwnedOutcomes") + attach_scene = _function_body( + messages_js, "_attachProjectedAnchorSceneToLastAssistant" + ) + script = f""" +const activeSid='sid-owned-outcome'; +const streamId='stream-owned-outcome'; +const _anchorRegistry={{}}; +let persisted=0; +function _anchorSceneHasOwnedOutcomes(scene){{{has_owned_outcomes}}} +function _anchorSceneHasWorklogWorthyRows(){{ return false; }} +function _projectLiveAnchorActivityScene(){{ + return {{ + version:'activity_scene_v1', + mode:'compact_worklog', + activity_rows:[], + artifacts:[], + side_effects:[{{source_event_type:'state_saved',payload:{{kind:'memory'}}}}], + }}; +}} +function _completeSettledAnchorSceneForTurn(messages,index,scene){{ return scene; }} +function _persistSettledAnchorScene(){{ persisted+=1; }} +function _attachProjectedAnchorSceneToLastAssistant(messages){{{attach_scene}}} +const messages=[{{role:'assistant',content:'final answer'}}]; +const renderedWorklog=_attachProjectedAnchorSceneToLastAssistant(messages); +console.log(JSON.stringify({{ + renderedWorklog, + persisted, + attached:messages[0]._anchor_activity_scene, +}})); +""" + result = subprocess.run( + [NODE, "-e", script], text=True, capture_output=True, check=False + ) + + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout) + assert data["renderedWorklog"] is False + assert data["persisted"] == 1 + assert data["attached"]["side_effects"][0]["source_event_type"] == "state_saved" + + def test_registry_updates_lifecycle_and_settled_final_projection(): data = _registry_snapshot() anchor = data["registry"]["anchor"] @@ -979,6 +1142,8 @@ def test_activity_scene_is_renderer_neutral_and_empty_safe(): "final_message_ref": None, "terminal_state": None, "activity_rows": [], + "artifacts": [], + "side_effects": [], } From be29c3f7c0d34dee007b9b1cf8aaaa80c8d71ec4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 02:12:32 -0700 Subject: [PATCH 21/82] Release: run-journal summary cache (#6291, @sjungwon03) (#6355) * perf(run-journal): cache unchanged run summaries * fix(run-journal): reject cache after missing-file race * run-journal cache: add st_ctime_ns to signature (close same-size mtime-preserving rewrite window) + regression test [maintainer fix on @sjungwon03 #6291] * docs(changelog): stamp #6291 run-journal summary cache --------- Co-authored-by: sjungwon03 Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/run_journal.py | 98 ++++++++++++++++++++++++++++++-- tests/test_run_journal.py | 115 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a83735..fd7bd432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Fixed +- **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291) + - **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400) - **Kanban board columns are now scrollable on mobile.** On a phone, a touch-drag inside a Kanban column was trapped in the column's nested scroller, so you couldn't scroll the board. The mobile layout now lets the column overscroll chain to the page/section, restoring natural scrolling; desktop and the pull-to-refresh behavior are unchanged. Thanks @jpalazz2. (#6306) diff --git a/api/run_journal.py b/api/run_journal.py index 44f6c34d..81abf2fc 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -10,6 +10,7 @@ import os import re import threading import time +from collections import OrderedDict from pathlib import Path from typing import Any, Iterable @@ -27,6 +28,14 @@ _WRITER_LOCKS_GUARD = threading.Lock() # ``_reserve_next_seq`` and ``delete_run_journal`` (which evicts stale entries). _SEQ_CACHE: dict[str, int] = {} _SEQ_CACHE_LOCK = threading.Lock() +# Summary callers only need terminal state and the latest cursor. Re-parsing a +# completed journal's full payload (which can include multi-megabyte tool or +# session results) on every status/reconnect probe is needless. This process +# cache is keyed by a complete stat identity, so it is never used after an +# atomic replacement, append, truncate, or same-path file recreation. +_SUMMARY_CACHE_MAX_ENTRIES = 128 +_SUMMARY_CACHE: OrderedDict[str, tuple[tuple[int, int, int, int, int], dict]] = OrderedDict() +_SUMMARY_CACHE_LOCK = threading.Lock() _TERMINAL_SSE_EVENTS = {"done", "cancel", "apperror", "error", "stream_end"} _FSYNC_MODE_ENV = "HERMES_WEBUI_RUN_JOURNAL_FSYNC" _FSYNC_MODE_EAGER = "eager" @@ -71,6 +80,68 @@ def _lock_for(path: Path) -> threading.Lock: return lock +def _summary_cache_signature(path: Path) -> tuple[int, int, int, int, int] | None: + """Return the complete filesystem identity used for summary-cache validity. + + Includes ``st_ctime_ns`` so a same-inode, same-size rewrite that restores the + original ``mtime_ns`` (e.g. an atomic replace) still invalidates the cache — + ctime advances on any metadata/content change and cannot be forged back. + """ + try: + stat = path.stat() + except OSError: + return None + return ( + int(stat.st_dev), + int(stat.st_ino), + int(stat.st_size), + int(stat.st_mtime_ns), + int(stat.st_ctime_ns), + ) + + +def _get_cached_summary(path: Path) -> dict | None: + signature = _summary_cache_signature(path) + if signature is None: + return None + key = str(path) + with _SUMMARY_CACHE_LOCK: + cached = _SUMMARY_CACHE.get(key) + if cached is None: + return None + cached_signature, summary = cached + if cached_signature != signature: + _SUMMARY_CACHE.pop(key, None) + return None + _SUMMARY_CACHE.move_to_end(key) + return dict(summary) + + +def _cache_summary( + path: Path, + summary: dict, + *, + expected_signature: tuple[int, int, int, int, int] | None = None, +) -> None: + signature = _summary_cache_signature(path) + # The pre-read signature is an enforced TOCTOU precondition. In particular, + # a journal created after a missing-file read has ``None -> signature`` and + # must not cache the empty/unknown result under the new file's identity. + if signature is None or signature != expected_signature: + return + key = str(path) + with _SUMMARY_CACHE_LOCK: + _SUMMARY_CACHE[key] = (signature, dict(summary)) + _SUMMARY_CACHE.move_to_end(key) + while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX_ENTRIES: + _SUMMARY_CACHE.popitem(last=False) + + +def _discard_cached_summary(path: Path) -> None: + with _SUMMARY_CACHE_LOCK: + _SUMMARY_CACHE.pop(str(path), None) + + def _read_jsonl(path: Path) -> tuple[list[dict], list[dict]]: events: list[dict] = [] malformed: list[dict] = [] @@ -351,6 +422,7 @@ def append_run_event( fh.flush() if _should_fsync_event(terminal_state): os.fsync(fh.fileno()) + _discard_cached_summary(path) if created_file: _fsync_parent_dir(path) return event @@ -427,8 +499,15 @@ def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) - def latest_run_summary(session_id: str, run_id: str, *, session_dir: Path | None = None) -> dict: - journal = read_run_events(session_id, run_id, session_dir=session_dir) - return _summary_from_events(session_id, run_id, journal.get("events") or []) + path = _run_path(session_id, run_id, session_dir=session_dir) + cached = _get_cached_summary(path) + if cached is not None: + return cached + pre_read_signature = _summary_cache_signature(path) + events, _malformed = _read_jsonl(path) + summary = _summary_from_events(session_id, run_id, events) + _cache_summary(path, summary, expected_signature=pre_read_signature) + return summary def session_journal_fingerprint(session_id: str, *, session_dir: Path | None = None) -> tuple[int, float, int]: @@ -471,8 +550,12 @@ def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict | journal_root = root / RUN_JOURNAL_DIR_NAME for path in journal_root.glob(f"*/{rid}.jsonl"): session_id = path.parent.name - events, _malformed = _read_jsonl(path) - summary = _summary_from_events(session_id, rid, events) + summary = _get_cached_summary(path) + if summary is None: + pre_read_signature = _summary_cache_signature(path) + events, _malformed = _read_jsonl(path) + summary = _summary_from_events(session_id, rid, events) + _cache_summary(path, summary, expected_signature=pre_read_signature) summary["path"] = str(path) return summary return None @@ -640,8 +723,11 @@ def delete_run_journal(session_id: str, *, session_dir: Path | None = None) -> b # ``_note_assigned_seq`` take — so a concurrent append on another path # cannot mutate the dict mid-iteration (``dictionary changed size``). with _SEQ_CACHE_LOCK: - for key in [k for k in _SEQ_CACHE if str(Path(k).parent) == dir_key]: - del _SEQ_CACHE[key] + for cache_key in [entry for entry in _SEQ_CACHE if str(Path(entry).parent) == dir_key]: + del _SEQ_CACHE[cache_key] + with _SUMMARY_CACHE_LOCK: + for cache_key in [entry for entry in _SUMMARY_CACHE if str(Path(entry).parent) == dir_key]: + del _SUMMARY_CACHE[cache_key] return removed diff --git a/tests/test_run_journal.py b/tests/test_run_journal.py index 6681e89f..cce7186f 100644 --- a/tests/test_run_journal.py +++ b/tests/test_run_journal.py @@ -1,4 +1,5 @@ import json +from pathlib import Path from api.run_journal import ( RunJournalWriter, @@ -104,6 +105,120 @@ def test_latest_summary_and_find_run_summary_classify_terminal_state(tmp_path): assert found["terminal_state"] == "interrupted-by-user" +def test_latest_summary_reuses_unchanged_journal_summary_without_reparsing(tmp_path, monkeypatch): + append_run_event("session_1", "run_1", "token", {"text": "ok"}, session_dir=tmp_path) + append_run_event("session_1", "run_1", "done", {"session": {}}, session_dir=tmp_path) + + first = latest_run_summary("session_1", "run_1", session_dir=tmp_path) + + monkeypatch.setattr( + "api.run_journal._read_jsonl", + lambda _path: (_ for _ in ()).throw(AssertionError("unchanged journal was reparsed")), + ) + repeated = latest_run_summary("session_1", "run_1", session_dir=tmp_path) + + assert repeated == first + + +def test_summary_cache_invalidates_on_same_size_rewrite_with_restored_mtime(tmp_path, monkeypatch): + # A same-inode, same-size rewrite that restores the original mtime_ns (e.g. an + # atomic replace, or a tool that preserves mtime) must still invalidate the + # cached summary. The signature includes st_ctime_ns — which advances on any + # content/metadata change and cannot be forged back — so device/inode/size/ + # mtime collisions alone can never serve a stale summary. Proven at the + # signature level (the enforced TOCTOU precondition for the cache) with a + # deterministic stat where ONLY ctime differs. + import api.run_journal as run_journal + + append_run_event("session_1", "run_1", "token", {"text": "ok"}, session_dir=tmp_path) + path = run_journal._run_path("session_1", "run_1", session_dir=tmp_path) + real = path.stat() + + class _Stat: + st_dev = real.st_dev + st_ino = real.st_ino + st_size = real.st_size + st_mtime_ns = real.st_mtime_ns + st_ctime_ns = real.st_ctime_ns # overwritten per-call below + + seq = {"ctime": real.st_ctime_ns} + + def fake_stat(self, *a, **k): + s = _Stat() + s.st_ctime_ns = seq["ctime"] + return s + + monkeypatch.setattr(Path, "stat", fake_stat) + sig_before = run_journal._summary_cache_signature(path) + # Same dev/inode/size/mtime, but a same-size in-place rewrite advanced ctime. + seq["ctime"] = real.st_ctime_ns + 1 + sig_after = run_journal._summary_cache_signature(path) + + assert sig_after is not None and sig_before is not None + assert sig_after != sig_before, "signature must change when only ctime advances" + assert sig_before[:4] == sig_after[:4], "dev/inode/size/mtime_ns unexpectedly changed" + + +def test_summary_cache_does_not_store_result_when_journal_changes_during_read(tmp_path, monkeypatch): + append_run_event("session_1", "run_1", "token", {"text": "ok"}, session_dir=tmp_path) + append_run_event("session_1", "run_1", "done", {"session": {}}, session_dir=tmp_path) + + import api.run_journal as run_journal + + original_read = run_journal._read_jsonl + + def append_after_read(path): + events, malformed = original_read(path) + append_run_event( + "session_1", + "run_1", + "cancel", + {"message": "Cancelled by user"}, + session_dir=tmp_path, + ) + return events, malformed + + monkeypatch.setattr(run_journal, "_read_jsonl", append_after_read) + + first = latest_run_summary("session_1", "run_1", session_dir=tmp_path) + second = latest_run_summary("session_1", "run_1", session_dir=tmp_path) + + assert first["terminal_state"] == "completed" + assert second["terminal_state"] == "interrupted-by-user" + + + +def test_summary_cache_rejects_first_append_that_races_missing_journal_read(tmp_path, monkeypatch): + import api.run_journal as run_journal + + original_read = run_journal._read_jsonl + appended = False + + def append_after_missing_read(path): + nonlocal appended + events, malformed = original_read(path) + if not appended: + appended = True + append_run_event( + "session_1", + "run_first_append", + "done", + {"session": {}}, + session_dir=tmp_path, + ) + return events, malformed + + monkeypatch.setattr(run_journal, "_read_jsonl", append_after_missing_read) + + raced = latest_run_summary("session_1", "run_first_append", session_dir=tmp_path) + refreshed = latest_run_summary("session_1", "run_first_append", session_dir=tmp_path) + + assert raced["terminal_state"] == "unknown" + assert refreshed["terminal_state"] == "completed" + assert refreshed["last_seq"] == 1 + assert refreshed["last_event_id"] == "run_first_append:1" + + def test_terminal_state_classification_distinguishes_crash_from_user_cancel(tmp_path): append_run_event("session_1", "run_cancelled", "cancel", {"message": "Cancelled by user"}, session_dir=tmp_path) append_run_event("session_1", "run_crashed", "apperror", {"type": "interrupted"}, session_dir=tmp_path) From c2b5a54b38ef36336c70876c2bb956ad212e8922 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 20 Jul 2026 17:22:02 +0800 Subject: [PATCH 22/82] Harden historical anchor hydration edges --- static/assistant_turn_anchors.js | 2 + static/ui.js | 56 ++++++-- ...sue6220_id_linked_tool_anchor_hydration.py | 126 +++++++++++++++++- 3 files changed, 171 insertions(+), 13 deletions(-) diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index 511a7f51..657a0b35 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -928,6 +928,7 @@ ); const done=_activityRowToolDone(kind,status,payload); const isError=_activityRowToolIsError(status,payload); + const isDiff=_own(payload,'is_diff')===true||_own(payload,'isDiff')===true; const signatureParts=[ toolName, toolCallId||'', @@ -943,6 +944,7 @@ output:_sanitizePayload(_own(payload,'output'))??null, done, is_error:isError, + ...(isDiff?{is_diff:true}:{}), duration:_activityPayloadFirst(payload,['duration','duration_seconds','elapsed'])??null, started_at:_activityPayloadFirst(payload,['started_at','startedAt'])??null, signature:signatureParts.join('|'), diff --git a/static/ui.js b/static/ui.js index 6ccdb864..f4b77b60 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12309,6 +12309,7 @@ function _anchorSceneToolCallFromRow(row, opts){ )||'', done:settled?true:(tool.done!==null&&tool.done!==undefined?tool.done:(row.status!=='running'&&row.status!=='pending')), is_error:!!(tool.is_error||payload.is_error||row.status==='error'||row.status==='failed'), + is_diff:!!(tool.is_diff||payload.is_diff||payload.isDiff), duration:tool.duration||payload.duration||payload.duration_seconds, started_at:firstValidTimestampSeconds(tool.started_at, payload.started_at, rowTs), created_at:firstValidTimestampSeconds(tool.created_at, payload.created_at, rowTs), @@ -14735,7 +14736,7 @@ function _idLinkedHistoricalToolArguments(toolCall){ const fn=toolCall.function; if(!fn||typeof fn!=='object'||Array.isArray(fn)) return null; const raw=fn.arguments; - if(raw===undefined||raw===null||raw==='') return {}; + if(raw===undefined||raw===null||raw==='') return null; if(raw&&typeof raw==='object'&&!Array.isArray(raw)) return raw; if(typeof raw!=='string') return null; try{ @@ -14747,12 +14748,33 @@ function _idLinkedHistoricalToolArguments(toolCall){ } function _idLinkedHistoricalToolResultRaw(message){ - if(!message||typeof message!=='object') return ''; + if(!message||typeof message!=='object') return null; const content=message.content; - if(typeof content==='string') return content; - if(content===undefined||content===null) return ''; - try{return JSON.stringify(content);} - catch(e){return String(content||'');} + return typeof content==='string'?content:null; +} + +function _idLinkedHistoricalRedactSnippet(value){ + let text=String(value||''); + if(!text) return ''; + if(typeof _redactToolTargetLabel==='function'){ + try{text=_redactToolTargetLabel(text);} + catch(e){} + } + return text; +} + +function _idLinkedHistoricalHasVisibleSidecar(message){ + if(!message||typeof message!=='object') return false; + const visibleKeys=['attachments','_attachments','_statusCard','status_card','statusCard','card','cards','artifact','artifacts','files','images','media']; + for(const key of visibleKeys){ + if(!Object.prototype.hasOwnProperty.call(message,key)) continue; + const value=message[key]; + if(value===undefined||value===null||value===false) continue; + if(Array.isArray(value)&&value.length===0) continue; + if(typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===0) continue; + return true; + } + return false; } // Claim legacy settled ownership only when the transcript itself proves a @@ -14775,18 +14797,25 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ for(let rawIdx=start;rawIdxpart&&typeof part==='object'&&part.type==='tool_use')) return null; const toolCalls=Array.isArray(message.tool_calls)?message.tool_calls:[]; if(toolCalls.length&&visibleText) return null; - if(!toolCalls.length) continue; + if(!toolCalls.length){ + if(visibleText) continue; + return null; + } + if(message.content!==undefined&&message.content!==null&&message.content!=='') return null; const messageRef=_idLinkedHistoricalMessageRef(message,rawIdx); if(!declarationRefs.includes(messageRef)) declarationRefs.push(messageRef); for(const toolCall of toolCalls){ @@ -14800,7 +14829,7 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ } continue; } - if(message.role!=='tool') continue; + if(role!=='tool') return null; const callId=String(message.tool_call_id||'').trim(); if(!callId||!declarationIds.has(callId)) return null; const matches=resultsById.get(callId)||[]; @@ -14827,8 +14856,12 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ const declaration=declarations[index]; const resultEntry=resultsById.get(declaration.callId)[0]; const args=_toolArgsSnapshot(declaration.args); - const resultSnippet=_cliToolResultSnippet(_idLinkedHistoricalToolResultRaw(resultEntry.message)); + const resultRaw=_idLinkedHistoricalToolResultRaw(resultEntry.message); + if(resultRaw===null) return null; + const resultSnippet=_idLinkedHistoricalRedactSnippet(_cliToolResultSnippet(resultRaw)); const patchSnippet=_cliPatchSnippetFromArgs(declaration.name,args); + const isDiff=_cliToolCardHasDiffSnippet(resultSnippet,patchSnippet); + const snippet=_idLinkedHistoricalRedactSnippet(_cliToolCardSnippet(resultSnippet,patchSnippet)); const status=String(resultEntry.message.status||'').trim().toLowerCase(); const isError=resultEntry.message.is_error===true||status==='error'||status==='failed'||status==='failure'; activityEvents.push({ @@ -14842,9 +14875,10 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ name:declaration.name, args, command:String(args.command||args.cmd||''), - snippet:_cliToolCardSnippet(resultSnippet,patchSnippet), + snippet, done:true, is_error:isError, + is_diff:isDiff, assistant_msg_idx:declaration.rawIdx, }, }); diff --git a/tests/test_issue6220_id_linked_tool_anchor_hydration.py b/tests/test_issue6220_id_linked_tool_anchor_hydration.py index 42c4b460..0a3db73b 100644 --- a/tests/test_issue6220_id_linked_tool_anchor_hydration.py +++ b/tests/test_issue6220_id_linked_tool_anchor_hydration.py @@ -66,7 +66,8 @@ vm.runInContext( sandbox, {{filename:'assistant_turn_anchors.js'}} ); -sandbox._redactToolTargetLabel = value => String(value); +sandbox._redactToolTargetLabel = value => String(value) + .replace(/([A-Za-z0-9_]*(?:TOKEN|API[_-]?KEY|SECRET|PASSWORD)[A-Za-z0-9_]*\\s*=\\s*)(?:"[^"]*"|'[^']*'|\\S+)/gi, '$1[redacted]'); for(const name of [ '_clipCliToolSnippet', @@ -77,12 +78,16 @@ for(const name of [ '_firstOwnedValue', '_cliPatchSnippetFromArgs', '_cliToolCardSnippet', + '_cliToolCardHasDiffSnippet', + '_anchorSceneToolCallFromRow', '_toolArgsSnapshot', '_assistantReasoningPayloadText', '_idLinkedHistoricalMessageText', '_idLinkedHistoricalMessageRef', '_idLinkedHistoricalToolArguments', '_idLinkedHistoricalToolResultRaw', + '_idLinkedHistoricalRedactSnippet', + '_idLinkedHistoricalHasVisibleSidecar', '_idLinkedHistoricalTurnScene', '_hydrateIdLinkedHistoricalToolScenes', ]){{ @@ -112,7 +117,12 @@ const second = {str(repeat).lower()} const owners = messages .map((message, index) => message && message._anchor_activity_scene ? {{index, scene:message._anchor_activity_scene}} : null) .filter(Boolean); -console.log(JSON.stringify({{first, second, unchanged:afterFirst===JSON.stringify(messages), owners}})); +sandbox.owners = owners; +const toolCards = vm.runInContext( + "owners.map(owner => owner.scene.activity_rows.map(row => _anchorSceneToolCallFromRow(row, {{settled:true}})))", + sandbox +); +console.log(JSON.stringify({{first, second, unchanged:afterFirst===JSON.stringify(messages), owners, toolCards}})); """ result = subprocess.run( [NODE, "-e", script], @@ -301,6 +311,118 @@ def test_ambiguous_or_richer_historical_tool_shapes_keep_legacy_ownership(messag assert data["owners"] == [] +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +@pytest.mark.parametrize("content", [ + {"stdout": "...", "env": "API_KEY=result-secret-123"}, + [{"stdout": "ok"}], + None, +]) +def test_non_string_historical_tool_results_keep_legacy_ownership(content): + messages = _linked_turn() + messages[2]["content"] = content + + data = _run_hydration(messages) + + assert data["first"] == 0 + assert data["owners"] == [] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_string_historical_tool_result_is_redacted_before_anchor_scene(): + messages = _linked_turn() + messages[2]["content"] = '{"output":"API_KEY=result-secret-123"}' + + data = _run_hydration(messages) + + assert data["first"] == 1 + row = data["owners"][0]["scene"]["activity_rows"][0] + assert "result-secret-123" not in json.dumps(row) + assert "API_KEY=[redacted]" in row["tool"]["snippet"] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +@pytest.mark.parametrize( + "messages", + [ + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "system", "content": "visible system status"}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "developer", "content": "visible developer note"}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "function", "name": "legacy_func", "content": "legacy function output"}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "assistant", "content": None, "tool_calls": []}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + { + "role": "assistant", + "content": None, + "attachments": [{"name": "evidence.txt"}], + "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + { + "role": "assistant", + "content": None, + "_statusCard": {"title": "Visible status"}, + "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": ""}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + ], +) +def test_non_whitelisted_intermediate_records_keep_legacy_ownership(messages): + data = _run_hydration(messages) + + assert data["first"] == 0 + assert data["owners"] == [] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_diff_historical_tool_result_preserves_diff_card_projection(): + messages = _linked_turn() + messages[2]["content"] = "diff --git a/demo.txt b/demo.txt\n@@\n-old\n+new\n" + + data = _run_hydration(messages) + + assert data["first"] == 1 + row = data["owners"][0]["scene"]["activity_rows"][0] + assert row["payload"]["is_diff"] is True + assert row["tool"]["is_diff"] is True + assert data["toolCards"][0][0]["is_diff"] is True + + @pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") def test_event_building_helper_exceptions_keep_legacy_ownership(): data = _run_hydration(_linked_turn(), throw_helper=True) From a61880b977b4b791682995f7925464913f4d67ee Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 03:52:18 -0700 Subject: [PATCH 23/82] Release: terminal-error lifecycle gate row (#6354, @franksong2702) (#6358) * test: add terminal-error lifecycle matrix row * test: reject empty terminal process rows --------- Co-authored-by: Frank Song Co-authored-by: nesquena-hermes --- .github/workflows/conversation-lifecycle.yml | 7 +- TESTING.md | 31 +- tests/browser_conversation_lifecycle.py | 451 ++++++++++++++++--- 3 files changed, 421 insertions(+), 68 deletions(-) diff --git a/.github/workflows/conversation-lifecycle.yml b/.github/workflows/conversation-lifecycle.yml index b3853fbd..fe9fd1c5 100644 --- a/.github/workflows/conversation-lifecycle.yml +++ b/.github/workflows/conversation-lifecycle.yml @@ -31,6 +31,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 continue-on-error: true + strategy: + fail-fast: false + matrix: + scenario: [normal, terminal-error] steps: - uses: actions/checkout@v4 @@ -53,12 +57,13 @@ jobs: - name: Run conversation lifecycle gate env: LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts + LIFECYCLE_SCENARIO: ${{ matrix.scenario }} run: python tests/browser_conversation_lifecycle.py - name: Upload failure artifacts if: failure() uses: actions/upload-artifact@v4 with: - name: conversation-lifecycle-failure + name: conversation-lifecycle-failure-${{ matrix.scenario }} path: lifecycle-artifacts if-no-files-found: ignore diff --git a/TESTING.md b/TESTING.md index 5a42edb9..3748ded7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -93,18 +93,24 @@ that breaks the page for everyone). ## Public conversation lifecycle gate -`tests/browser_conversation_lifecycle.py` adds the first public, deterministic -chat golden path. It drives the real composer and real WebUI server in Chromium, -while a localhost-only fixture supplies reasoning, tool, and final-answer events -through the existing Hermes Gateway Runs API. The gate asserts semantic activity -during live streaming, after settlement, and after a hard reload, including +`tests/browser_conversation_lifecycle.py` adds a public deterministic +multi-row lifecycle gate. It drives the real composer and real WebUI server in Chromium, +while a localhost-only fixture supplies reasoning, tool, process, and final/error +events through the existing Hermes Gateway Runs API. The gate now covers both +normal and terminal-error proof-matrix rows, asserting semantic activity during +live streaming, after settlement, and after hard reload, including transcript-backed `activity_scene_v1` persistence and zero unexpected browser errors. It uses isolated temporary state and no provider credentials. ```bash pip install -r requirements.txt playwright python -m playwright install --with-deps chromium + +# Normal-path deterministic conversation lifecycle gate. python tests/browser_conversation_lifecycle.py + +# Terminal-error lifecycle gate (new row in the proof matrix). +LIFECYCLE_SCENARIO=terminal-error python tests/browser_conversation_lifecycle.py ``` To certify that the gate catches its target failure, the test owns an opt-in @@ -114,12 +120,19 @@ must fail at the hard-reload boundary: ```bash LIFECYCLE_TEST_BITE=drop-anchor-persistence \ python tests/browser_conversation_lifecycle.py + +# Terminal-state-specific mutation bite: remove terminal row from persisted scene +# so hard reload cannot recover terminal status. +LIFECYCLE_SCENARIO=terminal-error \ +LIFECYCLE_TEST_BITE=drop-terminal-anchor-row \ + python tests/browser_conversation_lifecycle.py ``` -The dedicated `Conversation lifecycle (informational)` workflow runs this test -without blocking merges while the public matrix is being established. The -maintainer's private QA harness remains broader; later public slices will add -session switching, reconnect/replay, cancellation, compression, and recovery. +The dedicated `Conversation lifecycle (informational)` workflow runs both current +proof rows (`normal` and `terminal-error`) and stays non-blocking while the public +matrix expands to additional behavior rows. The maintainer's private QA harness +remains broader; later public slices will add session switching, reconnect/replay, +cancellation, compression, and recovery. `tests/test_static_js_runtime_lint.py` runs this automatically when eslint is present diff --git a/tests/browser_conversation_lifecycle.py b/tests/browser_conversation_lifecycle.py index ea534713..539d7804 100644 --- a/tests/browser_conversation_lifecycle.py +++ b/tests/browser_conversation_lifecycle.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Public browser gate for the normal conversation lifecycle. +"""Public browser gate for the normal + terminal-error lifecycle. This test boots the real WebUI server with isolated state, drives the real chat composer in Chromium, and supplies deterministic runtime events through the @@ -28,9 +28,12 @@ from urllib.parse import urlsplit PROMPT = "Exercise the public conversation lifecycle gate." REASONING_TEXT = "Checking the persistent assistant turn." FINAL_TEXT = "Lifecycle gate final answer." +FINAL_ACK_TEXT = "Lifecycle" FINAL_PREFIX = "Lifecycle gate " FINAL_SUFFIX = "final answer." -FINAL_ACK_TEXT = "Lifecycle" +TERMINAL_PROCESS_TEXT = "Lifecycle terminal process check" +TERMINAL_ERROR_TEXT = "Lifecycle gate encountered a terminal-side error." +SCENARIO = os.environ.get("LIFECYCLE_SCENARIO", "normal").strip() or "normal" TOOL_NAME = "read_file" TOOL_ID = "lifecycle-tool-1" TEST_BITE = os.environ.get("LIFECYCLE_TEST_BITE", "").strip() @@ -39,6 +42,52 @@ ANCHOR_SCENE_PERSIST_TIMEOUT = 60.0 ANCHOR_SCENE_PROJECTION_TIMEOUT = 10_000 +def _latest_anchor_scene_from_disk(state_root: Path, session_id: str) -> dict | None: + session_file_candidates = [ + state_root / "webui-state" / "sessions" / f"{session_id}.json", + state_root / "sessions" / f"{session_id}.json", + ] + for session_file in session_file_candidates: + if not session_file.exists(): + continue + try: + raw = json.loads(session_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + records = raw.get("anchor_activity_scenes") + if not isinstance(records, dict): + continue + candidates = [] + for record in records.values(): + if not isinstance(record, dict): + continue + scene = record.get("scene") + if not isinstance(scene, dict): + continue + idx = record.get("message_index") + try: + message_index = int(idx) + except (TypeError, ValueError): + message_index = -1 + candidates.append((message_index, scene)) + if not candidates: + continue + _, latest = max(candidates, key=lambda item: item[0]) + return latest + return None + + +def _safe_request_post_data(request_or_route_request) -> str: + raw = getattr(request_or_route_request, "post_data", None) + if callable(raw): + raw = raw() + if raw is None: + return "" + if isinstance(raw, bytes): + return raw.decode("utf-8") + return str(raw) + + def _free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) @@ -238,7 +287,8 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path): class DeterministicGateway: """A localhost-only Gateway Runs server with test-controlled phase gates.""" - def __init__(self) -> None: + def __init__(self, scenario: str) -> None: + self.scenario = scenario self.activity_ready = threading.Event() self.release_settle = threading.Event() self.final_prefix_ready = threading.Event() @@ -302,6 +352,11 @@ class DeterministicGateway: "event": "reasoning.available", "text": REASONING_TEXT, }) + if owner.scenario == "terminal-error": + self._event("message.delta", { + "event": "message.delta", + "delta": TERMINAL_PROCESS_TEXT, + }) self._event("tool.started", { "event": "tool.started", "tool": TOOL_NAME, @@ -319,21 +374,30 @@ class DeterministicGateway: owner.activity_ready.set() if not owner.release_settle.wait(timeout=30): return - self._event("message.delta", { - "event": "message.delta", - "delta": FINAL_PREFIX, - }) - owner.final_prefix_ready.set() - if not owner.release_terminal.wait(timeout=30): - return - self._event("message.delta", { - "event": "message.delta", - "delta": FINAL_SUFFIX, - }) - self._event("run.completed", { - "event": "run.completed", - "usage": {"input_tokens": 12, "output_tokens": 5}, - }) + if owner.scenario == "terminal-error": + owner.final_prefix_ready.set() + if not owner.release_terminal.wait(timeout=30): + return + self._event("run.failed", { + "event": "run.failed", + "error": TERMINAL_ERROR_TEXT, + }) + else: + self._event("message.delta", { + "event": "message.delta", + "delta": FINAL_PREFIX, + }) + owner.final_prefix_ready.set() + if not owner.release_terminal.wait(timeout=30): + return + self._event("message.delta", { + "event": "message.delta", + "delta": FINAL_SUFFIX, + }) + self._event("run.completed", { + "event": "run.completed", + "usage": {"input_tokens": 12, "output_tokens": 5}, + }) self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError): @@ -417,6 +481,9 @@ def _capture_anchor_scene_requests(page): def _activity_snapshot(page) -> dict: return page.evaluate( """() => { + const messages = (typeof S !== 'undefined' && Array.isArray(S.messages)) ? S.messages : []; + const assistants = messages.filter((message) => message && message.role === 'assistant'); + const lastAssistant = assistants.length ? assistants[assistants.length - 1] : null; const turn = document.querySelector('#liveAssistantTurn') || Array.from(document.querySelectorAll('.assistant-turn')).pop() || null; const groups = turn ? Array.from(turn.querySelectorAll('[data-anchor-scene-owner="1"]')) : []; @@ -424,7 +491,9 @@ def _activity_snapshot(page) -> dict: const visibleFinal = turn ? Array.from(turn.querySelectorAll('.assistant-segment .msg-body')) .filter(el => { const segment = el.closest('.assistant-segment'); + const role = segment && segment.getAttribute('data-anchor-row-role'); return segment && !segment.hidden && + role !== 'prose' && !segment.classList.contains('assistant-segment-worklog-source') && getComputedStyle(segment).display !== 'none'; }) @@ -450,11 +519,20 @@ def _activity_snapshot(page) -> dict: rows: rows.map(row => ({ role: row.getAttribute('data-anchor-row-role'), source: row.getAttribute('data-anchor-source-event-type'), + status: row.getAttribute('data-anchor-row-status'), tool: row.getAttribute('data-tool-name'), text: row.innerText.trim(), classes: row.className, })), visibleFinal, + assistantMessage: lastAssistant ? { + turnDuration: lastAssistant._turnDuration, + hasError: Boolean(lastAssistant._error), + anchorTerminalState: lastAssistant._anchor_activity_scene + && (lastAssistant._anchor_activity_scene.terminal_state + || (lastAssistant._anchor_activity_scene.lifecycle + && lastAssistant._anchor_activity_scene.lifecycle.terminal_state)) || null, + } : null, transcript: (document.querySelector('#msgInner') || {}).innerText || '', }; }""" @@ -485,31 +563,121 @@ def _expand_settled_worklog(page) -> None: ) +def _terminal_rows(snapshot: dict) -> list[dict]: + return [row for row in snapshot["rows"] if row["role"] == "terminal"] + + +def _process_rows(snapshot: dict) -> list[dict]: + return [ + row for row in snapshot["rows"] + if row["role"] == "prose" and _is_terminal_process_row_text(row.get("text") or "") + ] + + +def _is_terminal_process_row_text(text: str) -> bool: + text = " ".join(text.split()) + if not text: + return False + return ( + TERMINAL_PROCESS_TEXT.startswith(text) + or text.startswith(TERMINAL_PROCESS_TEXT) + ) + + +def _tool_rows(snapshot: dict) -> list[dict]: + return [row for row in snapshot["rows"] if row["role"] == "tool"] + + +def _is_terminal_row_error(row: dict) -> bool: + status = (row.get("status") or "").strip() + if status in {"error", "failed"}: + return True + if status and status not in {"", "done"}: + return False + row_text = (row.get("text") or "").lower() + row_classes = (row.get("classes") or "") + return ( + "error" in row_text + or "warning" in row_classes + or "agent-activity-status-error" in row_classes + ) + + +def _assert_no_running_tool_rows(rows: list[dict]) -> None: + running = [ + row + for row in rows + if ( + row.get("status") == "running" + or ("tool-card-running" in row.get("classes", "")) + ) + ] + assert not running, rows + + def _assert_live_activity(snapshot: dict) -> None: assert snapshot["live"], snapshot assert snapshot["groupCount"] == 1, snapshot roles = [row["role"] for row in snapshot["rows"]] assert roles.count("thinking") == 1, snapshot assert roles.count("tool") == 1, snapshot - tool_rows = [row for row in snapshot["rows"] if row["role"] == "tool"] + tool_rows = _tool_rows(snapshot) assert len(tool_rows) == 1 and tool_rows[0]["tool"] == TOOL_NAME, snapshot - assert "tool-card-running" not in tool_rows[0]["classes"], snapshot + _assert_no_running_tool_rows(tool_rows) assert any(REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot assert all(FINAL_TEXT not in text for text in snapshot["visibleFinal"]), snapshot assert any(character.isdigit() for character in snapshot["summary"][0]["label"]), snapshot -def _assert_settled(snapshot: dict) -> None: +def _assert_settled(snapshot: dict, scenario: str) -> None: assert not snapshot["live"], snapshot assert snapshot["groupCount"] == 1, snapshot roles = [row["role"] for row in snapshot["rows"]] assert "thinking" in roles and "tool" in roles, snapshot - tool_rows = [row for row in snapshot["rows"] if row["role"] == "tool"] + tool_rows = _tool_rows(snapshot) assert len(tool_rows) == 1 and tool_rows[0]["tool"] == TOOL_NAME, snapshot - assert "tool-card-running" not in tool_rows[0]["classes"], snapshot + _assert_no_running_tool_rows(tool_rows) assert any(REASONING_TEXT in row["text"] for row in snapshot["rows"]), snapshot - assert sum(FINAL_TEXT in text for text in snapshot["visibleFinal"]) == 1, snapshot - assert snapshot["transcript"].count(FINAL_TEXT) == 1, snapshot + if scenario == "terminal-error": + assert "terminal" in roles, snapshot + terminal_rows = _terminal_rows(snapshot) + assert terminal_rows, snapshot + assert all(_is_terminal_row_error(row) for row in terminal_rows), snapshot + assert snapshot["assistantMessage"] is not None, snapshot + turn_duration = snapshot["assistantMessage"].get("turnDuration") + assert isinstance(turn_duration, (int, float)) and turn_duration > 0, snapshot + assert snapshot["assistantMessage"]["hasError"] is True, snapshot + assert snapshot["assistantMessage"]["anchorTerminalState"] in {"error", "failed"}, snapshot + assert all(FINAL_TEXT not in text for text in snapshot["visibleFinal"]), snapshot + assert sum(TERMINAL_ERROR_TEXT in text for text in snapshot["visibleFinal"]) == 1, snapshot + assert TERMINAL_ERROR_TEXT in snapshot["transcript"], snapshot + else: + assert sum(FINAL_TEXT in text for text in snapshot["visibleFinal"]) == 1, snapshot + assert snapshot["transcript"].count(FINAL_TEXT) == 1, snapshot + + +def _parse_json_payload(raw: str | bytes | None) -> dict: + if raw is None: + return {} + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + if not raw: + return {} + try: + payload = json.loads(raw) + except (TypeError, json.JSONDecodeError): + return {} + if not isinstance(payload, dict): + return {} + return payload + + +def _assert_process_row_present(snapshot: dict) -> list[dict]: + rows = _process_rows(snapshot) + assert len(rows) == 1, snapshot + assert all(_is_terminal_process_row_text(row["text"]) for row in rows), snapshot + assert all(TERMINAL_PROCESS_TEXT not in text for text in snapshot["visibleFinal"]), snapshot + return rows def _semantic_activity(snapshot: dict) -> list[dict]: @@ -542,7 +710,24 @@ def main() -> int: tempfile.mkdtemp(prefix="hermes-lifecycle-artifacts-") ) artifact_dir.mkdir(parents=True, exist_ok=True) - gateway = DeterministicGateway() + scenario = SCENARIO + if scenario not in {"normal", "terminal-error"}: + raise ValueError( + f"Unsupported LIFECYCLE_SCENARIO {scenario!r}; " + "expected 'normal' or 'terminal-error'" + ) + if TEST_BITE not in {"", "drop-anchor-persistence", "drop-terminal-anchor-row"}: + raise ValueError( + f"Unsupported LIFECYCLE_TEST_BITE {TEST_BITE!r}; " + "expected one of '', 'drop-anchor-persistence', 'drop-terminal-anchor-row'" + ) + if TEST_BITE == "drop-terminal-anchor-row" and scenario != "terminal-error": + raise ValueError( + "drop-terminal-anchor-row is only valid for " + "LIFECYCLE_SCENARIO=terminal-error" + ) + + gateway = DeterministicGateway(scenario) gateway.start() agent_dir = state_dir / "no-agent" @@ -598,15 +783,43 @@ def main() -> int: context = browser.new_context(base_url=base_url) page = context.new_page() anchor_scene_requests = _capture_anchor_scene_requests(page) - if TEST_BITE == "drop-anchor-persistence": - page.route( - "**/api/session/anchor-scene", - lambda route: route.fulfill( - status=200, - content_type="application/json", - body='{"ok":true}', - ), - ) + if TEST_BITE: + def _route_anchor_scene(route): + if TEST_BITE == "drop-anchor-persistence": + route.fulfill( + status=200, + content_type="application/json", + body='{"ok":true}', + ) + return + if TEST_BITE == "drop-terminal-anchor-row": + raw_payload = _safe_request_post_data(route.request) + payload = _parse_json_payload(raw_payload) + if isinstance(payload, dict): + scene = payload.get("scene") + if isinstance(scene, dict): + rows = scene.get("activity_rows") + if isinstance(rows, list): + kept = [ + row for row in rows + if not (isinstance(row, dict) and row.get("role") == "terminal") + ] + if len(kept) != len(rows): + mutated = dict(payload) + updated_scene = dict(scene) + updated_scene["activity_rows"] = kept + mutated["scene"] = updated_scene + response = route.fetch(post_data=json.dumps(mutated)) + route.fulfill(response=response) + return + response = route.fetch() + route.fulfill(response=response) + return + response = route.fetch() + route.fulfill(response=response) + return + + page.route("**/api/session/anchor-scene", _route_anchor_scene) errors = _capture_page_errors(page) page.goto("/", wait_until="domcontentloaded") page.wait_for_selector("#msg", state="visible", timeout=15000) @@ -631,54 +844,153 @@ def main() -> int: ) live_snapshot = _activity_snapshot(page) _assert_live_activity(live_snapshot) - print("OK live activity: one Anchor worklog with reasoning + completed tool") + if scenario == "terminal-error": + _assert_process_row_present(live_snapshot) + print("OK live activity: terminal-error run keeps reasoning + completed tool") + else: + print("OK live activity: one Anchor worklog with reasoning + completed tool") _wait_for_live_anchor_projection(page) gateway.release_settle.set() if not gateway.final_prefix_ready.wait(timeout=10): raise AssertionError("mock Gateway did not emit the final-answer prefix") - page.wait_for_function( - """text => { - const turn = document.querySelector('#liveAssistantTurn'); - return turn && (turn.innerText || '').includes(text); - }""", - arg=FINAL_ACK_TEXT, - timeout=10000, - ) - gateway.release_terminal.set() - page.wait_for_function( - """text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId && - !document.querySelector('#liveAssistantTurn') && - (document.querySelector('#msgInner') || {}).innerText?.includes(text)""", - arg=FINAL_TEXT, - timeout=15000, - ) + if scenario == "normal": + page.wait_for_function( + """text => { + const turn = document.querySelector('#liveAssistantTurn'); + return Boolean(turn) && turn.innerText.includes(text); + }""", + arg=FINAL_ACK_TEXT, + timeout=10000, + ) + gateway.release_terminal.set() + page.wait_for_function( + """text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId && + ((document.querySelector('#msgInner') || {}).innerText || '').includes(text)""", + arg=FINAL_TEXT, + timeout=15000, + ) + else: + gateway.release_terminal.set() + page.wait_for_function( + """text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId && + !document.querySelector('#liveAssistantTurn') && + ((document.querySelector('#msgInner') || {}).innerText||'').includes(text)""", + arg=TERMINAL_ERROR_TEXT, + timeout=15000, + ) session_id = page.evaluate("S.session && S.session.session_id") assert session_id, "active session id missing after settlement" - if not TEST_BITE: + if TEST_BITE == "drop-terminal-anchor-row": scene = _wait_for_persisted_scene( base_url, session_id, anchor_scene_requests=anchor_scene_requests, ) assert scene.get("version") == "activity_scene_v1", scene + scene_rows = scene.get("activity_rows") or [] + scene_roles = [ + row.get("role") for row in scene_rows + if isinstance(row, dict) + ] + assert any( + isinstance(row, dict) and row.get("role") == "prose" + for row in scene_rows + ), scene + assert any( + isinstance(row, dict) and row.get("role") == "thinking" + for row in scene_rows + ), scene + assert any( + isinstance(row, dict) and row.get("role") == "tool" + for row in scene_rows + ), scene + assert all( + not ( + isinstance(row, dict) + and row.get("role") == "terminal" + ) + for row in scene_rows + ), scene + print( + "OK persisted scene via API: roles=%s terminal_present=%s" + % (sorted(set(scene_roles)), any(role == "terminal" for role in scene_roles)) + ) + persisted_scene = _latest_anchor_scene_from_disk(state_dir, session_id) + assert persisted_scene is not None, { + "session_id": session_id, + "state_dir": str(state_dir / "webui-state"), + } + persisted_rows = persisted_scene.get("activity_rows") or [] + persisted_roles = [ + row.get("role") for row in persisted_rows + if isinstance(row, dict) + ] + assert any( + isinstance(row, dict) and row.get("role") == "thinking" + for row in persisted_rows + ), { + "persisted_rows": persisted_rows, + } + assert any( + isinstance(row, dict) and row.get("role") == "prose" + for row in persisted_rows + ), { + "persisted_rows": persisted_rows, + } + assert any( + isinstance(row, dict) and row.get("role") == "tool" + for row in persisted_rows + ), { + "persisted_rows": persisted_rows, + } + assert all( + not ( + isinstance(row, dict) + and row.get("role") == "terminal" + ) + for row in persisted_rows + ), { + "persisted_rows": persisted_rows, + } + print( + "OK persisted scene on disk: roles=%s terminal_present=%s" + % (sorted(set(persisted_roles)), "terminal" in persisted_roles) + ) + elif not TEST_BITE: + scene = _wait_for_persisted_scene( + base_url, + session_id, + anchor_scene_requests=anchor_scene_requests, + ) + assert scene.get("version") == "activity_scene_v1", scene + if scenario == "terminal-error": + scene_rows = scene.get("activity_rows") or [] + assert any( + isinstance(row, dict) and row.get("role") == "terminal" for row in scene_rows + ), scene _expand_settled_worklog(page) page.wait_for_selector( '.assistant-turn [data-anchor-settled-scene-owner="1"] [data-anchor-scene-row="1"]', timeout=10000, ) settled_snapshot = _activity_snapshot(page) - _assert_settled(settled_snapshot) + _assert_settled(settled_snapshot, scenario) + if scenario == "terminal-error": + _assert_process_row_present(settled_snapshot) assert _semantic_activity(settled_snapshot) == _semantic_activity(live_snapshot), { "live": _semantic_activity(live_snapshot), "settled": _semantic_activity(settled_snapshot), } - print("OK settled: final prose and the same semantic activity coexist without duplication") + if scenario == "terminal-error": + print("OK settled: terminal row and same activity survived terminal settlement") + else: + print("OK settled: final prose and the same semantic activity coexist without duplication") page.reload(wait_until="domcontentloaded") page.wait_for_function( "text => (document.querySelector('#msgInner') || {}).innerText?.includes(text)", - arg=FINAL_TEXT, + arg=TERMINAL_ERROR_TEXT if scenario == "terminal-error" else FINAL_TEXT, timeout=15000, ) _expand_settled_worklog(page) @@ -687,11 +999,34 @@ def main() -> int: timeout=2000 if TEST_BITE else 10000, ) reloaded_snapshot = _activity_snapshot(page) - _assert_settled(reloaded_snapshot) + _assert_settled(reloaded_snapshot, scenario) + if scenario == "terminal-error": + _assert_process_row_present(reloaded_snapshot) assert _semantic_activity(reloaded_snapshot) == _semantic_activity(settled_snapshot), { "settled": _semantic_activity(settled_snapshot), "reloaded": _semantic_activity(reloaded_snapshot), } + if scenario == "terminal-error": + settled_terminal = _terminal_rows(settled_snapshot) + reloaded_terminal = _terminal_rows(reloaded_snapshot) + settled_process = _process_rows(settled_snapshot) + reloaded_process = _process_rows(reloaded_snapshot) + assert len(settled_process) == len(reloaded_process) == 1, { + "settled_process": settled_process, + "reloaded_process": reloaded_process, + } + assert settled_process[0]["text"] == reloaded_process[0]["text"], { + "settled_process": settled_process, + "reloaded_process": reloaded_process, + } + assert len(settled_terminal) == len(reloaded_terminal) == 1, { + "settled_terminal": settled_terminal, + "reloaded_terminal": reloaded_terminal, + } + assert settled_terminal[0]["text"] == reloaded_terminal[0]["text"], { + "settled_terminal": settled_terminal[0], + "reloaded_terminal": reloaded_terminal[0], + } print("OK hard reload: transcript-backed Anchor scene preserves settled parity") assert gateway.request_body and gateway.request_body.get("input") == PROMPT, gateway.request_body @@ -710,7 +1045,7 @@ def main() -> int: page.screenshot(path=str(artifact_dir / "failure.png"), full_page=True) (artifact_dir / "snapshot.json").write_text( json.dumps({ - "scenario": "normal-live-to-final", + "scenario": scenario, "test_bite": TEST_BITE or None, "browser_errors": errors, "anchor_scene_requests": anchor_scene_requests, From 4bdc22b7ce63e5ffaaaed3aacd38f68fd28d42ba Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 07:29:22 -0700 Subject: [PATCH 24/82] Release: gateway-default MoA send (#5869, @rodboev) (#6365) * fix(#5853): allow gateway-default MoA sends * fix(#5853): freeze gateway auth to one locked snapshot * docs(changelog): stamp #5869 gateway-default MoA send --------- Co-authored-by: Rod Boev Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/config.py | 19 + api/routes.py | 33 +- tests/test_agent_runtime_revision_guard.py | 8 +- tests/test_issue5057_moa_webui_route.py | 444 ++++++++++++++++++++- 5 files changed, 496 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd7bd432..4e75b0b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Fixed +- **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853) + - **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291) - **Live Stream: a settled Worklog now shows the side effects a turn performed (memory/skill saves).** The `state_saved` SSE event (memory + skill saves) was classified as an Assistant-Turn-Anchor side effect but the renderer-neutral scene projection discarded it, so the browser only flashed a toast and a reloaded Worklog lost that record. Anchor `artifacts`/`side_effects` are now projected into the settled `activity_scene_v1`. Purely additive — turns with no side effects render byte-identical (side-effect-only turns still don't force a Worklog), and the change is compatible with the stable-run-identity plumbing. Thanks @franksong2702. (#6204, #3400) diff --git a/api/config.py b/api/config.py index 586c2661..8c417ee9 100644 --- a/api/config.py +++ b/api/config.py @@ -484,6 +484,25 @@ def get_config() -> dict: return _cfg_cache +def get_config_snapshot() -> dict: + """Return a request-owned config snapshot captured under the cache lock.""" + with _cfg_lock: + config_path = _get_config_path() + try: + current_mtime = config_path.stat().st_mtime + except OSError: + current_mtime = 0.0 + path_changed = _cfg_path != config_path + mtime_stale = current_mtime != _cfg_mtime + if not _cfg_cache or path_changed or (mtime_stale and not _cfg_has_in_memory_overrides()): + _refresh_config_cache(config_path) + try: + active_cfg = cfg if cfg is not _cfg_cache else _cfg_cache + except NameError: + active_cfg = _cfg_cache + return copy.deepcopy(active_cfg) + + def get_webui_session_save_mode(config_data: dict | None = None) -> str: """Return the validated first-turn session persistence mode. diff --git a/api/routes.py b/api/routes.py index fd57db10..d99873a1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2823,7 +2823,9 @@ from api.config import ( set_reasoning_display, set_reasoning_effort, create_stream_channel, + get_config, get_webui_session_save_mode, + get_config_snapshot, STREAM_GOAL_RELATED, PENDING_GOAL_CONTINUATION, _get_config_path, @@ -21260,6 +21262,7 @@ def _start_run( route: str, diag=None, moa_config=None, + gateway_chat_enabled: bool | None = None, ): """Shared start-run helper for /api/chat/start and start_session_turn. @@ -21300,6 +21303,7 @@ def _start_run( diag=diag, source=request.source or source, moa_config=moa_config, + external_runtime_owned=gateway_chat_enabled, ) def _legacy_adapter_factory(): @@ -21340,7 +21344,7 @@ def _start_run( diag=diag, source=source, moa_config=moa_config, - external_runtime_owned=webui_gateway_chat_enabled(get_config()), + external_runtime_owned=gateway_chat_enabled, ) @@ -22066,7 +22070,8 @@ def _handle_chat_start(handler, body, diag=None): _pp_provider, _pp_default, _pp_cfg = _read_profile_model_config(s, requested_provider) explicit_model_pick = bool(body.get("explicit_model_pick")) moa_config = None - gateway_chat_enabled = webui_gateway_chat_enabled(get_config()) + config_snapshot = get_config_snapshot() + gateway_chat_enabled = webui_gateway_chat_enabled(config_snapshot) if body.get("moa_config"): if gateway_chat_enabled: return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409) @@ -22113,9 +22118,25 @@ def _handle_chat_start(handler, body, diag=None): explicit_model_pick=explicit_model_pick, profile_provider=catalog_profile_provider, ) - if model_provider == "moa" and moa_config is None: - if webui_gateway_chat_enabled(get_config()): + if model_provider == "moa" and gateway_chat_enabled: + from api.config import get_effective_default_model + + model_config = config_snapshot.get("model") if isinstance(config_snapshot, dict) else None + configured_default, configured_default_provider, configured_default_is_moa = ( + _moa_fast_path_model_state(get_effective_default_model(config_snapshot)) + ) + configured_provider = _clean_session_model_provider( + model_config.get("provider") if isinstance(model_config, dict) else None + ) + if configured_provider is None and configured_default_is_moa: + configured_provider = configured_default_provider + if ( + configured_provider != "moa" + or model != configured_default + or explicit_model_pick + ): return bad(handler, "MoA override is unavailable on gateway-backed sessions", 409) + elif model_provider == "moa" and moa_config is None: from api.commands import resolve_moa_config try: @@ -22136,6 +22157,7 @@ def _handle_chat_start(handler, body, diag=None): "source": "webui", "route": "/api/chat/start", "diag": diag, + "gateway_chat_enabled": gateway_chat_enabled, } if not gateway_chat_enabled and moa_config is not None: start_run_kwargs["moa_config"] = moa_config @@ -25744,9 +25766,6 @@ def _handle_session_import(handler, body): return j(handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}) -# ── MCP Server helpers ── -from api.config import get_config, _save_yaml_config_file, _get_config_path, reload_config - def _mask_secrets(obj): """Mask sensitive values in env vars and headers.""" if not isinstance(obj, dict): diff --git a/tests/test_agent_runtime_revision_guard.py b/tests/test_agent_runtime_revision_guard.py index 0cc1d5e7..506bf6a0 100644 --- a/tests/test_agent_runtime_revision_guard.py +++ b/tests/test_agent_runtime_revision_guard.py @@ -595,8 +595,11 @@ def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch): captured = {} monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False) monkeypatch.setattr("api.runtime_adapter.runtime_adapter_runner_enabled", lambda: False) - monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) - monkeypatch.setattr(routes, "get_config", lambda: {}) + monkeypatch.setattr( + routes, + "get_config", + lambda: (_ for _ in ()).throw(AssertionError("_start_run reread shared config")), + ) monkeypatch.setattr( routes, "ensure_agent_runtime_current", @@ -621,6 +624,7 @@ def test_gateway_owned_start_run_bypasses_local_runtime_barrier(monkeypatch): normalized_model=False, source="webui", route="/api/chat/start", + gateway_chat_enabled=True, ) assert response["stream_id"] == "gateway-stream-1" diff --git a/tests/test_issue5057_moa_webui_route.py b/tests/test_issue5057_moa_webui_route.py index a3796b39..20e29eb1 100644 --- a/tests/test_issue5057_moa_webui_route.py +++ b/tests/test_issue5057_moa_webui_route.py @@ -12,6 +12,40 @@ import pytest from tests.conftest import TEST_BASE, requires_agent_modules +def test_config_snapshot_waits_for_reload_lock(monkeypatch, tmp_path): + """Snapshot capture cannot run concurrently with cache replacement.""" + import threading + + from api import config + + config_path = tmp_path / "config.yaml" + config_path.write_text("{}", encoding="utf-8") + shared_config = {"chat_backend": "gateway"} + monkeypatch.setattr(config, "_cfg_cache", shared_config) + monkeypatch.setattr(config, "cfg", shared_config) + monkeypatch.setattr(config, "_cfg_path", config_path) + monkeypatch.setattr(config, "_cfg_mtime", config_path.stat().st_mtime) + monkeypatch.setattr(config, "_cfg_fingerprint", config._fingerprint_config(shared_config)) + monkeypatch.setattr(config, "_get_config_path", lambda: config_path) + + complete = threading.Event() + result = {} + + def read_snapshot(): + result["snapshot"] = config.get_config_snapshot() + complete.set() + + with config._cfg_lock: + reader = threading.Thread(target=read_snapshot) + reader.start() + assert not complete.wait(0.1) + reader.join(timeout=1) + + assert complete.is_set() + assert result["snapshot"] == shared_config + assert result["snapshot"] is not shared_config + + def _install_fake_moa_config(monkeypatch, *, default_preset="moa-default", usage_text="Usage: /moa "): hermes_cli_pkg = sys.modules.get("hermes_cli") or ModuleType("hermes_cli") # monkeypatch.setattr restores the REAL hermes_cli.__path__ on teardown; @@ -174,7 +208,7 @@ def test_moa_gateway_chat_start_fails_closed(monkeypatch, tmp_path): monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) monkeypatch.setattr(routes, "_start_run", start_run) - monkeypatch.setattr(routes, "get_config", lambda: {"chat_backend": "gateway"}) + monkeypatch.setattr(routes, "get_config_snapshot", lambda: {"chat_backend": "gateway"}) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) monkeypatch.setattr(commands, "resolve_moa_config", resolve_moa_config) @@ -192,3 +226,411 @@ def test_moa_gateway_chat_start_fails_closed(monkeypatch, tmp_path): body = json.loads(handler.wfile.getvalue().decode("utf-8")) assert handler.status == 409 assert body["error"] == "MoA override is unavailable on gateway-backed sessions" + + +def test_moa_gateway_configured_default_reaches_start_run(monkeypatch, tmp_path): + """Gateway sessions may use only the configured MoA default.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-default" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "moa" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + captured = {} + + def start_run(*_args, **kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + config_snapshot = { + "chat_backend": "gateway", + "model": {"provider": "moa", "default": "@moa:moa-configured"}, + } + monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + routes._handle_chat_start( + _Handler(), + { + "session_id": "sess-moa-gateway-default", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-configured", + "model_provider": "moa", + }, + ) + + assert captured["model"] == "moa-configured" + assert captured["model_provider"] == "moa" + assert "moa_config" not in captured + + +def test_moa_gateway_string_model_config_reaches_start_run(monkeypatch, tmp_path): + """String-form gateway defaults may also authorize MoA sends.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-string-default" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "moa" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + captured = {} + + def start_run(*_args, **kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(routes, "get_config_snapshot", lambda: {"chat_backend": "gateway", "model": "@moa:moa-configured"}) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + routes._handle_chat_start( + _Handler(), + { + "session_id": "sess-moa-gateway-string-default", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-configured", + "model_provider": "moa", + }, + ) + + assert captured["model"] == "moa-configured" + assert captured["model_provider"] == "moa" + assert captured["gateway_chat_enabled"] is True + assert "moa_config" not in captured + + +@pytest.mark.parametrize( + ("initial_config", "mutated_config", "expected_status"), + [ + ( + {"chat_backend": "gateway", "model": {"provider": "openai", "default": "gpt-5.5"}}, + {"chat_backend": "gateway", "model": {"provider": "moa", "default": "@moa:moa-configured"}}, + 409, + ), + ( + {"chat_backend": "gateway", "model": {"provider": "moa", "default": "@moa:moa-configured"}}, + {"chat_backend": "gateway", "model": {"provider": "openai", "default": "gpt-5.5"}}, + 200, + ), + ], +) +def test_moa_gateway_authorization_uses_request_owned_config( + monkeypatch, tmp_path, initial_config, mutated_config, expected_status +): + """Gateway MoA authorization stays on the request's config snapshot.""" + import io + + import api.routes as routes + + class _Handler: + def __init__(self): + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-config-race" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "moa" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + captured = {} + config_snapshot = initial_config + original_resolve = routes._resolve_compatible_session_model_state + + def resolve_and_mutate(*args, **kwargs): + result = original_resolve(*args, **kwargs) + config_snapshot.clear() + config_snapshot.update(mutated_config) + return result + + def start_run(*_args, **kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", resolve_and_mutate) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(routes, "get_config_snapshot", lambda: routes.copy.deepcopy(config_snapshot)) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + handler = _Handler() + routes._handle_chat_start( + handler, + { + "session_id": "sess-moa-gateway-config-race", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-configured", + "model_provider": "moa", + }, + ) + + assert handler.status == expected_status + if expected_status == 200: + assert captured["gateway_chat_enabled"] is True + else: + assert not captured + + +def test_moa_gateway_explicit_configured_default_fails_closed(monkeypatch, tmp_path): + """A browser picker cannot select the gateway's MoA default.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-picker" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "moa" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + def start_run(*_args, **_kwargs): + raise AssertionError("explicit MoA picker selection must fail before starting a run") + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + config_snapshot = { + "chat_backend": "gateway", + "model": {"provider": "moa", "default": "@moa:moa-configured"}, + } + monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + handler = _Handler() + routes._handle_chat_start( + handler, + { + "session_id": "sess-moa-gateway-picker", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-configured", + "model_provider": "moa", + "explicit_model_pick": True, + }, + ) + + body = json.loads(handler.wfile.getvalue().decode("utf-8")) + assert handler.status == 409 + assert body["error"] == "MoA override is unavailable on gateway-backed sessions" + + +def test_moa_gateway_spoofed_provider_fails_closed(monkeypatch, tmp_path): + """A client cannot claim MoA for a model outside the configured default.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-moa-gateway-spoofed" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "openai-codex" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + def start_run(*_args, **_kwargs): + raise AssertionError("spoofed MoA provider must fail before starting a run") + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + config_snapshot = { + "chat_backend": "gateway", + "model": {"provider": "moa", "default": "@moa:moa-configured"}, + } + monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + handler = _Handler() + routes._handle_chat_start( + handler, + { + "session_id": "sess-moa-gateway-spoofed", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "moa/moa-spoofed", + "model_provider": "moa", + }, + ) + + body = json.loads(handler.wfile.getvalue().decode("utf-8")) + assert handler.status == 409 + assert body["error"] == "MoA override is unavailable on gateway-backed sessions" + + +def test_gateway_non_moa_model_reaches_start_run(monkeypatch, tmp_path): + """Gateway sends keep their non-MoA provider when the default uses MoA.""" + import api.routes as routes + + class _Handler: + def __init__(self): + import io + + self.status = None + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + self.response_headers.append(("__end__", "")) + + class _Session: + session_id = "sess-gateway-non-moa" + workspace = str(tmp_path) + model = "gpt-5.5" + model_provider = "openai-codex" + profile = "default" + messages = [] + context_messages = [] + pending_user_message = None + + captured = {} + + def start_run(*_args, **kwargs): + captured.update(kwargs) + return {"ok": True} + + monkeypatch.setattr(routes, "get_session", lambda _sid: _Session()) + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_start_run", start_run) + for name in ("HERMES_MODEL", "OPENAI_MODEL", "LLM_MODEL"): + monkeypatch.delenv(name, raising=False) + config_snapshot = { + "chat_backend": "gateway", + "model": {"provider": "moa", "default": "@moa:moa-configured"}, + } + monkeypatch.setattr(routes, "get_config_snapshot", lambda: config_snapshot) + monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True) + + routes._handle_chat_start( + _Handler(), + { + "session_id": "sess-gateway-non-moa", + "message": "diagnose issue", + "workspace": str(tmp_path), + "model": "gpt-5.5", + "model_provider": "openai-codex", + }, + ) + + assert captured["model_provider"] == "openai-codex" + assert "moa_config" not in captured From 33d55ec897f0ee3feb462a1f000dde026f95152e Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 09:30:04 -0700 Subject: [PATCH 25/82] Release: raster-data-URI redaction fast-path (#6311, @inch772) (#6367) * fix(redaction): skip native raster data URIs * fix(redaction): accept mixed-case raster MIME types * fix(redaction): validate complete raster payloads * docs(changelog): stamp #6311 raster-data-URI redaction fast-path --------- Co-authored-by: Su Ahn Lee <11433303+inch772@users.noreply.github.com> Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/helpers.py | 420 +++++++++++++++++++++++- tests/test_raster_data_uri_redaction.py | 362 ++++++++++++++++++++ 3 files changed, 782 insertions(+), 2 deletions(-) create mode 100644 tests/test_raster_data_uri_redaction.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e75b0b7..2e9dc877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Fixed +- **Large embedded images no longer slow down API response redaction.** Native raster image data URIs (`data:image/png|jpeg|gif|webp|bmp;base64,…`) are skipped by the credential-redaction pass instead of being scanned byte-by-byte on every response — a multi-megabyte image is opaque binary, not text a credential regex should rewrite. The fast path only applies after the *entire* base64 payload is validated (canonical round-trip + structural walk), so a string that merely starts with an image header but carries trailing or appended text still goes through full redaction — a credential can never ride along in an image's tail. Non-image strings, and sibling secrets in the same payload, are unaffected. Thanks @inch772. (#6311) + - **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853) - **Content-search and sidebar polling are faster: run-journal summaries are now cached.** Repeated reads of an unchanged run journal reuse a cached summary instead of re-parsing the whole file, keyed on a complete filesystem signature (device, inode, size, mtime, and ctime) so the cache is always dropped the instant the journal is appended to, replaced, truncated, or recreated — including a same-size rewrite that preserves mtime. Bounded LRU; a first-write race can never pin a stale empty result. Thanks @sjungwon03. (#6291) diff --git a/api/helpers.py b/api/helpers.py index 1a060435..d285cfe4 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -1,6 +1,8 @@ """ Hermes Web UI -- HTTP helper functions. """ +import base64 as _base64 +import binascii as _binascii import functools import json as _json import logging @@ -341,6 +343,13 @@ def _build_redact_fn(): r"""(Authorization:\s*(?:Bearer|Bot)\s+)([^\s'",\]\)]+)""", _re.IGNORECASE, ) + # A rejected image data URI can place a syntactically valid AWS key at an + # arbitrary base64 alignment, immediately after another base64 character. + # The general credential regex uses token boundaries to avoid rewriting + # prose identifiers; this defense-in-depth pass ensures the fail-closed + # image path still removes the maintainer's embedded-suffix attack even + # when hermes-agent is unavailable and the local fallback owns redaction. + _EMBEDDED_AWS_ACCESS_KEY_RE = _re.compile(r"AKIA[A-Z0-9]{16}") _ENV_RE = _re.compile( r"([A-Z0-9_]{0,50}(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)[A-Z0-9_]{0,50})" r"\s*=\s*(['\"]?)(\S+)\2" @@ -408,6 +417,7 @@ def _build_redact_fn(): if not isinstance(text, str) or not text: return text text = _CRED_RE.sub(lambda m: _mask(m.group(1)), text) + text = _EMBEDDED_AWS_ACCESS_KEY_RE.sub(lambda m: _mask(m.group(0)), text) text = _AUTH_HDR_RE.sub(lambda m: m.group(1) + _mask(m.group(2)), text) text = _ENV_RE.sub(_env_replacement, text) text = _PRIVKEY_RE.sub("[REDACTED PRIVATE KEY]", text) @@ -576,6 +586,360 @@ def _redact_text(text: str, *, _enabled: bool | None = None) -> str: return _redact_fn_cached(text) +_RASTER_IMAGE_DATA_URI_PREFIXES = ( + ("data:image/png;base64,", "png"), + ("data:image/jpeg;base64,", "jpeg"), + ("data:image/jpg;base64,", "jpeg"), + ("data:image/gif;base64,", "gif"), + ("data:image/webp;base64,", "webp"), + ("data:image/bmp;base64,", "bmp"), +) + + +def _is_native_raster_data_uri(text: str) -> bool: + """Return whether *text* is one complete, canonical raster data URI. + + Native image content is opaque binary, not text that the credential regexes + can safely rewrite. The exemption is a credential-boundary decision, so a + matching header or magic prefix is not enough: decode the entire canonical + base64 payload and require the image format to terminate exactly at the end + of the decoded bytes. Any malformed, ambiguous, or trailing content falls + through to normal text redaction. + """ + if not isinstance(text, str): + return False + image_kind = None + payload_start = 0 + for prefix, candidate_kind in _RASTER_IMAGE_DATA_URI_PREFIXES: + # URI schemes and MIME type tokens are case-insensitive. Only normalize + # this short header slice — never the multi-megabyte base64 payload. + if text[:len(prefix)].lower() == prefix: + image_kind = candidate_kind + payload_start = len(prefix) + break + if image_kind is None: + return False + + payload = text[payload_start:] + if not payload: + return False + try: + raw = _base64.b64decode(payload, validate=True) + except (_binascii.Error, ValueError): + return False + # validate=True rejects foreign characters and misplaced padding; this + # round-trip also rejects non-canonical pad bits and missing/extra padding. + if _base64.b64encode(raw).decode("ascii") != payload: + return False + + if image_kind == "png": + return _is_complete_png(raw) + if image_kind == "jpeg": + return _is_complete_jpeg(raw) + if image_kind == "gif": + return _is_complete_gif(raw) + if image_kind == "webp": + return _is_complete_webp(raw) + if image_kind == "bmp": + return _is_complete_bmp(raw) + return False + + +def _is_complete_png(raw: bytes) -> bool: + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + return False + pos = 8 + chunk_index = 0 + saw_idat = False + while pos < len(raw): + if pos + 12 > len(raw): + return False + length = int.from_bytes(raw[pos:pos + 4], "big") + chunk_type = raw[pos + 4:pos + 8] + data_start = pos + 8 + data_end = data_start + length + chunk_end = data_end + 4 + if chunk_end > len(raw): + return False + if not all((65 <= value <= 90) or (97 <= value <= 122) for value in chunk_type): + return False + if not 65 <= chunk_type[2] <= 90: # PNG reserved bit must be zero. + return False + expected_crc = int.from_bytes(raw[data_end:chunk_end], "big") + actual_crc = _binascii.crc32(chunk_type + raw[data_start:data_end]) & 0xFFFFFFFF + if actual_crc != expected_crc: + return False + + if chunk_index == 0: + if chunk_type != b"IHDR" or length != 13: + return False + width = int.from_bytes(raw[data_start:data_start + 4], "big") + height = int.from_bytes(raw[data_start + 4:data_start + 8], "big") + bit_depth = raw[data_start + 8] + color_type = raw[data_start + 9] + valid_depths = { + 0: {1, 2, 4, 8, 16}, + 2: {8, 16}, + 3: {1, 2, 4, 8}, + 4: {8, 16}, + 6: {8, 16}, + } + if ( + not width + or not height + or bit_depth not in valid_depths.get(color_type, set()) + or raw[data_start + 10] != 0 + or raw[data_start + 11] != 0 + or raw[data_start + 12] not in {0, 1} + ): + return False + elif chunk_type == b"IHDR": + return False + + if chunk_type == b"IDAT": + saw_idat = True + if chunk_type == b"IEND": + return length == 0 and saw_idat and chunk_end == len(raw) + pos = chunk_end + chunk_index += 1 + return False + + +_JPEG_SOF_MARKERS = { + 0xC0, 0xC1, 0xC2, 0xC3, + 0xC5, 0xC6, 0xC7, + 0xC9, 0xCA, 0xCB, + 0xCD, 0xCE, 0xCF, +} + + +def _is_complete_jpeg(raw: bytes) -> bool: + if len(raw) < 4 or raw[:2] != b"\xff\xd8": + return False + pos = 2 + saw_sof = False + saw_scan = False + while pos < len(raw): + marker_start = pos + if raw[pos] != 0xFF: + return False + while pos < len(raw) and raw[pos] == 0xFF: + pos += 1 + if pos >= len(raw): + return False + marker = raw[pos] + pos += 1 + if marker == 0xD9: + return saw_sof and saw_scan and pos == len(raw) + if marker in {0x00, 0x01, 0xD8} or 0xD0 <= marker <= 0xD7: + return False + if pos + 2 > len(raw): + return False + segment_length = int.from_bytes(raw[pos:pos + 2], "big") + if segment_length < 2: + return False + segment_end = pos + segment_length + if segment_end > len(raw): + return False + if marker in _JPEG_SOF_MARKERS: + if segment_length < 8: + return False + saw_sof = True + if marker != 0xDA: + pos = segment_end + continue + + saw_scan = True + pos = segment_end + while pos < len(raw): + if raw[pos] != 0xFF: + pos += 1 + continue + marker_start = pos + while pos < len(raw) and raw[pos] == 0xFF: + pos += 1 + if pos >= len(raw): + return False + scan_marker = raw[pos] + if scan_marker == 0x00 or 0xD0 <= scan_marker <= 0xD7: + pos += 1 + continue + pos = marker_start + break + else: + return False + return False + + +def _gif_subblocks_end(raw: bytes, pos: int) -> int | None: + while pos < len(raw): + size = raw[pos] + pos += 1 + if size == 0: + return pos + if pos + size > len(raw): + return None + pos += size + return None + + +def _is_complete_gif(raw: bytes) -> bool: + if len(raw) < 14 or not raw.startswith((b"GIF87a", b"GIF89a")): + return False + width = int.from_bytes(raw[6:8], "little") + height = int.from_bytes(raw[8:10], "little") + if not width or not height: + return False + packed = raw[10] + pos = 13 + if packed & 0x80: + pos += 3 * (1 << ((packed & 0x07) + 1)) + if pos > len(raw): + return False + saw_image = False + while pos < len(raw): + introducer = raw[pos] + pos += 1 + if introducer == 0x3B: + return saw_image and pos == len(raw) + if introducer == 0x21: + if pos >= len(raw): + return False + pos += 1 # extension label + end = _gif_subblocks_end(raw, pos) + if end is None: + return False + pos = end + continue + if introducer != 0x2C or pos + 9 > len(raw): + return False + image_width = int.from_bytes(raw[pos + 4:pos + 6], "little") + image_height = int.from_bytes(raw[pos + 6:pos + 8], "little") + image_packed = raw[pos + 8] + if not image_width or not image_height: + return False + pos += 9 + if image_packed & 0x80: + pos += 3 * (1 << ((image_packed & 0x07) + 1)) + if pos >= len(raw): + return False + lzw_minimum_code_size = raw[pos] + if not 2 <= lzw_minimum_code_size <= 11: + return False + pos += 1 + end = _gif_subblocks_end(raw, pos) + if end is None: + return False + pos = end + saw_image = True + return False + + +def _is_webp_image_chunk(chunk_type: bytes, data: bytes) -> bool: + if chunk_type == b"VP8 ": + if len(data) < 10 or data[3:6] != b"\x9d\x01\x2a": + return False + width = int.from_bytes(data[6:8], "little") & 0x3FFF + height = int.from_bytes(data[8:10], "little") & 0x3FFF + return bool(width and height) + if chunk_type == b"VP8L": + # The three high bits of the fifth byte are the version number. The + # current lossless bitstream defines only version zero. + return len(data) >= 5 and data[0] == 0x2F and not data[4] & 0xE0 + return False + + +def _is_complete_webp_frame(data: bytes) -> bool: + """Validate the nested chunks in one extended-WebP animation frame.""" + if len(data) < 16 or data[15] & 0xFC: + return False + pos = 16 + saw_image = False + while pos < len(data): + if pos + 8 > len(data): + return False + chunk_type = data[pos:pos + 4] + chunk_size = int.from_bytes(data[pos + 4:pos + 8], "little") + data_start = pos + 8 + data_end = data_start + chunk_size + chunk_end = data_end + (chunk_size & 1) + if chunk_end > len(data): + return False + chunk_data = data[data_start:data_end] + if chunk_type in {b"VP8 ", b"VP8L"}: + if saw_image or not _is_webp_image_chunk(chunk_type, chunk_data): + return False + saw_image = True + elif chunk_type != b"ALPH": + return False + pos = chunk_end + return saw_image and pos == len(data) + + +def _is_complete_webp(raw: bytes) -> bool: + if ( + len(raw) < 20 + or raw[:4] != b"RIFF" + or raw[8:12] != b"WEBP" + or int.from_bytes(raw[4:8], "little") + 8 != len(raw) + ): + return False + pos = 12 + saw_image = False + while pos < len(raw): + if pos + 8 > len(raw): + return False + chunk_type = raw[pos:pos + 4] + chunk_size = int.from_bytes(raw[pos + 4:pos + 8], "little") + data_start = pos + 8 + data_end = data_start + chunk_size + chunk_end = data_end + (chunk_size & 1) + if chunk_end > len(raw): + return False + data = raw[data_start:data_end] + if chunk_type in {b"VP8 ", b"VP8L"}: + if saw_image or not _is_webp_image_chunk(chunk_type, data): + return False + saw_image = True + elif chunk_type == b"VP8X": + if len(data) != 10 or data[0] & 0x81 or any(data[1:4]): + return False + elif chunk_type == b"ANMF": + if not _is_complete_webp_frame(data): + return False + saw_image = True + pos = chunk_end + return saw_image and pos == len(raw) + + +def _is_complete_bmp(raw: bytes) -> bool: + if len(raw) < 26 or raw[:2] != b"BM": + return False + if int.from_bytes(raw[2:6], "little") != len(raw): + return False + pixel_offset = int.from_bytes(raw[10:14], "little") + dib_size = int.from_bytes(raw[14:18], "little") + if dib_size == 12: + width = int.from_bytes(raw[18:20], "little") + height = int.from_bytes(raw[20:22], "little") + planes = int.from_bytes(raw[22:24], "little") + bits_per_pixel = int.from_bytes(raw[24:26], "little") + elif dib_size >= 40 and 14 + dib_size <= len(raw): + width = int.from_bytes(raw[18:22], "little", signed=True) + height = int.from_bytes(raw[22:26], "little", signed=True) + planes = int.from_bytes(raw[26:28], "little") + bits_per_pixel = int.from_bytes(raw[28:30], "little") + else: + return False + return ( + bool(width) + and bool(height) + and planes == 1 + and bits_per_pixel in {1, 2, 4, 8, 16, 24, 32} + and 14 + dib_size <= pixel_offset < len(raw) + ) + + def _redact_value(v, *, _enabled: bool | None = None): """Recursively redact credentials from strings, dicts, and lists. @@ -585,12 +949,64 @@ def _redact_value(v, *, _enabled: bool | None = None): if isinstance(v, str): return _redact_text(v, _enabled=_enabled) if isinstance(v, dict): - return {k: _redact_value(val, _enabled=_enabled) for k, val in v.items()} + return { + key: _redact_value(value, _enabled=_enabled) + for key, value in v.items() + } if isinstance(v, list): return [_redact_value(item, _enabled=_enabled) for item in v] return v +def _redact_message_content_part(part, *, _enabled: bool): + """Redact one canonical ``messages[*].content[*]`` part. + + The raster exemption exists only at this authoritative schema position. + Image-shaped dictionaries in metadata, tools, todos, journals, or arbitrary + nested values remain on the normal fail-closed redaction path. + """ + if not _enabled or not ( + isinstance(part, dict) + and part.get("type") == "image_url" + and isinstance(part.get("image_url"), dict) + ): + return _redact_value(part, _enabled=_enabled) + result = {} + for key, value in part.items(): + if key != "image_url": + result[key] = _redact_value(value, _enabled=_enabled) + continue + result[key] = { + image_key: image_value + if image_key == "url" and _is_native_raster_data_uri(image_value) + else _redact_value(image_value, _enabled=_enabled) + for image_key, image_value in value.items() + } + return result + + +def _redact_messages(messages, *, _enabled: bool): + if not isinstance(messages, list): + return _redact_value(messages, _enabled=_enabled) + redacted = [] + for message in messages: + if not isinstance(message, dict): + redacted.append(_redact_value(message, _enabled=_enabled)) + continue + item = {} + allow_native_image = message.get("role") == "user" + for key, value in message.items(): + if allow_native_image and key == "content" and isinstance(value, list): + item[key] = [ + _redact_message_content_part(part, _enabled=_enabled) + for part in value + ] + else: + item[key] = _redact_value(value, _enabled=_enabled) + redacted.append(item) + return redacted + + def redact_session_data(session_dict: dict) -> dict: """Redact credentials from message content, tool data, and session sidecars. @@ -610,7 +1026,7 @@ def redact_session_data(session_dict: dict) -> dict: if isinstance(result.get('title'), str): result['title'] = _redact_text(result['title'], _enabled=_enabled) if 'messages' in result: - result['messages'] = _redact_value(result['messages'], _enabled=_enabled) + result['messages'] = _redact_messages(result['messages'], _enabled=_enabled) if 'tool_calls' in result: result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled) if 'todo_state' in result: diff --git a/tests/test_raster_data_uri_redaction.py b/tests/test_raster_data_uri_redaction.py new file mode 100644 index 00000000..99c49f0d --- /dev/null +++ b/tests/test_raster_data_uri_redaction.py @@ -0,0 +1,362 @@ +"""Regression coverage for large native image redaction overhead. + +Native multimodal messages store raster images as base64 data URIs. Those +opaque image bytes must not be sent through the text credential redactor: a +large image can randomly contain one of the cheap prefilter's markers and then +pay for every regex pass in the agent redactor. +""" +import base64 +import json +import struct +import zlib + +import pytest + +from api import helpers +from api.session_export_html import render_session_html + + +_FAKE_AWS_KEY = "AKIATESTFAKEKEY12345" +_ONE_PIXEL_JPEG = base64.b64decode( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k=" +) +_ONE_PIXEL_GIF = base64.b64decode( + "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" +) +_ONE_PIXEL_WEBP = base64.b64decode( + "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuU" +) + + +def _one_pixel_bmp() -> bytes: + pixel_data = b"\x00\x00\x00\x00" # BGR black + row padding + file_size = 54 + len(pixel_data) + file_header = b"BM" + struct.pack(" bytes: + crc = zlib.crc32(kind + data) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", crc) + + +def _valid_png_bytes_with_sensitive_marker(*, extra_bytes: int = 0) -> bytes: + """Build a real 1x1 RGBA PNG whose base64 contains an ``AKIA`` quartet.""" + marker_bytes = base64.b64decode("AKIA") + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0) + # The first data byte starts at absolute offset 41. One byte of padding + # aligns marker_bytes to a base64 quantum, preserving the literal AKIA + # quartet that exercises the redaction prefilter. + marker_chunk = _png_chunk( + b"raNd", + b"\x00" + marker_bytes + (b"image-bytes" * 32) + (b"x" * extra_bytes), + ) + idat = _png_chunk(b"IDAT", zlib.compress(b"\x00\x00\x00\x00\x00")) + prefix = b"\x89PNG\r\n\x1a\n" + _png_chunk(b"IHDR", ihdr) + marker_chunk + idat + # Make the complete PNG length a multiple of three. This lets the + # maintainer-supplied post-IEND attack append decoded bytes whose standard + # base64 representation remains the literal fake credential. + for pad_len in range(3): + raw = prefix + _png_chunk(b"paDd", b"x" * pad_len) + _png_chunk(b"IEND", b"") + if len(raw) % 3 == 0: + encoded = base64.b64encode(raw).decode("ascii") + assert "AKIA" in encoded + return raw + raise AssertionError("could not align synthetic PNG") + + +def _png_data_uri_with_sensitive_marker() -> str: + raw = _valid_png_bytes_with_sensitive_marker() + encoded = base64.b64encode(raw).decode("ascii") + assert "AKIA" in encoded + return f"data:image/png;base64,{encoded}" + + +def _png_data_uri_with_post_iend_credential() -> str: + raw = _valid_png_bytes_with_sensitive_marker() + suffix = base64.b64decode(_FAKE_AWS_KEY, validate=True) + encoded = base64.b64encode(raw + suffix).decode("ascii") + assert encoded.endswith(_FAKE_AWS_KEY) + return f"data:image/png;base64,{encoded}" + + +def test_native_raster_data_uri_bypasses_text_redactor(monkeypatch): + uri = _png_data_uri_with_sensitive_marker() + calls = [] + monkeypatch.setattr( + helpers, + "_redact_fn_cached", + lambda text: calls.append(text) or "unexpected-redaction", + ) + + content_part = { + "type": "image_url", + "image_url": {"url": uri, "detail": "auto"}, + } + + redacted = helpers.redact_session_data( + {"messages": [{"role": "user", "content": [content_part]}]} + ) + assert redacted["messages"][0]["content"][0] == content_part + assert calls == [] + + +def test_multimegabyte_native_raster_still_bypasses_text_redactor(monkeypatch): + raw = _valid_png_bytes_with_sensitive_marker(extra_bytes=1_000_000) + uri = f"data:image/png;base64,{base64.b64encode(raw).decode('ascii')}" + calls = [] + monkeypatch.setattr( + helpers, + "_redact_fn_cached", + lambda text: calls.append(text) or "unexpected-redaction", + ) + + result = helpers.redact_session_data( + { + "messages": [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": uri}}], + } + ] + } + ) + + assert result["messages"][0]["content"][0]["image_url"]["url"] == uri + assert calls == [] + + +def test_native_raster_data_uri_accepts_uppercase_mime(monkeypatch): + uri = _png_data_uri_with_sensitive_marker().replace( + "data:image/png", + "data:image/PNG", + 1, + ) + calls = [] + monkeypatch.setattr( + helpers, + "_redact_fn_cached", + lambda text: calls.append(text) or "unexpected-redaction", + ) + + content_part = {"type": "image_url", "image_url": {"url": uri}} + + redacted = helpers.redact_session_data( + {"messages": [{"role": "user", "content": [content_part]}]} + ) + assert redacted["messages"][0]["content"][0] == content_part + assert calls == [] + + +def test_raster_data_uri_outside_image_part_keeps_security_boundary(monkeypatch): + uri = _png_data_uri_with_sensitive_marker() + calls = [] + monkeypatch.setattr( + helpers, + "_redact_fn_cached", + lambda text: calls.append(text) or "redacted", + ) + + assert helpers._redact_value({"content": uri}, _enabled=True) == { + "content": "redacted", + } + assert calls == [uri] + + +def test_non_raster_image_part_keeps_security_boundary(monkeypatch): + uri = "data:image/svg+xml;base64," + base64.b64encode( + b" " + base64.b64decode("AKIA") + b" sensitive text" + ).decode("ascii") + assert "AKIA" in uri + calls = [] + monkeypatch.setattr( + helpers, + "_redact_fn_cached", + lambda text: calls.append(text) or "redacted", + ) + + result = helpers._redact_value( + {"type": "image_url", "image_url": {"url": uri}}, + _enabled=True, + ) + + assert result["image_url"]["url"] == "redacted" + assert calls == [uri] + + +def test_declared_raster_with_wrong_magic_keeps_security_boundary(monkeypatch): + encoded = base64.b64encode(b"not-a-png" + base64.b64decode("AKIA")).decode("ascii") + uri = f"data:image/png;base64,{encoded}" + calls = [] + monkeypatch.setattr( + helpers, + "_redact_fn_cached", + lambda text: calls.append(text) or "redacted", + ) + + result = helpers._redact_value( + {"type": "image_url", "image_url": {"url": uri}}, + _enabled=True, + ) + + assert result["image_url"]["url"] == "redacted" + assert calls == [uri] + + +def test_post_iend_credential_falls_through_to_text_redaction(monkeypatch): + uri = _png_data_uri_with_post_iend_credential() + calls = [] + + def redact(text): + calls.append(text) + return text.replace(_FAKE_AWS_KEY, "[REDACTED]") + + monkeypatch.setattr(helpers, "_redact_fn_cached", redact) + content_part = {"type": "image_url", "image_url": {"url": uri}} + + result = helpers.redact_session_data( + {"messages": [{"role": "user", "content": [content_part]}]} + ) + + assert _FAKE_AWS_KEY not in result["messages"][0]["content"][0]["image_url"]["url"] + assert calls == [uri] + assert helpers._is_native_raster_data_uri(uri) is False + + +def test_post_iend_credential_is_removed_by_real_redactor(): + uri = _png_data_uri_with_post_iend_credential() + result = helpers.redact_session_data( + { + "messages": [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": uri}}], + } + ] + } + ) + + assert _FAKE_AWS_KEY not in json.dumps(result) + + +def test_image_exemption_is_scoped_to_direct_message_content_parts(monkeypatch): + valid_uri = _png_data_uri_with_sensitive_marker() + calls = [] + monkeypatch.setattr( + helpers, + "_redact_fn_cached", + lambda text: calls.append(text) or text.replace("AKIA", "[REDACTED]"), + ) + image_shaped = {"type": "image_url", "image_url": {"url": valid_uri}} + session = { + "messages": [ + { + "role": "user", + "content": [ + image_shaped, + {"type": "text", "text": f"sibling={_FAKE_AWS_KEY}"}, + ], + "metadata": image_shaped, + }, + {"role": "assistant", "content": [image_shaped]}, + ], + "tool_calls": [image_shaped], + "todo_state": {"image": image_shaped}, + "runtime_journal_snapshot": {"messages": [image_shaped]}, + } + + result = helpers.redact_session_data(session) + + # Only the canonical messages[*].content[*] image URL stays opaque. + assert result["messages"][0]["content"][0]["image_url"]["url"] == valid_uri + assert _FAKE_AWS_KEY not in result["messages"][0]["content"][1]["text"] + assert "AKIA" not in result["messages"][0]["metadata"]["image_url"]["url"] + assert "AKIA" not in result["messages"][1]["content"][0]["image_url"]["url"] + assert "AKIA" not in result["tool_calls"][0]["image_url"]["url"] + assert "AKIA" not in result["todo_state"]["image"]["image_url"]["url"] + assert "AKIA" not in result["runtime_journal_snapshot"]["messages"][0]["image_url"]["url"] + assert calls + + +def test_crafted_image_is_redacted_from_json_html_and_journal(monkeypatch): + uri = _png_data_uri_with_post_iend_credential() + monkeypatch.setattr( + helpers, + "_redact_fn_cached", + lambda text: text.replace(_FAKE_AWS_KEY, "[REDACTED]"), + ) + crafted_part = {"type": "image_url", "image_url": {"url": uri}} + safe = helpers.redact_session_data( + { + "title": "security regression", + "messages": [{"role": "user", "content": [crafted_part]}], + "runtime_journal_snapshot": { + "messages": [crafted_part], + "persisted_payload": uri, + }, + } + ) + + json_export = json.dumps(safe) + html_export = render_session_html(safe) + assert _FAKE_AWS_KEY not in json_export + assert _FAKE_AWS_KEY not in html_export + assert _FAKE_AWS_KEY not in json.dumps(safe["runtime_journal_snapshot"]) + + +def test_uncertain_base64_forms_fail_closed(): + valid_uri = _png_data_uri_with_sensitive_marker() + header, payload = valid_uri.split(",", 1) + hostile = [ + f"{header},{payload[:16]}\n{payload[16:]}", + f"{valid_uri}?token={_FAKE_AWS_KEY}", + f"{valid_uri} {_FAKE_AWS_KEY}", + f"{header},{payload[:-1]}", + f"{header},{payload}=A", + ] + + assert all(not helpers._is_native_raster_data_uri(value) for value in hostile) + + +@pytest.mark.parametrize( + "mime,raw", + [ + ("png", _valid_png_bytes_with_sensitive_marker()), + ("jpeg", _ONE_PIXEL_JPEG), + ("gif", _ONE_PIXEL_GIF), + ("webp", _ONE_PIXEL_WEBP), + ("bmp", _one_pixel_bmp()), + ], +) +def test_supported_raster_requires_exact_terminal_boundary(mime, raw): + exact = f"data:image/{mime};base64,{base64.b64encode(raw).decode('ascii')}" + trailing = ( + f"data:image/{mime};base64," + f"{base64.b64encode(raw + b'trailing-bytes').decode('ascii')}" + ) + + assert helpers._is_native_raster_data_uri(exact) is True + assert helpers._is_native_raster_data_uri(trailing) is False + + +def test_webp_animation_frame_requires_real_nested_image_chunk(): + fake_frame = b"\x00" * 16 + anmf = b"ANMF" + struct.pack(" Date: Mon, 20 Jul 2026 10:49:23 -0700 Subject: [PATCH 26/82] =?UTF-8?q?feat(extensions):=20token-v1=20proxy?= =?UTF-8?q?=E2=86=92sidecar=20authentication=20boundary=20(#6331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extensions): token-v1 proxy->sidecar auth boundary Mint a per-extension secret core injects (X-Hermes-Sidecar-Token) on every proxied request; sidecars validate it. Closes the hole where a loopback sidecar port is reachable by any local process and cannot distinguish a proxied request from a direct one. - api/extension_sidecar_auth.py: per-extension token lifecycle (atomic mint, re-read-verified so an unpersisted token is never injected, per-request mtime-cached read for live rotation, path-escape-safe) - manifest proxy_auth negotiation: absent=legacy, token-v1=enforce, unknown=fail-closed - proxy: inject token, strip inbound + response x-hermes-*, auth-off posture (loopback-only local_unprotected, else 503), fail-closed when token unavailable - consent-time auth_required in status payload; mint-on-consent - docs/EXTENSIONS.md proxy_auth section - 7 new tests (26/26 green) * fix(extensions): address Codex+Fable gate on token-v1 (6 findings) - align token-module extension-id grammar with core _EXTENSION_ID_RE (was narrower -> legally-named ext consented then 503'd forever) - resolve token dir dynamically (mirror _extension_state_dir) -> real test isolation, no import-time STATE_DIR cache - cross-process mint: O_CREAT|O_EXCL no-clobber claim (was os.replace clobber) - rotation cache keyed on full fingerprint (ino/dev/mtime/ctime/size) + re-fingerprint after read -> same-size/mtime replacement no longer stale - validate token format on read (url-safe, 16-256) -> malformed file can't leak via a ValueError echoed in a 502 - consent fails 503 when token can't be provisioned (was silent-swallow -> persisted consent then 503 forever) - rename status auth_required -> posture enum (protected|local_unprotected): nothing is blocked for loopback, so 'required' was misleading - panels.js: render local_unprotected warning on the consent row (+ CSS) - docs: token-path resolution order, 401-vs-503, explicit 'legacy' acceptance - tests: route-level token-injection+response-strip test; fix illusory isolation in token-module test; 27/27 green * fix(extensions): close 2 token-mint races (Codex re-gate round 2) - mint via temp-file + atomic os.replace (not O_CREAT|O_EXCL) so the final path is never observed empty/half-written — a concurrent loser can no longer read an empty token file and 503 - single _stable_read helper (fingerprint-read-refingerprint, bounded retry on mid-read change) used by BOTH ensure_token and current_token — a token that changes during the read is never returned or cached stale - stress-verified: 20 concurrent first-mints converge on 1 persisted token; 27/27 * fix(extensions): atomic no-clobber token publish via os.link (Codex re-gate round 3) os.replace fixed empty-file exposure but still clobbered cross-process: two processes could both write+replace and a reader between them got a token no longer on disk -> 401. Switch to the repo's TOCTOU-safe os.link create-or-fail idiom (session_recovery.py:627): write temp -> link into place (fails if a winner already published) -> loser drops its temp and reads the winner via _stable_read. Proven: 16 concurrent PROCESSES converge on 1 persisted token, all matching disk. 27/27 tests green. * fix(extensions): resolve Frank+Greptile #6331 review — token-v1 fail-closed when auth off (consent+resolution), token-v1-only proxy_auth/posture status fields, fullmatch ext-id validator * fix(extensions): finish Frank and Greptile sidecar review --------- Co-authored-by: nesquena-hermes Co-authored-by: nesquena-hermes --- api/extension_sidecar_auth.py | 241 ++++++++++++++++++++++ api/extensions.py | 112 +++++++++- api/routes.py | 16 +- docs/EXTENSIONS.md | 36 +++- static/panels.js | 8 + static/style.css | 1 + tests/test_extension_sidecar_proxy.py | 284 ++++++++++++++++++++++++++ 7 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 api/extension_sidecar_auth.py diff --git a/api/extension_sidecar_auth.py b/api/extension_sidecar_auth.py new file mode 100644 index 00000000..fdca26f7 --- /dev/null +++ b/api/extension_sidecar_auth.py @@ -0,0 +1,241 @@ +"""Extension sidecar proxy authentication (token-v1). + +A loopback-sidecar extension runs a stdlib HTTP server on 127.0.0.1:. The +browser reaches it ONLY through core's consent-gated proxy +(``/api/extensions/{id}/sidecar/*``). But the loopback port is reachable by any +local process, and the proxy strips every inbound credential before forwarding +(see ``_extension_sidecar_proxy_request_headers`` in ``api/routes.py``), so the +sidecar cannot tell a proxied request from a direct one. + +This module mints a per-extension shared secret that core injects on every +forwarded request (header ``X-Hermes-Sidecar-Token``) and the sidecar validates. +It converts "anyone who can send a loopback TCP packet" (other-UID users, host +containers, sandboxed network-only processes) into "processes that can read the +user's state dir" — the same protection level core's own signing key already has +(``.pbkdf2_key`` / ``.signing_key`` are 0600 files in the same directory). + +SCOPE (be honest — this is documented in the contract too): the token does NOT +defend against arbitrary same-UID code. A same-user process can read the token, +read core's signing key, or just run the sidecar's underlying tool directly. No +mechanism available here (token, HMAC, nonce, UDS with 0600) changes that. + +Design notes (from the reviewed design doc, §9.2): + * Per-extension token file under ``STATE_DIR/sidecar-auth/.token``. + * Unlike ``auth._load_key``, we NEVER return an in-memory-only token: if the + file cannot be persisted+re-read, we report "unavailable" so core fails + closed (503) rather than injecting a secret no sidecar can ever read. + * The token is re-read from disk per request (fingerprint-cached on + inode/mtime/ctime/size) so rotation / deletion takes effect with no restart, + and a stale cached token can't keep validating after the file changes. + * Cross-process mint writes a unique temporary file, then publishes it with + an atomic ``os.link`` create-or-fail operation. Concurrent first-mints + converge on a single winning file; losers re-read the winner rather than + clobbering it. +""" +from __future__ import annotations + +import os +import re +import secrets +import threading +from pathlib import Path +from typing import Dict, Optional, Tuple + +# Keep this grammar identical to ``_EXTENSION_ID_RE`` in ``api/extensions.py`` — +# a narrower pattern here silently 503s legally-named extensions (uppercase, +# dots, up to 128 chars). The pattern is filesystem-safe as a filename: no path +# separators, cannot start with a dot, so no dotfile / ".."-prefix tricks. +_EXT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +# Injected/validated tokens are exactly secrets.token_urlsafe output: url-safe +# base64 alphabet. Reject anything else (bounds length, and stops a malformed +# token file's contents from reaching a header where a ValueError could echo it). +_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{16,256}$") + +_TOKEN_DIR_NAME = "sidecar-auth" +_TOKEN_BYTES = 32 # secrets.token_urlsafe(32) -> ~43 url-safe chars + +# Per-request read cache: ext_id -> (token, fingerprint). Guarded by _LOCK. +_LOCK = threading.Lock() +_CACHE: Dict[str, Tuple[str, Tuple]] = {} + + +def _token_dir() -> Path: + # Resolve STATE_DIR dynamically (mirrors ``_extension_state_dir`` in + # api/extensions.py) so tests and relocated installs see the current dir + # rather than the value cached at import time. + from api.config import STATE_DIR + return STATE_DIR / _TOKEN_DIR_NAME + + +def _token_path(ext_id: str) -> Path: + return _token_dir() / f"{ext_id}.token" + + +def _valid_ext_id(ext_id: object) -> bool: + # Use fullmatch (not match) so a trailing newline can't slip through: in + # Python's ``re`` a ``$`` anchor also matches just before a final ``\n``, + # so ``match`` would accept ``"templates\n"`` and _token_path would build a + # filename containing a literal newline. fullmatch anchors truly to the end. + # Validate the exact value that _token_path() uses. Trimming only for the + # check would accept one ID but construct a filename from a different one. + return isinstance(ext_id, str) and bool(_EXT_ID_RE.fullmatch(ext_id)) + + +def _fingerprint(path: Path) -> Optional[Tuple]: + try: + st = path.stat() + except OSError: + return None + return (st.st_ino, st.st_dev, st.st_mtime_ns, st.st_ctime_ns, st.st_size) + + +def _read_token_file(path: Path) -> Optional[str]: + """Return the token text iff it is present and well-formed, else None + (fail closed). Only a valid token-v1 shape is ever returned, so malformed + file contents can never reach a header. Thin wrapper over the stable reader.""" + tok, _fp = _stable_read(path) + return tok + + +def _stable_read(path: Path) -> Tuple[Optional[str], Optional[Tuple]]: + """Read the token together with a fingerprint that is verified to bracket the + read (fingerprint before AND after; retry on any mid-read change). Guarantees + the returned (token, fingerprint) pair reflects a single consistent on-disk + state — never new content under an old stat key, and never a value that + changed underneath us. Returns (None, None) when absent/malformed/racing.""" + for _ in range(4): # bounded retry; a file being rotated settles fast + fp_before = _fingerprint(path) + if fp_before is None: + return (None, None) + try: + raw = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return (None, None) + fp_after = _fingerprint(path) + if fp_after != fp_before: + continue # changed mid-read — retry for a consistent snapshot + tok = raw.strip() + if not tok or not _TOKEN_RE.match(tok): + return (None, None) + return (tok, fp_before) + return (None, None) # never settled — fail closed + + +def ensure_token(ext_id: str) -> Optional[str]: + """Get-or-create the per-extension token, returning it ONLY if it is durably + persisted and re-readable from disk. Returns None if it can't be persisted + (caller must then treat the sidecar proxy as unavailable / 503) — we never + hand back an ephemeral token a sidecar could never read. + + Idempotent + atomic across processes: the token is written to a unique temp + file, then published with an atomic hard-link create-or-fail operation, so + the final path is only ever visible fully written and is never clobbered. + One concurrent writer wins; every loser reads that persisted winner. + """ + if not _valid_ext_id(ext_id): + return None + path = _token_path(ext_id) + + existing = _read_token_file(path) + if existing is not None: + return existing + + with _LOCK: + existing = _read_token_file(path) + if existing is not None: + return existing + token = secrets.token_urlsafe(_TOKEN_BYTES) + tmp: Optional[Path] = None + try: + d = _token_dir() + d.mkdir(parents=True, exist_ok=True) + try: + os.chmod(d, 0o700) + except OSError: + pass # best-effort dir hardening; file perms are the real guard + # Write to a unique temp, fsync, then publish with atomic + # create-or-fail (os.link, the repo's TOCTOU-safe idiom — see + # session_recovery.py:627). Unlike os.replace, link does NOT clobber: + # if another process already published, ours fails, we drop our temp, + # and everyone reads the single winning file. This closes both the + # empty-file-exposure race AND the cross-process clobber race. + tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, token.encode("utf-8")) + os.fsync(fd) + finally: + os.close(fd) + try: + os.link(str(tmp), str(path)) + try: + os.chmod(path, 0o600) + except OSError: + pass + except FileExistsError: + pass # another writer won; we return the winner via _stable_read below + finally: + try: + tmp.unlink() + except OSError: + pass + tmp = None + except OSError: + # Persistence failed → report unavailable. Do NOT return the + # in-memory token (the _load_key trap: core would inject a secret the + # sidecar can never read → permanent 401 that looks like a mismatch). + if tmp is not None: + try: + tmp.unlink() + except OSError: + pass + return None + # Return whatever is durably on disk now — under a concurrent mint this + # may be another writer's token, which is correct (all readers converge + # on the winning file). Only a persisted, well-formed token is returned. + persisted, fp = _stable_read(path) + if persisted is not None and fp is not None: + _CACHE[ext_id] = (persisted, fp) + return persisted + + +def current_token(ext_id: str) -> Optional[str]: + """Return the current on-disk token for validation/injection, re-reading the + file when its fingerprint changed since last read — so rotation + deletion + take effect immediately and a stale cached token stops validating. Does NOT + mint. Returns None when no token exists (fail closed).""" + if not _valid_ext_id(ext_id): + return None + path = _token_path(ext_id) + fp = _fingerprint(path) + if fp is None: + with _LOCK: + _CACHE.pop(ext_id, None) + return None + with _LOCK: + cached = _CACHE.get(ext_id) + if cached is not None and cached[1] == fp: + return cached[0] + tok, tok_fp = _stable_read(path) # bracketed read: content matches tok_fp + with _LOCK: + if tok is None or tok_fp is None: + _CACHE.pop(ext_id, None) + else: + _CACHE[ext_id] = (tok, tok_fp) + return tok + + +def reset_token(ext_id: str) -> Optional[str]: + """Rotate: delete then re-mint. Returns the new token or None on failure. + (Recovery entry point; also usable by a future 'reset token' action.)""" + if not _valid_ext_id(ext_id): + return None + with _LOCK: + _CACHE.pop(ext_id, None) + try: + _token_path(ext_id).unlink() + except FileNotFoundError: + pass + except OSError: + return None + return ensure_token(ext_id) diff --git a/api/extensions.py b/api/extensions.py index 8c86514a..ba7104e4 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -898,6 +898,21 @@ def _sidecar_from_manifest_entry( return None else: health_path = _DEFAULT_SIDECAR_HEALTH_PATH + # proxy_auth negotiation (token-v1). Absent = explicit legacy (unauthenticated + # proxy→sidecar, unchanged behavior for any pre-existing declaration). token-v1 + # = core mints+injects a per-extension token and the sidecar validates it. + # Any unknown value fails closed (reject the sidecar) so a typo can never + # silently downgrade to unauthenticated. + raw_proxy_auth = raw.get("proxy_auth") + if raw_proxy_auth is None: + proxy_auth = "legacy" + elif raw_proxy_auth in ("legacy", "token-v1"): + proxy_auth = str(raw_proxy_auth) + else: + _add_diagnostic_warning( + diagnostics, "sidecar_proxy_auth_unsupported", _SIDECAR_WARNING_SOURCE + ) + return None sidecar_id = _manifest_entry_text(entry, "id") name = _manifest_entry_text(entry, "name") return { @@ -907,6 +922,7 @@ def _sidecar_from_manifest_entry( "origin": origin, "health_path": health_path, "health_url": f"{origin}{health_path}", + "proxy_auth": proxy_auth, } @@ -1159,16 +1175,37 @@ def _sidecar_proxy_public_status( approved_origin: Optional[str], *, available: bool, + proxy_auth: str = "legacy", ) -> Dict[str, Any]: consented = bool(available and approved_origin == origin) origin_changed = bool(available and approved_origin and approved_origin != origin) - return { + payload: Dict[str, Any] = { "available": available, "consented": consented, "consent_required": bool(available and not consented), "path": _extension_sidecar_proxy_path(extension_id), "origin_changed": origin_changed, } + # proxy_auth / posture are token-v1-only fields. Legacy sidecars keep the + # original payload shape byte-for-byte (the public status contract tests pin + # it), so a token-v1 negotiation field never leaks onto a legacy record. + # (Frank #6331 blocker 2 — fields were previously emitted on every record and + # broke the legacy exact-shape contract on shard 4.) + if proxy_auth == "token-v1": + # token-v1 posture (§9.1). "protected" = WebUI auth ON (the only posture + # in which token-v1 proxying is permitted; auth-off is now fail-closed at + # both consent and resolution, so "local_unprotected" signals the panel + # to tell the operator to enable authentication before the proxy will work). + posture = None + if available: + try: + from api.auth import is_auth_enabled + posture = "protected" if is_auth_enabled() else "local_unprotected" + except Exception: + posture = None + payload["proxy_auth"] = proxy_auth + payload["posture"] = posture + return payload def _extension_sidecar_records( @@ -1216,11 +1253,21 @@ def _extension_sidecar_records( sidecar["origin"], item.get("approved_origin"), available=available, + proxy_auth=sidecar.get("proxy_auth", "legacy"), ) item["duplicate_id"] = id_counts.get(ext_id, 0) > 1 item["proxy"] = proxy if len(records) < _MAX_URL_LIST: - records.append({**sidecar, "proxy": proxy}) + # Build the PUBLIC sidecar record explicitly. The internal `sidecar` + # dict carries a top-level `proxy_auth` for the consent/resolution + # logic, but the public status contract only exposes proxy_auth (and + # posture) INSIDE `proxy`, and only for token-v1 records — legacy + # sidecars keep their original public shape byte-for-byte. Spreading + # `**sidecar` leaked a top-level `proxy_auth: legacy` and broke the + # exact-shape contract tests (Frank #6331 blocker 2). + public_record = {k: v for k, v in sidecar.items() if k != "proxy_auth"} + public_record["proxy"] = proxy + records.append(public_record) else: _add_diagnostic_warning(diagnostics, "sidecar_list_truncated", _SIDECAR_WARNING_SOURCE) break @@ -1564,6 +1611,32 @@ def set_extension_sidecar_proxy_consent(extension_id: object, approved: object) if sidecar is None or proxy.get("available") is not True: raise ExtensionSidecarProxyError("Extension sidecar proxy is unavailable", status=409) consent_map[ext_id] = sidecar["origin"] + # For token-v1, mint the per-extension token NOW and fail the consent + # if it can't be persisted+read — otherwise we'd persist consent and + # then 503 on every request (a mint failure must surface at grant + # time, not as a mysterious later error). + if sidecar.get("proxy_auth") == "token-v1": + # token-v1 REQUIRES configured WebUI authentication. Without it, + # this consent endpoint is itself unauthenticated, so any caller + # that can reach the WebUI listener could self-grant consent and + # then drive the token-bearing proxy (a forwarding oracle) — the + # sidecar token file blocks direct port access but does NOT stop a + # caller who simply asks core to inject it. Loopback origin is not + # sufficient: another local UID can still reach the listener + # without reading the 0600 token file. Fail closed regardless of + # origin. (Frank #6331 blocker 1.) + from api.auth import is_auth_enabled + if not is_auth_enabled(): + raise ExtensionSidecarProxyError( + "Sidecar token-v1 proxy requires WebUI authentication; " + "set a password in Settings before granting consent.", + status=403, + ) + from api import extension_sidecar_auth as _sc_auth + if not _sc_auth.ensure_token(ext_id): + raise ExtensionSidecarProxyError( + "Could not provision the sidecar auth token", status=503 + ) else: consent_map.pop(ext_id, None) _write_extension_state( @@ -1625,11 +1698,46 @@ def resolve_extension_sidecar_proxy_target( upstream_url = f"{sidecar['origin']}{normalized_path}" if query: upstream_url = f"{upstream_url}?{query}" + + # token-v1 auth (§9.1/§9.2). Legacy sidecars proxy unchanged; token-v1 + # sidecars get a per-extension secret injected, and only proxy when the + # trust posture is sound. + proxy_auth = sidecar.get("proxy_auth", "legacy") + inject_token: Optional[str] = None + if proxy_auth == "token-v1": + from api.auth import is_auth_enabled + from api import extension_sidecar_auth as _sc_auth + + # token-v1 REQUIRES configured WebUI authentication — fail closed + # regardless of the upstream origin (loopback included). The consent + # endpoint and this resolution path are both unauthenticated when auth is + # off, so a caller that can reach the WebUI listener could otherwise drive + # a token-bearing proxy to the sidecar (a forwarding oracle). Loopback is + # NOT a sufficient guard: another local UID can reach the listener without + # ever reading the 0600 token file. (Frank #6331 blocker 1 — this + # mirrors the consent-time check so a pre-existing consent granted while + # auth was enabled cannot be exercised after auth is turned off.) + if not is_auth_enabled(): + raise ExtensionSidecarProxyError( + "Sidecar token-v1 proxy requires WebUI authentication; " + "set a password in Settings.", + status=403, + ) + token = _sc_auth.current_token(ext_id) or _sc_auth.ensure_token(ext_id) + if not token: + # Token could not be persisted+read → fail closed rather than proxy + # unauthenticated (never inject an ephemeral secret). + raise ExtensionSidecarProxyError( + "Sidecar proxy auth token is unavailable", status=503 + ) + inject_token = token return { "extension_id": ext_id, "origin": sidecar["origin"], "proxy_path": proxy["path"], "upstream_url": upstream_url, + "proxy_auth": proxy_auth, + "auth_token": inject_token, } diff --git a/api/routes.py b/api/routes.py index d99873a1..ed3c7229 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5547,6 +5547,7 @@ def _extension_sidecar_proxy_request_headers(handler) -> dict[str, str]: lower in blocked_headers or lower in {"authorization", "cookie", "content-length", "host", "origin", "referer"} or lower.startswith("x-csrf") + or lower.startswith("x-hermes-") ): continue headers[str(name)] = str(value) @@ -5560,7 +5561,11 @@ def _send_extension_sidecar_proxy_response(handler, status: int, body: bytes, he if headers and hasattr(headers, "items"): for name, value in headers.items(): lower = str(name).lower() - if lower in blocked_headers or lower in {"content-length", "set-cookie"}: + if ( + lower in blocked_headers + or lower in {"content-length", "set-cookie"} + or lower.startswith("x-hermes-") + ): continue if lower == "content-type": sent_content_type = True @@ -5657,10 +5662,17 @@ def _handle_extension_sidecar_proxy( proxy_path, query=parsed.query, ) + proxied_headers = _extension_sidecar_proxy_request_headers(handler) + # token-v1: inject the per-extension shared secret core minted. The + # inbound x-hermes-* strip above guarantees the client cannot have + # forged this header. + _auth_token = target.get("auth_token") + if _auth_token: + proxied_headers["X-Hermes-Sidecar-Token"] = _auth_token request = Request( target["upstream_url"], data=request_body, - headers=_extension_sidecar_proxy_request_headers(handler), + headers=proxied_headers, method=method, ) opener = _extension_sidecar_proxy_same_origin_opener(target["origin"]) diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 9e60f48f..8788697c 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -165,7 +165,8 @@ opt-in proxy: "sidecar": { "type": "loopback", "origin": "http://127.0.0.1:17787", - "health_path": "/health" + "health_path": "/health", + "proxy_auth": "token-v1" } } ] @@ -178,6 +179,39 @@ declared and optionally check its health from the browser. If the operator later approves the proxy in **Settings → Extensions**, WebUI may proxy requests only through the fixed per-extension sidecar path for that extension. +### Sidecar proxy authentication (`proxy_auth`) + +The loopback port a sidecar binds is reachable by **any local process**, and the +proxy strips every inbound credential (cookies, `Authorization`, CSRF, `x-hermes-*`) +before forwarding — so a sidecar cannot, on its own, tell a proxied request from a +direct one. The `proxy_auth` field closes that gap: + +- **`token-v1`** (recommended for any sidecar that mutates state) — WebUI mints a + per-extension secret at `STATE_DIR/sidecar-auth/.token` (mode `0600`) and + injects it as the `X-Hermes-Sidecar-Token` header on every proxied request. The + sidecar resolves the token file in this order — `HERMES_EXT_SIDECAR_TOKEN_FILE` + → `$HERMES_WEBUI_STATE_DIR/sidecar-auth/.token` → + `$HERMES_HOME/webui/sidecar-auth/.token` → platform default + (`~/.hermes/webui/…`, `%LOCALAPPDATA%\hermes\webui\…` on Windows) — and must + validate the header on every route except `/health`, returning **`401` on a + missing/mismatched token** and **`503` when the token file is absent/unreadable**. + The canonical scaffold in the extensions repository (`examples/`, see + `docs/SIDECAR_CONTRACT.md`) does all of this for you — do not hand-roll it. +- **absent (or the explicit literal `"legacy"`)** — **legacy** mode (no token; + unchanged behavior). Only appropriate for read-only, non-sensitive sidecars. +- Any **other** value fails closed (the sidecar declaration is rejected). + +**Auth-off posture:** WebUI authentication is optional and off by default. Because +the consent endpoint and proxy route are unauthenticated in that mode, `token-v1` +fails closed regardless of whether the sidecar origin is loopback: consent and proxy +resolution return `403` until WebUI authentication is configured. Otherwise, any +caller that can reach WebUI could ask core to inject the token and use it as a +forwarding oracle. The extensions panel exposes the `local_unprotected` posture so +the operator is told to enable authentication before granting consent. The token +protects against other-UID and sandboxed local callers; it does **not** defend against +arbitrary same-UID code (which can read the token file, WebUI's own signing key, or +run the sidecar's tool directly). + Extension entries may declare browser-local settings when they also request extension-owned storage: diff --git a/static/panels.js b/static/panels.js index 582943cd..e4e30752 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9813,6 +9813,13 @@ function _extensionSidecarCard(sidecars){ const proxyConsentRequired=proxy.consent_required===true; const proxyOriginChanged=proxy.origin_changed===true; const proxyPath=(proxy&&proxy.path)||''; + // token-v1 posture: 'local_unprotected' = WebUI auth is off, so proxy consent + // is grantable by any unauthenticated local caller. Warn the operator to + // enable authentication before wiring up a sidecar (design §9.1). + const proxyUnprotected=proxy.posture==='local_unprotected'; + const proxyWarning=proxyUnprotected + ?`
⚠ WebUI authentication is off. This sidecar's proxy consent can be granted by any local process. Set a password in Settings → Password before using sidecar extensions.
` + :''; const proxyStatus=proxyConsented ?'consented' :(proxyOriginChanged @@ -9837,6 +9844,7 @@ function _extensionSidecarCard(sidecars){
Proxy path${esc(proxyPath)}
${proxyButton}
+ ${proxyWarning} `; }).join('')}`:'
No loopback sidecars declared.
'; diff --git a/static/style.css b/static/style.css index 06f203da..9814b6da 100644 --- a/static/style.css +++ b/static/style.css @@ -5452,6 +5452,7 @@ main.main > #mainPlugin{display:none;} .extension-sidecar-row-head{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;} .extension-sidecar-title{font-size:13px;font-weight:600;color:var(--text);overflow-wrap:anywhere;min-width:0;} .extension-sidecar-meta{margin-top:2px;font-size:11px;color:var(--muted);overflow-wrap:anywhere;} +.extension-sidecar-warning{margin-top:8px;padding:7px 9px;border:1px solid var(--warning,#d29922);border-radius:6px;background:var(--warning-bg,rgba(210,153,34,0.12));color:var(--warning,#d29922);font-size:11px;line-height:1.4;overflow-wrap:anywhere;} .extension-sidecar-fields{display:grid;grid-template-columns:1fr;gap:5px;margin-top:8px;} .extension-sidecar-fields>div{display:flex;align-items:flex-start;gap:8px;min-width:0;font-size:12px;color:var(--muted);} .extension-sidecar-fields span{flex:0 0 82px;} diff --git a/tests/test_extension_sidecar_proxy.py b/tests/test_extension_sidecar_proxy.py index beba0a0c..023dd598 100644 --- a/tests/test_extension_sidecar_proxy.py +++ b/tests/test_extension_sidecar_proxy.py @@ -135,6 +135,8 @@ def test_extension_sidecar_proxy_requires_consent_and_reconfirms_after_origin_ch "origin": "http://127.0.0.1:17787", "proxy_path": "/api/extensions/templates/sidecar/", "upstream_url": "http://127.0.0.1:17787/v1/ping?debug=1", + "proxy_auth": "legacy", + "auth_token": None, } encoded_target = resolve_extension_sidecar_proxy_target("templates", "v1%2Fprivate") @@ -963,3 +965,285 @@ def test_extension_sidecar_proxy_consent_route_is_wired(monkeypatch): "status": 200, "data": {"ok": True, "id": "templates", "approved": True}, } + + +def test_extension_sidecar_proxy_consent_route_fails_closed_auth_off(tmp_path, monkeypatch): + # Frank #6331 blocker 1 (route-level regression): with WebUI auth OFF, driving + # the REAL consent function through the HTTP route for a token-v1 sidecar must + # return 403 (fail closed), not persist consent. Uses the real + # set_extension_sidecar_proxy_consent (NOT a mock) so the route + auth gate are + # exercised end-to-end. + from api import routes + + monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False) + from api.auth import _invalidate_password_hash_cache, is_auth_enabled + _invalidate_password_hash_cache() + assert is_auth_enabled() is False + _token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787") + + captured = {} + + def fake_bad(handler, msg, status=400): + captured["status"] = status + captured["msg"] = msg + return True + + def fake_j(handler, data, status=200, headers=None): + captured["status"] = status + captured["data"] = data + return True + + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + monkeypatch.setattr(routes, "read_body", lambda handler: {"id": "templates", "approved": True}) + monkeypatch.setattr(routes, "j", fake_j) + monkeypatch.setattr(routes, "bad", fake_bad) + handler = FakeHandler() + + assert routes.handle_post( + handler, + SimpleNamespace(path="/api/extensions/sidecar-proxy-consent"), + ) is True + # Must be a fail-closed 403 from the real consent function, not a 200 grant. + assert captured.get("status") == 403 + assert "data" not in captured + # And no consent must have been persisted. + from api.extensions import resolve_extension_sidecar_proxy_target, ExtensionSidecarProxyError + with pytest.raises(ExtensionSidecarProxyError) as exc: + resolve_extension_sidecar_proxy_target("templates", "v1/ping") + assert exc.value.status == 403 + + +# ── token-v1 proxy auth (§9.1/§9.2) ───────────────────────────────────────── + +def _token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787"): + return _configure_manifest_extension( + monkeypatch, + tmp_path, + { + "extensions": [ + { + "id": "templates", + "sidecar": { + "type": "loopback", + "origin": origin, + "proxy_auth": "token-v1", + }, + } + ] + }, + ) + + +def test_token_v1_injects_persisted_token_when_auth_enabled(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "pw") + from api.auth import _invalidate_password_hash_cache + _invalidate_password_hash_cache() + _token_v1_manifest(monkeypatch, tmp_path) + from api.extensions import ( + resolve_extension_sidecar_proxy_target, + set_extension_sidecar_proxy_consent, + ) + import api.extension_sidecar_auth as sc + + set_extension_sidecar_proxy_consent("templates", True) + target = resolve_extension_sidecar_proxy_target("templates", "v1/ping") + assert target["proxy_auth"] == "token-v1" + assert target["auth_token"] and len(target["auth_token"]) > 30 + assert target["auth_token"] == sc.current_token("templates") + + +def test_token_v1_auth_off_blocks_consent_and_resolution_fail_closed(tmp_path, monkeypatch): + # Frank #6331 blocker 1: with WebUI auth OFF, token-v1 must fail closed at BOTH + # the consent grant and the resolution path — regardless of a loopback origin. + # A loopback sidecar is NOT a sufficient guard: any caller that can reach the + # (unauthenticated) WebUI listener could otherwise self-grant consent and drive + # the token-bearing proxy (a forwarding oracle); another local UID can reach the + # listener without ever reading the 0600 token file. + monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False) + from api.auth import _invalidate_password_hash_cache, is_auth_enabled + _invalidate_password_hash_cache() + assert is_auth_enabled() is False + _token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787") + from api.extensions import ( + ExtensionSidecarProxyError, + set_extension_sidecar_proxy_consent, + ) + + # Consent grant is rejected fail-closed (403) even for a loopback origin. + with pytest.raises(ExtensionSidecarProxyError) as consent_exc: + set_extension_sidecar_proxy_consent("templates", True) + assert consent_exc.value.status == 403 + + +def test_token_v1_resolution_fails_closed_when_auth_disabled_after_consent(tmp_path, monkeypatch): + # Defense in depth: even if a consent record somehow exists (e.g. granted while + # auth was enabled, then auth turned off), the resolution path must ALSO fail + # closed so a stale consent can't be exercised without WebUI auth. + monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "pw") + from api.auth import _invalidate_password_hash_cache, is_auth_enabled + _invalidate_password_hash_cache() + _token_v1_manifest(monkeypatch, tmp_path, origin="http://127.0.0.1:17787") + from api.extensions import ( + ExtensionSidecarProxyError, + resolve_extension_sidecar_proxy_target, + set_extension_sidecar_proxy_consent, + ) + + # Grant consent WHILE auth is enabled (allowed). + assert is_auth_enabled() is True + set_extension_sidecar_proxy_consent("templates", True) + # Now disable auth and confirm resolution refuses the pre-existing consent. + monkeypatch.delenv("HERMES_WEBUI_PASSWORD", raising=False) + _invalidate_password_hash_cache() + assert is_auth_enabled() is False + with pytest.raises(ExtensionSidecarProxyError) as exc: + resolve_extension_sidecar_proxy_target("templates", "v1/ping") + assert exc.value.status == 403 + + +def test_unknown_proxy_auth_value_rejects_sidecar(tmp_path, monkeypatch): + _configure_manifest_extension( + monkeypatch, + tmp_path, + { + "extensions": [ + { + "id": "templates", + "sidecar": { + "type": "loopback", + "origin": "http://127.0.0.1:17787", + "proxy_auth": "totally-bogus", + }, + } + ] + }, + ) + from api.extensions import ( + ExtensionSidecarProxyError, + set_extension_sidecar_proxy_consent, + ) + + # Unknown proxy_auth -> _sidecar_from_manifest_entry rejects the record, so the + # sidecar never becomes available/consentable (fail closed, not silent-open). + # The rejection surfaces at consent time (the earliest resolve path). + with pytest.raises(ExtensionSidecarProxyError): + set_extension_sidecar_proxy_consent("templates", True) + + +def test_status_payload_flags_local_unprotected_when_auth_off(tmp_path, monkeypatch): + _token_v1_manifest(monkeypatch, tmp_path) + from api.extensions import get_extension_status + + status = get_extension_status() + sidecars = status.get("sidecars") or [] + tmpl = next((s for s in sidecars if s.get("id") == "templates"), None) + assert tmpl is not None + proxy = tmpl["proxy"] + assert proxy["proxy_auth"] == "token-v1" + assert proxy["posture"] == "local_unprotected" # auth off -> panel warns pre-consent + + +def test_inbound_x_hermes_header_is_stripped(monkeypatch): + from api.routes import _extension_sidecar_proxy_request_headers + + class H: + headers = { + "X-Hermes-Sidecar-Token": "forged", + "X-Custom": "ok", + "Cookie": "secret", + } + + out = _extension_sidecar_proxy_request_headers(H()) + assert "X-Custom" in out + assert not any(k.lower().startswith("x-hermes-") for k in out) + assert not any(k.lower() == "cookie" for k in out) + + +def test_token_module_persists_and_rotates(tmp_path, monkeypatch): + import api.extension_sidecar_auth as sc + + # Patch the dynamic dir resolver (mirrors how _extension_state_dir is patched + # elsewhere) so the token lands in an isolated tmp dir, not the shared session + # state dir. + token_dir = tmp_path / "sidecar-auth" + monkeypatch.setattr(sc, "_token_dir", lambda: token_dir) + sc._CACHE.clear() + tok = sc.ensure_token("templates") + assert tok and sc.current_token("templates") == tok + assert (token_dir / "templates.token").exists() + assert sc.current_token("never") is None # fail closed + assert sc.ensure_token("../evil") is None # path-escape rejected + # IDs are filename components. Reject rather than silently trim whitespace so + # validation and _token_path() always operate on the exact same string. + for invalid_id in ("templates\n", " templates", "templates "): + assert sc.ensure_token(invalid_id) is None + assert not (token_dir / f"{invalid_id}.token").exists() + # canonical (wider) id grammar is honored: uppercase/dot ids work + assert sc.ensure_token("RSS.Feeds") is not None + new = sc.reset_token("templates") + assert new and new != tok and sc.current_token("templates") == new + sc._CACHE.clear() + + +def test_token_v1_route_injects_token_and_strips_response(monkeypatch): + # The 5 most load-bearing lines: the injected token must reach the upstream + # Request, and any x-hermes-* on the response must be stripped before it + # reaches the browser. (Fable coreA item 3.) + from api import routes + + captured = {} + + class FakeResponse: + def __init__(self): + self.status = 200 + self.headers = { + "Content-Type": "application/json", + "X-Hermes-Echo": "leak-me", # must be stripped from client response + } + + def read(self, *_a): + return b"{}" + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + class FakeOpener: + def open(self, request, timeout=10): + captured["headers"] = {k.lower(): v for k, v in request.header_items()} + return FakeResponse() + + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + monkeypatch.setattr( + "api.extensions.resolve_extension_sidecar_proxy_target", + lambda extension_id, proxy_path, query="": { + "extension_id": extension_id, + "origin": "http://127.0.0.1:17787", + "proxy_path": "/api/extensions/templates/sidecar/", + "upstream_url": "http://127.0.0.1:17787/v1/ping", + "proxy_auth": "token-v1", + "auth_token": "injected-token-abc", + }, + ) + monkeypatch.setattr( + routes, "_extension_sidecar_proxy_same_origin_opener", lambda o: FakeOpener() + ) + + handler = FakeHandler() + handler.headers = { + "Accept": "application/json", + "Host": "webui.local", + "Origin": "http://webui.local", + "Referer": "http://webui.local/", + } + result = routes.handle_get( + handler, + SimpleNamespace(path="/api/extensions/templates/sidecar/v1/ping", query=""), + ) + assert result is True + # token injected on the way to the sidecar + assert captured["headers"].get("x-hermes-sidecar-token") == "injected-token-abc" + # token/x-hermes header stripped on the way back to the browser + assert handler.header("X-Hermes-Echo") is None From 302843c89f22d1ce2ad10619acd33382c9f48111 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 13:11:29 -0700 Subject: [PATCH 27/82] Release: compact completed image tool-results (#6315, @sjungwon03) (#6371) * fix(session): compact completed native vision results * streaming: guard image-part compaction against unhashable part.type (isinstance str) + regression test [maintainer fix on @sjungwon03 #6315] * docs(changelog): stamp #6315 completed-image tool-result compaction --------- Co-authored-by: sjungwon03 Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/routes.py | 2 + api/streaming.py | 74 +++++++- tests/test_context_history_sanitization.py | 167 ++++++++++++++++++ tests/test_issue1217_transcript_compaction.py | 28 +++ tests/test_run_journal_streaming_static.py | 11 ++ 6 files changed, 283 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e9dc877..7aebca53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ ### Fixed +- **Completed image tool-results are compacted from persisted history, cutting long-session memory and save/load cost.** After a turn finishes, base64 image parts in persisted *tool* results are replaced with a small `[screenshot]` text placeholder, so JSON sidecar saves/loads and session API responses no longer scale with image bytes (the active model still gets the full image while the tool call is running). The transform preserves every non-image part in place (text, documents, provider-specific parts) without flattening or reordering, only touches `role: tool` messages (user attachments are untouched), runs on all live and settled persistence writebacks, and is idempotent. Thanks @sjungwon03. (#6315, #1217) + - **Large embedded images no longer slow down API response redaction.** Native raster image data URIs (`data:image/png|jpeg|gif|webp|bmp;base64,…`) are skipped by the credential-redaction pass instead of being scanned byte-by-byte on every response — a multi-megabyte image is opaque binary, not text a credential regex should rewrite. The fast path only applies after the *entire* base64 payload is validated (canonical round-trip + structural walk), so a string that merely starts with an image header but carries trailing or appended text still goes through full redaction — a credential can never ride along in an image's tail. Non-image strings, and sibling secrets in the same payload, are unaffected. Thanks @inch772. (#6311) - **Gateway-backed sessions can once again send with a MoA (Mixture-of-Agents) default model.** A legitimate gateway-default MoA send was being rejected as if it were a disallowed per-request override. The chat-start handler now distinguishes the two: a send is allowed only when the *configured* provider is MoA, the resolved model matches the configured default, and no explicit picker override was made — all evaluated against one request-owned config snapshot captured atomically, so a concurrent profile/config change can't flip the decision mid-request. Explicit `moa_config` overrides on gateway sessions still return 409. Thanks @rodboev. (#5869, #5853) diff --git a/api/routes.py b/api/routes.py index ed3c7229..97e8044a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -22364,6 +22364,7 @@ def _handle_chat_sync(handler, body): _restore_display_reasoning_metadata, _restore_reasoning_metadata, _sanitize_messages_for_api, + _compact_session_image_parts_for_persistence, _context_messages_for_new_turn, _workspace_context_prefix, ) @@ -22441,6 +22442,7 @@ def _handle_chat_sync(handler, body): msg, source=getattr(s, "pending_user_source", None) or "webui", ) + _compact_session_image_parts_for_persistence(s) # Only auto-generate title when still default; preserves user renames if s.title == "Untitled": s.title = title_from(s.messages, s.title) diff --git a/api/streaming.py b/api/streaming.py index d7f91ccd..a05997c1 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4225,6 +4225,76 @@ def _should_strip_reasoning_content( return False +def _compact_image_parts_for_persistence(messages) -> int: + """Replace persisted image parts with text placeholders after a completed turn. + + The active model receives native image parts while a tool call is running. Once + the turn has completed, retaining base64 data URLs in both the visible + transcript and ``context_messages`` makes every JSON sidecar save/load and + session API response scale with the image bytes. Hermes Agent's durable + session store applies the same text-only policy for completed multimodal + tool results. Keep text parts and the surrounding tool-call chain intact so + future turns retain the conversational record and can re-open the original + image from the preceding tool-call arguments when needed. + + This intentionally mutates the owned session message rows in place. It is + called only after ``run_conversation()`` has returned, so it never removes + image parts that the current model invocation still needs. + """ + changed = 0 + for message in messages or (): + # Mirror Hermes Agent's durable-session policy: native *tool* results + # are transient input for the current model call. User attachments are + # a separate product contract and must remain intact here. + if not isinstance(message, dict) or message.get('role') != 'tool': + continue + content = message.get('content') + if not isinstance(content, list): + continue + + compacted_content = [] + image_parts = 0 + for part in content: + if not isinstance(part, dict): + # A provider can legally return scalar content alongside typed + # parts. Preserve it unchanged rather than flattening/reordering + # the structured result during durable-session compaction. + compacted_content.append(part) + continue + part_type = part.get('type') + # Guard the set-membership with an isinstance check: a JSON-valid + # part can carry an unhashable ``type`` (e.g. a list), and + # ``unhashable in {...}`` raises TypeError — which would turn an + # otherwise-complete streaming send into the error path before the + # session is saved. Only the three string image types are compacted; + # every other part (including non-string ``type`` values) is preserved. + if isinstance(part_type, str) and part_type in {'image', 'image_url', 'input_image'}: + compacted_content.append({'type': 'text', 'text': '[screenshot]'}) + image_parts += 1 + else: + compacted_content.append(part) + + if image_parts: + message['content'] = compacted_content + changed += image_parts + return changed + + +def _compact_session_image_parts_for_persistence(session) -> int: + """Compact completed native-vision tool results in both durable histories.""" + changed = ( + _compact_image_parts_for_persistence(getattr(session, 'context_messages', None)) + + _compact_image_parts_for_persistence(getattr(session, 'messages', None)) + ) + if changed: + logger.info( + "Compacted %d completed image message part(s) for session %s", + changed, + getattr(session, 'session_id', None), + ) + return changed + + def _sanitize_messages_for_api( messages, *, @@ -9045,6 +9115,7 @@ def _run_agent_streaming( msg_text, source=getattr(s, 'pending_user_source', None) or 'webui', ) + _compact_session_image_parts_for_persistence(s) _advance_truncation_watermark_after_commit(s) # #3831 # Strip XML tool-call blocks from assistant message content. # DeepSeek and some other providers emit ... @@ -9389,8 +9460,8 @@ def _run_agent_streaming( msg_text, source=getattr(s, 'pending_user_source', None) or 'webui', ) + _compact_session_image_parts_for_persistence(s) _advance_truncation_watermark_after_commit(s) # #3831 - # Skip the error block — jump directly to the # normal post-result persistence path by # leaving _assistant_added truthy (set below). _assistant_added = True # prevent re-entering guard @@ -10609,6 +10680,7 @@ def _run_agent_streaming( msg_text, source=getattr(s, 'pending_user_source', None) or 'webui', ) + _compact_session_image_parts_for_persistence(s) _advance_truncation_watermark_after_commit(s) # #3831 s.save() logger.info('[webui] self-heal (except path): retry succeeded') diff --git a/tests/test_context_history_sanitization.py b/tests/test_context_history_sanitization.py index aadfd352..f412e425 100644 --- a/tests/test_context_history_sanitization.py +++ b/tests/test_context_history_sanitization.py @@ -7,6 +7,8 @@ from types import SimpleNamespace from unittest.mock import patch from api.streaming import ( + _compact_image_parts_for_persistence, + _compact_session_image_parts_for_persistence, _restore_reasoning_metadata, _sanitize_messages_for_api, _strip_oob_blocks, @@ -24,6 +26,171 @@ def _contains_oob(value) -> bool: return "OUT-OF-BAND USER MESSAGE" in json.dumps(value) + +def test_compact_image_parts_for_persistence_keeps_text_and_drops_base64(): + image_data_url = "data:image/png;base64," + ("A" * 1_000_000) + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "vision-1"}], + }, + { + "role": "tool", + "tool_call_id": "vision-1", + "content": [ + {"type": "text", "text": "Image loaded into your context."}, + {"type": "image_url", "image_url": {"url": image_data_url}}, + ], + }, + ] + + changed = _compact_image_parts_for_persistence(messages) + + assert changed == 1 + assert messages[1]["content"] == [ + {"type": "text", "text": "Image loaded into your context."}, + {"type": "text", "text": "[screenshot]"}, + ] + assert "data:image" not in json.dumps(messages) + assert messages[0]["tool_calls"] == [{"id": "vision-1"}] + + + + +def test_compact_image_parts_for_persistence_counts_each_image_part(): + messages = [ + { + "role": "tool", + "content": [ + {"type": "text", "text": "Before and after"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + {"type": "input_image", "image_url": {"url": "data:image/png;base64,BBBB"}}, + ], + } + ] + + changed = _compact_image_parts_for_persistence(messages) + + assert changed == 2 + assert messages[0]["content"] == [ + {"type": "text", "text": "Before and after"}, + {"type": "text", "text": "[screenshot]"}, + {"type": "text", "text": "[screenshot]"}, + ] + + +def test_compact_image_parts_for_persistence_preserves_interleaved_non_image_parts(): + document = {"type": "document", "document": {"name": "report.pdf", "id": "doc-1"}} + scalar = "provider extension payload" + messages = [ + { + "role": "tool", + "content": [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + {"type": "text", "text": "between"}, + document, + scalar, + {"type": "input_image", "image_url": {"url": "data:image/png;base64,BBBB"}}, + {"type": "input_text", "content": "after"}, + ], + } + ] + + changed = _compact_image_parts_for_persistence(messages) + + assert changed == 2 + assert messages[0]["content"] == [ + {"type": "text", "text": "[screenshot]"}, + {"type": "text", "text": "between"}, + document, + scalar, + {"type": "text", "text": "[screenshot]"}, + {"type": "input_text", "content": "after"}, + ] + assert "data:image" not in json.dumps(messages) + assert _compact_image_parts_for_persistence(messages) == 0 + + +def test_compact_image_parts_for_persistence_survives_unhashable_part_type(): + # A JSON-valid part can carry a non-string (unhashable) ``type`` such as a + # list or dict. ``part.get("type") in {...}`` would raise TypeError on an + # unhashable value, turning an otherwise-complete streaming send into the + # error path before the session is saved. Such parts must be preserved + # unchanged, and real image parts alongside them must still compact. + list_type = {"type": ["provider-extension"], "data": "x"} + dict_type = {"type": {"nested": "kind"}, "data": "y"} + messages = [ + { + "role": "tool", + "content": [ + list_type, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + dict_type, + ], + } + ] + + changed = _compact_image_parts_for_persistence(messages) + + assert changed == 1 + assert messages[0]["content"] == [ + list_type, + {"type": "text", "text": "[screenshot]"}, + dict_type, + ] + assert "data:image" not in json.dumps(messages) + # Idempotent: a second pass changes nothing and still does not raise. + assert _compact_image_parts_for_persistence(messages) == 0 + + +def test_compact_session_image_parts_for_persistence_compacts_both_histories(): + image = {"type": "image_url", "image_url": {"url": "data:image/png;base64," + ("A" * 4096)}} + session = SimpleNamespace( + session_id="vision-turn", + messages=[{"role": "tool", "content": [{"type": "text", "text": "display"}, image.copy()]}], + context_messages=[{"role": "tool", "content": [{"type": "text", "text": "context"}, image.copy()]}], + ) + + changed = _compact_session_image_parts_for_persistence(session) + + assert changed == 2 + assert session.messages[0]["content"] == [ + {"type": "text", "text": "display"}, + {"type": "text", "text": "[screenshot]"}, + ] + assert session.context_messages[0]["content"] == [ + {"type": "text", "text": "context"}, + {"type": "text", "text": "[screenshot]"}, + ] + + +def test_compact_image_parts_for_persistence_keeps_user_image_attachment(): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Inspect this attachment"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + ], + } + ] + + changed = _compact_image_parts_for_persistence(messages) + + assert changed == 0 + assert messages[0]["content"][1]["type"] == "image_url" + + +def test_compact_image_parts_for_persistence_leaves_text_history_unchanged(): + messages = [{"role": "tool", "content": "plain tool output"}] + + changed = _compact_image_parts_for_persistence(messages) + + assert changed == 0 + assert messages == [{"role": "tool", "content": "plain tool output"}] + + def test_strip_oob_blocks_string(): content = f"visible before\n{OOB_BLOCK}\nvisible after" diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index 78953ec6..f2954b44 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -767,8 +767,21 @@ def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkey ) session.save(touch_updated_at=False) + native_result = [ + {"role": "assistant", "content": "", "tool_calls": [{"id": "vision-1"}]}, + { + "role": "tool", + "tool_call_id": "vision-1", + "content": [ + {"type": "text", "text": "Vision result"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, + {"type": "document", "document": {"id": "doc-1"}}, + ], + }, + ] replayed_result = previous_context + previous_context + [ {"role": "user", "content": "simple follow-up"}, + *native_result, {"role": "assistant", "content": "short answer"}, ] @@ -793,6 +806,7 @@ def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkey assert handler.status == 200 reloaded = Session.load(session.session_id) + assert reloaded is not None # Stable per-message ids (#context-message-stable-id) are now stamped on # context rows; compare on the semantic fields so the dedup invariant is @@ -807,9 +821,23 @@ def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkey {"role": "user", "content": "[Session Arc Summary (d1, node 39)]\n" + "old context\n" * 400}, {"role": "assistant", "content": "previous answer"}, {"role": "user", "content": "simple follow-up"}, + {"role": "assistant", "content": "", "tool_calls": [{"id": "vision-1"}]}, + { + "role": "tool", + "tool_call_id": "vision-1", + "content": [ + {"type": "text", "text": "Vision result"}, + {"type": "text", "text": "[screenshot]"}, + {"type": "document", "document": {"id": "doc-1"}}, + ], + }, {"role": "assistant", "content": "short answer"}, ] assert _no_id(reloaded.context_messages) == expected + assert "data:image" not in json.dumps(reloaded.context_messages) + assert "data:image" not in json.dumps(reloaded.messages) + assert any(row.get("tool_call_id") == "vision-1" for row in reloaded.context_messages) + assert any(row.get("tool_call_id") == "vision-1" for row in reloaded.messages) assert _no_id(reloaded.context_messages).count(expected[0]) == 1 # The new turn's rows carry unique stable ids. (This session preloads # id-less legacy rows, which stay id-less until they age out of context; diff --git a/tests/test_run_journal_streaming_static.py b/tests/test_run_journal_streaming_static.py index a2542171..c61ba1b8 100644 --- a/tests/test_run_journal_streaming_static.py +++ b/tests/test_run_journal_streaming_static.py @@ -25,6 +25,17 @@ def test_streaming_journals_sse_events_before_queue_delivery(): assert "queue_item = (event, data, event_id) if event_id and hasattr(q, \"subscribe_with_snapshot\") else (event, data)" in block + +def test_streaming_compacts_all_successful_agent_result_writebacks(): + src = Path("api/streaming.py").read_text(encoding="utf-8") + run_src = src[src.index("def _run_agent_streaming("):] + + # Normal completion plus both credential self-heal retry-success paths must + # each compact after committing their own result shape. The retries use + # different local variable names, so guard the durable invariant itself. + assert run_src.count("_compact_session_image_parts_for_persistence(s)") == 3 + + def test_visible_process_echo_compare_ignores_all_whitespace(): token_text = "先把 issue 4249 拉下来\n\n先看正文和评论" interim_text = "先把 issue 4249 拉下来先看正文和评论" From 9dfe1367c76c23b453602d3263f8b0cac37899f1 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 21 Jul 2026 04:42:59 +0800 Subject: [PATCH 28/82] Preserve historical anchor final text --- static/messages.js | 7 + static/ui.js | 20 ++- ...5224_terminal_error_transcript_preserve.py | 1 + ...sue6220_id_linked_tool_anchor_hydration.py | 155 +++++++++++++++++- 4 files changed, 173 insertions(+), 10 deletions(-) diff --git a/static/messages.js b/static/messages.js index 9f1532f1..debd4e56 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6490,6 +6490,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return `${m.role}|${ts}|${body.slice(0,160)}`; } const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene']; + function _isHistoricalAnchorActivityScene(scene){ + if(!scene||typeof scene!=='object') return false; + const identity=scene.identity&&typeof scene.identity==='object'?scene.identity:null; + const turnId=identity&&typeof identity.turn_id==='string'?identity.turn_id:''; + return turnId.indexOf('historical:')===0; + } function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){ if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages; if(!prevMessages.length||!nextMessages.length) return nextMessages; @@ -6504,6 +6510,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const k=_messageIdentityKey(nm); if(!k) continue; const pm=prevIdx.get(k); if(!pm) continue; for(const f of _EPHEMERAL_TURN_FIELDS){ + if(f==='_anchor_activity_scene'&&_isHistoricalAnchorActivityScene(pm[f])) continue; if(pm[f]!=null && nm[f]==null) nm[f]=pm[f]; } } diff --git a/static/ui.js b/static/ui.js index f4b77b60..79a0e837 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14710,14 +14710,16 @@ function _toolArgsSnapshot(args, limit){ function _idLinkedHistoricalMessageText(message){ if(!message||typeof message!=='object') return ''; const content=message.content; - if(typeof content==='string') return content.trim(); + if(typeof content==='string') return content; if(!Array.isArray(content)) return ''; - return content.map(part=>{ - if(typeof part==='string') return part; + return content.filter(part=>part&&typeof part==='object'&&part.type==='text').map(part=>{ if(!part||typeof part!=='object') return ''; - if(part.type!=='text'&&part.type!=='input_text'&&part.type!=='output_text') return ''; return String(part.text||part.content||''); - }).join('').trim(); + }).join('\n'); +} + +function _idLinkedHistoricalMessageHasVisibleText(message){ + return _idLinkedHistoricalMessageText(message).trim()!==''; } function _idLinkedHistoricalMessageRef(message, rawIdx){ @@ -14802,17 +14804,17 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ if(message._anchor_activity_scene) return null; if(role==='assistant'){ assistantIndexes.push(rawIdx); - const visibleText=_idLinkedHistoricalMessageText(message); + const hasVisibleText=_idLinkedHistoricalMessageHasVisibleText(message); const reasoningText=_assistantReasoningPayloadText(message); - if(visibleText) visibleAssistantIndexes.push(rawIdx); + if(hasVisibleText) visibleAssistantIndexes.push(rawIdx); if(reasoningText) return null; if(_idLinkedHistoricalHasVisibleSidecar(message)) return null; if(Array.isArray(message._partial_tool_calls)&&message._partial_tool_calls.length) return null; if(Array.isArray(message.content)&&message.content.some(part=>part&&typeof part==='object'&&part.type==='tool_use')) return null; const toolCalls=Array.isArray(message.tool_calls)?message.tool_calls:[]; - if(toolCalls.length&&visibleText) return null; + if(toolCalls.length&&hasVisibleText) return null; if(!toolCalls.length){ - if(visibleText) continue; + if(hasVisibleText) continue; return null; } if(message.content!==undefined&&message.content!==null&&message.content!=='') return null; diff --git a/tests/test_issue5224_terminal_error_transcript_preserve.py b/tests/test_issue5224_terminal_error_transcript_preserve.py index f281f926..29ac384d 100644 --- a/tests/test_issue5224_terminal_error_transcript_preserve.py +++ b/tests/test_issue5224_terminal_error_transcript_preserve.py @@ -61,6 +61,7 @@ function installRuntimeHelpers() { "_filterRecoveryControlMessages", "_replaceMarkerOnlyAssistantWithStreamError", "_messageIdentityKey", + "_isHistoricalAnchorActivityScene", "_carryForwardEphemeralTurnFields", "_isTerminalStreamErrorMarkerMessage", "_ensureSingleTerminalStreamErrorMarker", diff --git a/tests/test_issue6220_id_linked_tool_anchor_hydration.py b/tests/test_issue6220_id_linked_tool_anchor_hydration.py index 0a3db73b..3feba674 100644 --- a/tests/test_issue6220_id_linked_tool_anchor_hydration.py +++ b/tests/test_issue6220_id_linked_tool_anchor_hydration.py @@ -83,6 +83,7 @@ for(const name of [ '_toolArgsSnapshot', '_assistantReasoningPayloadText', '_idLinkedHistoricalMessageText', + '_idLinkedHistoricalMessageHasVisibleText', '_idLinkedHistoricalMessageRef', '_idLinkedHistoricalToolArguments', '_idLinkedHistoricalToolResultRaw', @@ -91,7 +92,8 @@ for(const name of [ '_idLinkedHistoricalTurnScene', '_hydrateIdLinkedHistoricalToolScenes', ]){{ - vm.runInContext(extractFunc(name), sandbox, {{filename:'ui.js:' + name}}); + const fn = src.indexOf('function ' + name) >= 0 ? extractFunc(name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'ui.js:' + name}}); }} if({str(throw_helper).lower()}){{ @@ -135,6 +137,111 @@ console.log(JSON.stringify({{first, second, unchanged:afterFirst===JSON.stringif return json.loads(result.stdout) +def _run_carry_forward_then_hydration(messages: list[dict]) -> dict: + assert NODE, "node is required for Anchor hydration behavior tests" + script = f""" +const fs = require('fs'); +const vm = require('vm'); +const uiSrc = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'ui.js'))}, 'utf8'); +const messagesSrc = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'messages.js'))}, 'utf8'); +let src = uiSrc; +{_EXTRACT_FUNC_JS} + +function extractFuncFrom(source, name){{ + const previous = src; + src = source; + try{{ return extractFunc(name); }} + finally{{ src = previous; }} +}} + +const sandbox = {{console}}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +sandbox._EPHEMERAL_TURN_FIELDS = [ + '_turnUsage', + '_turnDuration', + '_turnTps', + '_gatewayRouting', + '_statusCard', + '_anchor_stream_id', + '_anchor_activity_scene', +]; +vm.createContext(sandbox); +vm.runInContext( + fs.readFileSync({json.dumps(str(ANCHOR_JS))}, 'utf8'), + sandbox, + {{filename:'assistant_turn_anchors.js'}} +); +sandbox._redactToolTargetLabel = value => String(value); + +for(const name of [ + '_clipCliToolSnippet', + '_cliToolResultText', + '_cliLooksLikePatchDiff', + '_cliToolResultSnippet', + '_prefixedCliDiffLines', + '_firstOwnedValue', + '_cliPatchSnippetFromArgs', + '_cliToolCardSnippet', + '_cliToolCardHasDiffSnippet', + '_anchorSceneToolCallFromRow', + '_toolArgsSnapshot', + '_assistantReasoningPayloadText', + '_idLinkedHistoricalMessageText', + '_idLinkedHistoricalMessageHasVisibleText', + '_idLinkedHistoricalMessageRef', + '_idLinkedHistoricalToolArguments', + '_idLinkedHistoricalToolResultRaw', + '_idLinkedHistoricalRedactSnippet', + '_idLinkedHistoricalHasVisibleSidecar', + '_idLinkedHistoricalTurnScene', + '_hydrateIdLinkedHistoricalToolScenes', +]){{ + const fn = uiSrc.indexOf('function ' + name) >= 0 ? extractFuncFrom(uiSrc, name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'ui.js:' + name}}); +}} +for(const name of [ + '_messageIdentityKey', + '_isHistoricalAnchorActivityScene', + '_carryForwardEphemeralTurnFields', +]){{ + const fn = messagesSrc.indexOf('function ' + name) >= 0 ? extractFuncFrom(messagesSrc, name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'messages.js:' + name}}); +}} + +const original = {json.dumps(messages)}; +const prev = JSON.parse(JSON.stringify(original)); +sandbox.prev = prev; +vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(prev, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox +); +const next = JSON.parse(JSON.stringify(original)); +sandbox.next = next; +vm.runInContext("_carryForwardEphemeralTurnFields(prev, next)", sandbox); +const carried = next + .map((message, index) => message && message._anchor_activity_scene ? {{index, turn_id:message._anchor_activity_scene.identity.turn_id, tools:message._anchor_activity_scene.activity_rows.map(row => row.tool_call_id)}} : null) + .filter(Boolean); +const hydrated = vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(next, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox +); +const owners = next + .map((message, index) => message && message._anchor_activity_scene ? {{index, turn_id:message._anchor_activity_scene.identity.turn_id, tools:message._anchor_activity_scene.activity_rows.map(row => row.tool_call_id)}} : null) + .filter(Boolean); +console.log(JSON.stringify({{carried, hydrated, owners}})); +""" + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + def _linked_turn(call_id: str = "call-1") -> list[dict]: return [ {"role": "user", "content": "Inspect the workspace"}, @@ -188,6 +295,32 @@ def test_id_linked_historical_tool_turn_hydrates_one_anchor_scene_idempotently() assert row["tool"]["done"] is True +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_final_answer_preserves_raw_string_whitespace(): + messages = _linked_turn() + messages[-1]["content"] = " indented code\n" + + data = _run_hydration(messages) + + assert data["first"] == 1 + assert data["owners"][0]["scene"]["final_answer"] == " indented code\n" + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_final_answer_joins_text_parts_like_renderer(): + messages = _linked_turn() + messages[-1]["content"] = [ + {"type": "text", "text": "First paragraph."}, + {"type": "input_text", "text": "ignored input"}, + {"type": "text", "text": "Second paragraph."}, + ] + + data = _run_hydration(messages) + + assert data["first"] == 1 + assert data["owners"][0]["scene"]["final_answer"] == "First paragraph.\nSecond paragraph." + + @pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") def test_id_linked_historical_multi_tool_turn_preserves_declaration_order(): messages = [ @@ -441,6 +574,26 @@ def test_reused_ids_in_adjacent_complete_turns_remain_turn_scoped(): assert data["owners"][0]["scene"]["identity"]["turn_id"] != data["owners"][1]["scene"]["identity"]["turn_id"] +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_duplicate_final_answers_rebuild_historical_scenes_after_refresh(): + first_turn = _linked_turn("call-first") + second_turn = _linked_turn("call-second") + first_turn[1]["tool_calls"][0]["function"]["name"] = "first_tool" + first_turn[2]["content"] = "first result" + first_turn[3]["content"] = "Done" + second_turn[1]["tool_calls"][0]["function"]["name"] = "second_tool" + second_turn[2]["content"] = "second result" + second_turn[3]["content"] = "Done" + + data = _run_carry_forward_then_hydration(first_turn + second_turn) + + assert data["carried"] == [] + assert data["hydrated"] == 2 + assert [owner["index"] for owner in data["owners"]] == [3, 7] + assert [owner["tools"] for owner in data["owners"]] == [["call-first"], ["call-second"]] + assert data["owners"][0]["turn_id"] != data["owners"][1]["turn_id"] + + def test_render_messages_hydrates_before_visibility_and_fallback_ownership_scan(): start = UI_JS.index("function renderMessages(options)") body = UI_JS[start : UI_JS.index("\nfunction ", start + 1)] From a725a0f71c95a8bb6f1cb14e880f0291b80858c6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 19:53:43 -0700 Subject: [PATCH 29/82] =?UTF-8?q?Release=20p1-batch:=20session=20cache=20c?= =?UTF-8?q?ap=20300=E2=86=92100=20(#6362)=20+=20virtualization=20comment?= =?UTF-8?q?=20(#6318)=20(#6375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(transcript): enable DOM virtualization by default (re-enable #4346 fix) (#6151) (#6155) * fix: revert DOM virtualization default to opt-in, fix gate RED (#6155) * fix(#6351): lower default session cache cap * Release p1-batch: session cache cap 300->100 (#6362) + virtualization comment (#6318) --------- Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com> Co-authored-by: webtecnica Co-authored-by: Rod Boev Co-authored-by: nesquena-hermes --- CHANGELOG.md | 4 ++ README.md | 2 +- api/config.py | 6 ++- static/boot.js | 6 ++- tests/test_issue4765_sessions_lru_eviction.py | 42 +++++++++++++++---- 5 files changed, 46 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aebca53..a6141f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,16 @@ - **Internal: removed a dead `rowIndex` parameter from the settled-scene row projector.** `attachLiveStream`'s `pushRow` helper carried an unused positional index left over from before final-segment eligibility moved to a row-identity `WeakSet`; dropping it is a pure no-op refactor (differential execution confirms byte-identical settled scenes — ordering, dedupe, final-prefix suppression, and sequence assignment all unchanged). Thanks @webtecnica. (#6258) +- **Internal: clarified the transcript-virtualization boot comment (opt-in default retained).** The comment above the boot-time `_virtualizeTranscript` apply now records that the #4346 Phase B footer-jitter fix resolved the original scroll-up flicker root cause, while virtualization stays opt-in (default OFF) until battle-tested further. Zero behavior change — the apply predicate, settings-load fallback, checkbox, and server default all remain opt-in/OFF and in agreement. Thanks @webtecnica. (#6318, #6155) + - **Docs: reconciled the assistant-reply lifecycle RFCs/contracts with shipped behavior.** The Stable Assistant Turn Anchors and Live-to-Final RFCs, `docs/CONTRACTS.md`, and the Phase 0 architecture inventory were updated from "proposed/scaffold" framing to "accepted/implemented" to match what actually ships (single assistant-turn owner projecting one `activity_scene_v1` into Compact Worklog / Transparent Stream / Final answer). Documentation-only — no runtime behavior or CI-gate change; the one code touch is a corrected comment header in `static/assistant_turn_anchors.js`. Remaining hardening stays tracked under #3400. Thanks @franksong2702. (#6144) - **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145) ### Fixed +- **Long-running installs use less memory: the default resident-session cache cap is lowered from 300 to 100.** The in-memory `SESSIONS` LRU (compact session objects) is a dominant contributor to WebUI RSS climbing over hours of continuous use; the shipped no-override default is now tuned for the common single-user install. Larger deployments can still raise it via `webui.sessions_cache_max` in `config.yaml` (preferred) or the `HERMES_WEBUI_SESSIONS_MAX` env fallback. Eviction safety is unchanged — only clean, persisted, non-active sessions are ever dropped, and an evicted session lazily reloads from its JSON sidecar on next access (no data loss). Thanks @rodboev. (#6362, #6351) + - **Completed image tool-results are compacted from persisted history, cutting long-session memory and save/load cost.** After a turn finishes, base64 image parts in persisted *tool* results are replaced with a small `[screenshot]` text placeholder, so JSON sidecar saves/loads and session API responses no longer scale with image bytes (the active model still gets the full image while the tool call is running). The transform preserves every non-image part in place (text, documents, provider-specific parts) without flattening or reordering, only touches `role: tool` messages (user attachments are untouched), runs on all live and settled persistence writebacks, and is idempotent. Thanks @sjungwon03. (#6315, #1217) - **Large embedded images no longer slow down API response redaction.** Native raster image data URIs (`data:image/png|jpeg|gif|webp|bmp;base64,…`) are skipped by the credential-redaction pass instead of being scanned byte-by-byte on every response — a multi-megabyte image is opaque binary, not text a credential regex should rewrite. The fast path only applies after the *entire* base64 payload is validated (canonical round-trip + structural walk), so a string that merely starts with an image header but carries trailing or appended text still goes through full redaction — a credential can never ride along in an image's tail. Non-image strings, and sibling secrets in the same payload, are unaffected. Thanks @inch772. (#6311) diff --git a/README.md b/README.md index 0b8b1e6e..e121ec52 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ Full list of environment variables: | `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file | | `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable | | `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) | -| `HERMES_WEBUI_SESSIONS_MAX` | `300` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions — an evicted session lazily reloads from its JSON sidecar on next access | +| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access | Extension deployments can inspect sanitized, authenticated diagnostics at `GET /api/extensions/status`; see [WebUI Extensions](docs/EXTENSIONS.md#diagnostics). diff --git a/api/config.py b/api/config.py index 8c417ee9..17010116 100644 --- a/api/config.py +++ b/api/config.py @@ -8502,16 +8502,18 @@ _INDEX_HTML_PATH = get_index_html_path() # ── Thread synchronisation ─────────────────────────────────────────────────── LOCK = threading.Lock() -# Max compact Session objects held in the in-memory LRU (issue #3506, #4765). +# Max compact Session objects held in the in-memory LRU (issue #3506, #4765, #6351). # Lighter than the agent cache (no live agent runtime), but still bounded so a # long-running self-hosted install cannot accumulate every session it ever # touched in RAM and eventually segfault (the #4765/#2233/#4633 crash cluster). +# The shipped default is tuned for the common single-user install; larger +# deployments can keep raising it through config.yaml or the legacy env fallback. # # Precedence for the effective cap is resolved by get_sessions_cache_max(): # 1. config.yaml webui.sessions_cache_max (preferred, no new env var) # 2. HERMES_WEBUI_SESSIONS_MAX env var (legacy operator override) # 3. DEFAULT_SESSIONS_CACHE_MAX (sane bounded default) -DEFAULT_SESSIONS_CACHE_MAX = 300 +DEFAULT_SESSIONS_CACHE_MAX = 100 SESSIONS_MAX = _env_int("HERMES_WEBUI_SESSIONS_MAX", DEFAULT_SESSIONS_CACHE_MAX) diff --git a/static/boot.js b/static/boot.js index 3b315435..3abfff6a 100644 --- a/static/boot.js +++ b/static/boot.js @@ -3225,8 +3225,10 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true; applyEmptyStateSuggestionPref(); // #4343: transcript virtualization is EXPERIMENTAL/opt-IN (default OFF). - // It caused scroll-up flicker on long sessions, so it's off for everyone - // unless explicitly opted in; long transcripts render in full by default. + // #4346 Phase B (footer-jitter suppression during virtual-scroll + // measurement re-renders) resolved the scroll-up flicker root cause, + // but virtualization remains opt-in until battle-tested further. + // Users can explicitly enable it via Settings → virtualize_transcript. window._virtualizeTranscript=s.virtualize_transcript===true; window._showTps=!!s.show_tps; window._fadeTextEffect=!!s.fade_text_effect; diff --git a/tests/test_issue4765_sessions_lru_eviction.py b/tests/test_issue4765_sessions_lru_eviction.py index 116395ea..83efcbf7 100644 --- a/tests/test_issue4765_sessions_lru_eviction.py +++ b/tests/test_issue4765_sessions_lru_eviction.py @@ -111,19 +111,43 @@ def _insert(sid_session): # ─────────────────────────── config knob ──────────────────────────────────── +def test_default_sessions_cache_cap_is_100(): + """The shipped no-override session cache default is 100 (#6351).""" + from api import config as _cfg + + assert _cfg.DEFAULT_SESSIONS_CACHE_MAX == 100 + + def test_cache_cap_reads_config_yaml_key(): """The cap is configurable via config.yaml webui.sessions_cache_max (#4765).""" from api import config as _cfg - assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 42}}) == 42 - # Invalid / missing values must fall back, never disable the bound. - fallback = _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": "nope"}}) - assert isinstance(fallback, int) and fallback >= 1 - assert _cfg.get_sessions_cache_max({"webui": {}}) >= 1 - assert _cfg.get_sessions_cache_max({}) >= 1 - # A zero/negative typo must not disable the cap. - assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 0}}) >= 1 - assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": -5}}) >= 1 + old_sessions_max = _cfg.SESSIONS_MAX + try: + _cfg.SESSIONS_MAX = 222 + assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 42}}) == 42 + # Invalid / missing values must fall back, never disable the bound. + assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": "nope"}}) == 222 + assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": 0}}) == 222 + assert _cfg.get_sessions_cache_max({"webui": {"sessions_cache_max": -5}}) == 222 + finally: + _cfg.SESSIONS_MAX = old_sessions_max + + +def test_cache_cap_preserves_environment_fallback(): + """The parsed env fallback still wins when config is absent or invalid (#6351).""" + from api import config as _cfg + + old_sessions_max = _cfg.SESSIONS_MAX + try: + _cfg.SESSIONS_MAX = 222 + assert _cfg.get_sessions_cache_max({"webui": {}}) == 222 + assert _cfg.get_sessions_cache_max({}) == 222 + _cfg.SESSIONS_MAX = _cfg.DEFAULT_SESSIONS_CACHE_MAX + assert _cfg.get_sessions_cache_max({"webui": {}}) == _cfg.DEFAULT_SESSIONS_CACHE_MAX + assert _cfg.get_sessions_cache_max({}) == _cfg.DEFAULT_SESSIONS_CACHE_MAX + finally: + _cfg.SESSIONS_MAX = old_sessions_max # ─────────────────────────── invariant 1: eviction ────────────────────────── From 0deef392f71091d9fbf05e1bf3867eb27a62892d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 20:26:57 -0700 Subject: [PATCH 30/82] Release: consolidated Kanban board fills vertical space (#6308) (#6376) * fix: Fix height of consolidated kanban board There was a lot of empty space below the kanban board columns in the condolidated view (particularly on desktop). Modify the CSS such that the consolidated view always fills the viewport vertical space. * Release: consolidated Kanban board fills vertical space (#6308) --------- Co-authored-by: Joe Palazzolo Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 ++ static/panels.js | 1 + static/style.css | 2 ++ tests/test_kanban_board_ui.py | 27 +++++++++++++++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6141f8c..2908a634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed +- **The consolidated Kanban board now fills the panel's vertical space.** In consolidated view (a single board, not split into per-profile lanes), the columns were previously clamped to a short fixed height, leaving a large empty gap below the board. The consolidated board now grows to the full panel height and scrolls as one tall surface; the per-profile lane view keeps its existing height clamp and contained scrolling. CSS-only. Thanks @jpalazz2. (#6308) + - **Long-running installs use less memory: the default resident-session cache cap is lowered from 300 to 100.** The in-memory `SESSIONS` LRU (compact session objects) is a dominant contributor to WebUI RSS climbing over hours of continuous use; the shipped no-override default is now tuned for the common single-user install. Larger deployments can still raise it via `webui.sessions_cache_max` in `config.yaml` (preferred) or the `HERMES_WEBUI_SESSIONS_MAX` env fallback. Eviction safety is unchanged — only clean, persisted, non-active sessions are ever dropped, and an evicted session lazily reloads from its JSON sidecar on next access (no data loss). Thanks @rodboev. (#6362, #6351) - **Completed image tool-results are compacted from persisted history, cutting long-session memory and save/load cost.** After a turn finishes, base64 image parts in persisted *tool* results are replaced with a small `[screenshot]` text placeholder, so JSON sidecar saves/loads and session API responses no longer scale with image bytes (the active model still gets the full image while the tool call is running). The transform preserves every non-image part in place (text, documents, provider-specific parts) without flattening or reordering, only touches `role: tool` messages (user attachments are untouched), runs on all live and settled persistence writebacks, and is idempotent. Thanks @sjungwon03. (#6315, #1217) diff --git a/static/panels.js b/static/panels.js index e4e30752..6bda3158 100644 --- a/static/panels.js +++ b/static/panels.js @@ -2526,6 +2526,7 @@ function _kanbanRenderBoard(){ board.innerHTML = _kanbanEmptyBoardHtml(); return; } + board.classList.toggle('kanban-board-consolidated', !_kanbanLanesByProfile); const columns = _kanbanVisibleTasks(); const total = columns.reduce((n, col) => n + (col.tasks || []).length, 0); if ($('kanbanSummary')) $('kanbanSummary').textContent = String(t('kanban_visible_tasks')).replace('{0}', total); diff --git a/static/style.css b/static/style.css index 9814b6da..be923c43 100644 --- a/static/style.css +++ b/static/style.css @@ -6696,10 +6696,12 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} .kanban-list-title{font-size:13px;font-weight:600;line-height:1.35;} .kanban-board-wrap{flex:1;min-height:0;overflow:auto;padding:16px;background:var(--bg);} .kanban-board{display:flex;gap:12px;min-height:100%;overflow-x:auto;padding-bottom:8px;} +.kanban-board.kanban-board-consolidated{height:100%;} .kanban-column{display:flex;flex-direction:column;min-width:260px;max-width:320px;flex:1;background:var(--panel);border:1px solid var(--border);border-radius:10px;min-height:240px;} .kanban-column-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text);} .kanban-count{font-size:11px;color:var(--muted);background:var(--input-bg);border:1px solid var(--border);border-radius:999px;padding:1px 7px;} .kanban-column-body{display:flex;flex-direction:column;gap:8px;padding:10px;min-height:0;max-height:min(68vh,720px);overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;} +.kanban-board-consolidated .kanban-column-body{height:100%;max-height:unset;overscroll-behavior:auto;} .kanban-card{border:1px solid var(--border);border-radius:9px;background:var(--bg);padding:10px;cursor:pointer;box-shadow:var(--shadow-sm);} .kanban-card:hover,.kanban-card.selected{border-color:var(--accent);} .kanban-card-title{font-size:13px;font-weight:650;color:var(--text);line-height:1.35;margin-bottom:6px;} diff --git a/tests/test_kanban_board_ui.py b/tests/test_kanban_board_ui.py index 112df424..9779222b 100644 --- a/tests/test_kanban_board_ui.py +++ b/tests/test_kanban_board_ui.py @@ -25,3 +25,30 @@ def test_kanban_columns_render_scrollable_card_lists(): assert "overflow-y:auto" in rule assert "overscroll-behavior:contain" in rule assert "scrollbar-gutter:stable" in rule + + +def test_kanban_consolidated_board_fills_viewport_height(): + """The consolidated (single-board) view must stretch to fill the viewport + instead of leaving empty space below the columns on desktop. + + Both the board element and its column bodies drop their capped heights and + take height:100% so the columns extend to the bottom of the board wrap. + """ + board_rule = _css_rule(".kanban-board.kanban-board-consolidated") + assert "height:100%" in board_rule + + body_rule = _css_rule(".kanban-board-consolidated .kanban-column-body") + assert "height:100%" in body_rule + assert "max-height:unset" in body_rule + # The consolidated view owns its own full-height scroll container, so it opts + # out of the scroll-chaining lock the capped lane view uses. + assert "overscroll-behavior:auto" in body_rule + + +def test_kanban_consolidated_class_toggled_by_lane_mode(): + """The consolidated-height CSS only takes effect when the board element + carries the `kanban-board-consolidated` class, which the renderer adds only + in the single-board (non-lanes) view. Pin the wiring so a render refactor + can't silently strip the class and reintroduce the empty-space regression. + """ + assert "classList.toggle('kanban-board-consolidated'" in PANELS_JS From 48b40d930a7a3924b41504cb14e9a71dbb5aa7bf Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 20:48:00 -0700 Subject: [PATCH 31/82] Release: isolate Hermes home per streaming turn (#5877, @starship-s) (#6379) * fix(profiles): isolate Hermes home per streaming turn Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.3-codex-spark * fix(profiles): gate skill isolation by capability Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol Assisted-by: Codex:gpt-5.3-codex-spark * fix(profiles): harden fallback lock boundaries Assisted-by: OpenCode:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol * test(profiles): adapt streaming isolation harness Assisted-by: Codex:gpt-5.3-codex-spark Assisted-by: Hermes Agent:gpt-5.6-sol * Release: isolate Hermes home per streaming turn (#5877, @starship-s) --------- Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com> Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/profiles.py | 188 ++- api/streaming.py | 148 ++- ...est_issue2023_hermes_home_skill_modules.py | 115 ++ tests/test_issue2848_checkpoint_profile.py | 78 +- tests/test_issue5567_profile_home_override.py | 1097 +++++++++++++++++ tests/test_sprint46.py | 5 +- tests/test_title_aux_routing.py | 9 +- tests/test_update_banner_fixes.py | 4 +- 9 files changed, 1587 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2908a634..51cf8af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed +- **Concurrent chats under different profiles can no longer cross the profile boundary mid-turn.** In a single WebUI process, two chats streaming at once could resolve different Hermes profiles, but agent construction and later prompt/config/skill reads still depended on process-global `HERMES_HOME` and globally-patched skill-module paths after the env lock was released — so a second profile's turn could overwrite those globals mid-first-turn and bleed one profile's `SOUL.md`/skills/config into another's chat. The resolved profile home is now bound through the agent's per-thread context-local override for the entire streaming turn (and detached worker scope) and reset in the run's terminal `finally`, so concurrent profiles stay isolated even if the process globals are clobbered mid-run. Checkpoint saves skip the skill lock (no deadlock) and the capability check rejects globally-patched module state (no leak), with a serialized snapshot/patch/restore fallback that keeps older agents safe. Thanks @starship-s. (#5877, #5567, #2023) + - **The consolidated Kanban board now fills the panel's vertical space.** In consolidated view (a single board, not split into per-profile lanes), the columns were previously clamped to a short fixed height, leaving a large empty gap below the board. The consolidated board now grows to the full panel height and scrolls as one tall surface; the per-profile lane view keeps its existing height clamp and contained scrolling. CSS-only. Thanks @jpalazz2. (#6308) - **Long-running installs use less memory: the default resident-session cache cap is lowered from 300 to 100.** The in-memory `SESSIONS` LRU (compact session objects) is a dominant contributor to WebUI RSS climbing over hours of continuous use; the shipped no-override default is now tuned for the common single-user install. Larger deployments can still raise it via `webui.sessions_cache_max` in `config.yaml` (preferred) or the `HERMES_WEBUI_SESSIONS_MAX` env fallback. Eviction safety is unchanged — only clean, persisted, non-active sessions are ever dropped, and an evicted session lazily reloads from its JSON sidecar on next access (no data loss). Thanks @rodboev. (#6362, #6351) diff --git a/api/profiles.py b/api/profiles.py index 7f047673..1bc80047 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -56,6 +56,7 @@ _loaded_profile_env_keys: set[str] = set() _tls = threading.local() _SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool") +_SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock() def snapshot_skill_home_modules() -> dict[str, dict[str, object]]: @@ -76,6 +77,71 @@ def snapshot_skill_home_modules() -> dict[str, dict[str, object]]: return snapshot +def _skill_modules_support_profile_home(profile_home: Path) -> bool: + """Return ``True`` when both skill modules resolve to this profile home.""" + expected_dir = (Path(profile_home) / 'skills').expanduser() + + for module_name in _SKILL_HOME_MODULES: + module = sys.modules.get(module_name) + if module is None: + logger.debug("Skill capability check: %s not pre-imported in sys.modules", module_name) + return False + + if not hasattr(module, 'SKILLS_DIR'): + logger.debug("Skill capability check: %s missing SKILLS_DIR", module_name) + return False + + if not hasattr(module, '_SKILLS_DIR_AT_IMPORT'): + logger.debug("Skill capability check: %s missing _SKILLS_DIR_AT_IMPORT", module_name) + return False + + try: + current_skills_dir = Path(module.SKILLS_DIR).expanduser() + import_skills_dir = Path(module._SKILLS_DIR_AT_IMPORT).expanduser() + except Exception: + logger.debug( + "Skill capability check: %s has invalid SKILLS_DIR or _SKILLS_DIR_AT_IMPORT", + module_name, + exc_info=True, + ) + return False + + if current_skills_dir != import_skills_dir: + logger.debug( + "Skill capability check: %s.SKILLS_DIR %r does not match imported baseline %r", + module_name, + current_skills_dir, + import_skills_dir, + ) + return False + + skills_dir = getattr(module, '_skills_dir', None) + if not callable(skills_dir): + logger.debug("Skill capability check: %s._skills_dir is not callable", module_name) + return False + + try: + resolved = Path(skills_dir()).expanduser() + except Exception: + logger.debug( + "Skill capability check: %s._skills_dir() failed", + module_name, + exc_info=True, + ) + return False + + if resolved != expected_dir: + logger.debug( + "Skill capability check: %s resolves %r instead of %r", + module_name, + str(resolved), + str(expected_dir), + ) + return False + + return True + + def patch_skill_home_modules(home: Path) -> None: """Patch imported skill modules that cache HERMES_HOME at import time.""" for module_name in _SKILL_HOME_MODULES: @@ -1100,6 +1166,8 @@ def profile_env_for_background_worker( session, purpose: str = "background worker", logger_override: Optional[logging.Logger] = None, + *, + scope_skill_modules: bool = True, ): """Temporarily route detached worker config reads through a profile. @@ -1152,10 +1220,15 @@ def profile_env_for_background_worker( ) _scope_token = None _has_scope = False + _secret_scope_mod = None # #5567: context-local Hermes-home override (hermes-agent v0.18.0+). None on # older agents → graceful no-op (falls back to the os.environ mirror below). _home_override_mod = None _home_override_token = None + _home_override_installed = False + has_profile_skill_home = False + should_restore_skill_modules = False + _acquired_skill_home_patch_lock = False try: _set_thread_env(**thread_env) _thread_ctx.block_process_env_fallback = True @@ -1180,9 +1253,43 @@ def profile_env_for_background_worker( _home_override_token = _home_override_mod.set_hermes_home_override( str(profile_home_path) ) + _home_override_installed = True except Exception: _home_override_token = None + _home_override_installed = False + + if scope_skill_modules: + if _home_override_mod is not None and _home_override_installed: + try: + has_profile_skill_home = _skill_modules_support_profile_home( + profile_home_path + ) + except Exception: + logger.debug( + "Failed to evaluate profile-home skill module capability for %s in %s", + profile, + purpose, + exc_info=True, + ) + has_profile_skill_home = False + + # #5567-fallback: if override is unavailable, or module-side + # profile resolution is missing/failed, serialize the full worker + # lifespan under the shared legacy patch lock. + should_restore_skill_modules = not ( + _home_override_installed and has_profile_skill_home + ) + if should_restore_skill_modules: + _SKILL_HOME_MODULE_PATCH_LOCK.acquire() + _acquired_skill_home_patch_lock = True + with _ENV_LOCK: + if scope_skill_modules and should_restore_skill_modules: + # Snapshot and patch before mutating process env so setup + # failures can unwind without leaking either state. + skill_home_snapshot = snapshot_skill_home_modules() + patch_skill_home_modules(profile_home_path) + old_runtime_env = _apply_profile_env_to_process( os.environ, safe_runtime_env, @@ -1190,48 +1297,43 @@ def profile_env_for_background_worker( ) had_hermes_home = "HERMES_HOME" in os.environ old_hermes_home = os.environ.get("HERMES_HOME") - skill_home_snapshot = snapshot_skill_home_modules() os.environ.update(safe_runtime_env) os.environ["HERMES_HOME"] = str(profile_home_path) - try: - patch_skill_home_modules(profile_home_path) - except Exception: - log.debug( - "Failed to patch skill modules for %s profile %s", - purpose, - profile, - exc_info=True, - ) yield finally: - # #5567: pop the context-local home override first (reverse of setup order). - if _home_override_mod is not None and _home_override_token is not None: - try: - _home_override_mod.reset_hermes_home_override(_home_override_token) - except Exception: - pass - if _has_scope and _secret_scope_mod is not None: - try: - _secret_scope_mod.reset_secret_scope(_scope_token) - except Exception: - pass - _thread_ctx.block_process_env_fallback = previous_block_process_env - if previous_thread_env: - _set_thread_env(**previous_thread_env) - else: - _clear_thread_env() - with _ENV_LOCK: - for key, old_value in old_runtime_env.items(): - if old_value is None: - os.environ.pop(key, None) + try: + with _ENV_LOCK: + for key, old_value in old_runtime_env.items(): + if old_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = old_value + if had_hermes_home: + os.environ["HERMES_HOME"] = old_hermes_home or "" else: - os.environ[key] = old_value - if had_hermes_home: - os.environ["HERMES_HOME"] = old_hermes_home or "" + os.environ.pop("HERMES_HOME", None) + if should_restore_skill_modules and skill_home_snapshot is not None: + restore_skill_home_modules(skill_home_snapshot) + finally: + if _acquired_skill_home_patch_lock: + _SKILL_HOME_MODULE_PATCH_LOCK.release() + _acquired_skill_home_patch_lock = False + # Reset context-local state after the fallback globals are restored. + if _home_override_mod is not None and _home_override_installed: + try: + _home_override_mod.reset_hermes_home_override(_home_override_token) + except Exception: + pass + if _has_scope and _secret_scope_mod is not None: + try: + _secret_scope_mod.reset_secret_scope(_scope_token) + except Exception: + pass + _thread_ctx.block_process_env_fallback = previous_block_process_env + if previous_thread_env: + _set_thread_env(**previous_thread_env) else: - os.environ.pop("HERMES_HOME", None) - if skill_home_snapshot is not None: - restore_skill_home_modules(skill_home_snapshot) + _clear_thread_env() @contextmanager @@ -1292,6 +1394,7 @@ def profile_env_for_active_request_readonly( getattr(_thread_ctx, "block_process_env_fallback", False) ) home_override_token = None + home_override_installed = False _scope_token = None _has_scope = False try: @@ -1307,7 +1410,16 @@ def profile_env_for_active_request_readonly( except Exception: pass if set_hermes_home_override is not None: - home_override_token = set_hermes_home_override(profile_home_path) + try: + home_override_token = set_hermes_home_override(profile_home_path) + home_override_installed = True + except Exception: + logger.debug( + "Failed to install Hermes-home override for active request profile %s in %s", + profile, + purpose, + exc_info=True, + ) yield finally: if _has_scope and _secret_scope_mod is not None: @@ -1315,7 +1427,7 @@ def profile_env_for_active_request_readonly( _secret_scope_mod.reset_secret_scope(_scope_token) except Exception: pass - if home_override_token is not None and reset_hermes_home_override is not None: + if reset_hermes_home_override is not None and home_override_installed: try: reset_hermes_home_override(home_override_token) except Exception: diff --git a/api/streaming.py b/api/streaming.py index a05997c1..115b6fe5 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1831,6 +1831,77 @@ def _build_agent_thread_env(profile_runtime_env: dict | None, workspace: str, se return env +_streaming_hermes_home_override_available = None + + +def _resolve_streaming_hermes_home_override(): + """Return hermes_constants module if context-local home override APIs exist. + + Cached import-safe resolver mirrors the optional pattern used by + `api.profiles` so older agent versions safely degrade to the process-global + env mirror fallback. + """ + global _streaming_hermes_home_override_available + import sys as _sys + + if _streaming_hermes_home_override_available is False: + return None + + mod = _sys.modules.get('hermes_constants') + if mod is None and _streaming_hermes_home_override_available is None: + try: + import hermes_constants # noqa: F401 + mod = _sys.modules.get('hermes_constants') + except Exception: + _streaming_hermes_home_override_available = False + return None + + if ( + mod is not None + and hasattr(mod, 'set_hermes_home_override') + and hasattr(mod, 'reset_hermes_home_override') + ): + _streaming_hermes_home_override_available = True + return mod + + _streaming_hermes_home_override_available = False + return None + + +def _set_streaming_hermes_home_override(profile_home: str): + """Install the context-local home override if available. + + Returns ``(module, token, installed)`` so callers can restore it with the + matching module in the inverse order. + """ + if not profile_home: + return None, None, False + + _home_override_mod = _resolve_streaming_hermes_home_override() + if _home_override_mod is None: + return None, None, False + + try: + _token = _home_override_mod.set_hermes_home_override(profile_home) + return _home_override_mod, _token, True + except Exception: + logger.debug( + "Failed to set streaming Hermes home override; continuing with os.environ mirror", + exc_info=True, + ) + return None, None, False + + +def _reset_streaming_hermes_home_override(override_mod, override_token, override_installed: bool) -> None: + """Reset the context-local home override if it was installed.""" + if override_mod is None or not override_installed: + return + try: + override_mod.reset_hermes_home_override(override_token) + except Exception: + logger.debug("Failed to reset streaming Hermes home override", exc_info=True) + + # ── Per-turn session identity (xsession wakeup misroute root fix — Option 1) ─ # WebUI bound per-turn session identity ONLY to the process-global # os.environ['HERMES_SESSION_KEY'] (turn-start, line ~3263) and released the @@ -5405,6 +5476,7 @@ def _save_streaming_checkpoint(session): session, "streaming checkpoint", logger_override=logger, + scope_skill_modules=False, ): session.save(skip_index=True) @@ -7465,6 +7537,10 @@ def _run_agent_streaming( _turn_session_identity_tokens = None _streaming_cron_profile_home_token = None _turn_pending_source = 'webui' + _streaming_hermes_home_override_ctx = (None, None, False) + _streaming_skill_home_snapshot = None + _restore_streaming_skill_home_modules = False + _acquired_streaming_skill_home_patch_lock = False # Initialised here (before any code that may raise) so the outer `finally` # block can safely check `if _checkpoint_stop is not None` even when an # exception fires before the checkpoint thread is created (Issue #765). @@ -7532,8 +7608,12 @@ def _run_agent_streaming( from api.profiles import ( filter_runtime_env_for_gateway_parity, patch_skill_home_modules, + restore_skill_home_modules, + snapshot_skill_home_modules, get_hermes_home_for_profile, get_profile_runtime_env, + _skill_modules_support_profile_home, + _SKILL_HOME_MODULE_PATCH_LOCK, ) _profile_home_path = get_hermes_home_for_profile(getattr(s, 'profile', None)) _profile_home = str(_profile_home_path) @@ -7545,6 +7625,10 @@ def _run_agent_streaming( _profile_runtime_env = {} _safe_profile_runtime_env = {} patch_skill_home_modules = None + snapshot_skill_home_modules = None + restore_skill_home_modules = None + _skill_modules_support_profile_home = None + _SKILL_HOME_MODULE_PATCH_LOCK = None # Profile-aware provider/model enrichment: when the session belongs # to a profile that specifies model.provider and model.default, use @@ -7594,6 +7678,7 @@ def _run_agent_streaming( session_id, _profile_home, ) + _streaming_hermes_home_override_ctx = _set_streaming_hermes_home_override(_profile_home) _set_thread_env(**_thread_env) # process_complete agent-wakeup wiring (ours-original, Option B): bind # this session's HERMES_SESSION_KEY to its WebUI session_id so the @@ -7609,11 +7694,41 @@ def _run_agent_streaming( ensure_agent_runtime_current() _prewarm_skill_tool_modules() _install_streaming_cronjob_profile_wrapper() + + # Full-turn serialization is only needed for static/legacy skill-module + # resolution, where process-global skill-module globals are still used. + # Dynamic-capable modules continue concurrent execution. + _streaming_override_installed = bool(_streaming_hermes_home_override_ctx[2]) + _streaming_modules_are_dynamic = False + if patch_skill_home_modules is not None and snapshot_skill_home_modules is not None: + if _streaming_override_installed and _skill_modules_support_profile_home is not None: + try: + _streaming_modules_are_dynamic = bool( + _skill_modules_support_profile_home(_profile_home_path) + ) + except Exception: + logger.debug( + "Failed to evaluate streaming skill-module home capability for profile %r", + _profile_home, + exc_info=True, + ) + _streaming_modules_are_dynamic = False + + if not (_streaming_override_installed and _streaming_modules_are_dynamic): + _restore_streaming_skill_home_modules = True + _SKILL_HOME_MODULE_PATCH_LOCK.acquire() + _acquired_streaming_skill_home_patch_lock = True + # Still set process-level env as fallback for tools that bypass thread-local # Acquire lock only for the env mutation, then release before the agent runs. # The finally block re-acquires to restore — keeping critical sections short # and preventing a deadlock where the restore would re-enter the same lock. with _ENV_LOCK: + if _restore_streaming_skill_home_modules: + # Snapshot and patch before mutating process env so setup + # failures can unwind without leaking either state. + _streaming_skill_home_snapshot = snapshot_skill_home_modules() + patch_skill_home_modules(Path(_profile_home)) old_profile_env = {key: os.environ.get(key) for key in _safe_profile_runtime_env} old_cwd = os.environ.get('TERMINAL_CWD') old_exec_ask = os.environ.get('HERMES_EXEC_ASK') @@ -7633,18 +7748,14 @@ def _run_agent_streaming( os.environ['HERMES_SESSION_CHAT_ID'] = str(session_id) if _profile_home: os.environ['HERMES_HOME'] = _profile_home - # Patch skill module caches to match the active profile. - # _set_hermes_home() does this for process-wide switches - # but per-request switches skip it (#1700). The in-chat - # cronjob tool is wrapped separately at its tool-call boundary - # with cron_profile_context_for_home (#4580) so cron.jobs path - # caches are not mutated for the entire agent turn. - # Modules were prewarmed by _prewarm_skill_tool_modules() - # above, so we only do lightweight sys.modules lookups and - # attribute assignments here — no first-time import under - # the lock (#2024). - if patch_skill_home_modules is not None: - patch_skill_home_modules(Path(_profile_home)) + # Prefer context-local Hermes-home overrides when available. + # In that mode, tools.skills_tool._skills_dir() and + # tools.skill_manager_tool._skills_dir() can resolve the active + # profile from get_hermes_home() and keep per-thread isolation + # without mutating module globals. If override installation + # succeeds for both modules, skip process-cache patching. + # If either module is static/missing/raises, the legacy path + # above has already snapshotted and patched under this lock. # Lock released — agent runs without holding it # ── MCP Server Discovery (lazy import, idempotent) ── # MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()` @@ -10856,6 +10967,19 @@ def _run_agent_streaming( _clear_thread_env() # TD1: always clear thread-local context if _streaming_cron_profile_home_token is not None: _STREAMING_CRON_PROFILE_HOME.reset(_streaming_cron_profile_home_token) + if _restore_streaming_skill_home_modules and _streaming_skill_home_snapshot is not None: + with _ENV_LOCK: + if restore_skill_home_modules is not None: + try: + restore_skill_home_modules(_streaming_skill_home_snapshot) + except Exception: + logger.debug("Failed to restore skill module state for streaming profile", exc_info=True) + _streaming_skill_home_snapshot = None + _restore_streaming_skill_home_modules = False + if _acquired_streaming_skill_home_patch_lock: + _SKILL_HOME_MODULE_PATCH_LOCK.release() + _acquired_streaming_skill_home_patch_lock = False + _reset_streaming_hermes_home_override(*_streaming_hermes_home_override_ctx) # xsession wakeup misroute root fix (Option 1): restore the per-turn # session-identity context-locals (reset-token semantics). MUST run on # every exit path so a reused thread-pool worker leaks no identity and diff --git a/tests/test_issue2023_hermes_home_skill_modules.py b/tests/test_issue2023_hermes_home_skill_modules.py index cec4b5b9..40b20803 100644 --- a/tests/test_issue2023_hermes_home_skill_modules.py +++ b/tests/test_issue2023_hermes_home_skill_modules.py @@ -33,3 +33,118 @@ def test_set_hermes_home_patches_both_skill_tool_module_caches(monkeypatch, tmp_ assert skills_tool.SKILLS_DIR == new_home / "skills" assert skill_manager_tool.HERMES_HOME == new_home assert skill_manager_tool.SKILLS_DIR == new_home / "skills" + + +def test_skill_modules_support_profile_home_returns_true_for_dynamic_modules(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + + skills_tool = types.ModuleType("tools.skills_tool") + skills_tool.HERMES_HOME = profile_home.parent + skills_tool.SKILLS_DIR = baseline + skills_tool._SKILLS_DIR_AT_IMPORT = baseline + skills_tool._skills_dir = lambda: expected + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.HERMES_HOME = profile_home.parent + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is True + + +def test_skill_modules_support_profile_home_returns_false_when_already_globally_patched(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + patched = tmp_path / "alpha" / "skills" + + skills_tool = types.ModuleType("tools.skills_tool") + skills_tool.HERMES_HOME = profile_home.parent + skills_tool.SKILLS_DIR = patched + skills_tool._SKILLS_DIR_AT_IMPORT = baseline + skills_tool._skills_dir = lambda: expected + + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.HERMES_HOME = profile_home.parent + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is False + + +def test_skill_modules_support_profile_home_returns_false_when_module_is_static(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + + skills_tool = types.ModuleType("tools.skills_tool") + skills_tool.SKILLS_DIR = baseline + skills_tool._SKILLS_DIR_AT_IMPORT = baseline + skills_tool._skills_dir = expected + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is False + + +def test_skill_modules_support_profile_home_returns_false_when_resolver_raises(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + + def _raise(): + raise RuntimeError("not callable") + + skills_tool = types.ModuleType("tools.skills_tool") + skills_tool.SKILLS_DIR = baseline + skills_tool._SKILLS_DIR_AT_IMPORT = baseline + skills_tool._skills_dir = _raise + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is False + + +def test_skill_modules_support_profile_home_returns_false_when_module_missing(monkeypatch, tmp_path): + from api.profiles import _skill_modules_support_profile_home + + profile_home = tmp_path / "profile" + expected = profile_home / "skills" + baseline = tmp_path / "base" / "skills" + + skill_manager_tool = types.ModuleType("tools.skill_manager_tool") + skill_manager_tool.SKILLS_DIR = baseline + skill_manager_tool._SKILLS_DIR_AT_IMPORT = baseline + skill_manager_tool._skills_dir = lambda: expected + + monkeypatch.delitem(sys.modules, "tools.skills_tool", raising=False) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool) + + assert _skill_modules_support_profile_home(profile_home) is False diff --git a/tests/test_issue2848_checkpoint_profile.py b/tests/test_issue2848_checkpoint_profile.py index f4792f78..c5029f69 100644 --- a/tests/test_issue2848_checkpoint_profile.py +++ b/tests/test_issue2848_checkpoint_profile.py @@ -1,7 +1,9 @@ """Regression coverage for #2848 checkpoint saves in background threads.""" from __future__ import annotations +import os from pathlib import Path +import threading def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path): @@ -20,7 +22,11 @@ def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path): captured = {} monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home) - monkeypatch.setattr(profiles, "get_profile_runtime_env", lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")}) + monkeypatch.setattr( + profiles, + "get_profile_runtime_env", + lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")}, + ) def fake_save(self, *args, **kwargs): captured["kwargs"] = kwargs @@ -35,3 +41,73 @@ def test_checkpoint_save_uses_session_profile_env(monkeypatch, tmp_path): assert captured["kwargs"] == {"skip_index": True} assert captured["thread_env"]["HERMES_HOME"] == str(profile_home) assert captured["thread_env"]["HERMES_CONFIG_PATH"] == str(profile_home / "config.yaml") + + +def test_checkpoint_save_completes_without_skill_lock(monkeypatch, tmp_path): + """Checkpoint saves must not block on the legacy skill module lock.""" + + from api.models import Session + from api.streaming import _save_streaming_checkpoint + import api.config as config + import api.profiles as profiles + + profile_home = tmp_path / "profiles" / "maiko" + profile_home.mkdir(parents=True) + captured = {} + + monkeypatch.setattr(profiles, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr( + profiles, + "get_profile_runtime_env", + lambda home: {"HERMES_CONFIG_PATH": str(Path(home) / "config.yaml")}, + ) + monkeypatch.setattr(profiles, "_resolve_hermes_home_override", lambda: None) + + patch_calls: list[dict] = [] + + def fake_save(self, *args, **kwargs): + captured["kwargs"] = kwargs + captured["thread_env"] = dict(getattr(config._thread_ctx, "env", {}) or {}) + captured["env_hermes_home"] = os.environ.get("HERMES_HOME") + + def patch_skill_home_modules(*_): + patch_calls.append({"patched": True}) + + monkeypatch.setattr(Session, "save", fake_save) + monkeypatch.setattr(profiles, "patch_skill_home_modules", patch_skill_home_modules) + + completion = threading.Event() + + session = Session(session_id="issue2848-lock", profile="maiko") + + acquired_lock = profiles._SKILL_HOME_MODULE_PATCH_LOCK.acquire(timeout=1) + assert acquired_lock, "lock was unexpectedly unavailable before checkpoint test" + + try: + def _worker() -> None: + try: + _save_streaming_checkpoint(session) + completion.set() + except Exception as exc: # pragma: no cover - defensive + captured["error"] = exc + completion.set() + + worker = threading.Thread(target=_worker, daemon=True) + worker.start() + + assert completion.wait(0.5), ( + "checkpoint worker should not block on skill module lock" + ) + finally: + profiles._SKILL_HOME_MODULE_PATCH_LOCK.release() + worker.join(timeout=1) + + assert captured.get("kwargs") == {"skip_index": True} + assert captured.get("thread_env", {}).get("HERMES_HOME") == str(profile_home) + assert ( + captured.get("thread_env", {}).get("HERMES_CONFIG_PATH") + == str(profile_home / "config.yaml") + ) + assert captured.get("env_hermes_home") == str(profile_home) + assert not patch_calls + assert "error" not in captured diff --git a/tests/test_issue5567_profile_home_override.py b/tests/test_issue5567_profile_home_override.py index 69b70477..bf373317 100644 --- a/tests/test_issue5567_profile_home_override.py +++ b/tests/test_issue5567_profile_home_override.py @@ -19,7 +19,14 @@ intentional mid-body `os.environ` clobber and NO mocking of the production reade Degrades gracefully on agents without the override (skips with a clear reason). """ import os +import json +import inspect import textwrap +import contextlib +import queue +import threading +import sys +import types from pathlib import Path import pytest @@ -123,3 +130,1093 @@ def test_graceful_degradation_resolver_is_optional(): assert mod is not None and hasattr(mod, "set_hermes_home_override") else: assert mod is None # older agent: graceful no-op, os.environ mirror stays + + +def test_profile_env_for_background_worker_uses_legacy_skill_module_patching(monkeypatch, tmp_path): + """When override support is unavailable for this run, fallback still patches skill modules.""" + profile_home = tmp_path / "legacy-profile-home" + profile_home.mkdir(parents=True, exist_ok=True) + + fake_skill_module = types.ModuleType("tools.skills_tool") + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool") + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module) + + monkeypatch.setenv("HERMES_HOME", "default-home") + monkeypatch.delenv("HERMES_TEST_PROFILE_ENV", raising=False) + + monkeypatch.setattr(profiles_api, "_hermes_home_override_available", False) + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr( + profiles_api, + "get_profile_runtime_env", + lambda home: {"HERMES_TEST_PROFILE_ENV": "legacy-runtime"}, + ) + monkeypatch.setattr( + profiles_api, + "filter_runtime_env_for_gateway_parity", + lambda env: env, + ) + + with profiles_api.profile_env_for_background_worker("legacy", "legacy worker"): + assert os.environ.get("HERMES_HOME") == str(profile_home) + assert os.environ.get("HERMES_TEST_PROFILE_ENV") == "legacy-runtime" + assert fake_skill_module.HERMES_HOME == profile_home + assert fake_skill_module.SKILLS_DIR == profile_home / "skills" + + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_module.SKILLS_DIR == "default-home/skills" + assert os.environ.get("HERMES_HOME") == "default-home" + assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None + + +def test_run_agent_streaming_installs_and_resets_profile_home_override(tmp_path, monkeypatch): + """Streaming must install the worker home override and clear it in teardown.""" + + import api.streaming as _streaming + + _session_id = "streaming-override-session" + _stream_id = "streaming-override-stream" + _workspace = tmp_path / "workspace" + _workspace.mkdir() + _home = tmp_path / "alpha" + _home.mkdir() + + class _Session: + def __init__(self): + self.session_id = _session_id + self.workspace = str(_workspace) + self.profile = "alpha" + self.model = "gpt-4" + self.model_provider = "hermes" + self.messages = [] + self.context_messages = [] + self.path = str(_workspace / "session.json") + self.active_stream_id = _stream_id + self.pending_user_message = None + self.pending_started_at = None + self.pending_user_source = None + self.pending_attachments = [] + + def save(self, *args, **kwargs): + return None + + _events = {} + + class _FakeMeter: + def begin_session(self, *args, **kwargs): + _events["begin_session"] = (_events.get("begin_session", 0) + 1) + + def end_session(self, *args, **kwargs): + _events["end_session"] = (_events.get("end_session", 0) + 1) + + def get_interval(self): + return 11.0 + + def set_pending_started_at(self, stream_id, pending_started_at): + _events.setdefault("set_pending_started_at", []).append( + (stream_id, pending_started_at) + ) + + def get_ttft_ms(self, stream_id): + _events.setdefault("get_ttft_ms", []).append(stream_id) + return None + + def get_stats(self): + return {} + + def record_token(self, *args, **kwargs): + pass + + def record_reasoning(self, *args, **kwargs): + pass + + q = queue.Queue() + _streaming.STREAMS[_stream_id] = q + + def _set_thread_env(**kwargs): + _events["set_thread_env"] = True + + def _set_override(profile_home: str): + _events["set_override_home"] = profile_home + return ("sentinel-module", None, True) + + def _reset_override(mod, reset_token, override_installed): + _events["reset_override"] = (mod, reset_token, override_installed) + + def _patch_skill_home_modules(*_): + _events["patch_skill_home_modules"] = _events.get("patch_skill_home_modules", 0) + 1 + raise AssertionError("patch_skill_home_modules must be skipped when context-local override installs") + + class _SentinelAgent: + def __init__(self, *args, **kwargs): + pass + + def run_conversation(self, *args, **kwargs): + _events["run_conversation"] = True + raise RuntimeError("streaming test sentinel") + + def _get_ai_agent(): + return _SentinelAgent + + def _discover_mcp_tools(): + _events["discover_mcp_tools"] = _events.get("discover_mcp_tools", 0) + 1 + + monkeypatch.setattr(_streaming, "register_active_run", lambda *args, **kwargs: None) + monkeypatch.setattr(_streaming, "update_active_run", lambda *args, **kwargs: None) + monkeypatch.setattr(_streaming, "get_session", lambda sid: _Session()) + monkeypatch.setattr(_streaming, "_get_session_agent_lock", lambda sid: contextlib.nullcontext()) + monkeypatch.setattr(_streaming, "_set_streaming_hermes_home_override", _set_override) + monkeypatch.setattr(_streaming, "_reset_streaming_hermes_home_override", _reset_override) + monkeypatch.setattr(_streaming, "_set_thread_env", _set_thread_env) + monkeypatch.setattr(_streaming, "_prewarm_skill_tool_modules", lambda: None) + monkeypatch.setattr(_streaming, "_install_streaming_cronjob_profile_wrapper", lambda: None) + monkeypatch.setattr(_streaming, "_clear_thread_env", lambda: None) + monkeypatch.setattr(_streaming, "_get_ai_agent", _get_ai_agent) + monkeypatch.setattr(_streaming, "_materialize_pending_user_turn_before_error", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "_snapshot_and_append_partial_on_error", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "append_turn_journal_event_for_stream", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "meter", lambda: _FakeMeter()) + monkeypatch.setattr(_streaming, "RunJournalWriter", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "_build_agent_thread_env", lambda *a, **k: {}) + monkeypatch.setattr( + _streaming, + "resolve_model_provider", + lambda model_with_provider_context, explicitly_picked=None: (model_with_provider_context, None, None), + ) + monkeypatch.setattr(_streaming, "_runtime_preferred_base_url", lambda rt, provider, configured_base_url: configured_base_url) + import api.profiles as profiles_api + + monkeypatch.setattr(profiles_api, "_skill_modules_support_profile_home", lambda profile_home: True) + import api.config as _config_mod + monkeypatch.setattr(_config_mod, "_resolve_cli_toolsets", lambda cfg: []) + monkeypatch.setattr(_config_mod, "get_config_for_profile_home", lambda profile_home: {}) + _fake_mcp_module = types.ModuleType("tools.mcp_tool") + _fake_mcp_module.discover_mcp_tools = _discover_mcp_tools + monkeypatch.setitem(sys.modules, "tools.mcp_tool", _fake_mcp_module) + + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: _home) + monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: {}) + monkeypatch.setattr(profiles_api, "patch_skill_home_modules", _patch_skill_home_modules) + monkeypatch.setattr(_streaming, "_apply_profile_home_context_to_streaming_model", + lambda model, provider_context, profile_home, has_profile: (model, provider_context, False)) + + _streaming._run_agent_streaming( + session_id=_session_id, + msg_text="hi", + model="gpt-4", + workspace=str(_workspace), + stream_id=_stream_id, + ) + + assert _events.get("set_override_home") == str(_home) + assert _events.get("reset_override") == ("sentinel-module", None, True) + assert _events.get("run_conversation") is True + assert _events.get("discover_mcp_tools", 0) == 1 + assert _events.get("set_thread_env") is True + assert _events.get("patch_skill_home_modules", 0) == 0 + assert _stream_id not in _streaming.STREAMS + + +def test_run_agent_streaming_falls_back_to_skill_module_patch_for_static_modules( + tmp_path, + monkeypatch, +): + """Streaming should use snapshot/patch/restore when skill modules are static.""" + + import api.streaming as _streaming + + _session_id = "streaming-override-static-session" + _stream_id = "streaming-override-static-stream" + _workspace = tmp_path / "workspace" + _workspace.mkdir() + _home = tmp_path / "alpha" + _home.mkdir() + + class _Session: + def __init__(self): + self.session_id = _session_id + self.workspace = str(_workspace) + self.profile = "alpha" + self.model = "gpt-4" + self.model_provider = "hermes" + self.messages = [] + self.context_messages = [] + self.path = str(_workspace / "session.json") + self.active_stream_id = _stream_id + self.pending_user_message = None + self.pending_started_at = None + self.pending_user_source = None + self.pending_attachments = [] + + def save(self, *args, **kwargs): + return None + + _events = {} + + class _FakeMeter: + def begin_session(self, *args, **kwargs): + _events["begin_session"] = (_events.get("begin_session", 0) + 1) + + def end_session(self, *args, **kwargs): + _events["end_session"] = (_events.get("end_session", 0) + 1) + + def get_interval(self): + return 11.0 + + def set_pending_started_at(self, stream_id, pending_started_at): + _events.setdefault("set_pending_started_at", []).append( + (stream_id, pending_started_at) + ) + + def get_ttft_ms(self, stream_id): + _events.setdefault("get_ttft_ms", []).append(stream_id) + return None + + def get_stats(self): + return {} + + q = queue.Queue() + _streaming.STREAMS[_stream_id] = q + + def _set_thread_env(**kwargs): + _events["set_thread_env"] = True + + def _set_override(profile_home: str): + _events["set_override_home"] = profile_home + return ("sentinel-module", None, True) + + def _reset_override(mod, reset_token, override_installed): + _events["reset_override"] = (mod, reset_token, override_installed) + + def _caller_info(): + _frame = inspect.currentframe() + _wrapper = _frame.f_back if _frame is not None else None + _caller = _wrapper.f_back if _wrapper is not None else None + try: + if _caller is None: + return "", "", -1 + return ( + Path(_caller.f_code.co_filename).name, + _caller.f_code.co_name, + _caller.f_lineno, + ) + finally: + del _wrapper + del _caller + del _frame + + def _snapshot_skill_home_modules(): + _events.setdefault("snapshot_skill_home_modules", []).append( + {"source": _caller_info(), "args": ()} + ) + payload = {"snapshot": True} + return payload + + def _patch_skill_home_modules(*args): + _events.setdefault("patch_skill_home_modules", []).append( + {"source": _caller_info(), "args": tuple(args)} + ) + + def _restore_skill_home_modules(snapshot): + _events.setdefault("restore_skill_home_modules", []).append( + {"source": _caller_info(), "snapshot": snapshot, "args": (snapshot,)} + ) + + class _SentinelAgent: + def __init__(self, *args, **kwargs): + pass + + def run_conversation(self, *args, **kwargs): + _events["run_conversation"] = True + raise RuntimeError("streaming test sentinel") + + def _get_ai_agent(): + return _SentinelAgent + + def _discover_mcp_tools(): + _events["discover_mcp_tools"] = _events.get("discover_mcp_tools", 0) + 1 + + monkeypatch.setattr(_streaming, "register_active_run", lambda *args, **kwargs: None) + monkeypatch.setattr(_streaming, "update_active_run", lambda *args, **kwargs: None) + monkeypatch.setattr(_streaming, "get_session", lambda sid: _Session()) + monkeypatch.setattr(_streaming, "_get_session_agent_lock", lambda sid: contextlib.nullcontext()) + monkeypatch.setattr(_streaming, "_set_streaming_hermes_home_override", _set_override) + monkeypatch.setattr(_streaming, "_reset_streaming_hermes_home_override", _reset_override) + monkeypatch.setattr(_streaming, "_set_thread_env", _set_thread_env) + monkeypatch.setattr(_streaming, "_prewarm_skill_tool_modules", lambda: None) + monkeypatch.setattr(_streaming, "_install_streaming_cronjob_profile_wrapper", lambda: None) + monkeypatch.setattr(_streaming, "_clear_thread_env", lambda: None) + monkeypatch.setattr(_streaming, "_get_ai_agent", _get_ai_agent) + monkeypatch.setattr(_streaming, "_materialize_pending_user_turn_before_error", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "_snapshot_and_append_partial_on_error", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "append_turn_journal_event_for_stream", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "meter", lambda: _FakeMeter()) + monkeypatch.setattr(_streaming, "RunJournalWriter", lambda *a, **k: None) + monkeypatch.setattr(_streaming, "_build_agent_thread_env", lambda *a, **k: {}) + monkeypatch.setattr( + _streaming, + "resolve_model_provider", + lambda model_with_provider_context, explicitly_picked=None: (model_with_provider_context, None, None), + ) + monkeypatch.setattr(_streaming, "_runtime_preferred_base_url", lambda rt, provider, configured_base_url: configured_base_url) + import api.profiles as profiles_api + + monkeypatch.setattr(profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules) + monkeypatch.setattr(profiles_api, "patch_skill_home_modules", _patch_skill_home_modules) + monkeypatch.setattr(profiles_api, "restore_skill_home_modules", _restore_skill_home_modules) + + fake_skills_tool = types.ModuleType('tools.skills_tool') + fake_skills_tool.HERMES_HOME = 'default-home' + fake_skills_tool.SKILLS_DIR = 'default-home/skills' + fake_skill_manager_tool = types.ModuleType('tools.skill_manager_tool') + fake_skill_manager_tool.HERMES_HOME = 'default-home' + fake_skill_manager_tool.SKILLS_DIR = 'default-home/skills' + monkeypatch.setitem(sys.modules, 'tools.skills_tool', fake_skills_tool) + monkeypatch.setitem(sys.modules, 'tools.skill_manager_tool', fake_skill_manager_tool) + + _fake_mcp_module = types.ModuleType("tools.mcp_tool") + _fake_mcp_module.discover_mcp_tools = _discover_mcp_tools + monkeypatch.setitem(sys.modules, "tools.mcp_tool", _fake_mcp_module) + + import api.config as _config_mod + monkeypatch.setattr(_config_mod, "_resolve_cli_toolsets", lambda cfg: []) + monkeypatch.setattr(_config_mod, "get_config_for_profile_home", lambda profile_home: {}) + + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", lambda name: _home) + monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: {}) + monkeypatch.setattr( + _streaming, + "_apply_profile_home_context_to_streaming_model", + lambda model, provider_context, profile_home, has_profile: (model, provider_context, False), + ) + + _streaming._run_agent_streaming( + session_id=_session_id, + msg_text="hi", + model="gpt-4", + workspace=str(_workspace), + stream_id=_stream_id, + ) + + assert _events.get("set_override_home") == str(_home) + assert _events.get("reset_override") == ("sentinel-module", None, True) + assert _events.get("run_conversation") is True + # Two call paths can reach the legacy skill-module helpers now: + # 1) upstream runtime-refresh model-resolution scope, and + # 2) the PR-owned streaming fallback branch at _run_agent_streaming. + # Assert that the streaming-owned fallback still executes once and fully + # restores the snapshot it captured. + _streaming_patch_calls = [ + entry["source"] + for entry in _events.get("patch_skill_home_modules", []) + if entry["source"][0] == "streaming.py" + ] + _streaming_snapshot_calls = [ + entry["source"] + for entry in _events.get("snapshot_skill_home_modules", []) + if entry["source"][0] == "streaming.py" + ] + _streaming_restore_calls = [ + entry["snapshot"] + for entry in _events.get("restore_skill_home_modules", []) + if entry["source"][0] == "streaming.py" + ] + assert len(_streaming_patch_calls) == 1 + assert len(_streaming_snapshot_calls) == 1 + assert _streaming_restore_calls == [{"snapshot": True}] + assert _stream_id not in _streaming.STREAMS + + +def test_profile_env_for_background_worker_uses_static_modules_fallback_when_dynamic_checks_fail( + tmp_path, + monkeypatch, +): + """Background worker falls back to module patching when dynamic checks fail.""" + + import api.profiles as _profiles_api + + profile_home = tmp_path / "legacy-home" + profile_home.mkdir(parents=True, exist_ok=True) + + events = {} + fake_skill_module = types.ModuleType("tools.skills_tool") + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_module._SKILLS_DIR_AT_IMPORT = "default-home/skills" + + fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool") + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module._SKILLS_DIR_AT_IMPORT = "default-home/skills" + + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module) + + fake_constants = types.SimpleNamespace() + + def _set_override(profile_home: str): + events["set_override_home"] = str(profile_home) + return None + + def _reset_override(token): + events["reset_token"] = token + + fake_constants.set_hermes_home_override = _set_override + fake_constants.reset_hermes_home_override = _reset_override + monkeypatch.setattr(_profiles_api, "_resolve_hermes_home_override", lambda: fake_constants) + monkeypatch.setattr(_profiles_api, "_hermes_home_override_available", True) + + def _snapshot_skill_home_modules(): + events["snapshot"] = True + return {"snapshot": True} + + def _patch_skill_home_modules(*_): + events["patch"] = events.get("patch", 0) + 1 + fake_skill_module.HERMES_HOME = profile_home + fake_skill_module.SKILLS_DIR = profile_home / "skills" + fake_skill_manager_module.HERMES_HOME = profile_home + fake_skill_manager_module.SKILLS_DIR = profile_home / "skills" + + def _restore_skill_home_modules(snapshot): + events["restore"] = snapshot + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + + monkeypatch.setattr(_profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules) + monkeypatch.setattr(_profiles_api, "patch_skill_home_modules", _patch_skill_home_modules) + monkeypatch.setattr(_profiles_api, "restore_skill_home_modules", _restore_skill_home_modules) + monkeypatch.setattr(_profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr(_profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(_profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env) + + with _profiles_api.profile_env_for_background_worker("legacy", "legacy worker"): + assert fake_skill_module.HERMES_HOME == profile_home + assert fake_skill_module.SKILLS_DIR == profile_home / "skills" + assert fake_skill_manager_module.HERMES_HOME == profile_home + assert fake_skill_manager_module.SKILLS_DIR == profile_home / "skills" + + assert events.get("snapshot") is True + assert events.get("patch") == 1 + assert events.get("restore") == {"snapshot": True} + assert events.get("reset_token") is None + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills" + + +def test_profile_env_for_background_worker_serializes_static_module_scope_with_lock(tmp_path, monkeypatch): + """`_SKILL_HOME_MODULE_PATCH_LOCK` serializes concurrent static module scopes.""" + + profile_alpha = tmp_path / "alpha" + profile_beta = tmp_path / "beta" + profile_alpha.mkdir() + profile_beta.mkdir() + + fake_skill_module = types.ModuleType("tools.skills_tool") + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool") + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module) + + events = { + "set": [], + "reset": [], + } + + fake_constants = types.SimpleNamespace() + + def _set_override(profile_home: str): + events["set"].append(str(profile_home)) + return None + + def _reset_override(reset_token): + events["reset"].append(reset_token) + + fake_constants.set_hermes_home_override = _set_override + fake_constants.reset_hermes_home_override = _reset_override + monkeypatch.setattr(profiles_api, "_resolve_hermes_home_override", lambda: fake_constants) + monkeypatch.setattr(profiles_api, "_hermes_home_override_available", True) + monkeypatch.setattr( + profiles_api, + "get_hermes_home_for_profile", + lambda profile: profile_alpha if profile == "alpha" else profile_beta, + ) + monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env) + + alpha_entered = threading.Event() + beta_entered = threading.Event() + alpha_release = threading.Event() + worker_errors: list[tuple[str, BaseException]] = [] + + def _worker_alpha() -> None: + try: + with profiles_api.profile_env_for_background_worker("alpha", "lock holder"): + assert fake_skill_module.HERMES_HOME == profile_alpha + assert fake_skill_module.SKILLS_DIR == profile_alpha / "skills" + assert fake_skill_manager_module.HERMES_HOME == profile_alpha + assert fake_skill_manager_module.SKILLS_DIR == profile_alpha / "skills" + alpha_entered.set() + assert alpha_release.wait(timeout=5) + raise RuntimeError("alpha worker sentinel") + except BaseException as exc: + worker_errors.append(("alpha", exc)) + + def _worker_beta() -> None: + try: + with profiles_api.profile_env_for_background_worker("beta", "lock waiter"): + beta_entered.set() + assert fake_skill_module.HERMES_HOME == profile_beta + assert fake_skill_module.SKILLS_DIR == profile_beta / "skills" + assert fake_skill_manager_module.HERMES_HOME == profile_beta + assert fake_skill_manager_module.SKILLS_DIR == profile_beta / "skills" + except BaseException as exc: + worker_errors.append(("beta", exc)) + + _thread_alpha = threading.Thread(target=_worker_alpha) + _thread_beta = threading.Thread(target=_worker_beta) + + _thread_alpha.start() + assert alpha_entered.wait(timeout=5) + + _thread_beta.start() + assert not beta_entered.wait(timeout=0.2) + alpha_release.set() + assert beta_entered.wait(timeout=5) + + _thread_alpha.join(timeout=5) + _thread_beta.join(timeout=5) + + assert not _thread_alpha.is_alive() + assert not _thread_beta.is_alive() + + alpha_error = next((exc for name, exc in worker_errors if name == "alpha"), None) + beta_error = next((exc for name, exc in worker_errors if name == "beta"), None) + assert beta_error is None + assert isinstance(alpha_error, RuntimeError) + assert str(alpha_error) == "alpha worker sentinel" + + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_module.SKILLS_DIR == "default-home/skills" + assert fake_skill_manager_module.HERMES_HOME == "default-home" + assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills" + + assert sorted(events["set"]) == [str(profile_alpha), str(profile_beta)] + assert len(events["reset"]) == 2 + assert all(token is None for token in events["reset"]) + + with profiles_api.profile_env_for_background_worker("alpha", "same-thread follow up"): + assert fake_skill_module.HERMES_HOME == profile_alpha + assert fake_skill_module.SKILLS_DIR == profile_alpha / "skills" + assert fake_skill_manager_module.HERMES_HOME == profile_alpha + assert fake_skill_manager_module.SKILLS_DIR == profile_alpha / "skills" + + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_module.SKILLS_DIR == "default-home/skills" + assert fake_skill_manager_module.HERMES_HOME == "default-home" + assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills" + + +@pytest.mark.skipif( + not HAS_OVERRIDE, + reason="requires v0.18.0 context-local home override support", +) +def test_profile_env_for_background_worker_uses_real_modules_and_serializes_overlap(tmp_path, monkeypatch): + """Real skill modules should serialize static fallback scopes across profiles.""" + + try: + import tools.skills_tool as skills_tool + import tools.skill_manager_tool as skill_manager_tool + except Exception as exc: + pytest.skip(f"hermes-agent skill modules unavailable for this environment: {exc}") + + required_attrs = ("_SKILLS_DIR_AT_IMPORT", "SKILLS_DIR", "HERMES_HOME") + for _name, _mod in ( + ("tools.skills_tool", skills_tool), + ("tools.skill_manager_tool", skill_manager_tool), + ): + for _attr in required_attrs: + if not hasattr(_mod, _attr): + pytest.skip(f"{_name} missing {_attr}") + + if not hasattr(skills_tool, "skills_list"): + pytest.skip("tools.skills_tool missing skills_list API") + + alpha_home = tmp_path / "alpha" + beta_home = tmp_path / "beta" + alpha_home.mkdir() + beta_home.mkdir() + + alpha_skill = f"alpha-only-{tmp_path.name}" + beta_skill = f"beta-only-{tmp_path.name}" + + def _write_skill(home: Path, name: str) -> None: + skill_dir = home / "skills" / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + textwrap.dedent( + f"""\ + --- + name: {name} + description: skill for overlap test + --- + skill body + """ + ), + encoding="utf-8", + ) + + def _parse_skill_names(raw: object) -> set[str]: + payload = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw + data = json.loads(payload) if isinstance(payload, str) else payload + if isinstance(data, dict): + data = data.get("skills", []) + return { + item.get("name") + for item in data or [] + if isinstance(item, dict) and item.get("name") is not None + } + + _write_skill(alpha_home, alpha_skill) + _write_skill(beta_home, beta_skill) + + baseline_override = hermes_constants.get_hermes_home_override() + baseline_env_home = os.environ.get("HERMES_HOME") + baseline_env_has = "HERMES_HOME" in os.environ + + baseline_skill_dir = skills_tool.SKILLS_DIR + baseline_skill_home = getattr(skills_tool, "HERMES_HOME", None) + baseline_skill_manager_dir = skill_manager_tool.SKILLS_DIR + baseline_skill_manager_home = getattr(skill_manager_tool, "HERMES_HOME", None) + + alpha_skills_dir = str(alpha_home / "skills") + + # Start with both modules deliberately patched to alpha. + skills_tool.HERMES_HOME = str(alpha_home) + skills_tool.SKILLS_DIR = alpha_skills_dir + skill_manager_tool.HERMES_HOME = str(alpha_home) + skill_manager_tool.SKILLS_DIR = alpha_skills_dir + + monkeypatch.setenv("HERMES_HOME", str(alpha_home)) + monkeypatch.setattr( + profiles_api, + "get_hermes_home_for_profile", + lambda profile: alpha_home if profile == "alpha" else beta_home, + ) + monkeypatch.setattr(profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env) + + alpha_ready = threading.Event() + alpha_listed = threading.Event() + beta_started = threading.Event() + beta_entered = threading.Event() + release_alpha = threading.Event() + + seen: dict[str, set[str]] = {} + observed: dict[str, dict[str, object]] = {} + worker_errors: list[tuple[str, BaseException]] = [] + + def _worker_alpha() -> None: + try: + with profiles_api.profile_env_for_background_worker("alpha", "real alpha worker"): + alpha_ready.set() + names = _parse_skill_names(skills_tool.skills_list()) + seen["alpha"] = names + assert alpha_skill in names + assert beta_skill not in names + alpha_listed.set() + assert release_alpha.wait(timeout=5) + except BaseException as exc: # pragma: no cover - defensive + worker_errors.append(("alpha", exc)) + finally: + observed["alpha"] = { + "override": hermes_constants.get_hermes_home_override(), + } + + def _worker_beta() -> None: + try: + beta_started.set() + with profiles_api.profile_env_for_background_worker("beta", "real beta worker"): + beta_entered.set() + names = _parse_skill_names(skills_tool.skills_list()) + seen["beta"] = names + assert beta_skill in names + assert alpha_skill not in names + except BaseException as exc: # pragma: no cover - defensive + worker_errors.append(("beta", exc)) + finally: + observed["beta"] = { + "override": hermes_constants.get_hermes_home_override(), + } + + thread_alpha = threading.Thread(target=_worker_alpha) + thread_beta = threading.Thread(target=_worker_beta) + + try: + thread_alpha.start() + assert alpha_ready.wait(timeout=5), "alpha worker should enter first" + + thread_beta.start() + assert beta_started.wait(timeout=5), "beta worker should attempt to start" + assert not beta_entered.wait(0.25), "beta must block on fallback scope lock while alpha holds it" + + assert alpha_listed.wait(timeout=5), "alpha must list skills while beta is waiting" + + release_alpha.set() + assert beta_entered.wait(timeout=5), "beta should enter after alpha exits" + + thread_alpha.join(timeout=5) + thread_beta.join(timeout=5) + + assert not thread_alpha.is_alive() + assert not thread_beta.is_alive() + assert not worker_errors + + assert seen.get("alpha", set()) == {alpha_skill} + assert seen.get("beta", set()) == {beta_skill} + + assert observed["alpha"]["override"] == baseline_override + assert observed["beta"]["override"] == baseline_override + + assert hermes_constants.get_hermes_home_override() == baseline_override + assert skills_tool.HERMES_HOME == str(alpha_home) + assert skills_tool.SKILLS_DIR == alpha_skills_dir + assert skill_manager_tool.HERMES_HOME == str(alpha_home) + assert skill_manager_tool.SKILLS_DIR == alpha_skills_dir + assert os.environ.get("HERMES_HOME") == str(alpha_home) + finally: + thread_alpha.join(timeout=1) + thread_beta.join(timeout=1) + release_alpha.set() + + if baseline_env_has: + if baseline_env_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = baseline_env_home + else: + os.environ.pop("HERMES_HOME", None) + + skills_tool.SKILLS_DIR = baseline_skill_dir + if baseline_skill_home is not None: + skills_tool.HERMES_HOME = baseline_skill_home + else: + skills_tool.__dict__.pop("HERMES_HOME", None) + + skill_manager_tool.SKILLS_DIR = baseline_skill_manager_dir + if baseline_skill_manager_home is not None: + skill_manager_tool.HERMES_HOME = baseline_skill_manager_home + else: + skill_manager_tool.__dict__.pop("HERMES_HOME", None) + + +def test_profile_env_for_background_worker_resets_override_when_dynamic_check_raises( + tmp_path, + monkeypatch, +): + """A dynamic capability check exception must still clear the override state.""" + import api.profiles as _profiles_api + + profile_home = tmp_path / "legacy-home" + profile_home.mkdir(parents=True, exist_ok=True) + + events = {} + fake_skill_module = types.ModuleType("tools.skills_tool") + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module = types.ModuleType("tools.skill_manager_tool") + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + + monkeypatch.setitem(sys.modules, "tools.skills_tool", fake_skill_module) + monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", fake_skill_manager_module) + + fake_constants = types.SimpleNamespace() + + def _set_override(profile_home: str): + events["set"] = str(profile_home) + return "override-token" + + def _reset_override(reset_token): + events["reset"] = reset_token + + fake_constants.set_hermes_home_override = _set_override + fake_constants.reset_hermes_home_override = _reset_override + monkeypatch.setattr(_profiles_api, "_resolve_hermes_home_override", lambda: fake_constants) + monkeypatch.setattr(_profiles_api, "_hermes_home_override_available", True) + + def _snapshot_skill_home_modules(): + events["snapshot"] = True + return {"snapshot": True} + + def _patch_skill_home_modules(*_): + events["patch"] = events.get("patch", 0) + 1 + fake_skill_module.HERMES_HOME = profile_home + fake_skill_module.SKILLS_DIR = profile_home / "skills" + fake_skill_manager_module.HERMES_HOME = profile_home + fake_skill_manager_module.SKILLS_DIR = profile_home / "skills" + + def _restore_skill_home_modules(snapshot): + events["restore"] = snapshot + fake_skill_module.HERMES_HOME = "default-home" + fake_skill_module.SKILLS_DIR = "default-home/skills" + fake_skill_manager_module.HERMES_HOME = "default-home" + fake_skill_manager_module.SKILLS_DIR = "default-home/skills" + + def _raise(*_): + raise RuntimeError("dynamic capability probe failed") + + monkeypatch.setattr(_profiles_api, "snapshot_skill_home_modules", _snapshot_skill_home_modules) + monkeypatch.setattr(_profiles_api, "patch_skill_home_modules", _patch_skill_home_modules) + monkeypatch.setattr(_profiles_api, "restore_skill_home_modules", _restore_skill_home_modules) + monkeypatch.setattr(_profiles_api, "_skill_modules_support_profile_home", _raise) + monkeypatch.setattr(_profiles_api, "get_hermes_home_for_profile", lambda profile: profile_home) + monkeypatch.setattr(_profiles_api, "get_profile_runtime_env", lambda home: {}) + monkeypatch.setattr(_profiles_api, "filter_runtime_env_for_gateway_parity", lambda env: env) + + with _profiles_api.profile_env_for_background_worker("legacy", "legacy worker"): + assert fake_skill_module.HERMES_HOME == profile_home + assert fake_skill_module.SKILLS_DIR == profile_home / "skills" + + assert events.get("set") == str(profile_home) + assert events.get("reset") == "override-token" + assert events.get("snapshot") is True + assert events.get("patch") == 1 + assert events.get("restore") == {"snapshot": True} + assert fake_skill_module.HERMES_HOME == "default-home" + assert fake_skill_module.SKILLS_DIR == "default-home/skills" + assert fake_skill_manager_module.HERMES_HOME == "default-home" + assert fake_skill_manager_module.SKILLS_DIR == "default-home/skills" + + +@pytest.mark.skipif( + not HAS_OVERRIDE, + reason="requires v0.18.0 context-local home override support", +) +def test_run_agent_streaming_override_helpers_with_concurrent_skills_list_workers(tmp_path, monkeypatch): + """Foreground streaming override and background profile scope resolve skills correctly. + + Foreground: call the streaming override helper for alpha and then clobber + process env to beta. + Background: run `profile_env_for_background_worker('beta', ...)` and then + also clobber env to alpha. + Both threads call real `tools.skills_tool.skills_list()` and should see their + own profile's skill directory. + """ + + import api.streaming as _streaming + try: + import tools.skills_tool as skills_tool + import tools.skill_manager_tool as skill_manager_tool + except Exception as exc: # pragma: no cover - hermes-agent dependency probe + pytest.skip(f"hermes-agent skill modules unavailable for this environment: {exc}") + + _home_alpha = tmp_path / "alpha" + _home_beta = tmp_path / "beta" + _home_alpha.mkdir() + _home_beta.mkdir() + + alpha_name = f"alpha-skill-{tmp_path.name}" + beta_name = f"beta-skill-{tmp_path.name}" + + def _write_skill_dir(root: Path, name: str) -> None: + skill_dir = root / "skills" / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + textwrap.dedent( + f"""\ + --- + name: {name} + description: {name} description + --- + skill body + """ + ), + encoding="utf-8", + ) + + _write_skill_dir(_home_alpha, alpha_name) + _write_skill_dir(_home_beta, beta_name) + + _baseline_override = hermes_constants.get_hermes_home_override() + _baseline_has_env = "HERMES_HOME" in os.environ + _baseline_env = os.environ.get("HERMES_HOME") + _baseline_skill_dir = skills_tool.SKILLS_DIR + _baseline_skill_home = getattr(skills_tool, "HERMES_HOME", None) + _baseline_manager_skill_dir = skill_manager_tool.SKILLS_DIR + _baseline_manager_skill_home = getattr(skill_manager_tool, "HERMES_HOME", None) + + # Force deterministic resolution path independent of previous suite side effects. + skills_tool.SKILLS_DIR = skills_tool._SKILLS_DIR_AT_IMPORT + if _baseline_skill_home is not None: + skills_tool.HERMES_HOME = _baseline_skill_home + skill_manager_tool.SKILLS_DIR = getattr( + skill_manager_tool, + "_SKILLS_DIR_AT_IMPORT", + _baseline_manager_skill_dir, + ) + if _baseline_manager_skill_home is not None: + skill_manager_tool.HERMES_HOME = _baseline_manager_skill_home + os.environ["HERMES_HOME"] = str(_baseline_skill_home) if _baseline_skill_home else "" + + def _get_profile_home(name: str) -> Path: + if name == "beta": + return _home_beta + if name == "alpha": + return _home_alpha + return _home_alpha + + # Keep the helper and module state deterministic for this threaded race test. + assert _baseline_override is None + _baseline_skills_tuple = (skills_tool._SKILLS_DIR_AT_IMPORT, _baseline_skill_home) + _baseline_manager_tuple = ( + getattr(skill_manager_tool, "_SKILLS_DIR_AT_IMPORT", _baseline_manager_skill_dir), + _baseline_manager_skill_home, + ) + monkeypatch.setattr(profiles_api, "get_hermes_home_for_profile", _get_profile_home) + + start_barrier = threading.Barrier(2) + clobber_barrier = threading.Barrier(2) + _results: dict[str, set[str]] = {} + _worker_errors: list[tuple[str, BaseException]] = [] + _lock = threading.Lock() + _post_reset_overrides: dict[str, object | None] = {} + _post_reset_skill_dirs: dict[str, Path] = {} + _post_reset_skill_homes: dict[str, object | None] = {} + _post_reset_manager_skill_dirs: dict[str, Path] = {} + _post_reset_manager_skill_homes: dict[str, object | None] = {} + _beta_scope_snapshots: dict[str, dict[str, tuple[object | None, object | None]]] = {} + + def _parse_skills(raw: str) -> set[str]: + payload = json.loads(raw) + return { + item.get("name") + for item in payload.get("skills", []) + if isinstance(item, dict) and "name" in item + } + + def _worker_alpha() -> None: + mod_ctx, reset_token, override_installed = _streaming._set_streaming_hermes_home_override( + str(_home_alpha) + ) + try: + start_barrier.wait(timeout=5) + os.environ["HERMES_HOME"] = str(_home_beta) + clobber_barrier.wait(timeout=5) + + names = _parse_skills(skills_tool.skills_list()) + with _lock: + _results["alpha"] = names + except BaseException as exc: + with _lock: + _worker_errors.append(("alpha", exc)) + finally: + _streaming._reset_streaming_hermes_home_override( + mod_ctx, + reset_token, + override_installed, + ) + with _lock: + _post_reset_overrides["alpha"] = hermes_constants.get_hermes_home_override() + _post_reset_skill_dirs["alpha"] = skills_tool.SKILLS_DIR + _post_reset_skill_homes["alpha"] = getattr(skills_tool, "HERMES_HOME", None) + _post_reset_manager_skill_dirs["alpha"] = skill_manager_tool.SKILLS_DIR + _post_reset_manager_skill_homes["alpha"] = getattr(skill_manager_tool, "HERMES_HOME", None) + + def _worker_beta() -> None: + pre_scope = ( + skills_tool.SKILLS_DIR, + getattr(skills_tool, "HERMES_HOME", None), + ) + try: + with profiles_api.profile_env_for_background_worker( + "beta", + "test-streaming-skill-list", + ): + start_barrier.wait(timeout=5) + os.environ["HERMES_HOME"] = str(_home_alpha) + clobber_barrier.wait(timeout=5) + + names = _parse_skills(skills_tool.skills_list()) + with _lock: + _results["beta"] = names + _beta_scope_snapshots["beta"] = { + "pre": pre_scope, + "during": ( + skills_tool.SKILLS_DIR, + getattr(skills_tool, "HERMES_HOME", None), + ), + } + except BaseException as exc: + with _lock: + _worker_errors.append(("beta", exc)) + finally: + with _lock: + _post_reset_overrides["beta"] = hermes_constants.get_hermes_home_override() + _post_reset_skill_dirs["beta"] = skills_tool.SKILLS_DIR + _post_reset_skill_homes["beta"] = getattr(skills_tool, "HERMES_HOME", None) + _post_reset_manager_skill_dirs["beta"] = skill_manager_tool.SKILLS_DIR + _post_reset_manager_skill_homes["beta"] = getattr(skill_manager_tool, "HERMES_HOME", None) + + _thread_alpha = threading.Thread(target=_worker_alpha) + _thread_beta = threading.Thread(target=_worker_beta) + + _thread_alpha.start() + _thread_beta.start() + _thread_alpha.join(timeout=10) + _thread_beta.join(timeout=10) + + try: + assert not _thread_alpha.is_alive() + assert not _thread_beta.is_alive() + assert _results.get("alpha") is not None + assert _results.get("beta") is not None + if _worker_errors: + raise _worker_errors[0][1] + + alpha_names = _results.get("alpha", set()) + beta_names = _results.get("beta", set()) + + assert alpha_name in alpha_names + assert beta_name not in alpha_names + assert beta_name in beta_names + assert alpha_name not in beta_names + + assert _post_reset_overrides["alpha"] == _baseline_override + assert _post_reset_overrides["beta"] == _baseline_override + + assert _post_reset_skill_dirs["alpha"] == skills_tool._SKILLS_DIR_AT_IMPORT + assert _post_reset_skill_dirs["beta"] == skills_tool._SKILLS_DIR_AT_IMPORT + assert _post_reset_manager_skill_dirs["alpha"] == _baseline_manager_tuple[0] + assert _post_reset_manager_skill_dirs["beta"] == _baseline_manager_tuple[0] + assert _post_reset_skill_homes["alpha"] == _baseline_skill_home + assert _post_reset_skill_homes["beta"] == _baseline_skill_home + assert _post_reset_manager_skill_homes["alpha"] == _baseline_manager_skill_home + assert _post_reset_manager_skill_homes["beta"] == _baseline_manager_skill_home + + beta_snapshot = _beta_scope_snapshots.get("beta") + assert beta_snapshot is not None + assert beta_snapshot["pre"] == _baseline_skills_tuple + assert beta_snapshot["during"] == _baseline_skills_tuple + finally: + if _baseline_has_env: + os.environ["HERMES_HOME"] = _baseline_env or "" + else: + os.environ.pop("HERMES_HOME", None) + skills_tool.SKILLS_DIR = _baseline_skill_dir + if _baseline_skill_home is not None: + skills_tool.HERMES_HOME = _baseline_skill_home + skill_manager_tool.SKILLS_DIR = _baseline_manager_skill_dir + if _baseline_manager_skill_home is not None: + skill_manager_tool.HERMES_HOME = _baseline_manager_skill_home diff --git a/tests/test_sprint46.py b/tests/test_sprint46.py index 9ca5bed6..9426f9d8 100644 --- a/tests/test_sprint46.py +++ b/tests/test_sprint46.py @@ -688,7 +688,6 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, } routes._run_manual_compression_job(sid, {"session_id": sid}) - assert EnvAssertingAgent.seen_env == { "HERMES_HOME": str(profile_home), "HERMES_TEST_PROFILE_ENV": "work-runtime", @@ -697,8 +696,8 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, "SKILL_MODULE_HOME": profile_home, "SKILL_MODULE_DIR": profile_home / "skills", } - assert str(getattr(fake_skill_module, "HERMES_HOME")) == "default-home" - assert str(getattr(fake_skill_module, "SKILLS_DIR")) == "default-home/skills" + assert str(fake_skill_module.HERMES_HOME) == "default-home" + assert str(fake_skill_module.SKILLS_DIR) == "default-home/skills" assert os.environ.get("HERMES_HOME") == "default-home" assert os.environ.get("HERMES_TEST_PROFILE_ENV") is None with routes._MANUAL_COMPRESSION_JOBS_LOCK: diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index 39f55f96..24c927ca 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -786,10 +786,13 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase): self.assertEqual(captured.get('hermes_home'), 'profile-home') self.assertEqual(str(captured.get('skill_module_home')), 'profile-home') - self.assertEqual(Path(str(captured.get('skill_module_dir'))), Path('profile-home') / 'skills') + self.assertEqual( + Path(str(captured.get('skill_module_dir'))), + Path('profile-home') / 'skills', + ) self.assertEqual(captured.get('restored_hermes_home'), 'default-home') - self.assertEqual(getattr(fake_skill_module, 'HERMES_HOME'), 'default-home') - self.assertEqual(getattr(fake_skill_module, 'SKILLS_DIR'), 'default-home/skills') + self.assertEqual(fake_skill_module.HERMES_HOME, 'default-home') + self.assertEqual(fake_skill_module.SKILLS_DIR, 'default-home/skills') self.assertEqual(mock_session.title, 'Profile Routed Title') def test_background_profile_env_routes_load_config_and_provider_credentials(self): diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 0da5ad41..5634834d 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -1785,8 +1785,8 @@ class TestUpdateSummaryRouteModelSelection: 'SKILL_MODULE_DIR': profile_home / 'skills', } assert captured['aux_create']['model'] == 'profile-compression-model' - assert getattr(fake_skill_module, 'HERMES_HOME') == 'default-home' - assert getattr(fake_skill_module, 'SKILLS_DIR') == 'default-home/skills' + assert fake_skill_module.HERMES_HOME == 'default-home' + assert fake_skill_module.SKILLS_DIR == 'default-home/skills' assert os.environ.get('HERMES_HOME') == 'default-home' assert os.environ.get('HERMES_TEST_PROFILE_ENV') == 'default-runtime' From 6b9b9dc808e51f570e56b77086ad4cd8e94cdf47 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 20:57:21 -0700 Subject: [PATCH 32/82] Release: Kanban New-Task modal reachable on mobile (#6301, @jpalazz2) (#6384) Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 ++ static/style.css | 5 +++-- tests/test_kanban_ui_static.py | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51cf8af3..deb84235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed +- **The Kanban "New task" modal is reachable on phones again.** On a narrow viewport, a tall create-task modal was vertically centered, pushing its top (the title and first input) above the top of the screen with no way to scroll back to it. The mobile overlay now uses `align-items: safe center` and `overflow-y: auto`, so an overflowing modal keeps its top on-screen and scrolls as needed; desktop and wider layouts are unchanged. Thanks @jpalazz2. (#6301) + - **Concurrent chats under different profiles can no longer cross the profile boundary mid-turn.** In a single WebUI process, two chats streaming at once could resolve different Hermes profiles, but agent construction and later prompt/config/skill reads still depended on process-global `HERMES_HOME` and globally-patched skill-module paths after the env lock was released — so a second profile's turn could overwrite those globals mid-first-turn and bleed one profile's `SOUL.md`/skills/config into another's chat. The resolved profile home is now bound through the agent's per-thread context-local override for the entire streaming turn (and detached worker scope) and reset in the run's terminal `finally`, so concurrent profiles stay isolated even if the process globals are clobbered mid-run. Checkpoint saves skip the skill lock (no deadlock) and the capability check rejects globally-patched module state (no leak), with a serialized snapshot/patch/restore fallback that keeps older agents safe. Thanks @starship-s. (#5877, #5567, #2023) - **The consolidated Kanban board now fills the panel's vertical space.** In consolidated view (a single board, not split into per-profile lanes), the columns were previously clamped to a short fixed height, leaving a large empty gap below the board. The consolidated board now grows to the full panel height and scrolls as one tall surface; the per-profile lane view keeps its existing height clamp and contained scrolling. CSS-only. Thanks @jpalazz2. (#6308) diff --git a/static/style.css b/static/style.css index be923c43..86829dbd 100644 --- a/static/style.css +++ b/static/style.css @@ -7023,8 +7023,9 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;} min-width:min(280px, calc(100vw - 24px)); max-width:calc(100vw - 24px); } - /* Modal scales to viewport width on phones with reasonable padding. */ - .kanban-modal-overlay{padding:12px;} + /* Modal scales to viewport width on phones with reasonable padding. + Ensure it's on screen and scrollable */ + .kanban-modal-overlay{align-items:safe center;overflow-y:auto;padding:12px;} .kanban-modal{padding:16px 16px 14px;border-radius:14px;} .kanban-modal-row-inline{flex-direction:column;gap:0;} .kanban-column-body{overscroll-behavior:auto;} diff --git a/tests/test_kanban_ui_static.py b/tests/test_kanban_ui_static.py index bf080210..82036810 100644 --- a/tests/test_kanban_ui_static.py +++ b/tests/test_kanban_ui_static.py @@ -815,6 +815,27 @@ def test_kanban_review_feedback_static_ui_fixes_exist(): assert "kanban-stats-grid" in PANELS +def test_kanban_modal_mobile_responsive_css(): + """On narrow phones (<=640px) a tall kanban modal must stay reachable: the + overlay scrolls (overflow-y:auto) and safe-centers its content + (align-items:safe center) so an overflowing modal is never clipped above + the fold where its top can't be scrolled back into view.""" + # There are several `.kanban-modal-overlay{...}` rules (skin override, base + # desktop, and the mobile <=640px override, which is last in the file). + # Match against COMPACT_STYLE — whitespace-stripped, like the sibling CSS + # tests — and take the last rule to isolate the mobile override, instead of + # depending on brittle @media-block bracket matching. + overlay_rules = re.findall(r"\.kanban-modal-overlay\{([^}]*)\}", COMPACT_STYLE) + assert overlay_rules, "no .kanban-modal-overlay rule found in style.css" + mobile = overlay_rules[-1] + assert "overflow-y:auto" in mobile, f"mobile overlay must be scrollable. Got: {mobile}" + # `align-items:safe center` compacts to `align-items:safecenter`. + assert "align-items:safecenter" in mobile, ( + f"mobile overlay must safe-center its content so an overflowing modal " + f"is never clipped above the fold. Got: {mobile}" + ) + + def test_kanban_task_detail_renderer_executes_with_log_and_formats_feedback(): import json import subprocess From 4f063d16f8cdb2b4d43a0deaec642e5c1017b3fd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 21:39:25 -0700 Subject: [PATCH 33/82] Release: Artifacts pane long-filename readability + long-parent containment (#6078, @rodboev) (#6386) * fix(#6067): keep artifact file names visible # Conflicts: # static/style.css # static/workspace.js * fix(#6067): bound long parent artifact tails inside the drawer * Release: Artifacts pane long-filename readability + long-parent containment (#6078, @rodboev) --------- Co-authored-by: Rod Boev Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + static/i18n.js | 1 + static/style.css | 8 +- static/workspace.js | 28 ++- tests/test_chinese_locale.py | 5 +- tests/test_czech_locale.py | 5 +- tests/test_issue6067_artifact_paths.py | 276 +++++++++++++++++++++++++ tests/test_japanese_locale.py | 5 +- tests/test_korean_locale.py | 5 +- tests/test_polish_locale.py | 5 +- tests/test_russian_locale.py | 5 +- tests/test_spanish_locale.py | 5 +- tests/test_turkish_locale.py | 5 +- tests/test_vietnamese_locale.py | 5 +- tests/test_zh_hant_locale.py | 5 +- 15 files changed, 343 insertions(+), 22 deletions(-) create mode 100644 tests/test_issue6067_artifact_paths.py diff --git a/CHANGELOG.md b/CHANGELOG.md index deb84235..3c34c5f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed +- **Workspace Artifacts pane: long filenames are fully readable and long parent directories stay inside the drawer.** A long artifact filename used to be ellipsized (so you couldn't tell files apart), and an unusually long immediate-parent directory could expand the Artifacts drawer horizontally and bleed past its edge. The list now shows the filename first with parent-preserving path truncation, and the directory tail is width-capped (`min-width: 0` + `overflow: hidden` + `text-overflow: ellipsis`) so it clips inside the card instead of blowing out the drawer. Adds behavioral/locale coverage for the artifact path lifecycle. Thanks @rodboev. (#6078, #6067) + - **The Kanban "New task" modal is reachable on phones again.** On a narrow viewport, a tall create-task modal was vertically centered, pushing its top (the title and first input) above the top of the screen with no way to scroll back to it. The mobile overlay now uses `align-items: safe center` and `overflow-y: auto`, so an overflowing modal keeps its top on-screen and scrolls as needed; desktop and wider layouts are unchanged. Thanks @jpalazz2. (#6301) - **Concurrent chats under different profiles can no longer cross the profile boundary mid-turn.** In a single WebUI process, two chats streaming at once could resolve different Hermes profiles, but agent construction and later prompt/config/skill reads still depended on process-global `HERMES_HOME` and globally-patched skill-module paths after the env lock was released — so a second profile's turn could overwrite those globals mid-first-turn and bleed one profile's `SOUL.md`/skills/config into another's chat. The resolved profile home is now bound through the agent's per-thread context-local override for the entire streaming turn (and detached worker scope) and reset in the run's terminal `finally`, so concurrent profiles stay isolated even if the process globals are clobbered mid-run. Checkpoint saves skip the skill lock (no deadlock) and the capability check rejects globally-patched module state (no leak), with a serialized snapshot/patch/restore fallback that keeps older agents safe. Thanks @starship-s. (#5877, #5567, #2023) diff --git a/static/i18n.js b/static/i18n.js index 141fcfcc..57822182 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -464,6 +464,7 @@ const LOCALES = { workspace_close_preview: 'Close preview', workspace_files_tab: 'Files', workspace_artifacts_tab: 'Artifacts', + workspace_artifact_source_session: 'session', workspace_hidden_files_visible: 'hidden visible', workspace_hidden_files_visible_title: 'Hidden files are visible — click for options', workspace_options: 'Workspace options', diff --git a/static/style.css b/static/style.css index 86829dbd..e5f2dfe6 100644 --- a/static/style.css +++ b/static/style.css @@ -6432,9 +6432,11 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .workspace-artifact-empty{padding:16px 8px;color:var(--muted);font-size:12px;line-height:1.5;text-align:center;} .workspace-artifact-item{display:block;width:100%;text-align:left;background:var(--surface-subtle);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:6px;cursor:pointer;} .workspace-artifact-item:hover{border-color:var(--accent);} -.workspace-artifact-name{font-family:'SF Mono',ui-monospace,monospace;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);} -.workspace-artifact-dir{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--muted);margin-top:1px;} -.workspace-artifact-meta{margin-top:3px;color:var(--muted);font-size:11px;} +.workspace-artifact-filename{font-family:'SF Mono',ui-monospace,monospace;font-size:12px;overflow-wrap:anywhere;} +.workspace-artifact-directory{display:flex;min-width:0;overflow:hidden;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;font-size:11px;color:var(--muted);} +.workspace-artifact-directory-head{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;} +.workspace-artifact-directory-tail{flex:0 0 auto;min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;} +.workspace-artifact-meta{display:inline-block;margin-top:4px;padding:1px 5px;border:1px solid var(--border);border-radius:999px;color:var(--muted);font-size:11px;line-height:1.3;} .workspace-artifacts-count{opacity:.65;font-size:11px;} .rightpanel[data-active-tab="artifacts"] .breadcrumb-bar,.rightpanel[data-active-tab="artifacts"] .file-tree,.rightpanel[data-active-tab="artifacts"] #wsEmptyState{display:none!important;} .rightpanel[data-active-tab="artifacts"] .workspace-artifacts{display:block;} diff --git a/static/workspace.js b/static/workspace.js index 622e1e24..1e6c6a72 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -586,16 +586,26 @@ function renderSessionArtifacts(){ if(normWs && p.startsWith(normWs)) return p.slice(normWs.length); return p; }; + const splitArtifactDisplayPath = (path) => { + const slash = path.lastIndexOf('/'); + if(slash < 0) return {name: path, head: '', tail: ''}; + const directory = path.slice(0, slash + 1); + const parentSlash = directory.lastIndexOf('/', directory.length - 2); + return { + name: path.slice(slash + 1), + head: directory.slice(0, parentSlash + 1), + tail: directory.slice(parentSlash + 1), + }; + }; root.innerHTML = items.map(item => { - const dPath = displayPath(item.path); - const idx = dPath.lastIndexOf('/'); - const fileName = idx >= 0 ? dPath.slice(idx + 1) : dPath; - const dirPath = idx >= 0 ? dPath.slice(0, idx) : ''; - return ``; + const path = displayPath(item.path); + const parts = splitArtifactDisplayPath(path); + const directory = (parts.head || parts.tail) + ? `
${esc(parts.head)}${esc(parts.tail)}
` + : ''; + const source = item.source ? esc(item.source) : esc(t('workspace_artifact_source_session') || 'session'); + const sourceAttrs = item.source ? '' : ' data-i18n="workspace_artifact_source_session"'; + return ``; }).join(''); } diff --git a/tests/test_chinese_locale.py b/tests/test_chinese_locale.py index c7f9be5d..92f187d2 100644 --- a/tests/test_chinese_locale.py +++ b/tests/test_chinese_locale.py @@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_czech_locale.py b/tests/test_czech_locale.py index 103988e1..c55130d9 100644 --- a/tests/test_czech_locale.py +++ b/tests/test_czech_locale.py @@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_issue6067_artifact_paths.py b/tests/test_issue6067_artifact_paths.py new file mode 100644 index 00000000..ab41c396 --- /dev/null +++ b/tests/test_issue6067_artifact_paths.py @@ -0,0 +1,276 @@ +import os +from pathlib import Path +import re +import sys + +import pytest + +ROOT = Path(os.environ.get("ISSUE6067_REPO_ROOT") or Path(__file__).resolve().parents[1]) +sys.path.insert(0, str(ROOT / "tests")) +from _layout_helpers import assert_layout_sane + + +WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") +I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") +EXPECT_VISIBLE = os.environ.get("ISSUE6067_EXPECT_VISIBLE", "1") != "0" +HEADLESS = os.environ.get("ISSUE6067_HEADLESS", "1") != "0" +SCREENSHOT_PATH = os.environ.get("ISSUE6067_SCREENSHOT_PATH") + +ARTIFACT_CASES = [ + {"path": "users.py", "source": "patch", "display": "users.py", "name": "users.py", "head": "", "tail": ""}, + {"path": "src/api/routes/users.py", "source": "tool", "display": "src/api/routes/users.py", "name": "users.py", "head": "src/api/", "tail": "routes/"}, + {"path": "dir/foo.py", "source": "single", "display": "dir/foo.py", "name": "foo.py", "head": "", "tail": "dir/"}, + {"path": "src/project/app/api/routes/handlers/users.py", "source": "", "display": "src/project/app/api/routes/handlers/users.py", "name": "users.py", "head": "src/project/app/api/routes/", "tail": "handlers/"}, + {"path": "/file.txt", "source": "root", "display": "/file.txt", "name": "file.txt", "head": "/", "tail": ""}, + {"path": "dir/", "source": "trail", "display": "dir/", "name": "", "head": "", "tail": "dir/"}, + {"path": "", "source": "empty", "display": "", "name": "", "head": "", "tail": ""}, + {"path": "src//users.py", "source": "gap", "display": "src//users.py", "name": "users.py", "head": "src/", "tail": "/"}, + {"path": "src/lastIndexOf/users.py", "source": "literal", "display": "src/lastIndexOf/users.py", "name": "users.py", "head": "src/", "tail": "lastIndexOf/"}, + {"path": "/workspace/über//routes/quote&.py", "source": "&", "display": "über//routes/quote&.py", "name": "quote&.py", "head": "über//", "tail": "routes/"}, + {"path": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/summary.json", "source": "", "display": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/summary.json", "name": "summary.json", "head": "", "tail": "2026-07-20-quarterly-revenue-analysis-with-regional-breakdown-and-confidence-intervals/"}, +] + + +def _function(source, name): + start = source.index(f"function {name}(") + brace = source.index("{", start) + depth = 0 + for pos in range(brace, len(source)): + if source[pos] == "{": + depth += 1 + elif source[pos] == "}": + depth -= 1 + if depth == 0: + return source[start:pos + 1] + raise AssertionError(f"could not extract {name}") + + +def _locale_blocks(): + matches = list(re.finditer(r"^ ('[^']+'|[A-Za-z][A-Za-z0-9-]*): \{$", I18N_JS, re.MULTILINE)) + end = I18N_JS.index("\n};", matches[-1].start()) + return { + match.group(1).strip("'"): I18N_JS[match.start() : (matches[index + 1].start() if index + 1 < len(matches) else end)] + for index, match in enumerate(matches) + } + + +def _filename_visible(locator) -> bool: + return locator.evaluate( + """node => { + const range = document.createRange(); + range.selectNodeContents(node); + const text = range.getBoundingClientRect(); + const box = node.getBoundingClientRect(); + return text.left >= box.left - 1 && text.right <= box.right + 1; + }""" + ) + + +def _path_suffix_visible(locator, suffix: str) -> bool: + return locator.evaluate( + """(node, suffix) => { + const textNode = node.firstChild; + if(!textNode || textNode.nodeType !== Node.TEXT_NODE) return false; + const value = textNode.textContent || ''; + const start = value.lastIndexOf(suffix); + if(start < 0) return false; + const range = document.createRange(); + range.setStart(textNode, start); + range.setEnd(textNode, start + suffix.length); + const text = range.getBoundingClientRect(); + const box = node.getBoundingClientRect(); + return text.left >= box.left - 1 && text.right <= box.right + 1; + }""", + suffix, + ) + + +def _directory_text_gap(locator) -> float: + return locator.evaluate( + """node => { + const head = node.querySelector('.workspace-artifact-directory-head'); + const tail = node.querySelector('.workspace-artifact-directory-tail'); + if(!head || !tail) return Number.POSITIVE_INFINITY; + const range = document.createRange(); + range.selectNodeContents(head); + const text = range.getBoundingClientRect(); + const tailBox = tail.getBoundingClientRect(); + return tailBox.left - text.right; + }""" + ) + + +def test_issue6067_session_source_key_is_english_fallback_owned(): + locale_blocks = _locale_blocks() + assert re.search(r"\bworkspace_artifact_source_session:\s*'session'", locale_blocks["en"]) + for locale, block in locale_blocks.items(): + if locale == "en": + continue + assert not re.search(r"\bworkspace_artifact_source_session:\s*'", block), ( + f"workspace_artifact_source_session must be absent from non-English locale {locale!r}" + ) + assert "_locale[key] ?? LOCALES.en[key]" in I18N_JS + + +def test_issue6067_artifact_filenames_remain_visible_across_artifact_widths(): + try: + from playwright.sync_api import sync_playwright + except Exception: # pragma: no cover - dependency missing path + pytest.skip("playwright is unavailable; run manual local browser proof for issue #6067") + + items = [{"path": case["path"], "source": case["source"]} for case in ARTIFACT_CASES] + renderer = _function(WORKSPACE_JS, "renderSessionArtifacts") + css = STYLE_CSS.replace("", "") + harness = f""" + + +
+
Artifacts proof fixture for issue #6067
+
+
+
Files
+
Artifacts
+
+
+
+
+ + + """ + with sync_playwright() as playwright: + browser = playwright.chromium.launch( + headless=HEADLESS, + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + page = browser.new_page(viewport={"width": 1024, "height": 600}) + page.set_content(harness) + if SCREENSHOT_PATH: + page.screenshot(path=SCREENSHOT_PATH) + page.set_viewport_size({"width": 1024, "height": 1400}) + buttons = page.locator(".workspace-artifact-item") + assert buttons.count() == len(ARTIFACT_CASES) + assert page.locator(".workspace-artifact-meta").all_text_contents() == [ + "patch", "tool", "single", "session", "root", "trail", "empty", "gap", "literal", "&", "session" + ] + if EXPECT_VISIBLE: + assert page.locator(".workspace-artifact-meta[data-i18n='workspace_artifact_source_session']").count() == 2 + for index, case in enumerate(ARTIFACT_CASES): + button = buttons.nth(index) + assert button.get_attribute("data-artifact-path") == case["path"] + assert button.get_attribute("title") == case["display"] + assert button.locator(".workspace-artifact-filename").inner_text() == case["name"] + directory = button.locator(".workspace-artifact-directory") + if case["head"] or case["tail"]: + assert directory.count() == 1 + assert button.locator(".workspace-artifact-directory-head").inner_text() == case["head"] + assert button.locator(".workspace-artifact-directory-tail").inner_text() == case["tail"] + else: + assert directory.count() == 0 + assert "" in buttons.nth(9).get_attribute("title") + else: + assert page.locator(".workspace-artifact-meta[data-i18n='workspace_artifact_source_session']").count() == 0 + assert page.locator(".workspace-artifact-path").all_text_contents() == [ + case["display"] for case in ARTIFACT_CASES + ] + + for width in (180, 360, 519, 521): + page.locator("#workspaceArtifacts").evaluate("(node, nextWidth) => node.style.width = nextWidth + 'px'", width) + if EXPECT_VISIBLE: + for index, case in enumerate(ARTIFACT_CASES): + if case["name"]: + assert _filename_visible(buttons.nth(index).locator(".workspace-artifact-filename")) + assert buttons.nth(4).locator(".workspace-artifact-directory-head").inner_text() == "/" + assert buttons.nth(7).locator(".workspace-artifact-directory-tail").inner_text() == "/" + deep_head = buttons.nth(3).locator(".workspace-artifact-directory-head") + assert deep_head.evaluate("node => node.scrollWidth >= node.clientWidth") + if width in (360, 521): + short_directory = buttons.nth(1).locator(".workspace-artifact-directory") + short_head = buttons.nth(1).locator(".workspace-artifact-directory-head") + assert short_head.evaluate("node => node.scrollWidth <= node.clientWidth + 1") + assert _directory_text_gap(short_directory) <= 2 + elif width == 180: + clipped_path = buttons.nth(1).locator(".workspace-artifact-path") + assert not _path_suffix_visible(clipped_path, "users.py") + assert_layout_sane(page, "#workspaceArtifacts", checks=["overlap", "container-escape", "degenerate", "raw-string"]) + + page.set_viewport_size({"width": 390, "height": 844}) + page.locator(".issue6067-proof-panel").evaluate( + "node => { node.style.width = '300px'; node.classList.add('mobile-open'); }" + ) + page.locator("#workspaceArtifacts").evaluate("node => node.style.removeProperty('width')") + long_row = buttons.nth(10) + long_tail = long_row.locator(".workspace-artifact-directory-tail") + for selector in ("#workspaceArtifacts", ".workspace-artifact-item"): + assert page.locator(selector).nth(10 if selector != "#workspaceArtifacts" else 0).evaluate( + "node => node.scrollWidth === node.clientWidth" + ) + assert long_row.locator(".workspace-artifact-filename").inner_text() == "summary.json" + assert _filename_visible(long_row.locator(".workspace-artifact-filename")) + assert long_tail.evaluate( + "node => node.scrollWidth > node.clientWidth && getComputedStyle(node).textOverflow === 'ellipsis'" + ) + assert_layout_sane(page, "#workspaceArtifacts", checks=["overlap", "clip", "container-escape", "degenerate", "raw-string"]) + + for index in range(buttons.count()): + buttons.nth(index).focus() + assert page.evaluate("() => document.activeElement.classList.contains('workspace-artifact-item')") + buttons.nth(index).click() + assert page.evaluate("opened") == [case["path"] for case in ARTIFACT_CASES] + browser.close() diff --git a/tests/test_japanese_locale.py b/tests/test_japanese_locale.py index e6783451..811f31f1 100644 --- a/tests/test_japanese_locale.py +++ b/tests/test_japanese_locale.py @@ -14,7 +14,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_korean_locale.py b/tests/test_korean_locale.py index 3c7ced76..40d374bc 100644 --- a/tests/test_korean_locale.py +++ b/tests/test_korean_locale.py @@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_polish_locale.py b/tests/test_polish_locale.py index 22abef9c..f3c583f8 100644 --- a/tests/test_polish_locale.py +++ b/tests/test_polish_locale.py @@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_russian_locale.py b/tests/test_russian_locale.py index 0e751f0c..a060081c 100644 --- a/tests/test_russian_locale.py +++ b/tests/test_russian_locale.py @@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_spanish_locale.py b/tests/test_spanish_locale.py index 39635c12..29d33ab3 100644 --- a/tests/test_spanish_locale.py +++ b/tests/test_spanish_locale.py @@ -4,7 +4,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_turkish_locale.py b/tests/test_turkish_locale.py index 5ece496b..a111e257 100644 --- a/tests/test_turkish_locale.py +++ b/tests/test_turkish_locale.py @@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_vietnamese_locale.py b/tests/test_vietnamese_locale.py index 577cc667..1c86cba6 100644 --- a/tests/test_vietnamese_locale.py +++ b/tests/test_vietnamese_locale.py @@ -5,7 +5,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: diff --git a/tests/test_zh_hant_locale.py b/tests/test_zh_hant_locale.py index bfa3a76b..29f53622 100644 --- a/tests/test_zh_hant_locale.py +++ b/tests/test_zh_hant_locale.py @@ -4,7 +4,10 @@ from tests.test_issue2147_profile_concept_help import PROFILE_CONCEPT_KEYS REPO = Path(__file__).resolve().parent.parent -PROFILE_CONCEPT_FALLBACK_KEYS = set(PROFILE_CONCEPT_KEYS) +PROFILE_CONCEPT_FALLBACK_KEYS = { + *PROFILE_CONCEPT_KEYS, + "workspace_artifact_source_session", +} def read(path: Path) -> str: From c0d9d815127c456764be9fdc3fe7aa8bed913dd2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 20 Jul 2026 21:45:11 -0700 Subject: [PATCH 34/82] Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) (#6387) * feat: add option to hide new-chat welcome panel * Release: add preference to hide new-chat welcome panel (#6183, @vaidu-ai) --------- Co-authored-by: vaidu-ai Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/config.py | 2 + static/boot.js | 9 ++ static/i18n.js | 30 ++++ static/index.html | 7 + static/panels.js | 17 +++ static/style.css | 1 + .../test_issue6160_hide_empty_state_panel.py | 128 ++++++++++++++++++ 8 files changed, 196 insertions(+) create mode 100644 tests/test_issue6160_hide_empty_state_panel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c34c5f8..61bd5d9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed +- **New chat: you can now hide the entire welcome panel, not just the suggestions.** A new opt-in Settings → Preferences toggle, "Hide new-chat welcome panel," hides the whole empty-state block (logo, heading, subtitle, and suggestion buttons) on an empty new conversation while keeping the composer available and focused. It extends the existing "hide new-chat suggestions" preference with the same persisted, hot-applied pattern; default is unchanged (panel shown). Thanks @vaidu-ai. (#6183, #6160) + - **Workspace Artifacts pane: long filenames are fully readable and long parent directories stay inside the drawer.** A long artifact filename used to be ellipsized (so you couldn't tell files apart), and an unusually long immediate-parent directory could expand the Artifacts drawer horizontally and bleed past its edge. The list now shows the filename first with parent-preserving path truncation, and the directory tail is width-capped (`min-width: 0` + `overflow: hidden` + `text-overflow: ellipsis`) so it clips inside the card instead of blowing out the drawer. Adds behavioral/locale coverage for the artifact path lifecycle. Thanks @rodboev. (#6078, #6067) - **The Kanban "New task" modal is reachable on phones again.** On a narrow viewport, a tall create-task modal was vertically centered, pushing its top (the title and first input) above the top of the screen with no way to scroll back to it. The mobile overlay now uses `align-items: safe center` and `overflow-y: auto`, so an overflowing modal keeps its top on-screen and scrolls as needed; desktop and wider layouts are unchanged. Thanks @jpalazz2. (#6301) diff --git a/api/config.py b/api/config.py index 17010116..015bb870 100644 --- a/api/config.py +++ b/api/config.py @@ -9139,6 +9139,7 @@ _SETTINGS_DEFAULTS = { "show_conversation_outline": False, # show opt-in desktop jump-to-question outline panel "show_busy_placeholder_hint": False, # opt-in busy composer placeholder hint "hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons + "hide_empty_state_panel": False, # hide the complete new-chat welcome panel "new_chat_on_workspace_switch": False, # #5473 opt-in: switching to a DIFFERENT workspace starts a new chat (leaving the current conversation on its original workspace) instead of mutating the current session's workspace in place. Default OFF preserves the shipped in-place-switch behavior. "virtualize_transcript": False, # #4343: virtualize long (>80 msg) transcripts. EXPERIMENTAL, opt-IN (default OFF). Was opt-out/default-on in #4325 but caused scroll-up flicker on long sessions with tall tool-call rows (variable-height anchor oscillation) — flipped off for everyone in #4343; re-enabling requires an explicit opt-in (see virtualize_transcript_optin migration in load_settings). "virtualize_transcript_optin": False, # #4343 migration marker: True only once the user explicitly enables virtualize_transcript AFTER the default-off flip. A stored virtualize_transcript=True WITHOUT this marker is a stale pre-flip value and is reset to False on load (force-off-for-everyone migration). @@ -9461,6 +9462,7 @@ _SETTINGS_BOOL_KEYS = { "show_conversation_outline", "show_busy_placeholder_hint", "hide_empty_state_suggestions", + "hide_empty_state_panel", "new_chat_on_workspace_switch", "virtualize_transcript", "virtualize_transcript_optin", diff --git a/static/boot.js b/static/boot.js index 3abfff6a..1e79ac4e 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2538,6 +2538,11 @@ function applyEmptyStateSuggestionPref(){ $('emptyState').classList.toggle('no-suggestions',window._hideEmptyStateSuggestions===true); } +function applyEmptyStatePanelPref(){ + if(!$('emptyState')) return; + $('emptyState').classList.toggle('no-welcome',window._hideEmptyStatePanel===true); +} + window.addEventListener('resize',()=>{ _syncWorkspacePanelInlineWidth(); syncWorkspacePanelState(); @@ -3224,6 +3229,8 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true; applyEmptyStateSuggestionPref(); + window._hideEmptyStatePanel=s.hide_empty_state_panel===true; + applyEmptyStatePanelPref(); // #4343: transcript virtualization is EXPERIMENTAL/opt-IN (default OFF). // #4346 Phase B (footer-jitter suppression during virtual-scroll // measurement re-renders) resolved the scroll-up flicker root cause, @@ -3381,6 +3388,8 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; if(typeof applyConversationOutlinePreference==='function') applyConversationOutlinePreference(); window._hideEmptyStateSuggestions=false; applyEmptyStateSuggestionPref(); + window._hideEmptyStatePanel=false; + applyEmptyStatePanelPref(); window._virtualizeTranscript=false; // settings-load failed: default-OFF (experimental/opt-in) (#4343) window._showTps=false; window._fadeTextEffect=false; diff --git a/static/i18n.js b/static/i18n.js index 57822182..54b34bbc 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -875,6 +875,8 @@ const LOCALES = { settings_desc_conversation_outline: 'Show a desktop-only jump list for user questions in the current conversation. Off by default.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', + settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel', + settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.', settings_label_virtualize_transcript: 'Virtualize long transcripts (experimental)', settings_desc_virtualize_transcript: "Experimental. Renders only the visible portion of very long chat transcripts (over 80 messages) to speed up scrolling. Off by default because it can cause scroll flicker on long sessions with large tool-call blocks. Turn it on to try it; leave it off for the full transcript to render at once (and so the browser's Find (Ctrl+F) matches the whole conversation).", settings_label_token_usage: 'Show token usage', @@ -2632,6 +2634,8 @@ const LOCALES = { settings_desc_conversation_outline: 'Mostra un elenco di salto solo desktop per le domande utente nella conversazione corrente. Disattivato per impostazione predefinita.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', + settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel', + settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.', settings_label_virtualize_transcript: 'Virtualize long transcripts', settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", settings_label_token_usage: 'Mostra uso token', @@ -4372,6 +4376,8 @@ const LOCALES = { settings_desc_conversation_outline: '現在の会話内のユーザー質問へ移動するデスクトップ専用リストを表示します。デフォルトはオフです。', settings_label_hide_suggestions: '新規チャットの候補を非表示', settings_desc_hide_suggestions: '空の新規チャット画面に表示されるデフォルトの候補ボタン3つを非表示にして、誤タップを防ぎます。', + settings_label_hide_empty_state_panel: '新規チャットのウェルカムパネルを非表示', + settings_desc_hide_empty_state_panel: '空の新規会話でロゴ、見出し、サブタイトル、候補ボタンを非表示にします。', settings_label_virtualize_transcript: 'Virtualize long transcripts', settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", settings_label_token_usage: 'トークン使用量を表示', @@ -5816,6 +5822,8 @@ const LOCALES = { settings_desc_conversation_outline: 'Показывает на рабочем столе список переходов к вопросам пользователя в текущей беседе. По умолчанию отключено.', settings_label_hide_suggestions: 'Скрывать подсказки нового чата', settings_desc_hide_suggestions: 'Скрывает три стандартные кнопки-подсказки на пустом экране нового чата, чтобы избежать случайных нажатий.', + settings_label_hide_empty_state_panel: 'Скрывать приветственную панель нового чата', + settings_desc_hide_empty_state_panel: 'Скрывает логотип, заголовок, подзаголовок и кнопки-подсказки в пустой новой беседе.', settings_label_virtualize_transcript: 'Оптимизировать длинные транскрипты', settings_desc_virtualize_transcript: 'Отрисовывает только видимую часть очень длинных транскриптов чата (более 80 сообщений), чтобы прокрутка была плавнее. Отключите, если заметите проблемы с отрисовкой или прокруткой либо хотите, чтобы поиск браузера (Ctrl+F) находил текст во всей беседе — тогда транскрипт будет отрисован целиком.', settings_label_token_usage: 'Показывать использование токенов', @@ -7509,6 +7517,8 @@ const LOCALES = { settings_desc_conversation_outline: 'Muestra una lista de salto solo en escritorio para las preguntas del usuario en la conversación actual. Desactivado de forma predeterminada.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', + settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel', + settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.', settings_label_virtualize_transcript: 'Virtualize long transcripts', settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", settings_label_token_usage: 'Mostrar uso de tokens', @@ -9171,6 +9181,8 @@ const LOCALES = { settings_desc_conversation_outline: 'Zeigt eine Desktop-Sprungliste für Benutzerfragen in der aktuellen Konversation. Standardmäßig deaktiviert.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', + settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel', + settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.', settings_label_virtualize_transcript: 'Virtualize long transcripts', settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", settings_label_token_usage: 'Token-Verbrauch anzeigen', @@ -10894,6 +10906,8 @@ const LOCALES = { settings_desc_conversation_outline: '显示仅限桌面的跳转列表,用于跳转到当前会话中的用户问题。默认关闭。', settings_label_hide_suggestions: '隐藏新聊天建议', settings_desc_hide_suggestions: '隐藏新聊天空白界面上的三个默认建议按钮,以避免误触。', + settings_label_hide_empty_state_panel: '隐藏新聊天欢迎面板', + settings_desc_hide_empty_state_panel: '隐藏空白新对话中的徽标、标题、副标题和建议按钮。', settings_label_virtualize_transcript: '对长篇聊天记录进行虚拟处理', settings_desc_virtualize_transcript: "对于非常长的聊天记录(超过80条消息),仅渲染可见部分,以实现更流畅的滚动。如果遇到任何渲染或滚动问题,或者希望浏览器的查找(Ctrl + F)匹配整个对话,请关闭此功能 \u2014 这样完整的聊天记录将一次性渲染出来。", settings_label_token_usage: '显示 token 用量', @@ -12870,6 +12884,8 @@ const LOCALES = { settings_desc_conversation_outline: '顯示僅限桌面的跳轉清單,用於跳轉到目前對話中的使用者問題。預設關閉。', settings_label_hide_suggestions: '隱藏新聊天建議', settings_desc_hide_suggestions: '隱藏空白新聊天畫面上的三個預設建議按鈕,避免誤觸。', + settings_label_hide_empty_state_panel: '隱藏新聊天歡迎面板', + settings_desc_hide_empty_state_panel: '隱藏空白新對話中的標誌、標題、副標題和建議按鈕。', settings_label_virtualize_transcript: 'Virtualize long transcripts', settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", settings_label_token_usage: '顯示 token 用量', @@ -14503,6 +14519,8 @@ const LOCALES = { settings_desc_conversation_outline: 'Mostra uma lista de atalhos somente no desktop para as perguntas do usuário na conversa atual. Desativado por padrão.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', + settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel', + settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.', settings_label_virtualize_transcript: 'Virtualize long transcripts', settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", settings_label_token_usage: 'Mostrar uso de tokens', @@ -16142,6 +16160,8 @@ const LOCALES = { settings_desc_conversation_outline: '현재 대화의 사용자 질문으로 이동하는 데스크톱 전용 목록을 표시합니다. 기본값은 꺼짐입니다.', settings_label_hide_suggestions: 'Hide new-chat suggestions', settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.', + settings_label_hide_empty_state_panel: 'Hide new-chat welcome panel', + settings_desc_hide_empty_state_panel: 'Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.', settings_label_virtualize_transcript: 'Virtualize long transcripts', settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", settings_label_token_usage: '토큰 사용량 표시', @@ -17896,6 +17916,8 @@ const LOCALES = { settings_desc_conversation_outline: 'Afficher une liste de saut réservée au bureau pour les questions utilisateur dans la conversation actuelle. Désactivé par défaut.', settings_label_hide_suggestions: 'Masquer les suggestions de nouvelle conversation', settings_desc_hide_suggestions: 'Masquer les trois boutons de suggestion par défaut sur l\'écran de nouvelle conversation vide pour éviter les clics accidentels.', + settings_label_hide_empty_state_panel: 'Masquer le panneau de bienvenue des nouvelles conversations', + settings_desc_hide_empty_state_panel: 'Masquer le logo, le titre, le sous-titre et les boutons de suggestion dans une nouvelle conversation vide.', settings_label_virtualize_transcript: 'Virtualiser les longs historiques (expérimental)', settings_desc_virtualize_transcript: 'Expérimental. Affiche uniquement la partie visible des historiques de conversation très longs (plus de 80 messages) pour accélérer le défilement. Désactivé par défaut car cela peut causer des scintillements lors du défilement sur de longues sessions avec de gros blocs d\'appels d\'outils. Activez-le pour essayer ; désactivez-le pour afficher l\'intégralité de l\'historique à la fois (et pour que la recherche du navigateur (Ctrl+F) corresponde à toute la conversation).', settings_label_token_usage: 'Afficher l\'utilisation des jetons', @@ -19741,6 +19763,8 @@ const LOCALES = { settings_label_font_size: 'Velikost písma', settings_label_gateway_status: 'Stav Gateway', settings_label_hide_suggestions: 'Skrýt návrhy nového chatu', + settings_label_hide_empty_state_panel: 'Skrýt uvítací panel nového chatu', + settings_desc_hide_empty_state_panel: 'Skrýt logo, nadpis, podnadpis a tlačítka návrhů v prázdné nové konverzaci.', settings_label_ignore_agent_updates: 'Ignorovat aktualizace Agenta', settings_label_large_text_paste_as_attachment: 'Přiložit velký vložený text jako soubor', settings_label_notifications: 'Notifikace prohlížeče', @@ -21302,6 +21326,8 @@ const LOCALES = { settings_label_conversation_outline: 'Konuşma ana hattını göster', settings_desc_conversation_outline: 'Geçerli konuşmadaki kullanıcı soruları için yalnızca masaüstünde görünen bir atlama listesi gösterir. Varsayılan kapalıdır.', settings_label_hide_suggestions: 'Yeni sohbet önerilerini gizle', + settings_label_hide_empty_state_panel: 'Yeni sohbet karşılama panelini gizle', + settings_desc_hide_empty_state_panel: 'Boş bir yeni konuşmadaki logoyu, başlığı, alt başlığı ve öneri düğmelerini gizleyin.', settings_desc_hide_suggestions: 'Yanlışlıkla dokunmayı önlemek için boş yeni sohbet ekranındaki üç varsayılan öneri düğmesini gizleyin.', settings_label_virtualize_transcript: 'Virtualize long transcripts', settings_desc_virtualize_transcript: "Render only the visible portion of very long chat transcripts (over 80 messages) for smoother scrolling. Turn this off if you see any rendering or scroll issues, or want the browser's Find (Ctrl+F) to match the whole conversation \u2014 the full transcript will render at once.", @@ -23057,6 +23083,8 @@ const LOCALES = { settings_desc_conversation_outline: 'Pokazuje desktopową listę skrótów do pytań użytkownika w bieżącej konwersacji. Domyślnie wyłączone.', settings_label_hide_suggestions: 'Ukryj sugestie nowej konwersacji', settings_desc_hide_suggestions: 'Ukryj trzy domyślne przyciski sugestii na pustym ekranie nowej konwersacji, aby zapobiec przypadkowym kliknięciom.', + settings_label_hide_empty_state_panel: 'Ukryj panel powitalny nowej konwersacji', + settings_desc_hide_empty_state_panel: 'Ukryj logo, nagłówek, podtytuł i przyciski sugestii w pustej nowej konwersacji.', settings_label_virtualize_transcript: 'Wirtualizuj długie transkrypcje', settings_desc_virtualize_transcript: "Renderuj tylko widoczną część bardzo długich zapisów czatu (ponad 80 wiadomości) dla płynniejszego przewijania. Wyłącz to, jeśli zauważysz jakiekolwiek problemy z renderowaniem lub przewijaniem, lub chcesz, aby wyszukiwanie w przeglądarce (Ctrl+F) obejmowało całą konwersację \u2014 pełny zapis wyrenderuje się od razu.", settings_label_token_usage: 'Pokaż zużycie tokenów', @@ -24632,6 +24660,8 @@ const LOCALES = { settings_label_conversation_outline: 'Hiện mục lục cuộc trò chuyện', settings_desc_conversation_outline: 'Hiển thị danh sách nhảy nhanh cho các câu hỏi của người dùng trong cuộc trò chuyện hiện tại trên desktop. Mặc định tắt.', settings_label_hide_suggestions: 'Ẩn gợi ý chat mới', + settings_label_hide_empty_state_panel: 'Ẩn bảng chào mừng cuộc trò chuyện mới', + settings_desc_hide_empty_state_panel: 'Ẩn biểu trưng, tiêu đề, phụ đề và các nút gợi ý trong cuộc trò chuyện mới trống.', settings_desc_hide_suggestions: 'Ẩn ba nút gợi ý mặc định trên màn hình chat mới để tránh bấm nhầm.', settings_label_token_usage: 'Hiện mức sử dụng token', settings_label_token_speed: 'Hiện tốc độ token (TPS)', diff --git a/static/index.html b/static/index.html index 6844ae21..98afb48d 100644 --- a/static/index.html +++ b/static/index.html @@ -1176,6 +1176,13 @@
Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.
+
+ +
Hide the logo, heading, subtitle, and suggestion buttons on an empty new conversation.
+
- +