* 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>
1910 lines
85 KiB
Python
1910 lines
85 KiB
Python
"""WebUI-owned YOLO compatibility tests for gateway Runs API chat.
|
|
|
|
The current Runs API can answer one approval but cannot toggle session YOLO.
|
|
These tests pin WebUI's compatibility behavior without relying on an Agent
|
|
branch that accepts extra request fields.
|
|
"""
|
|
|
|
import json
|
|
import pathlib
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import urllib.parse
|
|
from contextlib import contextmanager
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import api.config as config
|
|
import api.gateway_chat as gateway_chat
|
|
from api.runner_client import RunnerClientError
|
|
|
|
try:
|
|
from tools.approval import disable_session_yolo, is_session_yolo_enabled
|
|
|
|
APPROVAL_AVAILABLE = True
|
|
except ImportError:
|
|
APPROVAL_AVAILABLE = False
|
|
|
|
|
|
def _js_block(source, start_marker, end_marker):
|
|
start = source.index(start_marker)
|
|
return source[start:source.index(end_marker, start)]
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_approval_card_yolo_resumes_current_prompt_with_one_webui_request():
|
|
messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text()
|
|
|
|
def extract(name, end_marker):
|
|
start = messages_js.index(f"async function {name}(")
|
|
return messages_js[start:messages_js.index(end_marker, start)]
|
|
|
|
script = "\n".join([
|
|
"const calls=[];",
|
|
"const S={session:{session_id:'browser-session'}};",
|
|
"let _approvalSessionId='browser-session';",
|
|
"let _approvalCurrentId='approval-1';",
|
|
"let _loadSessionGeneration=1;",
|
|
"let _approvalResponding=null;",
|
|
"let _approvalClearedOwner=null;",
|
|
"let _approvalDisplayedOwner={sid:'browser-session',approvalId:'approval-1',runId:'run-1',mirrorToken:'mirror-1'};",
|
|
"let _yoloEnabled=false;",
|
|
"const _approvalPendingBySession=new Map([['browser-session',{pending:{approval_id:'approval-1',run_id:'run-1',_gateway_mirror_token:'mirror-1'}}]]);",
|
|
"const api=async(path,opts={})=>{calls.push([path,JSON.parse(opts.body||'{}')]);return {ok:true,yolo_enabled:true};};",
|
|
"const $=()=>({disabled:false,classList:{contains:v=>v==='visible',add(){},remove(){}}});",
|
|
"const t=k=>k; const showToast=()=>{}; const setStatus=()=>{};",
|
|
"const _unmarkApprovalDismissed=()=>{};",
|
|
"const _setApprovalControlsDisabled=()=>{}; const _clearApprovalPendingForSession=()=>{};",
|
|
"const hideApprovalCard=()=>{}; const _updateYoloPill=()=>{};",
|
|
_js_block(messages_js, "function _approvalMirrorOwnerFor(", "\nfunction _setApprovalControlsDisabled"),
|
|
_js_block(messages_js, "function _applyApprovalYoloProjection(", "\nfunction toggleApprovalCardCollapsed"),
|
|
extract("respondApproval", "\nfunction startApprovalPolling"),
|
|
extract("toggleYoloFromApproval", "\n// ── Approval polling"),
|
|
"(async()=>{",
|
|
" const ok=await toggleYoloFromApproval();",
|
|
" if(!ok) throw new Error('action failed');",
|
|
" if(calls.length!==1) throw new Error('expected one request '+JSON.stringify(calls));",
|
|
" const [path,body]=calls[0];",
|
|
" if(path!=='/api/approval/respond') throw new Error('wrong endpoint '+path);",
|
|
" if(JSON.stringify(body)!==JSON.stringify({session_id:'browser-session',choice:'once',approval_id:'approval-1',run_id:'run-1',mirror_token:'mirror-1',yolo:true})) throw new Error('wrong body '+JSON.stringify(body));",
|
|
" if(!_yoloEnabled) throw new Error('UI state not enabled');",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_approval_card_yolo_marks_skip_all_busy_while_request_is_pending():
|
|
messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text()
|
|
|
|
script = "\n".join([
|
|
"const classes=()=>{const values=new Set();return {add:v=>values.add(v),remove:v=>values.delete(v),contains:v=>values.has(v)};};",
|
|
"const buttons=Object.fromEntries(['approvalBtnOnce','approvalBtnSession','approvalBtnAlways','approvalBtnDeny','approvalSkipAll'].map(id=>[id,{id,disabled:false,classList:classes()}]));",
|
|
"const card={classList:{contains:v=>v==='visible'}};",
|
|
"const $=id=>id==='approvalCard'?card:buttons[id];",
|
|
"const S={session:{session_id:'browser-session'}};",
|
|
"let _loadSessionGeneration=1; let _approvalSessionId='browser-session'; let _approvalCurrentId='approval-1';",
|
|
"let _approvalResponding=null; let _approvalClearedOwner=null; let _approvalDisplayedOwner={sid:'browser-session',approvalId:'approval-1',runId:'',mirrorToken:''}; let _yoloEnabled=false; let finishRequest;",
|
|
"const _approvalPendingBySession=new Map([['browser-session',{pending:{approval_id:'approval-1'}}]]);",
|
|
"const api=async()=>await new Promise(resolve=>{finishRequest=resolve;});",
|
|
"const t=k=>k; const showToast=()=>{}; const setStatus=()=>{}; const _updateYoloPill=()=>{};",
|
|
"const _unmarkApprovalDismissed=()=>{}; const _clearApprovalPendingForSession=()=>{}; const hideApprovalCard=()=>{};",
|
|
_js_block(messages_js, "function _approvalMirrorOwnerFor(", "\nfunction showApprovalForSession"),
|
|
_js_block(messages_js, "function _applyApprovalYoloProjection(", "\nfunction toggleApprovalCardCollapsed"),
|
|
_js_block(messages_js, "async function respondApproval(", "\nfunction startApprovalPolling"),
|
|
_js_block(messages_js, "async function toggleYoloFromApproval(", "\n// ── Approval polling"),
|
|
"(async()=>{",
|
|
" const action=toggleYoloFromApproval();",
|
|
" if(!buttons.approvalSkipAll.disabled) throw new Error('clicked Skip all stayed enabled');",
|
|
" if(!buttons.approvalSkipAll.classList.contains('loading')) throw new Error('clicked Skip all lacks loading state');",
|
|
" if(buttons.approvalBtnOnce.classList.contains('loading')) throw new Error('Allow once incorrectly shows loading state');",
|
|
" for(const button of Object.values(buttons)){if(!button.disabled) throw new Error(button.id+' stayed enabled');}",
|
|
" finishRequest({ok:true,yolo_enabled:true});",
|
|
" if(!(await action)) throw new Error('action failed');",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_card_yolo_drains_all_already_parked_local_approvals(monkeypatch):
|
|
from api import routes
|
|
from tools.approval import _ApprovalEntry
|
|
|
|
sid = "webui-card-yolo-drain-local"
|
|
approvals = [
|
|
{"approval_id": "approval-local-1", "command": "first"},
|
|
{"approval_id": "approval-local-2", "command": "second"},
|
|
]
|
|
entries = [_ApprovalEntry(dict(approval)) for approval in approvals]
|
|
response = {}
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None))
|
|
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
|
|
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False)
|
|
|
|
with routes._lock:
|
|
routes._pending[sid] = [dict(approval) for approval in approvals]
|
|
routes._gateway_queues[sid] = list(entries)
|
|
|
|
try:
|
|
routes._handle_approval_respond(
|
|
object(),
|
|
{
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": "approval-local-1",
|
|
"yolo": True,
|
|
},
|
|
)
|
|
|
|
assert response["status"] == 200
|
|
assert response["payload"]["ok"] is True
|
|
assert response["payload"]["yolo_enabled"] is True
|
|
assert [entry.result for entry in entries] == ["once", "once"]
|
|
assert all(entry.event.is_set() for entry in entries)
|
|
with routes._lock:
|
|
assert sid not in routes._pending
|
|
assert sid not in routes._gateway_queues
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_card_yolo_drains_all_run_backed_and_local_approvals(monkeypatch):
|
|
from api import route_approvals, routes
|
|
from tools.approval import _ApprovalEntry
|
|
|
|
sid = "webui-card-yolo-drain-mixed"
|
|
run_approvals = [
|
|
{
|
|
"approval_id": "approval-run-1",
|
|
"run_id": "run-drain-1",
|
|
"command": "remote first",
|
|
"_gateway_agent_identity_v1": True,
|
|
},
|
|
{
|
|
"approval_id": "approval-run-2",
|
|
"run_id": "run-drain-2",
|
|
"command": "remote second",
|
|
"_gateway_agent_identity_v1": True,
|
|
},
|
|
]
|
|
local_approval = {"approval_id": "approval-local", "command": "local third"}
|
|
local_entry = _ApprovalEntry(dict(local_approval))
|
|
response = {}
|
|
relays = []
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
def fake_respond(_self, run_id, approval_id, choice):
|
|
relays.append((run_id, approval_id, choice))
|
|
return {"resolved": 1}
|
|
|
|
for approval in run_approvals:
|
|
route_approvals.submit_gateway_pending_mirror(sid, dict(approval))
|
|
with routes._lock:
|
|
routes._gateway_queues[sid] = [local_entry]
|
|
routes._pending[sid].append(dict(local_approval))
|
|
first_mirror = route_approvals.gateway_pending_mirror(
|
|
sid,
|
|
approval_id="approval-run-1",
|
|
run_id="run-drain-1",
|
|
)
|
|
assert first_mirror is not None
|
|
assert first_mirror.get("_gateway_mirror_token")
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
|
|
try:
|
|
routes._handle_approval_respond(
|
|
object(),
|
|
{
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": first_mirror["approval_id"],
|
|
"run_id": first_mirror["run_id"],
|
|
"mirror_token": first_mirror["_gateway_mirror_token"],
|
|
"yolo": True,
|
|
},
|
|
)
|
|
|
|
assert response["status"] == 200
|
|
assert response["payload"] == {
|
|
"ok": True,
|
|
"choice": "once",
|
|
"relayed": True,
|
|
"yolo_enabled": True,
|
|
}
|
|
assert relays == [
|
|
("run-drain-1", "approval-run-1", "once"),
|
|
("run-drain-2", "approval-run-2", "once"),
|
|
]
|
|
assert local_entry.result == "once"
|
|
assert local_entry.event.is_set()
|
|
with routes._lock:
|
|
assert sid not in routes._pending
|
|
assert sid not in routes._gateway_queues
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
for approval in run_approvals:
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=approval["run_id"])
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
@pytest.mark.parametrize("enable_yolo", [False, True])
|
|
def test_stale_card_run_owner_cannot_rebind_to_current_run(monkeypatch, enable_yolo):
|
|
from api import route_approvals, routes
|
|
from tools.approval import _ApprovalEntry
|
|
|
|
sid = "webui-card-stale-run-owner"
|
|
stream_id = "stream-current-owner"
|
|
current_run_id = "run-current-owner"
|
|
approval_id = "gateway-reused-approval-id"
|
|
response = {}
|
|
relays = []
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
def fake_respond(_self, run_id, got_approval_id, choice):
|
|
relays.append((run_id, got_approval_id, choice))
|
|
return {"resolved": 1}
|
|
|
|
current = {
|
|
"command": "current command",
|
|
"approval_id": approval_id,
|
|
"run_id": current_run_id,
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
current_entry = _ApprovalEntry(current)
|
|
with routes._lock:
|
|
routes._gateway_queues[sid] = [current_entry]
|
|
route_approvals.submit_gateway_pending_mirror(sid, current)
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id))
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
|
gateway_chat._STREAM_RUN_IDS[stream_id] = current_run_id
|
|
|
|
try:
|
|
body: dict[str, object] = {
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": approval_id,
|
|
"run_id": "run-stale-owner",
|
|
"mirror_token": "mirror-stale-owner",
|
|
}
|
|
if enable_yolo:
|
|
body["yolo"] = True
|
|
routes._handle_approval_respond(object(), body)
|
|
|
|
assert response["status"] == 409
|
|
assert response["payload"]["ok"] is False
|
|
assert response["payload"]["code"] == "gateway_run_unavailable"
|
|
assert relays == []
|
|
assert not current_entry.event.is_set()
|
|
assert route_approvals.gateway_pending_mirror(
|
|
sid, approval_id=approval_id, run_id=current_run_id
|
|
) is not None
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
gateway_chat._STREAM_RUN_IDS.pop(stream_id, None)
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=current_run_id)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_approval_card_yolo_uses_authoritative_disabled_response():
|
|
messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text()
|
|
|
|
def extract(name, end_marker):
|
|
start = messages_js.index(f"async function {name}(")
|
|
return messages_js[start:messages_js.index(end_marker, start)]
|
|
|
|
script = "\n".join([
|
|
"const toasts=[]; let pillUpdates=0;",
|
|
"const S={session:{session_id:'browser-session'}};",
|
|
"let _approvalSessionId='browser-session';",
|
|
"let _approvalCurrentId='approval-1';",
|
|
"let _loadSessionGeneration=1;",
|
|
"let _approvalResponding=null;",
|
|
"let _approvalClearedOwner=null;",
|
|
"let _approvalDisplayedOwner={sid:'browser-session',approvalId:'approval-1',runId:'',mirrorToken:''};",
|
|
"let _yoloEnabled=true;",
|
|
"const _approvalPendingBySession=new Map([['browser-session',{pending:{approval_id:'approval-1'}}]]);",
|
|
"const api=async()=>({ok:true,yolo_enabled:false});",
|
|
"const $=()=>({disabled:false,classList:{contains:v=>v==='visible',add(){},remove(){}}});",
|
|
"const t=k=>k; const showToast=msg=>toasts.push(msg); const setStatus=()=>{};",
|
|
"const _unmarkApprovalDismissed=()=>{};",
|
|
"const _setApprovalControlsDisabled=()=>{}; const _clearApprovalPendingForSession=()=>{};",
|
|
"const hideApprovalCard=()=>{}; const _updateYoloPill=()=>{pillUpdates+=1;};",
|
|
_js_block(messages_js, "function _approvalMirrorOwnerFor(", "\nfunction _setApprovalControlsDisabled"),
|
|
_js_block(messages_js, "function _applyApprovalYoloProjection(", "\nfunction toggleApprovalCardCollapsed"),
|
|
extract("respondApproval", "\nfunction startApprovalPolling"),
|
|
extract("toggleYoloFromApproval", "\n// ── Approval polling"),
|
|
"(async()=>{",
|
|
" const ok=await toggleYoloFromApproval();",
|
|
" if(!ok) throw new Error('approval action failed');",
|
|
" if(_yoloEnabled!==false) throw new Error('authoritative disabled state was ignored');",
|
|
" if(pillUpdates!==1) throw new Error('pill was not updated exactly once');",
|
|
" if(JSON.stringify(toasts)!==JSON.stringify(['yolo_disabled'])) throw new Error('wrong toast '+JSON.stringify(toasts));",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_failed_approval_relay_applies_authoritative_yolo_and_restores_card():
|
|
messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text()
|
|
|
|
def extract(start_marker, end_marker):
|
|
start = messages_js.index(start_marker)
|
|
return messages_js[start:messages_js.index(end_marker, start)]
|
|
|
|
script = "\n".join([
|
|
"const toasts=[]; let pillUpdates=0; let cardRenders=0; let statuses=[];",
|
|
"const S={session:{session_id:'browser-session'}};",
|
|
"let _approvalSessionId='browser-session';",
|
|
"let _approvalCurrentId='approval-1';",
|
|
"let _loadSessionGeneration=1;",
|
|
"let _approvalResponding=null;",
|
|
"let _approvalClearedOwner=null;",
|
|
"let _approvalDisplayedOwner={sid:'browser-session',approvalId:'approval-1',runId:'',mirrorToken:''};",
|
|
"let _yoloEnabled=false;",
|
|
"const _approvalPendingBySession=new Map([['browser-session',{pending:{approval_id:'approval-1'}}]]);",
|
|
"const api=async()=>{const e=new Error('relay failed');e.status=502;e.body=JSON.stringify({ok:false,error:'relay failed',yolo_enabled:true});throw e;};",
|
|
"const $=()=>({disabled:false,classList:{contains:v=>v==='visible',add(){},remove(){}}});",
|
|
"const t=k=>k; const showToast=msg=>toasts.push(msg); const setStatus=msg=>statuses.push(msg);",
|
|
"const _unmarkApprovalDismissed=()=>{};",
|
|
"const _setApprovalControlsDisabled=()=>{}; const _clearApprovalPendingForSession=()=>{};",
|
|
"const hideApprovalCard=()=>{}; const _updateYoloPill=()=>{pillUpdates+=1;};",
|
|
"const _approvalPromptBelongsToActiveSession=()=>true;",
|
|
"const _renderPendingApprovalForActiveSession=()=>{cardRenders+=1;};",
|
|
extract("function _approvalMirrorOwnerFor(", "\nfunction _setApprovalControlsDisabled"),
|
|
extract("function _restoreFailedApprovalResponse(", "\nfunction toggleApprovalCardCollapsed"),
|
|
extract("async function respondApproval(", "\nfunction startApprovalPolling"),
|
|
extract("async function toggleYoloFromApproval(", "\n// ── Approval polling"),
|
|
"(async()=>{",
|
|
" const ok=await toggleYoloFromApproval();",
|
|
" if(ok) throw new Error('failed relay reported success');",
|
|
" if(_yoloEnabled!==true) throw new Error('authoritative enabled state was ignored');",
|
|
" if(pillUpdates!==1) throw new Error('pill was not updated exactly once');",
|
|
" if(cardRenders!==1) throw new Error('approval card was not restored');",
|
|
" if(JSON.stringify(toasts)!==JSON.stringify(['relay failed'])) throw new Error('wrong toast '+JSON.stringify(toasts));",
|
|
" if(JSON.stringify(statuses)!==JSON.stringify(['relay failed'])) throw new Error('wrong status '+JSON.stringify(statuses));",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_yolo_command_resumes_visible_approval_through_same_atomic_action():
|
|
commands_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "commands.js").read_text()
|
|
start = commands_js.index("async function cmdYolo(")
|
|
body = commands_js[start:commands_js.index("\n// ── Branch / fork command", start)]
|
|
script = "\n".join([
|
|
"const calls=[];",
|
|
"const S={session:{session_id:'browser-session'}};",
|
|
"let _loadSessionGeneration=1;",
|
|
"let _approvalSessionId='browser-session';",
|
|
"let _approvalCurrentId='approval-1';",
|
|
"let _yoloEnabled=false;",
|
|
"let atomicCalls=0;",
|
|
"const api=async(path,opts={})=>{calls.push([path,opts]);return {yolo_enabled:false};};",
|
|
"const toggleYoloFromApproval=async()=>{atomicCalls+=1;return true;};",
|
|
"const $=()=>({classList:{contains:()=>true}});",
|
|
"const _captureApprovalResponseOwner=()=>({sid:'browser-session',generation:1,approvalId:'approval-1'});",
|
|
"const t=k=>k; const showToast=()=>{}; const _updateYoloPill=()=>{}; const hideApprovalCard=()=>{};",
|
|
body,
|
|
"(async()=>{",
|
|
" await cmdYolo();",
|
|
" if(atomicCalls!==1) throw new Error('atomic action not used');",
|
|
" if(calls.length!==1||!calls[0][0].startsWith('/api/session/yolo?')) throw new Error('unexpected requests '+JSON.stringify(calls));",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_yolo_command_uses_authoritative_post_response_state():
|
|
commands_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "commands.js").read_text()
|
|
start = commands_js.index("async function cmdYolo(")
|
|
body = commands_js[start:commands_js.index("\n// ── Branch / fork command", start)]
|
|
script = "\n".join([
|
|
"const calls=[]; const toasts=[]; let pillUpdates=0; let cardHides=0;",
|
|
"const S={session:{session_id:'browser-session'}};",
|
|
"let _loadSessionGeneration=1;",
|
|
"let _approvalSessionId=null; let _approvalCurrentId=null; let _yoloEnabled=false;",
|
|
"const api=async(path,opts={})=>{calls.push(path);return path.includes('?')?{yolo_enabled:false}:{ok:true,yolo_enabled:false};};",
|
|
"const toggleYoloFromApproval=async()=>{throw new Error('card path must not run');};",
|
|
"const $=()=>({classList:{contains:()=>false}});",
|
|
"const t=k=>k; const showToast=msg=>toasts.push(msg);",
|
|
"const _updateYoloPill=()=>{pillUpdates+=1;}; const hideApprovalCard=()=>{cardHides+=1;};",
|
|
body,
|
|
"(async()=>{",
|
|
" await cmdYolo();",
|
|
" if(_yoloEnabled!==false) throw new Error('authoritative disabled state was ignored');",
|
|
" if(pillUpdates!==1) throw new Error('pill was not updated exactly once');",
|
|
" if(cardHides!==0) throw new Error('card hidden despite settled disabled state');",
|
|
" if(JSON.stringify(toasts)!==JSON.stringify(['yolo_disabled'])) throw new Error('wrong toast '+JSON.stringify(toasts));",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_yolo_command_applies_authoritative_state_from_failed_post():
|
|
commands_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "commands.js").read_text()
|
|
start = commands_js.index("async function cmdYolo(")
|
|
body = commands_js[start:commands_js.index("\n// ── Branch / fork command", start)]
|
|
script = "\n".join([
|
|
"const toasts=[]; let pillUpdates=0; let cardHides=0;",
|
|
"const S={session:{session_id:'browser-session'}};",
|
|
"let _loadSessionGeneration=1;",
|
|
"let _approvalSessionId=null; let _approvalCurrentId=null; let _yoloEnabled=false;",
|
|
"const api=async(path)=>{if(path.includes('?'))return {yolo_enabled:false};const e=new Error('relay busy');e.body=JSON.stringify({error:'relay busy',yolo_enabled:true});throw e;};",
|
|
"const toggleYoloFromApproval=async()=>{throw new Error('card path must not run');};",
|
|
"const $=()=>({classList:{contains:()=>false}});",
|
|
"const t=k=>k; const showToast=msg=>toasts.push(msg);",
|
|
"const _updateYoloPill=()=>{pillUpdates+=1;}; const hideApprovalCard=()=>{cardHides+=1;};",
|
|
body,
|
|
"(async()=>{",
|
|
" await cmdYolo();",
|
|
" if(_yoloEnabled!==true) throw new Error('authoritative enabled error state was ignored');",
|
|
" if(pillUpdates!==1) throw new Error('pill was not updated exactly once');",
|
|
" if(cardHides!==0) throw new Error('failed request hid the card');",
|
|
" if(JSON.stringify(toasts)!==JSON.stringify(['YOLO: relay busy'])) throw new Error('wrong toast '+JSON.stringify(toasts));",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
def _run_with_approval_events(monkeypatch, *, yolo_enabled, auto_error=None):
|
|
requests = []
|
|
browser_events = []
|
|
approvals = []
|
|
responses = []
|
|
|
|
class Response:
|
|
def __init__(self, *, body=b"", lines=()):
|
|
self.body = body
|
|
self.lines = list(lines)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *args):
|
|
return False
|
|
|
|
def read(self, _size=-1):
|
|
return self.body
|
|
|
|
def __iter__(self):
|
|
return iter(self.lines)
|
|
|
|
event_lines = [
|
|
b'event: approval.request\n',
|
|
b'data: {"event":"approval.request","approval_id":"approval-1","tool":"terminal","command":"one"}\n',
|
|
b'event: approval.request\n',
|
|
b'data: {"event":"approval.request","approval_id":"approval-2","tool":"terminal","command":"two"}\n',
|
|
b'event: run.completed\n',
|
|
b'data: {"event":"run.completed","output":"done"}\n',
|
|
]
|
|
responses.extend([
|
|
Response(body=b'{"run_id":"run-1"}'),
|
|
Response(lines=event_lines),
|
|
])
|
|
|
|
def fake_urlopen(req, timeout=0):
|
|
requests.append(req)
|
|
return responses.pop(0)
|
|
|
|
def fake_auto(base_url, api_key, run_id, approval_id):
|
|
approvals.append((base_url, api_key, run_id, approval_id))
|
|
if auto_error:
|
|
raise auto_error
|
|
|
|
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
|
|
monkeypatch.setattr(gateway_chat, "update_active_run", lambda *a, **k: None)
|
|
monkeypatch.setattr(gateway_chat, "_publish_gateway_run_id", lambda *a, **k: None)
|
|
if yolo_enabled is not None:
|
|
monkeypatch.setattr(gateway_chat, "_gateway_session_yolo_enabled", lambda _sid: yolo_enabled)
|
|
monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", fake_auto)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setattr("api.route_approvals.submit_gateway_pending_mirror", lambda _sid, data: (data, 1))
|
|
monkeypatch.setattr("api.route_approvals.retire_gateway_pending_mirror", lambda *a, **k: None)
|
|
|
|
final, _usage = gateway_chat._run_gateway_runs_api_streaming(
|
|
"browser-session",
|
|
"hello",
|
|
"test-model",
|
|
"/tmp",
|
|
"stream-1",
|
|
"http://gateway.local",
|
|
"secret",
|
|
[],
|
|
{},
|
|
put_gateway_event=lambda name, data: browser_events.append((name, data)),
|
|
cancel_event=threading.Event(),
|
|
)
|
|
return final, requests, approvals, browser_events
|
|
|
|
|
|
def test_enabled_gateway_yolo_auto_approves_every_later_prompt(monkeypatch):
|
|
final, requests, approvals, browser_events = _run_with_approval_events(
|
|
monkeypatch, yolo_enabled=True
|
|
)
|
|
|
|
assert final == "done"
|
|
assert approvals == [
|
|
("http://gateway.local", "secret", "run-1", "approval-1"),
|
|
("http://gateway.local", "secret", "run-1", "approval-2"),
|
|
]
|
|
assert not [event for event in browser_events if event[0] == "approval"]
|
|
run_body = json.loads(requests[0].data)
|
|
assert "yolo" not in run_body
|
|
|
|
|
|
def test_disabled_gateway_yolo_surfaces_prompts_without_auto_approval(monkeypatch):
|
|
_final, _requests, approvals, browser_events = _run_with_approval_events(
|
|
monkeypatch, yolo_enabled=False
|
|
)
|
|
|
|
assert approvals == []
|
|
assert [event[1]["approval_id"] for event in browser_events if event[0] == "approval"] == [
|
|
"approval-1",
|
|
"approval-2",
|
|
]
|
|
|
|
|
|
def test_gateway_yolo_auto_approval_failure_falls_back_to_visible_card(monkeypatch):
|
|
_final, _requests, approvals, browser_events = _run_with_approval_events(
|
|
monkeypatch,
|
|
yolo_enabled=True,
|
|
auto_error=RunnerClientError("pristine API rejected approval"),
|
|
)
|
|
|
|
assert len(approvals) == 2
|
|
assert [event[1]["approval_id"] for event in browser_events if event[0] == "approval"] == [
|
|
"approval-1",
|
|
"approval-2",
|
|
]
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_inflight_yolo_relay_does_not_auto_approve_later_prompt(monkeypatch):
|
|
from api import route_approvals
|
|
|
|
sid = "browser-session"
|
|
disable_session_yolo(sid)
|
|
token = route_approvals.begin_session_yolo_transition(sid)
|
|
try:
|
|
_final, _requests, approvals, browser_events = _run_with_approval_events(
|
|
monkeypatch,
|
|
yolo_enabled=None,
|
|
)
|
|
|
|
assert approvals == []
|
|
assert [event[1]["approval_id"] for event in browser_events if event[0] == "approval"] == [
|
|
"approval-1",
|
|
"approval-2",
|
|
]
|
|
finally:
|
|
route_approvals.finish_session_yolo_transition(sid, token, succeeded=False)
|
|
disable_session_yolo(sid)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_overlapping_yolo_relays_publish_only_confirmed_success():
|
|
from api import route_approvals
|
|
|
|
sid = "webui-yolo-overlapping-relays"
|
|
disable_session_yolo(sid)
|
|
first = route_approvals.begin_session_yolo_transition(sid)
|
|
second = route_approvals.begin_session_yolo_transition(sid)
|
|
try:
|
|
assert is_session_yolo_enabled(sid) is False
|
|
route_approvals.finish_session_yolo_transition(sid, first, succeeded=True)
|
|
assert is_session_yolo_enabled(sid) is True
|
|
route_approvals.finish_session_yolo_transition(sid, second, succeeded=False)
|
|
assert is_session_yolo_enabled(sid) is True
|
|
finally:
|
|
route_approvals.finish_session_yolo_transition(sid, first, succeeded=False)
|
|
route_approvals.finish_session_yolo_transition(sid, second, succeeded=False)
|
|
disable_session_yolo(sid)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
@pytest.mark.parametrize("relay_fails", [False, True])
|
|
def test_card_yolo_uses_plain_runs_approval_and_rolls_back_on_failure(monkeypatch, relay_fails):
|
|
from api import route_approvals as route_approvals
|
|
from api import routes
|
|
|
|
sid = "webui-yolo-route-failure" if relay_fails else "webui-yolo-route-success"
|
|
stream_id = "stream-yolo-route"
|
|
run_id = "run-yolo-route"
|
|
disable_session_yolo(sid)
|
|
captured = {}
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
captured["payload"] = data
|
|
captured["status"] = status
|
|
return data
|
|
|
|
calls = []
|
|
states_during_relay = []
|
|
|
|
def fake_respond(_self, got_run_id, approval_id, choice):
|
|
calls.append((got_run_id, approval_id, choice))
|
|
states_during_relay.append(bool(is_session_yolo_enabled(sid)))
|
|
if relay_fails:
|
|
raise RunnerClientError("relay failed")
|
|
return {"resolved": 1}
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id))
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
|
gateway_chat._STREAM_RUN_IDS[stream_id] = run_id
|
|
|
|
approval = {
|
|
"command": "touch /tmp/webui-yolo-test",
|
|
"description": "test",
|
|
"approval_id": "approval-route",
|
|
"run_id": run_id,
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
route_approvals.submit_gateway_pending_mirror(sid, approval)
|
|
try:
|
|
routes._handle_approval_respond(
|
|
object(),
|
|
{
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": "approval-route",
|
|
"yolo": True,
|
|
},
|
|
)
|
|
assert calls == [(run_id, "approval-route", "once")]
|
|
assert states_during_relay == [False]
|
|
assert captured["status"] == (502 if relay_fails else 200)
|
|
assert captured["payload"]["ok"] is (not relay_fails)
|
|
assert is_session_yolo_enabled(sid) is (not relay_fails)
|
|
if not relay_fails:
|
|
assert captured["payload"]["yolo_enabled"] is True
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
gateway_chat._STREAM_RUN_IDS.pop(stream_id, None)
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_failed_relay_does_not_undo_concurrent_explicit_yolo_enable(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-yolo-route-concurrent-enable"
|
|
stream_id = "stream-yolo-route-concurrent"
|
|
run_id = "run-yolo-route-concurrent"
|
|
relay_started = threading.Event()
|
|
release_relay = threading.Event()
|
|
response = {}
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_respond(_self, _run_id, _approval_id, _choice):
|
|
relay_started.set()
|
|
assert release_relay.wait(timeout=5)
|
|
raise RunnerClientError("relay failed")
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id))
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
|
gateway_chat._STREAM_RUN_IDS[stream_id] = run_id
|
|
route_approvals.submit_gateway_pending_mirror(sid, {
|
|
"command": "touch /tmp/webui-yolo-test",
|
|
"description": "test",
|
|
"approval_id": "approval-route-concurrent",
|
|
"run_id": run_id,
|
|
"_gateway_agent_identity_v1": True,
|
|
})
|
|
|
|
worker = threading.Thread(target=routes._handle_approval_respond, args=(
|
|
object(),
|
|
{
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": "approval-route-concurrent",
|
|
"yolo": True,
|
|
},
|
|
))
|
|
try:
|
|
worker.start()
|
|
assert relay_started.wait(timeout=5)
|
|
explicit_enable = getattr(
|
|
route_approvals,
|
|
"set_session_yolo_enabled",
|
|
lambda session_key, _enabled: route_approvals.enable_session_yolo(session_key),
|
|
)
|
|
explicit_enable(sid, True)
|
|
release_relay.set()
|
|
worker.join(timeout=5)
|
|
assert not worker.is_alive()
|
|
assert response["status"] == 502
|
|
assert response["payload"]["ok"] is False
|
|
assert response["payload"]["yolo_enabled"] is True
|
|
assert is_session_yolo_enabled(sid) is True
|
|
finally:
|
|
release_relay.set()
|
|
worker.join(timeout=5)
|
|
disable_session_yolo(sid)
|
|
gateway_chat._STREAM_RUN_IDS.pop(stream_id, None)
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_successful_relay_reports_concurrent_explicit_yolo_disable(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-yolo-route-concurrent-disable"
|
|
stream_id = "stream-yolo-route-concurrent-disable"
|
|
run_id = "run-yolo-route-concurrent-disable"
|
|
relay_started = threading.Event()
|
|
release_relay = threading.Event()
|
|
response = {}
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_respond(_self, _run_id, _approval_id, _choice):
|
|
relay_started.set()
|
|
assert release_relay.wait(timeout=5)
|
|
return {"resolved": 1}
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=stream_id))
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
|
gateway_chat._STREAM_RUN_IDS[stream_id] = run_id
|
|
route_approvals.submit_gateway_pending_mirror(sid, {
|
|
"command": "touch /tmp/webui-yolo-test",
|
|
"description": "test",
|
|
"approval_id": "approval-route-concurrent-disable",
|
|
"run_id": run_id,
|
|
"_gateway_agent_identity_v1": True,
|
|
})
|
|
|
|
worker = threading.Thread(target=routes._handle_approval_respond, args=(
|
|
object(),
|
|
{
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": "approval-route-concurrent-disable",
|
|
"yolo": True,
|
|
},
|
|
))
|
|
try:
|
|
worker.start()
|
|
assert relay_started.wait(timeout=5)
|
|
route_approvals.set_session_yolo_enabled(sid, False)
|
|
release_relay.set()
|
|
worker.join(timeout=5)
|
|
assert not worker.is_alive()
|
|
assert response["status"] == 200
|
|
assert response["payload"]["ok"] is True
|
|
assert response["payload"]["yolo_enabled"] is False
|
|
assert is_session_yolo_enabled(sid) is False
|
|
finally:
|
|
release_relay.set()
|
|
worker.join(timeout=5)
|
|
disable_session_yolo(sid)
|
|
gateway_chat._STREAM_RUN_IDS.pop(stream_id, None)
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_yolo_post_without_local_card_relays_run_backed_approval(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-yolo-post-poll-lag"
|
|
run_id = "run-yolo-post-poll-lag"
|
|
handler = object()
|
|
response = {}
|
|
calls = []
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_respond(_self, got_run_id, approval_id, choice):
|
|
calls.append((got_run_id, approval_id, choice))
|
|
return {"resolved": 1}
|
|
|
|
def fake_j(got_handler, data, status=200, extra_headers=None):
|
|
assert got_handler is handler
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": True})
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False)
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
|
route_approvals.submit_gateway_pending_mirror(sid, {
|
|
"command": "touch /tmp/webui-yolo-test",
|
|
"description": "test",
|
|
"approval_id": "approval-yolo-post",
|
|
"run_id": run_id,
|
|
"_gateway_agent_identity_v1": True,
|
|
})
|
|
|
|
try:
|
|
routes.handle_post(handler, urllib.parse.urlparse("/api/session/yolo"))
|
|
|
|
assert response["status"] == 200
|
|
assert response["payload"]["ok"] is True
|
|
assert response["payload"]["yolo_enabled"] is True
|
|
assert calls == [(run_id, "approval-yolo-post", "once")]
|
|
assert is_session_yolo_enabled(sid) is True
|
|
assert route_approvals.gateway_pending_mirror(sid, run_id=run_id) is None
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_yolo_post_preserves_mirror_while_owned_relay_fails(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-yolo-post-owned-failure"
|
|
run_id = "run-yolo-post-owned-failure"
|
|
approval_id = "approval-yolo-post-owned-failure"
|
|
card_handler = object()
|
|
toggle_handler = object()
|
|
relay_started = threading.Event()
|
|
release_relay = threading.Event()
|
|
toggle_entered = threading.Event()
|
|
responses = {}
|
|
relay_attempts = []
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_respond(_self, _run_id, _approval_id, _choice):
|
|
relay_attempts.append((_run_id, _approval_id, _choice))
|
|
relay_started.set()
|
|
assert release_relay.wait(timeout=5)
|
|
raise RunnerClientError("relay failed")
|
|
|
|
def fake_read_body(_handler):
|
|
toggle_entered.set()
|
|
return {"session_id": sid, "enabled": True}
|
|
|
|
def fake_j(handler, data, status=200, extra_headers=None):
|
|
responses[handler] = {"payload": data, "status": status}
|
|
return data
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "read_body", fake_read_body)
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"get_session",
|
|
lambda _sid, **_kwargs: SimpleNamespace(active_stream_id=None, profile=None),
|
|
)
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
|
|
route_approvals.submit_gateway_pending_mirror(sid, {
|
|
"command": "touch /tmp/webui-yolo-test",
|
|
"description": "test",
|
|
"approval_id": approval_id,
|
|
"run_id": run_id,
|
|
"_gateway_agent_identity_v1": True,
|
|
})
|
|
worker = threading.Thread(target=routes._handle_approval_respond, args=(
|
|
card_handler,
|
|
{"session_id": sid, "choice": "once", "approval_id": approval_id, "yolo": True},
|
|
))
|
|
toggle_worker = threading.Thread(
|
|
target=lambda: routes.handle_post(
|
|
toggle_handler, urllib.parse.urlparse("/api/session/yolo")
|
|
)
|
|
)
|
|
|
|
try:
|
|
worker.start()
|
|
assert relay_started.wait(timeout=5)
|
|
toggle_worker.start()
|
|
assert toggle_entered.wait(timeout=5)
|
|
assert toggle_worker.is_alive()
|
|
assert toggle_handler not in responses
|
|
|
|
release_relay.set()
|
|
worker.join(timeout=5)
|
|
toggle_worker.join(timeout=5)
|
|
assert not worker.is_alive()
|
|
assert not toggle_worker.is_alive()
|
|
assert relay_attempts == [
|
|
(run_id, approval_id, "once"),
|
|
(run_id, approval_id, "once"),
|
|
]
|
|
assert responses[card_handler]["status"] == 502
|
|
assert responses[toggle_handler]["status"] == 502
|
|
assert responses[card_handler]["payload"]["yolo_enabled"] is False
|
|
assert responses[toggle_handler]["payload"]["yolo_enabled"] is False
|
|
assert is_session_yolo_enabled(sid) is False
|
|
assert route_approvals.gateway_pending_mirror(
|
|
sid, approval_id=approval_id, run_id=run_id
|
|
) is not None
|
|
finally:
|
|
release_relay.set()
|
|
worker.join(timeout=5)
|
|
toggle_worker.join(timeout=5)
|
|
disable_session_yolo(sid)
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_yolo_post_keeps_transition_unconfirmed_until_second_mirror_check(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-yolo-post-transition-window"
|
|
handler = object()
|
|
observed_transition_states = []
|
|
observed_gateway_states = []
|
|
response = {}
|
|
mirror = {
|
|
"command": "touch /tmp/webui-yolo-test",
|
|
"description": "test",
|
|
"approval_id": "approval-transition-window",
|
|
"run_id": "run-transition-window",
|
|
"_gateway_mirror": True,
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
def fake_mirrors(_sid):
|
|
with route_approvals._yolo_transition_lock:
|
|
observed_transition_states.append(bool(route_approvals._yolo_transitions.get(sid)))
|
|
return [dict(mirror)]
|
|
|
|
def fake_relay(_sid, _mirror, _choice, *, enable_yolo):
|
|
assert enable_yolo is False
|
|
with route_approvals._yolo_transition_lock:
|
|
observed_transition_states.append(bool(route_approvals._yolo_transitions.get(sid)))
|
|
observed_gateway_states.append(gateway_chat._gateway_session_yolo_enabled(sid))
|
|
return {
|
|
"ok": True,
|
|
"choice": "once",
|
|
"relayed": True,
|
|
"yolo_enabled": False,
|
|
}, 200
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": True})
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(routes, "gateway_pending_mirrors", fake_mirrors)
|
|
monkeypatch.setattr(routes, "_relay_gateway_run_approval", fake_relay)
|
|
|
|
try:
|
|
routes.handle_post(handler, urllib.parse.urlparse("/api/session/yolo"))
|
|
assert response["status"] == 200
|
|
assert response["payload"]["ok"] is True
|
|
assert response["payload"]["yolo_enabled"] is True
|
|
assert observed_transition_states == [True, True]
|
|
assert observed_gateway_states == [False]
|
|
assert is_session_yolo_enabled(sid) is True
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_no_run_approval_success_reports_authoritative_yolo_state(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-no-run-authoritative-success"
|
|
response = {}
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
original_enable = route_approvals.enable_session_yolo
|
|
|
|
def racing_enable(session_key):
|
|
original_enable(session_key)
|
|
disable_session_yolo(session_key)
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None))
|
|
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: True)
|
|
monkeypatch.setattr(routes, "resolve_gateway_pending_local_no_run_mirror", lambda *_a: (True, 1, None, 0))
|
|
monkeypatch.setattr(route_approvals, "enable_session_yolo", racing_enable)
|
|
|
|
try:
|
|
routes._handle_approval_respond(
|
|
object(),
|
|
{"session_id": sid, "choice": "once", "approval_id": "approval-no-run", "yolo": True},
|
|
)
|
|
assert response["status"] == 200
|
|
assert response["payload"]["ok"] is True
|
|
assert response["payload"]["yolo_enabled"] is False
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_local_approval_success_reports_authoritative_yolo_state(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-local-authoritative-success"
|
|
response = {}
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
original_enable = route_approvals.enable_session_yolo
|
|
|
|
def racing_enable(session_key):
|
|
original_enable(session_key)
|
|
disable_session_yolo(session_key)
|
|
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None))
|
|
monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False)
|
|
monkeypatch.setattr("api.runtime_adapter.runtime_adapter_enabled", lambda: False)
|
|
monkeypatch.setattr(routes, "_resolve_approval_legacy", lambda *_a: True)
|
|
monkeypatch.setattr(route_approvals, "enable_session_yolo", racing_enable)
|
|
|
|
try:
|
|
routes._handle_approval_respond(
|
|
object(),
|
|
{"session_id": sid, "choice": "once", "approval_id": "approval-local", "yolo": True},
|
|
)
|
|
assert response["status"] == 200
|
|
assert response["payload"]["ok"] is True
|
|
assert response["payload"]["yolo_enabled"] is False
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_yolo_post_serializes_post_snapshot_gateway_approval(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-yolo-post-snapshot-handoff"
|
|
handler = object()
|
|
response = {}
|
|
worker_results = []
|
|
workers = []
|
|
auto_approved = []
|
|
approval = {
|
|
"command": "touch /tmp/webui-yolo-test",
|
|
"description": "test",
|
|
"approval_id": "approval-post-snapshot",
|
|
"run_id": "run-post-snapshot",
|
|
"_gateway_mirror": True,
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
|
|
class NotifyingLock:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self.contended = threading.Event()
|
|
|
|
def __enter__(self):
|
|
if not self._lock.acquire(blocking=False):
|
|
self.contended.set()
|
|
self._lock.acquire()
|
|
return self
|
|
|
|
def __exit__(self, _exc_type, _exc, _tb):
|
|
self._lock.release()
|
|
|
|
handoff_lock = NotifyingLock()
|
|
original_finish = routes.finish_session_yolo_transition
|
|
disable_session_yolo(sid)
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
def racing_finish(session_key, token, *, succeeded):
|
|
worker = threading.Thread(
|
|
target=lambda: worker_results.append(
|
|
gateway_chat._settle_gateway_run_approval(sid, dict(approval), "http://gateway", "")
|
|
)
|
|
)
|
|
worker.start()
|
|
workers.append(worker)
|
|
assert handoff_lock.contended.wait(timeout=1)
|
|
return original_finish(session_key, token, succeeded=succeeded)
|
|
|
|
monkeypatch.setattr(route_approvals, "gateway_yolo_handoff", lambda _sid: handoff_lock)
|
|
monkeypatch.setattr(routes, "gateway_yolo_handoff", lambda _sid: handoff_lock)
|
|
monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", lambda *_a: auto_approved.append(True))
|
|
monkeypatch.setattr(routes, "finish_session_yolo_transition", racing_finish)
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": True})
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False)
|
|
monkeypatch.setattr(routes, "gateway_pending_mirror", lambda _sid: None)
|
|
monkeypatch.setattr(routes, "reconcile_gateway_pending_mirror_locked", lambda _sid: (None, 0, False))
|
|
monkeypatch.setattr(routes, "resolve_gateway_approval", lambda *_a, **_k: 0)
|
|
|
|
try:
|
|
routes.handle_post(handler, urllib.parse.urlparse("/api/session/yolo"))
|
|
for worker in workers:
|
|
worker.join(timeout=1)
|
|
assert not worker.is_alive()
|
|
assert response["status"] == 200
|
|
assert response["payload"] == {"ok": True, "yolo_enabled": True}
|
|
assert worker_results == [(True, None, 0)]
|
|
assert auto_approved == [True]
|
|
assert route_approvals.gateway_pending_mirror(sid) is None
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_yolo_drain_serializes_post_drain_local_approval(monkeypatch):
|
|
from api import route_approvals, routes
|
|
from tools.approval import _ApprovalEntry
|
|
|
|
sid = "webui-yolo-post-drain-local-handoff"
|
|
response = {}
|
|
worker_results = []
|
|
worker_errors = []
|
|
workers = []
|
|
initial = _ApprovalEntry({"approval_id": "approval-initial", "command": "initial"})
|
|
late = _ApprovalEntry({"command": "late"})
|
|
|
|
class NotifyingLock:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self.contended = threading.Event()
|
|
|
|
def __enter__(self):
|
|
if not self._lock.acquire(blocking=False):
|
|
self.contended.set()
|
|
self._lock.acquire()
|
|
return self
|
|
|
|
def __exit__(self, _exc_type, _exc, _tb):
|
|
self._lock.release()
|
|
|
|
handoff_lock = NotifyingLock()
|
|
original_drain = route_approvals.resolve_gateway_pending_local_all
|
|
disable_session_yolo(sid)
|
|
with routes._lock:
|
|
routes._gateway_queues[sid] = [initial]
|
|
routes._pending[sid] = [dict(initial.data)]
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
def notify_late_local_approval():
|
|
try:
|
|
worker_results.append(
|
|
route_approvals.settle_gateway_pending_local_notification(sid, dict(late.data))
|
|
)
|
|
except BaseException as exc:
|
|
worker_errors.append(exc)
|
|
|
|
def paused_after_drain(session_key, choice, reason=None):
|
|
result = original_drain(session_key, choice, reason)
|
|
with routes._lock:
|
|
routes._gateway_queues.setdefault(sid, []).append(late)
|
|
worker = threading.Thread(target=notify_late_local_approval)
|
|
worker.start()
|
|
workers.append(worker)
|
|
assert handoff_lock.contended.wait(timeout=1)
|
|
assert not late.event.is_set()
|
|
return result
|
|
|
|
monkeypatch.setattr(route_approvals, "gateway_yolo_handoff", lambda _sid: handoff_lock)
|
|
monkeypatch.setattr(routes, "gateway_yolo_handoff", lambda _sid: handoff_lock)
|
|
monkeypatch.setattr(routes, "resolve_gateway_pending_local_all", paused_after_drain)
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
|
|
try:
|
|
routes._handle_approval_respond(
|
|
object(),
|
|
{
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": "approval-initial",
|
|
"yolo": True,
|
|
},
|
|
)
|
|
for worker in workers:
|
|
worker.join(timeout=1)
|
|
assert not worker.is_alive()
|
|
|
|
assert worker_errors == []
|
|
assert response["status"] == 200
|
|
assert response["payload"]["ok"] is True
|
|
assert response["payload"]["yolo_enabled"] is True
|
|
assert initial.event.is_set()
|
|
assert initial.result == "once"
|
|
assert late.event.is_set()
|
|
assert late.result == "once"
|
|
assert worker_results == [(True, None, 0)]
|
|
with routes._lock:
|
|
assert sid not in routes._gateway_queues
|
|
assert sid not in routes._pending
|
|
finally:
|
|
for worker in workers:
|
|
worker.join(timeout=1)
|
|
disable_session_yolo(sid)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_yolo_disable_linearizes_after_selected_gateway_autoapproval(monkeypatch):
|
|
from api import routes
|
|
|
|
sid = "webui-yolo-disable-autoapprove-race"
|
|
approval_started = threading.Event()
|
|
release_approval = threading.Event()
|
|
disable_entered = threading.Event()
|
|
disable_handoff_attempted = threading.Event()
|
|
disable_handoff_acquired = threading.Event()
|
|
events = []
|
|
worker_results = []
|
|
response = {}
|
|
approval = {
|
|
"approval_id": "approval-disable-race",
|
|
"run_id": "run-disable-race",
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
routes.set_session_yolo_enabled(sid, True)
|
|
original_handoff = routes.gateway_yolo_handoff
|
|
|
|
@contextmanager
|
|
def observed_disable_handoff(session_key):
|
|
disable_handoff_attempted.set()
|
|
with original_handoff(session_key):
|
|
disable_handoff_acquired.set()
|
|
yield
|
|
|
|
def fake_auto_approve(*_args):
|
|
events.append("approval-started")
|
|
approval_started.set()
|
|
assert release_approval.wait(timeout=1)
|
|
events.append("approval-finished")
|
|
|
|
def fake_read_body(_handler):
|
|
disable_entered.set()
|
|
return {"session_id": sid, "enabled": False}
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
events.append("disable-response")
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", fake_auto_approve)
|
|
monkeypatch.setattr(routes, "read_body", fake_read_body)
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "gateway_yolo_handoff", observed_disable_handoff)
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False)
|
|
|
|
approval_worker = threading.Thread(
|
|
target=lambda: worker_results.append(
|
|
gateway_chat._settle_gateway_run_approval(sid, dict(approval), "http://gateway", "")
|
|
)
|
|
)
|
|
disable_worker = threading.Thread(
|
|
target=lambda: routes.handle_post(object(), urllib.parse.urlparse("/api/session/yolo"))
|
|
)
|
|
|
|
try:
|
|
approval_worker.start()
|
|
assert approval_started.wait(timeout=1)
|
|
disable_worker.start()
|
|
assert disable_entered.wait(timeout=1)
|
|
assert disable_handoff_attempted.wait(timeout=1)
|
|
assert not disable_handoff_acquired.is_set()
|
|
assert "disable-response" not in events
|
|
release_approval.set()
|
|
approval_worker.join(timeout=1)
|
|
disable_worker.join(timeout=1)
|
|
assert not approval_worker.is_alive()
|
|
assert not disable_worker.is_alive()
|
|
assert disable_handoff_acquired.is_set()
|
|
assert worker_results == [(True, None, 0)]
|
|
assert events == ["approval-started", "approval-finished", "disable-response"]
|
|
assert response["payload"] == {"ok": True, "yolo_enabled": False}
|
|
assert is_session_yolo_enabled(sid) is False
|
|
finally:
|
|
release_approval.set()
|
|
disable_session_yolo(sid)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_disable_that_wins_handoff_precedes_enable_drain(monkeypatch):
|
|
from api import routes
|
|
from tools.approval import _ApprovalEntry
|
|
|
|
sid = "webui-yolo-disable-before-enable-handoff"
|
|
handoff_lock = threading.Lock()
|
|
enable_handoff_attempted = threading.Event()
|
|
allow_enable_handoff = threading.Event()
|
|
responses = {}
|
|
worker_errors = []
|
|
entry = _ApprovalEntry({"approval_id": "approval-after-disable", "command": "late enable"})
|
|
|
|
@contextmanager
|
|
def ordered_handoff(_session_key):
|
|
role = threading.current_thread().name
|
|
if role == "yolo-enable":
|
|
enable_handoff_attempted.set()
|
|
assert allow_enable_handoff.wait(timeout=1)
|
|
with handoff_lock:
|
|
yield
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
responses[threading.current_thread().name] = {"payload": data, "status": status}
|
|
return data
|
|
|
|
def run_enable():
|
|
try:
|
|
routes._handle_approval_respond(
|
|
object(),
|
|
{
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": "approval-after-disable",
|
|
"yolo": True,
|
|
},
|
|
)
|
|
except BaseException as exc:
|
|
worker_errors.append(exc)
|
|
|
|
def run_disable():
|
|
try:
|
|
routes.handle_post(object(), urllib.parse.urlparse("/api/session/yolo"))
|
|
except BaseException as exc:
|
|
worker_errors.append(exc)
|
|
|
|
disable_session_yolo(sid)
|
|
with routes._lock:
|
|
routes._gateway_queues[sid] = [entry]
|
|
routes._pending[sid] = [dict(entry.data)]
|
|
|
|
monkeypatch.setattr(routes, "gateway_yolo_handoff", ordered_handoff)
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": False})
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False)
|
|
|
|
enable_worker = threading.Thread(target=run_enable, name="yolo-enable")
|
|
disable_worker = threading.Thread(target=run_disable, name="yolo-disable")
|
|
try:
|
|
enable_worker.start()
|
|
assert enable_handoff_attempted.wait(timeout=1)
|
|
disable_worker.start()
|
|
disable_worker.join(timeout=1)
|
|
assert not disable_worker.is_alive()
|
|
assert responses["yolo-disable"]["payload"] == {"ok": True, "yolo_enabled": False}
|
|
assert not entry.event.is_set()
|
|
|
|
allow_enable_handoff.set()
|
|
enable_worker.join(timeout=1)
|
|
assert not enable_worker.is_alive()
|
|
assert worker_errors == []
|
|
assert responses["yolo-enable"]["status"] == 200
|
|
assert responses["yolo-enable"]["payload"]["ok"] is True
|
|
assert responses["yolo-enable"]["payload"]["yolo_enabled"] is True
|
|
assert entry.event.is_set()
|
|
assert entry.result == "once"
|
|
assert is_session_yolo_enabled(sid) is True
|
|
finally:
|
|
allow_enable_handoff.set()
|
|
enable_worker.join(timeout=1)
|
|
disable_worker.join(timeout=1)
|
|
disable_session_yolo(sid)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
def test_yolo_post_first_relay_serializes_next_gateway_approval(monkeypatch):
|
|
from api import route_approvals, routes
|
|
|
|
sid = "webui-yolo-first-relay-next-approval"
|
|
run_id = "run-first-relay-next-approval"
|
|
response = {}
|
|
stream_results = []
|
|
stream_workers = []
|
|
auto_approved = []
|
|
approval_a = {
|
|
"command": "touch /tmp/webui-yolo-a",
|
|
"description": "first",
|
|
"approval_id": "approval-first",
|
|
"run_id": run_id,
|
|
"_gateway_mirror": True,
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
approval_b = {
|
|
"command": "touch /tmp/webui-yolo-b",
|
|
"description": "second",
|
|
"approval_id": "approval-second",
|
|
"run_id": run_id,
|
|
"_gateway_mirror": True,
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
|
|
class NotifyingLock:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self.contended = threading.Event()
|
|
|
|
def __enter__(self):
|
|
if not self._lock.acquire(blocking=False):
|
|
self.contended.set()
|
|
self._lock.acquire()
|
|
return self
|
|
|
|
def __exit__(self, _exc_type, _exc, _tb):
|
|
self._lock.release()
|
|
|
|
handoff_lock = NotifyingLock()
|
|
disable_session_yolo(sid)
|
|
route_approvals.submit_gateway_pending_mirror(sid, dict(approval_a))
|
|
|
|
def next_stream_approval():
|
|
stream_results.append(
|
|
gateway_chat._settle_gateway_run_approval(sid, dict(approval_b), "http://gateway", "")
|
|
)
|
|
|
|
def fake_respond(_self, got_run_id, approval_id, choice):
|
|
assert (got_run_id, approval_id, choice) == (run_id, "approval-first", "once")
|
|
worker = threading.Thread(target=next_stream_approval)
|
|
worker.start()
|
|
stream_workers.append(worker)
|
|
assert handoff_lock.contended.wait(timeout=1)
|
|
return {"resolved": 1}
|
|
|
|
def fake_auto_approve(_base_url, _api_key, got_run_id, approval_id):
|
|
auto_approved.append((got_run_id, approval_id))
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
monkeypatch.setattr(route_approvals, "gateway_yolo_handoff", lambda _sid: handoff_lock)
|
|
monkeypatch.setattr(routes, "gateway_yolo_handoff", lambda _sid: handoff_lock)
|
|
monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", fake_auto_approve)
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
monkeypatch.setattr(routes, "read_body", lambda _handler: {"session_id": sid, "enabled": True})
|
|
monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True)
|
|
monkeypatch.setattr(routes, "_handle_extension_sidecar_proxy", lambda *_a, **_k: False)
|
|
|
|
try:
|
|
routes.handle_post(object(), urllib.parse.urlparse("/api/session/yolo"))
|
|
for worker in stream_workers:
|
|
worker.join(timeout=1)
|
|
assert not worker.is_alive()
|
|
assert response["status"] == 200
|
|
assert response["payload"] == {
|
|
"ok": True,
|
|
"choice": "once",
|
|
"relayed": True,
|
|
"yolo_enabled": True,
|
|
}
|
|
assert stream_results == [(True, None, 0)]
|
|
assert auto_approved == [(run_id, "approval-second")]
|
|
assert route_approvals.gateway_pending_mirror(sid) is None
|
|
finally:
|
|
disable_session_yolo(sid)
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(not APPROVAL_AVAILABLE, reason="tools.approval unavailable")
|
|
@pytest.mark.parametrize("relay_fails", [False, True])
|
|
def test_card_yolo_first_relay_serializes_next_gateway_approval(monkeypatch, relay_fails):
|
|
from api import route_approvals, routes
|
|
|
|
sid = (
|
|
"webui-card-yolo-first-relay-failure"
|
|
if relay_fails
|
|
else "webui-card-yolo-first-relay-success"
|
|
)
|
|
run_id = "run-card-yolo-first-relay"
|
|
response = {}
|
|
stream_results = []
|
|
stream_workers = []
|
|
auto_approved = []
|
|
approval_a = {
|
|
"command": "touch /tmp/webui-yolo-a",
|
|
"description": "first",
|
|
"approval_id": "approval-card-first",
|
|
"run_id": run_id,
|
|
"_gateway_mirror": True,
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
approval_b = {
|
|
"command": "touch /tmp/webui-yolo-b",
|
|
"description": "second",
|
|
"approval_id": "approval-card-second",
|
|
"run_id": run_id,
|
|
"_gateway_mirror": True,
|
|
"_gateway_agent_identity_v1": True,
|
|
}
|
|
|
|
class NotifyingLock:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self.contended = threading.Event()
|
|
|
|
def __enter__(self):
|
|
if not self._lock.acquire(blocking=False):
|
|
self.contended.set()
|
|
self._lock.acquire()
|
|
return self
|
|
|
|
def __exit__(self, _exc_type, _exc, _tb):
|
|
self._lock.release()
|
|
|
|
handoff_lock = NotifyingLock()
|
|
disable_session_yolo(sid)
|
|
route_approvals.submit_gateway_pending_mirror(sid, dict(approval_a))
|
|
|
|
def next_stream_approval():
|
|
stream_results.append(
|
|
gateway_chat._settle_gateway_run_approval(
|
|
sid, dict(approval_b), "http://gateway", ""
|
|
)
|
|
)
|
|
|
|
def fake_respond(_self, got_run_id, approval_id, choice):
|
|
assert (got_run_id, approval_id, choice) == (
|
|
run_id,
|
|
"approval-card-first",
|
|
"once",
|
|
)
|
|
worker = threading.Thread(target=next_stream_approval)
|
|
worker.start()
|
|
stream_workers.append(worker)
|
|
handoff_lock.contended.wait(timeout=1)
|
|
if relay_fails:
|
|
raise RunnerClientError("relay failed")
|
|
return {"resolved": 1}
|
|
|
|
def fake_auto_approve(_base_url, _api_key, got_run_id, approval_id):
|
|
auto_approved.append((got_run_id, approval_id))
|
|
|
|
def fake_j(_handler, data, status=200, extra_headers=None):
|
|
response.update(payload=data, status=status)
|
|
return data
|
|
|
|
monkeypatch.setattr(route_approvals, "gateway_yolo_handoff", lambda _sid: handoff_lock)
|
|
monkeypatch.setattr(routes, "gateway_yolo_handoff", lambda _sid: handoff_lock)
|
|
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(active_stream_id=None))
|
|
monkeypatch.setattr(gateway_chat, "_auto_approve_gateway_run", fake_auto_approve)
|
|
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
|
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(routes, "j", fake_j)
|
|
|
|
try:
|
|
routes._handle_approval_respond(
|
|
object(),
|
|
{
|
|
"session_id": sid,
|
|
"choice": "once",
|
|
"approval_id": "approval-card-first",
|
|
"yolo": True,
|
|
},
|
|
)
|
|
for worker in stream_workers:
|
|
worker.join(timeout=1)
|
|
assert not worker.is_alive()
|
|
|
|
assert handoff_lock.contended.is_set()
|
|
assert response["status"] == (502 if relay_fails else 200)
|
|
assert response["payload"]["ok"] is (not relay_fails)
|
|
assert response["payload"]["yolo_enabled"] is (not relay_fails)
|
|
assert stream_results and stream_results[0][0] is (not relay_fails)
|
|
if relay_fails:
|
|
assert auto_approved == []
|
|
assert route_approvals.gateway_pending_mirror(
|
|
sid, approval_id=approval_b["approval_id"], run_id=run_id
|
|
) is not None
|
|
else:
|
|
assert auto_approved == [(run_id, approval_b["approval_id"])]
|
|
assert route_approvals.gateway_pending_mirror(
|
|
sid, approval_id=approval_b["approval_id"], run_id=run_id
|
|
) is None
|
|
finally:
|
|
for worker in stream_workers:
|
|
worker.join(timeout=1)
|
|
disable_session_yolo(sid)
|
|
route_approvals.retire_gateway_pending_mirror(sid, run_id=run_id)
|
|
with routes._lock:
|
|
routes._pending.pop(sid, None)
|
|
routes._gateway_queues.pop(sid, None)
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_card_yolo_response_does_not_overwrite_switched_session_state():
|
|
messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text()
|
|
|
|
def extract(name, end_marker):
|
|
start = messages_js.index(f"async function {name}(")
|
|
return messages_js[start:messages_js.index(end_marker, start)]
|
|
|
|
respond_approval = extract("respondApproval", "\nfunction startApprovalPolling")
|
|
toggle_yolo = extract("toggleYoloFromApproval", "\n// ── Approval polling")
|
|
|
|
def run(api_source, expected_ok):
|
|
script = "\n".join([
|
|
"const toasts=[]; let pillUpdates=0;",
|
|
"const S={session:{session_id:'old-session'}};",
|
|
"let _loadSessionGeneration=1;",
|
|
"let _approvalSessionId='old-session';",
|
|
"let _approvalCurrentId='approval-1';",
|
|
"let _approvalResponding=null;",
|
|
"let _approvalClearedOwner=null;",
|
|
"let _approvalDisplayedOwner={sid:'old-session',approvalId:'approval-1',runId:'',mirrorToken:''};",
|
|
"let _yoloEnabled=false;",
|
|
"const _approvalPendingBySession=new Map([['old-session',{pending:{approval_id:'approval-1'}}]]);",
|
|
f"const api={api_source};",
|
|
"const $=()=>({disabled:false,classList:{contains:v=>v==='visible',add(){},remove(){}}});",
|
|
"const t=k=>k; const showToast=msg=>toasts.push(msg); const setStatus=()=>{};",
|
|
"const _unmarkApprovalDismissed=()=>{};",
|
|
"const _setApprovalControlsDisabled=()=>{}; const _clearApprovalPendingForSession=()=>{};",
|
|
"const hideApprovalCard=()=>{}; const _updateYoloPill=()=>{pillUpdates+=1;};",
|
|
"const _restoreFailedApprovalResponse=()=>{};",
|
|
_js_block(messages_js, "function _approvalMirrorOwnerFor(", "\nfunction _setApprovalControlsDisabled"),
|
|
_js_block(messages_js, "function _applyApprovalYoloProjection(", "\nfunction toggleApprovalCardCollapsed"),
|
|
respond_approval,
|
|
toggle_yolo,
|
|
"(async()=>{",
|
|
" const ok=await toggleYoloFromApproval();",
|
|
f" if(ok!=={str(expected_ok).lower()}) throw new Error('unexpected result '+ok);",
|
|
" if(_yoloEnabled!==false) throw new Error('stale response changed new session state');",
|
|
" if(pillUpdates!==0) throw new Error('stale response updated the new session pill');",
|
|
" if(toasts.length!==0) throw new Error('stale response emitted a toast');",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
result = subprocess.run([shutil.which("node"), "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
run(
|
|
"async()=>{S.session.session_id='new-session';return {ok:true,yolo_enabled:true};}",
|
|
False,
|
|
)
|
|
run(
|
|
"async()=>{S.session.session_id='new-session';const e=new Error('relay failed');e.body=JSON.stringify({error:'relay failed',yolo_enabled:true});throw e;}",
|
|
False,
|
|
)
|
|
run(
|
|
"async()=>{_loadSessionGeneration=2;return {ok:true,yolo_enabled:true};}",
|
|
False,
|
|
)
|
|
run(
|
|
"async()=>{_loadSessionGeneration=2;const e=new Error('relay failed');e.body=JSON.stringify({error:'relay failed',yolo_enabled:true});throw e;}",
|
|
False,
|
|
)
|
|
|
|
|
|
def _run_card_yolo_owner_scenario(scenario):
|
|
messages_js = (pathlib.Path(__file__).resolve().parents[1] / "static" / "messages.js").read_text()
|
|
|
|
def extract(start_marker, end_marker):
|
|
start = messages_js.index(start_marker)
|
|
return messages_js[start:messages_js.index(end_marker, start)]
|
|
|
|
owner_sid = "card-session"
|
|
active_sid = "other-session" if scenario.startswith("inactive-") else owner_sid
|
|
approval_id = "Approval-Case-ID" if scenario.startswith("case-distinct-") else "approval-1"
|
|
script = "\n".join([
|
|
"const calls=[]; const toasts=[]; const statuses=[]; const controlWrites=[];",
|
|
"let pillUpdates=0; let cardHideMutations=0; let pendingClears=0; let cardRenders=0;",
|
|
f"const scenario={json.dumps(scenario)};",
|
|
f"const ownerSid={json.dumps(owner_sid)}; const approvalId={json.dumps(approval_id)};",
|
|
f"const S={{session:{{session_id:{json.dumps(active_sid)}}}}};",
|
|
"let _loadSessionGeneration=1;",
|
|
"let _approvalSessionId=ownerSid; let _approvalCurrentId=approvalId;",
|
|
"let _approvalResponding=null; let _approvalClearedOwner=null; let _yoloEnabled=false;",
|
|
"let _approvalHideTimer=null; let _approvalVisibleSince=0; let _approvalSignature='owner-card';",
|
|
"const initialPending=(scenario==='mirror-distinct-success'||scenario==='poll-cleared-success')?{approval_id:approvalId,run_id:'run-owner-1',_gateway_mirror_token:'mirror-owner-1'}:{approval_id:approvalId};",
|
|
"let _approvalDisplayedOwner={sid:ownerSid,approvalId,runId:initialPending.run_id||'',mirrorToken:initialPending._gateway_mirror_token||''};",
|
|
"const _approvalPendingBySession=new Map([[ownerSid,{pending:initialPending}]]);",
|
|
"const classList={",
|
|
" values:new Set(['visible']),",
|
|
" contains(v){return this.values.has(v);},",
|
|
" add(v){this.values.add(v);},",
|
|
" remove(...values){for(const v of values){if(v==='visible'&&this.values.has(v))cardHideMutations+=1;this.values.delete(v);}},",
|
|
" toggle(v,on){if(on===undefined)on=!this.values.has(v);if(on)this.values.add(v);else this.values.delete(v);return on;},",
|
|
"};",
|
|
"const card={classList,hidden:false,setAttribute(){},removeAttribute(){}};",
|
|
"const textNode={textContent:''};",
|
|
"const makeButton=()=>{let value=false;return{classList:{add(){},remove(){}},get disabled(){return value;},set disabled(next){value=!!next;controlWrites.push(value);}};};",
|
|
"const elements={approvalCard:card,approvalCmd:{...textNode},approvalDesc:{...textNode},approvalCounter:{...textNode,style:{}},approvalBtnOnce:makeButton(),approvalBtnSession:makeButton(),approvalBtnAlways:makeButton(),approvalBtnDeny:makeButton()};",
|
|
"const $=id=>elements[id]||null;",
|
|
"const t=k=>k; const showToast=msg=>toasts.push(msg); const setStatus=msg=>statuses.push(msg);",
|
|
"const _setPromptFlyoutHidden=()=>{};",
|
|
"const _updateYoloPill=()=>{pillUpdates+=1;}; const syncTopbar=()=>{};",
|
|
"const _unmarkApprovalDismissed=()=>{};",
|
|
"const _clearApprovalPendingForSession=sid=>{pendingClears+=1;_approvalPendingBySession.delete(sid);};",
|
|
"const _approvalPromptBelongsToActiveSession=sid=>!!(sid&&S.session&&S.session.session_id===sid);",
|
|
"const _renderPendingApprovalForActiveSession=()=>{cardRenders+=1;};",
|
|
"const api=async(path,opts={})=>{",
|
|
" calls.push({path,body:opts.body?JSON.parse(opts.body):null});",
|
|
" if(path.startsWith('/api/approval/pending'))return {pending:null};",
|
|
" if(scenario.startsWith('same-generation-')){_loadSessionGeneration=2;_approvalSessionId=ownerSid;_approvalCurrentId=approvalId;classList.add('visible');}",
|
|
" if(scenario==='poll-cleared-success'){_approvalPendingBySession.delete(ownerSid);}",
|
|
" if(scenario==='mirror-distinct-success'){_approvalPendingBySession.set(ownerSid,{pending:{approval_id:approvalId,run_id:'run-owner-2',_gateway_mirror_token:'mirror-owner-2'}});_approvalDisplayedOwner={sid:ownerSid,approvalId,runId:'run-owner-2',mirrorToken:'mirror-owner-2'};classList.add('visible');}",
|
|
" if(scenario==='case-distinct-id'){_approvalCurrentId=approvalId.toLowerCase();_approvalDisplayedOwner={sid:ownerSid,approvalId:_approvalCurrentId,runId:'',mirrorToken:''};classList.add('visible');}",
|
|
" if(scenario==='case-distinct-pending'){_approvalPendingBySession.set(ownerSid,{pending:{approval_id:approvalId.toLowerCase()}});}",
|
|
" if(scenario.endsWith('-error')){const e=new Error('relay failed');e.body=JSON.stringify({error:'relay failed',yolo_enabled:true});throw e;}",
|
|
" return {ok:true,yolo_enabled:true,stale_cleared:scenario==='same-generation-stale-cleared'||scenario==='case-distinct-pending'};",
|
|
"};",
|
|
extract("function _clearApprovalHideTimer(", "\n// Track session_id of the active approval"),
|
|
extract("function _approvalMirrorOwnerFor(", "\nfunction startApprovalPolling"),
|
|
extract("async function toggleYoloFromApproval(", "\n// ── Approval polling"),
|
|
"(async()=>{",
|
|
" const ok=await toggleYoloFromApproval();",
|
|
" await new Promise(resolve=>setTimeout(resolve,0));",
|
|
" if(scenario==='poll-cleared-success'){",
|
|
" if(!ok)throw new Error('retired polling projection discarded valid success');",
|
|
" if(calls.length!==1)throw new Error('valid response made extra requests '+JSON.stringify(calls));",
|
|
" if(_approvalPendingBySession.has(ownerSid))throw new Error('polling projection was recreated');",
|
|
" if(_approvalResponding!==null)throw new Error('response owner was not released');",
|
|
" if(cardHideMutations!==1||pendingClears!==0||cardRenders!==0)throw new Error('wrong retired-owner cleanup '+JSON.stringify({cardHideMutations,pendingClears,cardRenders}));",
|
|
" if(_yoloEnabled!==true||pillUpdates!==1)throw new Error('valid success did not project YOLO');",
|
|
" if(JSON.stringify(toasts)!==JSON.stringify(['yolo_enabled'])||statuses.length)throw new Error('wrong valid-success feedback '+JSON.stringify({toasts,statuses}));",
|
|
" return;",
|
|
" }",
|
|
" if(scenario==='case-distinct-pending'){",
|
|
" if(!ok)throw new Error('owned response reported failure');",
|
|
" if(calls.length!==2)throw new Error('expected response plus requery '+JSON.stringify(calls));",
|
|
" if(calls[0].body.approval_id!==approvalId)throw new Error('approval ID changed '+JSON.stringify(calls[0].body));",
|
|
" const successor=_approvalPendingBySession.get(ownerSid);",
|
|
" if(!successor||successor.pending.approval_id!==approvalId.toLowerCase())throw new Error('case-distinct pending successor was cleared');",
|
|
" if(JSON.stringify(controlWrites)!==JSON.stringify([true,true,true,true]))throw new Error('response changed successor controls '+JSON.stringify(controlWrites));",
|
|
" if(_approvalResponding!==null)throw new Error('response owner was not released');",
|
|
" if(cardHideMutations!==1||pendingClears!==0||cardRenders!==0)throw new Error('wrong owned cleanup '+JSON.stringify({cardHideMutations,pendingClears,cardRenders}));",
|
|
" if(_yoloEnabled!==true||pillUpdates!==1)throw new Error('owned response did not project YOLO');",
|
|
" if(JSON.stringify(toasts)!==JSON.stringify(['yolo_enabled'])||statuses.length)throw new Error('wrong owned feedback '+JSON.stringify({toasts,statuses}));",
|
|
" return;",
|
|
" }",
|
|
" if(scenario.startsWith('inactive-')){",
|
|
" if(ok!==false)throw new Error('inactive card action reported success');",
|
|
" if(calls.length!==0)throw new Error('inactive card posted '+JSON.stringify(calls));",
|
|
" if(controlWrites.length!==0)throw new Error('inactive card changed controls '+JSON.stringify(controlWrites));",
|
|
" }else{",
|
|
" if(calls.length!==1)throw new Error('stale response made extra requests '+JSON.stringify(calls));",
|
|
" if(calls[0].body.approval_id!==approvalId)throw new Error('approval ID changed '+JSON.stringify(calls[0].body));",
|
|
" if(JSON.stringify(controlWrites)!==JSON.stringify([true,true,true,true]))throw new Error('stale response changed controls '+JSON.stringify(controlWrites));",
|
|
" }",
|
|
" if(_approvalResponding!==null)throw new Error('response owner was not released');",
|
|
" if(cardHideMutations!==0)throw new Error('stale response hid successor card');",
|
|
" if(pendingClears!==0)throw new Error('stale response cleared successor pending state');",
|
|
" if(cardRenders!==0)throw new Error('stale response rendered a card');",
|
|
" if(_yoloEnabled!==false||pillUpdates!==0)throw new Error('stale response changed YOLO projection');",
|
|
" if(toasts.length||statuses.length)throw new Error('stale response emitted feedback '+JSON.stringify({toasts,statuses}));",
|
|
"})().catch(e=>{console.error(e.stack||e);process.exit(1)});",
|
|
])
|
|
node = shutil.which("node")
|
|
assert node is not None
|
|
result = subprocess.run([node, "-e", script], text=True, capture_output=True)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
@pytest.mark.parametrize("scenario", ["inactive-success", "inactive-error"])
|
|
def test_card_yolo_rejects_visible_approval_owned_by_inactive_session(scenario):
|
|
_run_card_yolo_owner_scenario(scenario)
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
@pytest.mark.parametrize("scenario", [
|
|
"same-generation-success",
|
|
"same-generation-stale-cleared",
|
|
"same-generation-error",
|
|
])
|
|
def test_card_yolo_ignores_response_from_prior_same_session_generation(scenario):
|
|
_run_card_yolo_owner_scenario(scenario)
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_card_yolo_keeps_case_distinct_successor_approval_untouched():
|
|
_run_card_yolo_owner_scenario("case-distinct-id")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_stale_clear_preserves_case_distinct_pending_successor():
|
|
_run_card_yolo_owner_scenario("case-distinct-pending")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_card_yolo_keeps_same_id_different_mirror_successor_untouched():
|
|
_run_card_yolo_owner_scenario("mirror-distinct-success")
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
|
|
def test_card_yolo_accepts_success_after_polling_retires_projection():
|
|
_run_card_yolo_owner_scenario("poll-cleared-success")
|