fix(hermes): recover browser terminal sessions
This commit is contained in:
parent
7d98f539a0
commit
af42cb40e6
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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>")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user