Bind approval UI to current request and verify panel cleanup

This commit is contained in:
saphid committed 2026-09-28 22:29:18 +10:00
1 parent 6a8e3fadbf
commit b5cf8253e6
6 files changed
+83 -12

No files matched your search

+7 -1
View File
@@ -150,4 +150,10 @@ launches of user games, power operations or keep-awake changes were performed.
binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP
endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame
regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked
because the Docker daemon was unavailable. CI runs those regressions. because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed
on this branch (run 36421345682).
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
browser profile and SSH tunnel and remove the profile and panel log.
![Assistant in Frame Chromium, after an opted-in request to the local test endpoint](img/assistant-panel.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+12 -4
View File
@@ -9,10 +9,10 @@ import os
from pathlib import Path from pathlib import Path
import re import re
import shlex import shlex
import signal
import shutil import shutil
import subprocess import subprocess
import sys import sys
import time
import uuid import uuid
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
@@ -32,6 +32,8 @@ def main():
from frame_mcp import Client from frame_mcp import Client
Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host') Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host')
profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex
log_path = ''
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
'-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
'-o', 'ServerAliveCountMax=2', '-R', '-o', 'ServerAliveCountMax=2', '-R',
@@ -44,10 +46,14 @@ def main():
stdout=subprocess.DEVNULL, timeout=30) stdout=subprocess.DEVNULL, timeout=30)
if probe.returncode or tunnel.poll() is not None: if probe.returncode or tunnel.poll() is not None:
raise RuntimeError('Could not forward Frame Control to the Frame') raise RuntimeError('Could not forward Frame Control to the Frame')
subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant', launched = subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant',
'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run', 'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run',
'--disable-background-networking', '--disable-sync', '--disable-background-networking', '--disable-sync',
f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45) f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45, stdout=subprocess.PIPE, text=True)
print(launched.stdout, end='', flush=True)
match = re.search(r'log (/tmp/panel-on-frame\.[A-Za-z0-9]+)', launched.stdout)
if match:
log_path = match.group(1)
print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True) print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True)
tunnel.wait() tunnel.wait()
raise RuntimeError('SSH tunnel ended') raise RuntimeError('SSH tunnel ended')
@@ -77,9 +83,11 @@ for sig in (signal.SIGTERM, signal.SIGKILL):
except ProcessLookupError: pass except ProcessLookupError: pass
time.sleep(.3) time.sleep(.3)
shutil.rmtree(profile, ignore_errors=True) shutil.rmtree(profile, ignore_errors=True)
if sys.argv[2]:
pathlib.Path(sys.argv[2]).unlink(missing_ok=True)
''' '''
result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias, result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
'python3 - ' + shlex.quote(profile)], input=cleanup, text=True, timeout=20) 'python3 - ' + shlex.quote(profile) + ' ' + shlex.quote(log_path)], input=cleanup, text=True, timeout=20)
if result.returncode: if result.returncode:
print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr) print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr)
+43
View File
@@ -0,0 +1,43 @@
// Run the actual page script with a tiny DOM/fetch fixture; no browser dependency.
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const elements = new Map();
const events = new Map();
const requests = [];
const element = id => {
if (!elements.has(id)) elements.set(id, {value:'', checked:false, disabled:false, textContent:'',
addEventListener(){}, reset(){}});
return elements.get(id);
};
const context = {
document:{getElementById:element}, location:{hash:''}, URLSearchParams,
window:{addEventListener:(name, fn) => events.set(name, fn)},
fetch:(path, options) => new Promise(resolve => requests.push({path, options, resolve})),
};
const html = fs.readFileSync(process.argv[2], 'utf8');
vm.runInNewContext(html.match(/<script>([\s\S]*?)<\/script>/)[1].replace('__FRAME_KEY__', '"test"'), context);
const answer = (index, data) => requests[index].resolve({ok:true,json:async () => data});
(async () => {
context.location.hash = '#confirm=first';
const first = events.get('hashchange')();
context.location.hash = '#confirm=second';
const second = events.get('hashchange')();
answer(1, {action:{name:'second'},approved:false});
await second;
answer(0, {action:{name:'first'},approved:false});
await first;
assert.match(element('action').textContent, /second/);
assert.doesNotMatch(element('action').textContent, /first/);
const approved = element('approve').onclick();
assert.equal(JSON.parse(requests[2].options.body).confirmation, 'second');
context.location.hash = '#confirm=third';
const third = events.get('hashchange')();
answer(3, {action:{name:'third'},approved:false});
await third;
answer(2, {message:'Approved for one use'});
await approved;
assert.equal(element('approval-status').textContent, '');
assert.match(element('action').textContent, /third/);
console.log('Approval navigation races: pass');
})().catch(error => { console.error(error); process.exitCode=1; });
+10
View File
@@ -2,6 +2,7 @@
import io import io
import json import json
import os import os
import shutil
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import sys import sys
@@ -149,6 +150,15 @@ class Assistant(unittest.TestCase):
capture.assert_not_called() capture.assert_not_called()
class AssistantPage(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is required for the page script regression')
def test_approval_navigation_races(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(['node', str(root / 'tests/assistant_ui.cjs'), str(root / 'ui/assistant.html')],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
class Protocol(unittest.TestCase): class Protocol(unittest.TestCase):
def test_stdio_initialize_list_call_errors_and_eof(self): def test_stdio_initialize_list_call_errors_and_eof(self):
messages = [ messages = [
+11 -7
View File
@@ -11,6 +11,7 @@ a { color:#70c9ff } section { background:#202d3c; border:1px solid #425268; bord
label { display:block; margin:14px 0 } input:not([type=checkbox]),textarea { display:block; width:100%; margin-top:6px; padding:12px; background:#101923; color:inherit; border:1px solid #728398; border-radius:6px; font:inherit } label { display:block; margin:14px 0 } input:not([type=checkbox]),textarea { display:block; width:100%; margin-top:6px; padding:12px; background:#101923; color:inherit; border:1px solid #728398; border-radius:6px; font:inherit }
input[type=checkbox] { width:24px; height:24px; vertical-align:middle; margin-right:10px } button { font:inherit; padding:12px 24px; min-height:52px; border:1px solid #728398; border-radius:6px; background:#30445b; color:white; cursor:pointer; margin:6px 12px 6px 0 } input[type=checkbox] { width:24px; height:24px; vertical-align:middle; margin-right:10px } button { font:inherit; padding:12px 24px; min-height:52px; border:1px solid #728398; border-radius:6px; background:#30445b; color:white; cursor:pointer; margin:6px 12px 6px 0 }
button.primary { background:#176b9c } button:disabled { opacity:.5; cursor:wait } :focus-visible { outline:3px solid #70c9ff; outline-offset:3px } button.primary { background:#176b9c } button:disabled { opacity:.5; cursor:wait } :focus-visible { outline:3px solid #70c9ff; outline-offset:3px }
summary { overflow-wrap:anywhere; cursor:pointer }
pre { white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; max-height:380px; overflow:auto } [hidden] { display:none!important } #status { min-height:1.5em } small { color:#b8c6d5 } pre { white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; max-height:380px; overflow:auto } [hidden] { display:none!important } #status { min-height:1.5em } small { color:#b8c6d5 }
</style> </style>
<header><h1>Frame Control · Assistant</h1><a href="/">Back to Frame Control</a></header> <header><h1>Frame Control · Assistant</h1><a href="/">Back to Frame Control</a></header>
@@ -24,7 +25,7 @@ pre { white-space:pre-wrap; overflow-wrap:anywhere; font:inherit; max-height:380
<h2 id="chat-title">Ask your chosen model</h2> <h2 id="chat-title">Ask your chosen model</h2>
<p>Nothing is sent until you opt in and press Send. Each request sends only the message below and, if selected, a fresh headset screenshot. Replies cannot operate your Frame.</p> <p>Nothing is sent until you opt in and press Send. Each request sends only the message below and, if selected, a fresh headset screenshot. Replies cannot operate your Frame.</p>
<form id="chat"> <form id="chat">
<details id="settings" open><summary>Endpoint and model settings</summary> <details id="settings" open><summary id="settings-label">Endpoint and model settings</summary>
<label>Chat-completions endpoint<input id="endpoint" type="url" placeholder="http://127.0.0.1:1234/v1/chat/completions" required autocomplete="off"></label> <label>Chat-completions endpoint<input id="endpoint" type="url" placeholder="http://127.0.0.1:1234/v1/chat/completions" required autocomplete="off"></label>
<small>Use an OpenAI-compatible endpoint. Loopback means the computer running Frame Control. Remote endpoints require HTTPS.</small> <small>Use an OpenAI-compatible endpoint. Loopback means the computer running Frame Control. Remote endpoints require HTTPS.</small>
<label>Model<input id="model" required placeholder="Model name from your endpoint" autocomplete="off"></label> <label>Model<input id="model" required placeholder="Model name from your endpoint" autocomplete="off"></label>
@@ -53,35 +54,38 @@ async function api(path, body) {
function revoke() { $('consent').checked = false; $('screenshot').checked = false; } function revoke() { $('consent').checked = false; $('screenshot').checked = false; }
$('endpoint').addEventListener('input', revoke); $('endpoint').addEventListener('input', revoke);
$('model').addEventListener('input', revoke); $('model').addEventListener('input', revoke);
$('clear').onclick = () => { generation++; $('chat').reset(); $('settings').open = true; $('reply').textContent = ''; $('status').textContent = 'Cleared. A request already sent cannot be recalled.'; }; $('clear').onclick = () => { generation++; $('chat').reset(); $('settings').open = true; $('settings-label').textContent = 'Endpoint and model settings'; $('reply').textContent = ''; $('status').textContent = 'Cleared. A request already sent cannot be recalled.'; };
$('chat').onsubmit = async event => { $('chat').onsubmit = async event => {
event.preventDefault(); event.preventDefault();
if (!$('consent').checked) { $('status').textContent = 'Opt in before sending a message.'; return; } if (!$('consent').checked) { $('status').textContent = 'Opt in before sending a message.'; return; }
const current = ++generation; const current = ++generation;
const body = Object.fromEntries(['endpoint','model','key','prompt'].map(id => [id,$(id).value])); const body = Object.fromEntries(['endpoint','model','key','prompt'].map(id => [id,$(id).value]));
Object.assign(body, {consent:true,screenshot:$('screenshot').checked}); Object.assign(body, {consent:true,screenshot:$('screenshot').checked});
$('settings').open = false; $('send').disabled = true; $('reply').textContent = ''; $('status').textContent = 'Sending to ' + body.endpoint + '…'; revoke(); $('settings-label').textContent = body.model + ' at ' + body.endpoint; $('settings').open = false; $('send').disabled = true; $('reply').textContent = ''; $('status').textContent = 'Sending to ' + body.endpoint + '…'; revoke();
try { const data = await api('/api/assistant/chat', body); if (current === generation) { $('reply').textContent = data.reply; $('status').textContent = 'Reply received.'; } } try { const data = await api('/api/assistant/chat', body); if (current === generation) { $('reply').textContent = data.reply; $('status').textContent = 'Reply received.'; } }
catch (error) { if (current === generation) $('status').textContent = error.message; } catch (error) { if (current === generation) $('status').textContent = error.message; }
finally { $('send').disabled = false; } finally { $('send').disabled = false; }
}; };
let confirmation; let confirmation, approvalGeneration = 0;
async function loadApproval() { async function loadApproval() {
const current = ++approvalGeneration;
confirmation = new URLSearchParams(location.hash.slice(1)).get('confirm'); confirmation = new URLSearchParams(location.hash.slice(1)).get('confirm');
$('approval').hidden = !confirmation; $('approval').hidden = !confirmation;
if (!confirmation) return; if (!confirmation) return;
$('approve').disabled = $('reject').disabled = true; $('approve').disabled = $('reject').disabled = true;
try { try {
const data = await api('/api/agent/approval?confirmation=' + encodeURIComponent(confirmation)); const data = await api('/api/agent/approval?confirmation=' + encodeURIComponent(confirmation));
if (current !== approvalGeneration) return;
$('action').textContent = JSON.stringify(data.action, null, 2); $('action').textContent = JSON.stringify(data.action, null, 2);
$('approval-status').textContent = data.approved ? 'Already approved. Ask the agent to retry.' : ''; $('approval-status').textContent = data.approved ? 'Already approved. Ask the agent to retry.' : '';
$('approve').disabled = data.approved; $('reject').disabled = false; $('approve').disabled = data.approved; $('reject').disabled = false;
} catch (error) { $('action').textContent = ''; $('approval-status').textContent = error.message; } } catch (error) { if (current === approvalGeneration) { $('action').textContent = ''; $('approval-status').textContent = error.message; } }
} }
for (const [id, accept] of [['approve',true],['reject',false]]) $(id).onclick = async () => { for (const [id, accept] of [['approve',true],['reject',false]]) $(id).onclick = async () => {
const current = approvalGeneration;
$('approve').disabled = $('reject').disabled = true; $('approve').disabled = $('reject').disabled = true;
try { const data = await api('/api/agent/approval', {confirmation,accept}); $('approval-status').textContent = data.message + (accept ? '. Ask the agent to retry now.' : '.'); } try { const data = await api('/api/agent/approval', {confirmation,accept}); if (current !== approvalGeneration) return; $('approval-status').textContent = data.message + (accept ? '. Ask the agent to retry now.' : '.'); }
catch (error) { $('approval-status').textContent = error.message; } catch (error) { if (current === approvalGeneration) $('approval-status').textContent = error.message; }
}; };
window.addEventListener('hashchange', loadApproval); loadApproval(); window.addEventListener('hashchange', loadApproval); loadApproval();
</script> </script>