* fix(approvals): handle gateway yolo in webui * fix(approvals): preserve concurrent yolo enables * fix(approvals): honor settled yolo state * fix(approvals): commit yolo after relay * fix(approvals): preserve gateway yolo relay state * fix(approvals): close remaining yolo race windows * fix(approvals): serialize gateway yolo handoff * fix(approvals): linearize yolo disable * fix(approvals): serialize initial yolo relay * fix(approvals): serialize card yolo handoff * fix(yolo): ignore stale card responses * fix(yolo): reject stale session generations * fix(yolo): bind responses to approval owner * fix(yolo): drain pending approvals with exact ownership * fix(approvals): show skip-all busy state * fix: close approval lifecycle races * test: update approval SSE handoff contract * test: include displayed approval owner helpers * fix: linearize yolo transition registration * Release — approval-card YOLO handoff + stale-response ownership fence (#6945) --------- Co-authored-by: dss539 <dss539@users.noreply.github.com> Co-authored-by: n <a@n>
401 lines
15 KiB
Python
401 lines
15 KiB
Python
"""Tests for #4771: failed gateway approval clicks must keep the card actionable."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
|
NODE = shutil.which("node")
|
|
|
|
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
|
|
|
|
|
def _extract_fn(src: str, name: str, prefix: str = "function ") -> str:
|
|
start = src.index(f"{prefix}{name}(")
|
|
paren = src.index("(", start)
|
|
paren_depth = 0
|
|
for i in range(paren, len(src)):
|
|
if src[i] == "(":
|
|
paren_depth += 1
|
|
elif src[i] == ")":
|
|
paren_depth -= 1
|
|
if paren_depth == 0:
|
|
brace = src.index("{", i + 1)
|
|
break
|
|
else:
|
|
raise AssertionError(f"{name} parameter list not closed")
|
|
depth = 0
|
|
for i in range(brace, len(src)):
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return src[start:i + 1]
|
|
raise AssertionError(f"{name} body not closed")
|
|
|
|
|
|
def _run_node(script: str) -> dict:
|
|
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as tf:
|
|
tf.write(script)
|
|
script_path = tf.name
|
|
try:
|
|
result = subprocess.run(
|
|
[NODE, script_path],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
finally:
|
|
Path(script_path).unlink(missing_ok=True)
|
|
|
|
|
|
def _run_failure_case(api_js: str) -> dict:
|
|
helpers = "\n".join(
|
|
[
|
|
_extract_fn(MESSAGES_JS, "_approvalDismissKey"),
|
|
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
|
|
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
|
|
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
|
|
_extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
|
|
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
|
|
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
|
|
_extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"),
|
|
_extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"),
|
|
_extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"),
|
|
_extract_fn(MESSAGES_JS, "_approvalOwnerForPending"),
|
|
_extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"),
|
|
_extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"),
|
|
_extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"),
|
|
_extract_fn(MESSAGES_JS, "_approvalResponseMatches"),
|
|
_extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"),
|
|
_extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"),
|
|
_extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"),
|
|
_extract_fn(MESSAGES_JS, "showApprovalCard"),
|
|
_extract_fn(MESSAGES_JS, "_restoreFailedApprovalResponse"),
|
|
_extract_fn(MESSAGES_JS, "respondApproval", prefix="async function "),
|
|
]
|
|
)
|
|
script = f"""
|
|
const output = {{}};
|
|
const _store = {{}};
|
|
const localStorage = {{
|
|
getItem: key => Object.prototype.hasOwnProperty.call(_store, key) ? _store[key] : null,
|
|
setItem: (key, value) => {{ _store[key] = String(value); }},
|
|
}};
|
|
const S = {{ session: {{ session_id: 'sess-1' }} }};
|
|
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
|
|
let syncTopbarCalls = 0;
|
|
let showToastCalls = [];
|
|
let statusCalls = [];
|
|
let hideCalls = 0;
|
|
let renderCalls = 0;
|
|
let _approvalSessionId = 'sess-1';
|
|
let _approvalCurrentId = 'appr-1';
|
|
let _approvalPendingBySession = new Map();
|
|
let _loadSessionGeneration = 1;
|
|
let _approvalResponding = null;
|
|
let _approvalClearedOwner = null;
|
|
let _approvalDisplayedOwner = null;
|
|
let _approvalSignature = '';
|
|
let _approvalVisibleSince = 0;
|
|
let _approvalHideTimer = null;
|
|
const _clarifyPendingBySession = new Map();
|
|
const buttons = new Map();
|
|
function makeButton(id) {{
|
|
return {{
|
|
id,
|
|
disabled: false,
|
|
classList: {{
|
|
values: new Set(),
|
|
add(value) {{ this.values.add(value); }},
|
|
remove(value) {{ this.values.delete(value); }},
|
|
contains(value) {{ return this.values.has(value); }},
|
|
}},
|
|
}};
|
|
}}
|
|
['approvalBtnOnce','approvalBtnSession','approvalBtnAlways','approvalBtnDeny'].forEach(id => buttons.set(id, makeButton(id)));
|
|
const card = {{
|
|
classList: {{
|
|
values: new Set(['visible']),
|
|
add(value) {{ this.values.add(value); }},
|
|
remove(value) {{ this.values.delete(value); }},
|
|
contains(value) {{ return this.values.has(value); }},
|
|
toggle(value, force) {{
|
|
if (force === undefined) {{
|
|
if (this.values.has(value)) this.values.delete(value);
|
|
else this.values.add(value);
|
|
}} else if (force) this.values.add(value);
|
|
else this.values.delete(value);
|
|
}},
|
|
}},
|
|
hidden: false,
|
|
attributes: {{}},
|
|
setAttribute(name, value) {{ this.attributes[name] = String(value); }},
|
|
removeAttribute(name) {{ delete this.attributes[name]; }},
|
|
querySelector() {{ return null; }},
|
|
}};
|
|
const approvalDesc = {{ textContent: '' }};
|
|
const approvalCmd = {{ textContent: '' }};
|
|
const approvalCounter = {{ textContent: '', style: {{ display: '' }} }};
|
|
const msg = {{}};
|
|
const document = {{ activeElement: null }};
|
|
const elements = {{
|
|
approvalCard: card,
|
|
approvalDesc,
|
|
approvalCmd,
|
|
approvalCounter,
|
|
approvalBtnOnce: buttons.get('approvalBtnOnce'),
|
|
approvalBtnSession: buttons.get('approvalBtnSession'),
|
|
approvalBtnAlways: buttons.get('approvalBtnAlways'),
|
|
approvalBtnDeny: buttons.get('approvalBtnDeny'),
|
|
msg,
|
|
}};
|
|
function $(id) {{ return elements[id] || null; }}
|
|
function syncTopbar() {{ syncTopbarCalls += 1; }}
|
|
function hideApprovalCard() {{ hideCalls += 1; card.classList.remove('visible'); }}
|
|
function _clearApprovalHideTimer() {{}}
|
|
function _syncApprovalCollapseButton() {{}}
|
|
function _syncApprovalTranscriptSpace() {{}}
|
|
function applyLocaleToDOM() {{}}
|
|
function setTimeout(fn) {{ return 1; }}
|
|
function showToast(msg) {{ showToastCalls.push(msg); }}
|
|
function setStatus(msg) {{ statusCalls.push(msg); }}
|
|
function t(key) {{ return key; }}
|
|
{api_js}
|
|
{helpers}
|
|
const pending = {{
|
|
approval_id: 'appr-1',
|
|
_session_id: 'sess-1',
|
|
command: 'rm -rf /tmp/test',
|
|
description: 'Dangerous command approval',
|
|
pattern_key: 'dangerous_command',
|
|
pattern_keys: ['dangerous_command'],
|
|
}};
|
|
_approvalPendingBySession.set('sess-1', {{ pending, pendingCount: 1 }});
|
|
showApprovalCard(pending, 1);
|
|
const realRenderPendingApprovalForActiveSession = _renderPendingApprovalForActiveSession;
|
|
renderCalls = 0;
|
|
_renderPendingApprovalForActiveSession = function() {{
|
|
renderCalls += 1;
|
|
return realRenderPendingApprovalForActiveSession();
|
|
}};
|
|
respondApproval('once').then(() => {{
|
|
output.sessionId = _approvalSessionId;
|
|
output.approvalId = _approvalCurrentId;
|
|
output.pendingApprovalId = _approvalPendingBySession.get('sess-1').pending.approval_id;
|
|
output.hideCalls = hideCalls;
|
|
output.renderCalls = renderCalls;
|
|
output.toast = showToastCalls[0] || null;
|
|
output.status = statusCalls[0] || null;
|
|
output.onceDisabled = buttons.get('approvalBtnOnce').disabled;
|
|
output.onceLoading = buttons.get('approvalBtnOnce').classList.contains('loading');
|
|
output.denyDisabled = buttons.get('approvalBtnDeny').disabled;
|
|
output.cardVisible = card.classList.contains('visible');
|
|
process.stdout.write(JSON.stringify(output));
|
|
}});
|
|
"""
|
|
return _run_node(script)
|
|
|
|
|
|
def test_failed_gateway_approval_keeps_card_and_reenables_buttons():
|
|
out = _run_failure_case(
|
|
"""function api() {
|
|
const err = new Error('Gateway approval could not be relayed because the active run is unavailable. Reopen the session or retry after it reconnects.');
|
|
err.status = 409;
|
|
return Promise.reject(err);
|
|
}"""
|
|
)
|
|
assert out["sessionId"] == "sess-1"
|
|
assert out["approvalId"] == "appr-1"
|
|
assert out["pendingApprovalId"] == "appr-1"
|
|
assert out["hideCalls"] == 0
|
|
assert out["renderCalls"] == 1
|
|
assert out["onceDisabled"] is False
|
|
assert out["onceLoading"] is False
|
|
assert out["denyDisabled"] is False
|
|
assert out["cardVisible"] is True
|
|
assert "active run is unavailable" in out["toast"]
|
|
assert "active run is unavailable" in out["status"]
|
|
|
|
|
|
def test_rejected_ok_false_approval_keeps_card_and_reenables_buttons():
|
|
out = _run_failure_case(
|
|
"""function api() {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
error: 'Approval response not accepted for this session.',
|
|
});
|
|
}"""
|
|
)
|
|
assert out["sessionId"] == "sess-1"
|
|
assert out["approvalId"] == "appr-1"
|
|
assert out["pendingApprovalId"] == "appr-1"
|
|
assert out["hideCalls"] == 0
|
|
assert out["renderCalls"] == 1
|
|
assert out["onceDisabled"] is False
|
|
assert out["onceLoading"] is False
|
|
assert out["denyDisabled"] is False
|
|
assert out["cardVisible"] is True
|
|
assert "Approval response not accepted for this session." == out["toast"]
|
|
assert "Approval response not accepted for this session." == out["status"]
|
|
|
|
|
|
def test_poll_rerender_keeps_inflight_buttons_disabled_and_blocks_duplicates():
|
|
helpers = "\n".join(
|
|
[
|
|
_extract_fn(MESSAGES_JS, "_approvalDismissKey"),
|
|
_extract_fn(MESSAGES_JS, "_getDismissedApprovals"),
|
|
_extract_fn(MESSAGES_JS, "_isApprovalDismissed"),
|
|
_extract_fn(MESSAGES_JS, "_unmarkApprovalDismissed"),
|
|
_extract_fn(MESSAGES_JS, "_promptActiveSessionId"),
|
|
_extract_fn(MESSAGES_JS, "_approvalPromptBelongsToActiveSession"),
|
|
_extract_fn(MESSAGES_JS, "_rememberApprovalPending"),
|
|
_extract_fn(MESSAGES_JS, "_clearApprovalPendingForSession"),
|
|
_extract_fn(MESSAGES_JS, "_renderPendingApprovalForActiveSession"),
|
|
_extract_fn(MESSAGES_JS, "_approvalMirrorOwnerFor"),
|
|
_extract_fn(MESSAGES_JS, "_approvalOwnerForPending"),
|
|
_extract_fn(MESSAGES_JS, "_approvalOwnerIdentityMatches"),
|
|
_extract_fn(MESSAGES_JS, "_captureApprovalResponseOwner"),
|
|
_extract_fn(MESSAGES_JS, "_approvalResponseOwnerIsCurrent"),
|
|
_extract_fn(MESSAGES_JS, "_approvalResponseMatches"),
|
|
_extract_fn(MESSAGES_JS, "_releaseApprovalResponseOwner"),
|
|
_extract_fn(MESSAGES_JS, "_setApprovalControlsDisabled"),
|
|
_extract_fn(MESSAGES_JS, "_setPromptFlyoutHidden"),
|
|
_extract_fn(MESSAGES_JS, "showApprovalCard"),
|
|
_extract_fn(MESSAGES_JS, "_restoreFailedApprovalResponse"),
|
|
_extract_fn(MESSAGES_JS, "respondApproval", prefix="async function "),
|
|
]
|
|
)
|
|
script = f"""
|
|
const output = {{}};
|
|
const _store = {{}};
|
|
const localStorage = {{
|
|
getItem: key => Object.prototype.hasOwnProperty.call(_store, key) ? _store[key] : null,
|
|
setItem: (key, value) => {{ _store[key] = String(value); }},
|
|
}};
|
|
const S = {{ session: {{ session_id: 'sess-1' }} }};
|
|
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
|
|
let _approvalSessionId = 'sess-1';
|
|
let _approvalCurrentId = 'appr-1';
|
|
let _approvalPendingBySession = new Map();
|
|
let _loadSessionGeneration = 1;
|
|
let _approvalResponding = null;
|
|
let _approvalClearedOwner = null;
|
|
let _approvalDisplayedOwner = null;
|
|
let _approvalSignature = '';
|
|
let _approvalVisibleSince = 0;
|
|
let _approvalHideTimer = null;
|
|
const _clarifyPendingBySession = new Map();
|
|
let resolveApi;
|
|
let apiCalls = 0;
|
|
const buttons = new Map();
|
|
function makeButton(id) {{
|
|
return {{
|
|
id,
|
|
disabled: false,
|
|
classList: {{
|
|
values: new Set(),
|
|
add(value) {{ this.values.add(value); }},
|
|
remove(value) {{ this.values.delete(value); }},
|
|
contains(value) {{ return this.values.has(value); }},
|
|
}},
|
|
}};
|
|
}}
|
|
['approvalBtnOnce','approvalBtnSession','approvalBtnAlways','approvalBtnDeny'].forEach(id => buttons.set(id, makeButton(id)));
|
|
const card = {{
|
|
classList: {{
|
|
values: new Set(['visible']),
|
|
add(value) {{ this.values.add(value); }},
|
|
remove(value) {{ this.values.delete(value); }},
|
|
contains(value) {{ return this.values.has(value); }},
|
|
toggle(value, force) {{
|
|
if (force === undefined) {{
|
|
if (this.values.has(value)) this.values.delete(value);
|
|
else this.values.add(value);
|
|
}} else if (force) this.values.add(value);
|
|
else this.values.delete(value);
|
|
}},
|
|
}},
|
|
hidden: false,
|
|
attributes: {{}},
|
|
setAttribute(name, value) {{ this.attributes[name] = String(value); }},
|
|
removeAttribute(name) {{ delete this.attributes[name]; }},
|
|
querySelector() {{ return null; }},
|
|
}};
|
|
const approvalDesc = {{ textContent: '' }};
|
|
const approvalCmd = {{ textContent: '' }};
|
|
const approvalCounter = {{ textContent: '', style: {{ display: '' }} }};
|
|
const msg = {{}};
|
|
const document = {{ activeElement: null }};
|
|
const elements = {{
|
|
approvalCard: card,
|
|
approvalDesc,
|
|
approvalCmd,
|
|
approvalCounter,
|
|
approvalBtnOnce: buttons.get('approvalBtnOnce'),
|
|
approvalBtnSession: buttons.get('approvalBtnSession'),
|
|
approvalBtnAlways: buttons.get('approvalBtnAlways'),
|
|
approvalBtnDeny: buttons.get('approvalBtnDeny'),
|
|
msg,
|
|
}};
|
|
function $(id) {{ return elements[id] || null; }}
|
|
function syncTopbar() {{}}
|
|
function hideApprovalCard() {{ card.classList.remove('visible'); }}
|
|
function _clearApprovalHideTimer() {{}}
|
|
function _syncApprovalCollapseButton() {{}}
|
|
function _syncApprovalTranscriptSpace() {{}}
|
|
function applyLocaleToDOM() {{}}
|
|
function setTimeout(fn) {{ return 1; }}
|
|
function showToast() {{}}
|
|
function setStatus() {{}}
|
|
function t(key) {{ return key; }}
|
|
function api() {{
|
|
apiCalls += 1;
|
|
return new Promise(resolve => {{ resolveApi = resolve; }});
|
|
}}
|
|
{helpers}
|
|
const pending = {{
|
|
approval_id: 'appr-1',
|
|
_session_id: 'sess-1',
|
|
command: 'rm -rf /tmp/test',
|
|
description: 'Dangerous command approval',
|
|
pattern_key: 'dangerous_command',
|
|
pattern_keys: ['dangerous_command'],
|
|
}};
|
|
_approvalPendingBySession.set('sess-1', {{ pending, pendingCount: 1 }});
|
|
showApprovalCard(pending, 1);
|
|
const first = respondApproval('once');
|
|
_renderPendingApprovalForActiveSession();
|
|
const second = respondApproval('once');
|
|
output.apiCallsDuringFlight = apiCalls;
|
|
output.onceDisabledDuringFlight = buttons.get('approvalBtnOnce').disabled;
|
|
output.onceLoadingDuringFlight = buttons.get('approvalBtnOnce').classList.contains('loading');
|
|
resolveApi({{ ok: false }});
|
|
Promise.all([first, second]).then(() => {{
|
|
output.apiCallsFinal = apiCalls;
|
|
output.onceDisabledAfter = buttons.get('approvalBtnOnce').disabled;
|
|
output.onceLoadingAfter = buttons.get('approvalBtnOnce').classList.contains('loading');
|
|
output.cardVisibleAfter = card.classList.contains('visible');
|
|
process.stdout.write(JSON.stringify(output));
|
|
}});
|
|
"""
|
|
out = _run_node(script)
|
|
assert out["apiCallsDuringFlight"] == 1
|
|
assert out["onceDisabledDuringFlight"] is True
|
|
assert out["onceLoadingDuringFlight"] is True
|
|
assert out["apiCallsFinal"] == 1
|
|
assert out["onceDisabledAfter"] is False
|
|
assert out["onceLoadingAfter"] is False
|
|
assert out["cardVisibleAfter"] is True
|