From 7d98f539a05bc31c8ca54c55453225b50cfdded1 Mon Sep 17 00:00:00 2001 From: jenkins Date: Sun, 9 Aug 2026 15:52:06 -0300 Subject: [PATCH] fix(hermes): make browser terminal copy reliable --- services/hermes/agent-deployment.yaml | 28 +++- services/hermes/kustomization.yaml | 1 + services/hermes/scripts/patch_ttyd_index.py | 176 ++++++++++++++++++++ testing/tests/test_hermes_herdr.py | 27 +++ 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 services/hermes/scripts/patch_ttyd_index.py diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 5be29eec9..633e1e5c7 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -24,7 +24,7 @@ spec: ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available - ai.bstein.dev/config-rev: "20260809-cassandra-readonly" + ai.bstein.dev/config-rev: "20260809-terminal-clipboard" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens @@ -269,6 +269,27 @@ spec: resources: requests: {cpu: 25m, memory: 32Mi} limits: {cpu: 250m, memory: 128Mi} + - name: prepare-ttyd-index + image: registry.bstein.dev/bstein/hermes-agent@sha256:7a1daefae2f068dcf14e7eb1f2f9aab2e1ce79c1b55b4bb0aa1092fbf4019bbc + imagePullPolicy: IfNotPresent + command: + - /opt/hermes/.venv/bin/python + - /opt/coordinator/patch_ttyd_index.py + - /opt/data/tools/bin/ttyd + - /ttyd-index/index.html + securityContext: + allowPrivilegeEscalation: false + runAsUser: 10000 + runAsGroup: 10000 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - {name: home, mountPath: /opt/data} + - {name: coordinator, mountPath: /opt/coordinator, readOnly: true} + - {name: ttyd-index, mountPath: /ttyd-index} + resources: + requests: {cpu: 25m, memory: 32Mi} + limits: {cpu: 250m, memory: 128Mi} containers: - name: hermes image: registry.bstein.dev/bstein/hermes-agent@sha256:7a1daefae2f068dcf14e7eb1f2f9aab2e1ce79c1b55b4bb0aa1092fbf4019bbc @@ -401,6 +422,7 @@ spec: --port 7681 \ --cwd /opt/data/workspace \ --terminal-type xterm-256color \ + --index /ttyd-index/index.html \ --client-option "titleFixed=Hermes Agent - HERDR" \ --client-option fontSize=15 \ /bin/sh -c ' @@ -423,6 +445,7 @@ spec: volumeMounts: - {name: home, mountPath: /opt/data} - {name: tmp, mountPath: /tmp} + - {name: ttyd-index, mountPath: /ttyd-index, readOnly: true} startupProbe: tcpSocket: {port: herdr-tui} periodSeconds: 5 @@ -603,3 +626,6 @@ spec: - name: tmp emptyDir: sizeLimit: 256Mi + - name: ttyd-index + emptyDir: + sizeLimit: 2Mi diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index 64740d87d..49251205c 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -50,6 +50,7 @@ configMapGenerator: - hermes_coordinator.py=scripts/hermes_coordinator.py - hermes_model_routing.py=scripts/hermes_model_routing.py - patch_hermes_auth.py=scripts/patch_hermes_auth.py + - patch_ttyd_index.py=scripts/patch_ttyd_index.py options: disableNameSuffixHash: true - name: hermes-agent-kubeconfig diff --git a/services/hermes/scripts/patch_ttyd_index.py b/services/hermes/scripts/patch_ttyd_index.py new file mode 100644 index 000000000..cb4a293c8 --- /dev/null +++ b/services/hermes/scripts/patch_ttyd_index.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Add reliable browser clipboard handling to ttyd's pinned client page.""" + +from __future__ import annotations + +import argparse +import socket +import subprocess +import time +import urllib.request +from pathlib import Path + + +MARKER = "atlas-ttyd-clipboard" +UPSTREAM_COPY = 'document.execCommand("copy")' +CLIPBOARD_ADAPTER = r""" + + +""" + + +def patch_html(content: str) -> str: + """Insert the adapter once and disable ttyd's false-success copy call.""" + if content.count(UPSTREAM_COPY) != 1 or content.count("") != 1: + raise RuntimeError("ttyd index patch context changed") + if MARKER in content: + raise RuntimeError("ttyd index is already patched") + content = content.replace(UPSTREAM_COPY, "void 0", 1) + return content.replace("", f"{CLIPBOARD_ADAPTER}", 1) + + +def fetch_embedded_index(ttyd: Path) -> str: + """Serve and retrieve ttyd's embedded page without vendoring its bundle.""" + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + process = subprocess.Popen( + [ + str(ttyd), + "--interface", + "127.0.0.1", + "--port", + str(port), + "/bin/sh", + "-c", + "sleep 30", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + for _ in range(100): + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{port}/", timeout=1 + ) as response: + return response.read().decode("utf-8") + except OSError: + if process.poll() is not None: + raise RuntimeError("temporary ttyd exited before serving its index") + time.sleep(0.05) + raise RuntimeError("timed out retrieving ttyd's embedded index") + finally: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) + + +def main() -> int: + """Generate one patched index for the ttyd sidecar.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ttyd", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + content = patch_html(fetch_embedded_index(args.ttyd)) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(content, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/testing/tests/test_hermes_herdr.py b/testing/tests/test_hermes_herdr.py index 0b0ca1931..69aea9717 100644 --- a/testing/tests/test_hermes_herdr.py +++ b/testing/tests/test_hermes_herdr.py @@ -26,6 +26,7 @@ def _load(name: str): dispatch = _load("herdr_dispatch") tab_router = _load("herdr_tab_router") auth_patch = _load("patch_hermes_auth") +ttyd_patch = _load("patch_ttyd_index") def test_herdr_plan_chooses_task_shape_and_caps_effort(): @@ -132,6 +133,25 @@ def test_auth_patch_fails_closed_on_upstream_drift(tmp_path: Path): auth_patch.patch(source, tmp_path / "patched.py") +def test_ttyd_clipboard_patch_uses_system_clipboard_and_preserves_interrupt(): + source = ( + '' + ) + content = ttyd_patch.patch_html(source) + + assert 'id="atlas-ttyd-clipboard"' in content + assert "navigator.clipboard.writeText(text)" in content + assert "term.hasSelection()" in content + assert "event.stopImmediatePropagation()" in content + assert 'document.execCommand("copy")' not in content + assert "document.execCommand('copy')" in content + + +def test_ttyd_clipboard_patch_fails_closed_on_upstream_drift(): + with pytest.raises(RuntimeError, match="context changed"): + ttyd_patch.patch_html("changed") + + def test_agent_tab_router_starts_hermes_in_matching_project(tmp_path: Path): projects = tmp_path / "projects" cassandra = projects / "cassandra" @@ -217,6 +237,13 @@ def test_agent_ttyd_defers_identity_to_owner_only_oauth_boundary(): assert "--check-origin" in command assert "--auth-header" not in command + assert "--index /ttyd-index/index.html" in command + + init_names = { + container["name"] + for container in deployment["spec"]["template"]["spec"]["initContainers"] + } + assert "prepare-ttyd-index" in init_names oauth_documents = [ document