fix(hermes): recover browser terminal sessions

This commit is contained in:
jenkins 2026-08-09 16:12:00 -03:00
parent 7d98f539a0
commit af42cb40e6
3 changed files with 112 additions and 5 deletions

View File

@ -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-terminal-clipboard" ai.bstein.dev/config-rev: "20260809-terminal-recovery"
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

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Add reliable browser clipboard handling to ttyd's pinned client page.""" """Add reliable clipboard and reconnect handling to ttyd's pinned client."""
from __future__ import annotations from __future__ import annotations
@ -27,9 +27,25 @@ CLIPBOARD_ADAPTER = r"""
top: 12px; top: 12px;
z-index: 10000; z-index: 10000;
} }
#atlas-terminal-recovery {
background: #181825;
border: 1px solid #89b4fa;
border-radius: 8px;
color: #cdd6f4;
font: 16px/1.4 monospace;
left: 50%;
padding: 12px 18px;
position: fixed;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10001;
}
</style> </style>
<script id="atlas-ttyd-clipboard"> <script id="atlas-ttyd-clipboard">
(() => { (() => {
let unloading = false;
let recoveryStarted = false;
const showStatus = (message, failed = false) => { const showStatus = (message, failed = false) => {
let status = document.getElementById('atlas-clipboard-status'); let status = document.getElementById('atlas-clipboard-status');
if (!status) { if (!status) {
@ -44,6 +60,78 @@ CLIPBOARD_ADAPTER = r"""
status.removeTimer = setTimeout(() => status.remove(), 1200); status.removeTimer = setTimeout(() => status.remove(), 1200);
}; };
const sleep = (milliseconds) => new Promise(
(resolve) => window.setTimeout(resolve, milliseconds),
);
const showRecoveryStatus = (message) => {
let status = document.getElementById('atlas-terminal-recovery');
if (!status) {
status = document.createElement('div');
status.id = 'atlas-terminal-recovery';
document.body.appendChild(status);
}
status.textContent = message;
};
const tokenUrl = new URL('token', window.location.href).toString();
const waitForFreshPage = async () => {
if (recoveryStarted || unloading) return;
recoveryStarted = true;
showRecoveryStatus('Reconnecting terminal automatically...');
let failures = 0;
while (!unloading) {
try {
const response = await fetch(tokenUrl, {
cache: 'no-store',
credentials: 'same-origin',
redirect: 'manual',
});
if (response.type === 'opaqueredirect'
|| (response.status >= 300 && response.status < 400)) {
window.location.reload();
return;
}
if (response.ok) {
const payload = await response.json();
if (typeof payload.token === 'string') {
window.location.reload();
return;
}
}
} catch (error) {
console.debug('[atlas] terminal endpoint is not ready', error);
}
failures += 1;
if (failures === 5) {
showRecoveryStatus('Waiting for terminal service...');
}
await sleep(Math.min(500 + failures * 250, 3000));
}
};
const NativeWebSocket = window.WebSocket;
window.WebSocket = class AtlasRecoveringWebSocket extends NativeWebSocket {
constructor(...args) {
super(...args);
this.addEventListener('close', () => {
if (!unloading) void waitForFreshPage();
}, { once: true });
}
};
window.addEventListener('beforeunload', () => {
unloading = true;
});
window.addEventListener('keydown', (event) => {
if (!recoveryStarted || event.key !== 'Enter') return;
event.preventDefault();
event.stopImmediatePropagation();
}, true);
const fallbackCopy = (text) => { const fallbackCopy = (text) => {
const input = document.createElement('textarea'); const input = document.createElement('textarea');
input.value = text; input.value = text;
@ -111,13 +199,13 @@ CLIPBOARD_ADAPTER = r"""
def patch_html(content: str) -> str: def patch_html(content: str) -> str:
"""Insert the adapter once and disable ttyd's false-success copy call.""" """Insert the adapter before ttyd and disable its false-success copy call."""
if content.count(UPSTREAM_COPY) != 1 or content.count("</body>") != 1: if content.count(UPSTREAM_COPY) != 1 or content.count("<body>") != 1:
raise RuntimeError("ttyd index patch context changed") raise RuntimeError("ttyd index patch context changed")
if MARKER in content: if MARKER in content:
raise RuntimeError("ttyd index is already patched") raise RuntimeError("ttyd index is already patched")
content = content.replace(UPSTREAM_COPY, "void 0", 1) content = content.replace(UPSTREAM_COPY, "void 0", 1)
return content.replace("</body>", f"{CLIPBOARD_ADAPTER}</body>", 1) return content.replace("<body>", f"<body>{CLIPBOARD_ADAPTER}", 1)
def fetch_embedded_index(ttyd: Path) -> str: def fetch_embedded_index(ttyd: Path) -> str:

View File

@ -147,6 +147,25 @@ def test_ttyd_clipboard_patch_uses_system_clipboard_and_preserves_interrupt():
assert "document.execCommand('copy')" in content assert "document.execCommand('copy')" in content
def test_ttyd_client_patch_recovers_with_a_fresh_authenticated_page():
source = (
'<html><body><script>document.execCommand("copy")</script></body></html>'
)
content = ttyd_patch.patch_html(source)
assert "class AtlasRecoveringWebSocket" in content
assert "new URL('token', window.location.href)" in content
assert "credentials: 'same-origin'" in content
assert "response.type === 'opaqueredirect'" in content
assert "typeof payload.token === 'string'" in content
assert "window.location.reload()" in content
assert "Reconnecting terminal automatically..." in content
assert "if (!recoveryStarted || event.key !== 'Enter') return" in content
assert content.index('id="atlas-ttyd-clipboard"') < content.index(
'<script>void 0</script>'
)
def test_ttyd_clipboard_patch_fails_closed_on_upstream_drift(): def test_ttyd_clipboard_patch_fails_closed_on_upstream_drift():
with pytest.raises(RuntimeError, match="context changed"): with pytest.raises(RuntimeError, match="context changed"):
ttyd_patch.patch_html("<html><body>changed</body></html>") ttyd_patch.patch_html("<html><body>changed</body></html>")