fix(hermes): make browser terminal copy reliable
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 181
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 181
This commit is contained in:
parent
f0d094dd6e
commit
7d98f539a0
@ -24,7 +24,7 @@ spec:
|
|||||||
ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code
|
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/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/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/agent-inject: "true"
|
||||||
vault.hashicorp.com/role: hermes-agent
|
vault.hashicorp.com/role: hermes-agent
|
||||||
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens
|
||||||
@ -269,6 +269,27 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests: {cpu: 25m, memory: 32Mi}
|
requests: {cpu: 25m, memory: 32Mi}
|
||||||
limits: {cpu: 250m, memory: 128Mi}
|
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:
|
containers:
|
||||||
- name: hermes
|
- name: hermes
|
||||||
image: registry.bstein.dev/bstein/hermes-agent@sha256:7a1daefae2f068dcf14e7eb1f2f9aab2e1ce79c1b55b4bb0aa1092fbf4019bbc
|
image: registry.bstein.dev/bstein/hermes-agent@sha256:7a1daefae2f068dcf14e7eb1f2f9aab2e1ce79c1b55b4bb0aa1092fbf4019bbc
|
||||||
@ -401,6 +422,7 @@ spec:
|
|||||||
--port 7681 \
|
--port 7681 \
|
||||||
--cwd /opt/data/workspace \
|
--cwd /opt/data/workspace \
|
||||||
--terminal-type xterm-256color \
|
--terminal-type xterm-256color \
|
||||||
|
--index /ttyd-index/index.html \
|
||||||
--client-option "titleFixed=Hermes Agent - HERDR" \
|
--client-option "titleFixed=Hermes Agent - HERDR" \
|
||||||
--client-option fontSize=15 \
|
--client-option fontSize=15 \
|
||||||
/bin/sh -c '
|
/bin/sh -c '
|
||||||
@ -423,6 +445,7 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- {name: home, mountPath: /opt/data}
|
- {name: home, mountPath: /opt/data}
|
||||||
- {name: tmp, mountPath: /tmp}
|
- {name: tmp, mountPath: /tmp}
|
||||||
|
- {name: ttyd-index, mountPath: /ttyd-index, readOnly: true}
|
||||||
startupProbe:
|
startupProbe:
|
||||||
tcpSocket: {port: herdr-tui}
|
tcpSocket: {port: herdr-tui}
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
@ -603,3 +626,6 @@ spec:
|
|||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir:
|
emptyDir:
|
||||||
sizeLimit: 256Mi
|
sizeLimit: 256Mi
|
||||||
|
- name: ttyd-index
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 2Mi
|
||||||
|
|||||||
@ -50,6 +50,7 @@ configMapGenerator:
|
|||||||
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
- hermes_coordinator.py=scripts/hermes_coordinator.py
|
||||||
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
- hermes_model_routing.py=scripts/hermes_model_routing.py
|
||||||
- patch_hermes_auth.py=scripts/patch_hermes_auth.py
|
- patch_hermes_auth.py=scripts/patch_hermes_auth.py
|
||||||
|
- patch_ttyd_index.py=scripts/patch_ttyd_index.py
|
||||||
options:
|
options:
|
||||||
disableNameSuffixHash: true
|
disableNameSuffixHash: true
|
||||||
- name: hermes-agent-kubeconfig
|
- name: hermes-agent-kubeconfig
|
||||||
|
|||||||
176
services/hermes/scripts/patch_ttyd_index.py
Normal file
176
services/hermes/scripts/patch_ttyd_index.py
Normal file
@ -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"""
|
||||||
|
<style>
|
||||||
|
#atlas-clipboard-status {
|
||||||
|
background: #181825;
|
||||||
|
border: 1px solid #f9e2af;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #f9e2af;
|
||||||
|
font: 13px/1.4 monospace;
|
||||||
|
padding: 6px 10px;
|
||||||
|
position: fixed;
|
||||||
|
right: 12px;
|
||||||
|
top: 12px;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script id="atlas-ttyd-clipboard">
|
||||||
|
(() => {
|
||||||
|
const showStatus = (message, failed = false) => {
|
||||||
|
let status = document.getElementById('atlas-clipboard-status');
|
||||||
|
if (!status) {
|
||||||
|
status = document.createElement('div');
|
||||||
|
status.id = 'atlas-clipboard-status';
|
||||||
|
document.body.appendChild(status);
|
||||||
|
}
|
||||||
|
status.textContent = message;
|
||||||
|
status.style.borderColor = failed ? '#f38ba8' : '#a6e3a1';
|
||||||
|
status.style.color = failed ? '#f38ba8' : '#a6e3a1';
|
||||||
|
clearTimeout(status.removeTimer);
|
||||||
|
status.removeTimer = setTimeout(() => status.remove(), 1200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackCopy = (text) => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = text;
|
||||||
|
input.setAttribute('readonly', '');
|
||||||
|
input.style.position = 'fixed';
|
||||||
|
input.style.opacity = '0';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
const copied = document.execCommand('copy');
|
||||||
|
input.remove();
|
||||||
|
if (!copied) throw new Error('browser rejected clipboard write');
|
||||||
|
};
|
||||||
|
|
||||||
|
const install = () => {
|
||||||
|
const term = window.term;
|
||||||
|
if (!term) {
|
||||||
|
setTimeout(install, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (term.__atlasClipboardInstalled) return;
|
||||||
|
term.__atlasClipboardInstalled = true;
|
||||||
|
|
||||||
|
const copySelection = async () => {
|
||||||
|
const text = term.getSelection();
|
||||||
|
if (!text) return false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text);
|
||||||
|
}
|
||||||
|
showStatus('Copied selection');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
fallbackCopy(text);
|
||||||
|
showStatus('Copied selection');
|
||||||
|
return true;
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('[atlas] clipboard copy failed', error, fallbackError);
|
||||||
|
showStatus('Clipboard copy failed', true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', () => {
|
||||||
|
if (term.hasSelection()) void copySelection();
|
||||||
|
});
|
||||||
|
window.addEventListener('keydown', (event) => {
|
||||||
|
const copyKey = event.key.toLowerCase() === 'c'
|
||||||
|
&& (event.ctrlKey || event.metaKey)
|
||||||
|
&& term.hasSelection();
|
||||||
|
if (!copyKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
void copySelection();
|
||||||
|
}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
install();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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("</body>") != 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("</body>", f"{CLIPBOARD_ADAPTER}</body>", 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())
|
||||||
@ -26,6 +26,7 @@ def _load(name: str):
|
|||||||
dispatch = _load("herdr_dispatch")
|
dispatch = _load("herdr_dispatch")
|
||||||
tab_router = _load("herdr_tab_router")
|
tab_router = _load("herdr_tab_router")
|
||||||
auth_patch = _load("patch_hermes_auth")
|
auth_patch = _load("patch_hermes_auth")
|
||||||
|
ttyd_patch = _load("patch_ttyd_index")
|
||||||
|
|
||||||
|
|
||||||
def test_herdr_plan_chooses_task_shape_and_caps_effort():
|
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")
|
auth_patch.patch(source, tmp_path / "patched.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ttyd_clipboard_patch_uses_system_clipboard_and_preserves_interrupt():
|
||||||
|
source = (
|
||||||
|
'<html><body><script>document.execCommand("copy")</script></body></html>'
|
||||||
|
)
|
||||||
|
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("<html><body>changed</body></html>")
|
||||||
|
|
||||||
|
|
||||||
def test_agent_tab_router_starts_hermes_in_matching_project(tmp_path: Path):
|
def test_agent_tab_router_starts_hermes_in_matching_project(tmp_path: Path):
|
||||||
projects = tmp_path / "projects"
|
projects = tmp_path / "projects"
|
||||||
cassandra = projects / "cassandra"
|
cassandra = projects / "cassandra"
|
||||||
@ -217,6 +237,13 @@ def test_agent_ttyd_defers_identity_to_owner_only_oauth_boundary():
|
|||||||
|
|
||||||
assert "--check-origin" in command
|
assert "--check-origin" in command
|
||||||
assert "--auth-header" not 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 = [
|
oauth_documents = [
|
||||||
document
|
document
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user