Files
hermes-webui/tests/test_issue6853_docker_agents_context.py
T
8686528b1b Release batch A: 6 low-risk gate-passed fixes (experimental) (#7089)
* fix(goal): suppress reserved SILENT sentinel at /api/goal ingress

A wake relay POSTing the exact [SILENT] suppression sentinel to
/api/goal lets it reach _start_chat_stream_for_session, which persists
it as pending_user_message. If 8701 restarts while the turn is pending,
session recovery materializes it as a visible _recovered user turn —
the same phantom-recovered-turn exposure #7018 closed for chat ingress.

Apply the shared _is_silent_control_message() guard immediately after
/api/goal session-ID validation and before session lookup or goal-state
mutation, returning the same 200 no-op. Matching stays exact and
case-sensitive; ordinary kickoff text is unaffected.

Add tests mirroring test_silent_control_suppression.py for the goal
path (args and text fields, before-lookup suppression, exact-match
semantics).

Closes #7019

* fix(sessions): keep evicted subagent parents in the import window

The sidebar nests a subagent row under its parent only when the parent row
is present in the same payload. The visible-window limit was applied as a
flat per-row recency slice, so a frozen orchestrator (which stops writing
while its leaves keep streaming) lost the recency race against its own
leaves and fell outside the window -- promoting those leaves to top-level
sidebar rows.

Re-add subagent parents that the oversampled candidate set already
projected, after the slice. No extra queries, no change to
CLI_VISIBLE_SESSION_LIMIT. webui ancestors are deliberately not imported
because that sidebar bucket already owns them.

Supersedes #7031.

* fix(sessions): document and pin the parent-recovery bound (greptile review)

Greptile flagged (P1) that a selected subagent child whose parent ranks below
the limit * 8 oversample is still promoted to a top-level row. That is real and
measured (the parent drops out at candidate #25 of 24), but it is the bound of
the design, not a regression: the walk reuses rows the projection already
fetched and never issues an extra query. Resolving arbitrarily old ancestors
needs an unbounded per-row lookup on the hot sidebar path -- the approach
rejected in #7031 -- so the bound is documented and pinned instead.

- Docstring: state that `limit` bounds the recency slice, not the row count,
  so callers must iterate rather than assume len(rows) <= limit; state that
  recovery is bounded by the oversampled candidate set.
- Inline comment: mark the bound at the exact lines Greptile flagged and point
  at `candidate_limit` as the knob if the window proves too tight.
- Tests: cover the candidate-window exhaustion Greptile said was untested --
  parent inside the oversample is recovered, parent beyond it stays unresolved
  -- plus the over-limit return contract and a parent-cycle guard.

No behaviour change; 7 tests pass.

* fix(share): add _hadAppearance guard to prevent fabricated appearance choice

Summary:
The inline appearance bootstrap in static/share.html writes hermes-theme and
hermes-skin to localStorage unconditionally on every page load, even when the
browser had no prior appearance state. This fabricates an explicit user choice
on first access via a shared link, making the server-side SETTINGS_DEFAULTS
unreachable for deployments that customise the default theme or skin.

Root Cause:
share.html:9 — the boot IIFE resolves a theme+skin and calls
localStorage.setItem() without guarding on whether the user had previously
chosen an appearance. The same bug was fixed in index.html by PR #6808
(commit tomtong2015) but share.html was left unchanged.

Change:
1. Added _hadAppearance guard before the two localStorage.setItem() calls:
   var _hadAppearance = localStorage.getItem('hermes-theme') !== null ||
                        localStorage.getItem('hermes-skin') !== null;
   if (_hadAppearance) { setItem('hermes-theme', t); setItem('hermes-skin', s); }
2. The first-paint DOM mutations (classList.add('dark'), dataset.skin) remain
   outside the guard — only persistence is protected.
3. Synced the skin allowlist with index.html: added neon-soft and neon-paint
   (zeus and verdigris were already present).

Verification:
- test_6808_appearance_bootstrap_no_fabricated_choice.py: 11/11 passed
  covering fresh-browser (no writes), pre-paint fallback, explicit state
  normalisation, and legacy migration survival.

Closes #7030

* fix(tests): pin rebuild budget in issue2513 custom-provider catalog test

test (3.13, 4) failed on this PR with:

  assert "@custom:alpha-proxy:alpha/remote" in alpha_ids
  E  AssertionError: assert '@custom:alpha-proxy:alpha/remote' in {'alpha/sticky'}
  WARNING api.config:config.py:8355 live provider-catalog rebuild exceeded
          4.0s budget - serving fallback, refreshing catalog out-of-band

Pre-existing wall-clock flake, not a regression from this PR: this branch
touches only api/agent_sessions.py and tests/test_subagent_parent_in_import_
window.py, and both api/config.py and this test file are byte-identical to
origin/master. The same shard passed on 3.11 and 3.12.

The test never pinned _LIVE_REBUILD_BUDGET_SECONDS, so it raced the global
4s budget in get_available_models(). On a starved runner the cold rebuild
overruns, the degraded fallback catalog is served, and the monkeypatched-
urlopen model alpha/remote is dropped - leaving only the config-declared
sticky model, exactly as CI observed.

Force the synchronous (unbounded) rebuild path, matching the existing
precedent in tests/test_issue2540_models_endpoint_error.py:20-24.

Verified: with the budget forced to 0.001s the unpatched test reproduces
the CI assertion verbatim; patched it passes at 0.001s, 0, and default.

* fix(#7013): preserve media deny coverage across platforms

* fix(docker): keep repository agents out of runtime context

* fix(docker): gate image runtime proof behind integration job

* test(models): stabilize custom provider catalog regression

* Release batch A: 6 low-risk gate-passed fixes (experimental)

Batched contributor fixes, each individually Codex-gated during the overnight
certifier cycles and re-verified clean-to-ship as a combined stage (Codex SAFE
TO SHIP on the combined diff; full suite green except 8 pre-existing approval
tests that fail identically on clean origin/master — CI green on same commit).

- #7019 (@webtecnica) suppress reserved [SILENT] sentinel at /api/goal
- #7031 (@carlotestor) keep evicted subagent parents in the sidebar import window
- #7030 (@webtecnica) share.html first-visit appearance guard + skin-id sync
- #6853 (@rodboev) exclude repo-root AGENTS.md from the Docker runtime image
- #7013 (@webtecnica) test-only: platform-neutral test portability (playwright
  import guard + media tests served from allowed roots)
- test-only (@carlotestor) pin custom-provider catalog rebuild budget (#7054)

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: carlotestor <carlotestor@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>

---------

Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: carlotestor <carlotestor@users.noreply.github.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: n <a@n>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-08-16 21:20:56 +00:00

115 lines
4.0 KiB
Python

"""Regression coverage for #6853: keep repository instructions out of the image."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import pytest
REPO = Path(__file__).resolve().parents[1]
def _dockerignore_rules() -> set[str]:
return {
line.strip()
for line in (REPO / ".dockerignore").read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
def _excluded_by_root_rule(path: str, rules: set[str]) -> bool:
"""Model the exact root-only rule used for this context boundary."""
return "/AGENTS.md" in rules and path == "AGENTS.md"
def test_repository_agents_file_is_excluded_from_docker_context():
"""The repository instruction file must not enter the runtime image."""
assert _excluded_by_root_rule("AGENTS.md", _dockerignore_rules())
def test_workspace_agents_file_is_not_recursively_excluded():
"""A selected workspace may still provide its own Agent instructions."""
assert not _excluded_by_root_rule("workspace/AGENTS.md", _dockerignore_rules())
def test_dockerfile_copies_context_to_apptoo_before_runtime_seed():
"""The image source and runtime seed source must share the filtered tree."""
dockerfile = (REPO / "Dockerfile").read_text(encoding="utf-8")
copy_lines = {
line.strip()
for line in dockerfile.splitlines()
if line.lstrip().startswith("COPY ")
}
assert "COPY --chown=root:root . /apptoo" in copy_lines
assert "COPY --chown=root:root . /app" not in copy_lines
def test_docker_init_has_both_filtered_context_seed_paths():
"""Both startup modes must seed /app from the Dockerfile context copy."""
init = (REPO / "docker_init.bash").read_text(encoding="utf-8")
assert "rsync -av --chown=hermeswebui:hermeswebui /apptoo/ /app/" in init
assert "cp -a /apptoo/. /app/" in init
assert "HERMES_WEBUI_DEFAULT_WORKSPACE=\"/workspace\"" in init
assert "cd /app" in init
def _docker_runtime_available() -> bool:
docker = shutil.which("docker")
if docker is None:
return False
result = subprocess.run(
[docker, "info"],
capture_output=True,
text=True,
check=False,
timeout=15,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return result.returncode == 0
@pytest.mark.skipif(
not os.environ.get("HERMES_RUN_DOCKER_RUNTIME_TESTS") or not _docker_runtime_available(),
reason="Docker runtime proof is opt-in for the CI integration job",
)
@pytest.mark.timeout(900)
def test_docker_runtime_keeps_app_clean_in_both_seed_modes_and_workspace_intact(tmp_path):
"""Build the image and exercise the exact normal/rootless seed destinations."""
docker = shutil.which("docker")
assert docker is not None
tag = f"hermes-webui-pr-6853-{os.getpid()}"
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "AGENTS.md").write_text("workspace instructions\n", encoding="utf-8")
try:
subprocess.run(
[docker, "build", "--progress=plain", "-t", tag, "."],
check=True,
timeout=900,
)
for user in (None, "1000:1000"):
command = (
"set -eu; rm -rf /app/* /app/.[!.]* /app/..?*; "
+ (
"rsync -a --chown=hermeswebui:hermeswebui /apptoo/ /app/"
if user is None
else "cp -a /apptoo/. /app/"
)
+ "; test -f /app/server.py; test ! -e /app/AGENTS.md; "
"test -f /workspace/AGENTS.md"
)
args = [docker, "run", "--rm", "--mount", f"type=bind,source={workspace},target=/workspace"]
if user is not None:
args.extend(["--user", user])
args.extend(["--entrypoint", "bash", tag, "-c", command])
subprocess.run(args, check=True, timeout=60)
finally:
subprocess.run([docker, "rmi", "-f", tag], check=False, timeout=60)