* 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>
115 lines
4.0 KiB
Python
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)
|