gui: integrate bypass requests into chat UI
Server changes:
- Session tracks pending bypass request and exposes methods
- New 9P files: session/{sid}/bypass (read/write), bypasswait (blocking)
- Publish bypass.request events for GUI listeners
GUI changes:
- Handle bypass.request events from eventwait
- Show inline amber banner with command and cwd
- Approve/Deny buttons resolve via 9P
Desktop notifications still work in parallel for non-GUI usage.
This commit is contained in:
parent
79738a4a39
commit
f2f1f80e4e
|
|
@ -463,6 +463,55 @@ func buildSessionChildren(
|
||||||
return []byte(status + "\n"), status, nil
|
return []byte(status + "\n"), status, nil
|
||||||
}, s.GoalSignal),
|
}, s.GoalSignal),
|
||||||
),
|
),
|
||||||
|
virtfs.FileNode("bypass", 0666,
|
||||||
|
virtfs.Doc("Bypass request handling. Read: pending request JSON or empty. Write: 'id approve' or 'id deny'."),
|
||||||
|
virtfs.Read(func() ([]byte, error) {
|
||||||
|
req := s.BypassPending()
|
||||||
|
if req == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(data, '\n'), nil
|
||||||
|
}),
|
||||||
|
virtfs.Write(func(data []byte) error {
|
||||||
|
input := strings.TrimSpace(string(data))
|
||||||
|
parts := strings.SplitN(input, " ", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return fmt.Errorf("expected 'id approve' or 'id deny'")
|
||||||
|
}
|
||||||
|
id, action := parts[0], parts[1]
|
||||||
|
approved := action == "approve"
|
||||||
|
return s.ResolveBypass(id, approved)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
virtfs.FileNode("bypasswait", 0444,
|
||||||
|
virtfs.Doc("Blocks until a bypass request arrives."),
|
||||||
|
virtfs.Read(func() ([]byte, error) {
|
||||||
|
req := s.BypassPending()
|
||||||
|
if req == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(data, '\n'), nil
|
||||||
|
}),
|
||||||
|
virtfs.BlockOnce(func() ([]byte, string, error) {
|
||||||
|
req := s.BypassPending()
|
||||||
|
if req == nil {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return append(data, '\n'), req.ID, nil
|
||||||
|
}, s.BypassSignal),
|
||||||
|
),
|
||||||
virtfs.FileNode("stats", 0444,
|
virtfs.FileNode("stats", 0444,
|
||||||
virtfs.Read(func() ([]byte, error) {
|
virtfs.Read(func() ([]byte, error) {
|
||||||
a, err := metrics.AggregateSession(s.ID)
|
a, err := metrics.AggregateSession(s.ID)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ type Session struct {
|
||||||
modelsMu sync.Mutex
|
modelsMu sync.Mutex
|
||||||
modelsCache string
|
modelsCache string
|
||||||
modelsCacheAt time.Time
|
modelsCacheAt time.Time
|
||||||
|
|
||||||
|
// Bypass request tracking
|
||||||
|
bypassMu sync.RWMutex
|
||||||
|
bypassPending *protocol.BypassRequest
|
||||||
|
bypassDialFn func() *toolclient.ToolsrvConn
|
||||||
|
bypassSignalCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ollieTmpDir() string {
|
func ollieTmpDir() string {
|
||||||
|
|
@ -178,6 +184,9 @@ func (s *Session) runBypassLoop(notifyFn BypassNotifyFunc) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the pending request for 9P access
|
||||||
|
s.SetBypassPending((*protocol.BypassRequest)(req), s.DialToolServer)
|
||||||
|
|
||||||
// Notify (non-blocking) - the notifier will write resolution when user responds
|
// Notify (non-blocking) - the notifier will write resolution when user responds
|
||||||
if notifyFn != nil {
|
if notifyFn != nil {
|
||||||
notifyFn((*protocol.BypassRequest)(req), s.ID, s.DialToolServer)
|
notifyFn((*protocol.BypassRequest)(req), s.ID, s.DialToolServer)
|
||||||
|
|
@ -247,6 +256,70 @@ func (s *Session) GoalSignal() <-chan struct{} {
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bypass request tracking ---
|
||||||
|
|
||||||
|
// SetBypassPending stores a pending bypass request and publishes an event.
|
||||||
|
func (s *Session) SetBypassPending(req *protocol.BypassRequest, dialFn func() *toolclient.ToolsrvConn) {
|
||||||
|
s.bypassMu.Lock()
|
||||||
|
s.bypassPending = req
|
||||||
|
s.bypassDialFn = dialFn
|
||||||
|
if s.bypassSignalCh != nil {
|
||||||
|
close(s.bypassSignalCh)
|
||||||
|
}
|
||||||
|
s.bypassSignalCh = make(chan struct{})
|
||||||
|
s.bypassMu.Unlock()
|
||||||
|
|
||||||
|
// Publish event for GUI listeners
|
||||||
|
PublishEvent("session."+s.ID+".bypass.request", req.ID+"\t"+req.Cmd+"\t"+req.Cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BypassPending returns the current pending bypass request, or nil if none.
|
||||||
|
func (s *Session) BypassPending() *protocol.BypassRequest {
|
||||||
|
s.bypassMu.RLock()
|
||||||
|
defer s.bypassMu.RUnlock()
|
||||||
|
return s.bypassPending
|
||||||
|
}
|
||||||
|
|
||||||
|
// BypassSignal returns a channel closed when the bypass state changes.
|
||||||
|
func (s *Session) BypassSignal() <-chan struct{} {
|
||||||
|
s.bypassMu.Lock()
|
||||||
|
if s.bypassSignalCh == nil {
|
||||||
|
s.bypassSignalCh = make(chan struct{})
|
||||||
|
}
|
||||||
|
ch := s.bypassSignalCh
|
||||||
|
s.bypassMu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveBypass resolves a pending bypass request and clears it.
|
||||||
|
func (s *Session) ResolveBypass(id string, approved bool) error {
|
||||||
|
s.bypassMu.Lock()
|
||||||
|
req := s.bypassPending
|
||||||
|
dialFn := s.bypassDialFn
|
||||||
|
if req == nil || req.ID != id {
|
||||||
|
s.bypassMu.Unlock()
|
||||||
|
return fmt.Errorf("no pending bypass with id %s", id)
|
||||||
|
}
|
||||||
|
s.bypassPending = nil
|
||||||
|
s.bypassDialFn = nil
|
||||||
|
if s.bypassSignalCh != nil {
|
||||||
|
close(s.bypassSignalCh)
|
||||||
|
}
|
||||||
|
s.bypassSignalCh = make(chan struct{})
|
||||||
|
s.bypassMu.Unlock()
|
||||||
|
|
||||||
|
// Send resolution to toolsrv
|
||||||
|
if dialFn != nil {
|
||||||
|
conn := dialFn()
|
||||||
|
if conn != nil {
|
||||||
|
err := conn.ResolveBypass(id, approved, "")
|
||||||
|
conn.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no connection to resolve bypass")
|
||||||
|
}
|
||||||
|
|
||||||
// Workflow returns the workflow profile name (default: "none").
|
// Workflow returns the workflow profile name (default: "none").
|
||||||
func (s *Session) Workflow() string {
|
func (s *Session) Workflow() string {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ Item {
|
||||||
id: chatPane
|
id: chatPane
|
||||||
property bool sidebarVisible: true
|
property bool sidebarVisible: true
|
||||||
property int maxVisibleBlocks: 80
|
property int maxVisibleBlocks: 80
|
||||||
|
property var pendingBypass: null // {sessionId, id, cmd, cwd}
|
||||||
signal toggleSidebar()
|
signal toggleSidebar()
|
||||||
// Do not opt the chat pane into focus navigation.
|
// Do not opt the chat pane into focus navigation.
|
||||||
activeFocusOnTab: false
|
activeFocusOnTab: false
|
||||||
|
|
@ -133,6 +134,78 @@ Item {
|
||||||
visible: !ollie.activeAgentId || !ollie.daemonConnected
|
visible: !ollie.activeAgentId || !ollie.daemonConnected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bypass request notification
|
||||||
|
Rectangle {
|
||||||
|
id: bypassBanner
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 8
|
||||||
|
height: bypassLayout.implicitHeight + 16
|
||||||
|
radius: 6
|
||||||
|
color: "#fff3e0" // amber light
|
||||||
|
border.color: "#ff9800"
|
||||||
|
border.width: 1
|
||||||
|
visible: chatPane.pendingBypass !== null
|
||||||
|
z: 100
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: bypassLayout
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "⚠️ Elevation Request"
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: theme.fontSize > 0 ? theme.fontSize + 2 : 14
|
||||||
|
color: "#e65100"
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: chatPane.pendingBypass ? chatPane.pendingBypass.cmd : ""
|
||||||
|
font.family: "monospace"
|
||||||
|
font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12
|
||||||
|
wrapMode: Text.WrapAnywhere
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: "#333"
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: chatPane.pendingBypass ? "cwd: " + chatPane.pendingBypass.cwd : ""
|
||||||
|
font.pixelSize: theme.fontSize > 0 ? theme.fontSize - 1 : 10
|
||||||
|
color: "#666"
|
||||||
|
visible: chatPane.pendingBypass && chatPane.pendingBypass.cwd !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: 8
|
||||||
|
Layout.topMargin: 4
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Approve"
|
||||||
|
highlighted: true
|
||||||
|
onClicked: {
|
||||||
|
if (chatPane.pendingBypass) {
|
||||||
|
ollie.resolveBypass(chatPane.pendingBypass.sessionId, chatPane.pendingBypass.id, true)
|
||||||
|
chatPane.pendingBypass = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Deny"
|
||||||
|
onClicked: {
|
||||||
|
if (chatPane.pendingBypass) {
|
||||||
|
ollie.resolveBypass(chatPane.pendingBypass.sessionId, chatPane.pendingBypass.id, false)
|
||||||
|
chatPane.pendingBypass = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ListView {
|
ListView {
|
||||||
id: chatView
|
id: chatView
|
||||||
model: chatModel
|
model: chatModel
|
||||||
|
|
@ -512,6 +585,17 @@ Connections {
|
||||||
function onActiveAgentIdChanged() {
|
function onActiveAgentIdChanged() {
|
||||||
if (ollie.activeAgentId) chatPane.loadChat()
|
if (ollie.activeAgentId) chatPane.loadChat()
|
||||||
}
|
}
|
||||||
|
function onBypassRequested(sessionId, id, cmd, cwd) {
|
||||||
|
// Show bypass request if it's for the active session
|
||||||
|
if (sessionId === ollie.activeSessionId) {
|
||||||
|
chatPane.pendingBypass = {
|
||||||
|
sessionId: sessionId,
|
||||||
|
id: id,
|
||||||
|
cmd: cmd,
|
||||||
|
cwd: cwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,18 @@ void Ollie9pClient::handleEvent(const QString &eventLine)
|
||||||
rest == QLatin1String("new") || rest == QLatin1String("kill") ||
|
rest == QLatin1String("new") || rest == QLatin1String("kill") ||
|
||||||
rest == QLatin1String("rename")) {
|
rest == QLatin1String("rename")) {
|
||||||
refreshSessions();
|
refreshSessions();
|
||||||
|
} else if (rest.startsWith(QLatin1String("bypass.request"))) {
|
||||||
|
// Bypass event: payload is "id\tcmd\tcwd"
|
||||||
|
if (payload.isEmpty()) return;
|
||||||
|
const int tab1 = payload.indexOf(QLatin1Char('\t'));
|
||||||
|
if (tab1 < 0) return;
|
||||||
|
const int tab2 = payload.indexOf(QLatin1Char('\t'), tab1 + 1);
|
||||||
|
if (tab2 < 0) return;
|
||||||
|
|
||||||
|
const QString id = payload.left(tab1).toString();
|
||||||
|
const QString cmd = payload.mid(tab1 + 1, tab2 - tab1 - 1).toString();
|
||||||
|
const QString cwd = payload.mid(tab2 + 1).toString();
|
||||||
|
emit bypassRequested(sessionId.toString(), id, cmd, cwd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -763,6 +775,20 @@ bool Ollie9pClient::renameAgent(const QString &sessionId, const QString &agentId
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Ollie9pClient::resolveBypass(const QString &sessionId, const QString &id, bool approve)
|
||||||
|
{
|
||||||
|
if (sessionId.isEmpty() || id.isEmpty()) return false;
|
||||||
|
if (!m_9p || !m_9p->isConnected()) return false;
|
||||||
|
|
||||||
|
QString path = "session/" + sessionId + "/bypass";
|
||||||
|
QString data = id + (approve ? " approve" : " deny");
|
||||||
|
if (m_9p->write(path, data.toUtf8())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
qDebug() << "resolveBypass failed:" << m_9p->lastError();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Root data loading (lazy) ---
|
// --- Root data loading (lazy) ---
|
||||||
|
|
||||||
void Ollie9pClient::ensureRootDataLoaded()
|
void Ollie9pClient::ensureRootDataLoaded()
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ public:
|
||||||
Q_INVOKABLE bool killAgent(const QString &sessionId, const QString &agentId);
|
Q_INVOKABLE bool killAgent(const QString &sessionId, const QString &agentId);
|
||||||
Q_INVOKABLE bool renameSession(const QString &sessionId, const QString &newName);
|
Q_INVOKABLE bool renameSession(const QString &sessionId, const QString &newName);
|
||||||
Q_INVOKABLE bool renameAgent(const QString &sessionId, const QString &agentId, const QString &newName);
|
Q_INVOKABLE bool renameAgent(const QString &sessionId, const QString &agentId, const QString &newName);
|
||||||
|
Q_INVOKABLE bool resolveBypass(const QString &sessionId, const QString &id, bool approve);
|
||||||
Q_INVOKABLE QString lastError() const { return m_9p ? m_9p->lastError() : QString(); }
|
Q_INVOKABLE QString lastError() const { return m_9p ? m_9p->lastError() : QString(); }
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
|
|
@ -99,6 +100,7 @@ signals:
|
||||||
void activeStateChanged();
|
void activeStateChanged();
|
||||||
void agentStateChanged(const QString &sessionId, const QString &agentId, const QString &state);
|
void agentStateChanged(const QString &sessionId, const QString &agentId, const QString &state);
|
||||||
void chatReceived(const QString &text);
|
void chatReceived(const QString &text);
|
||||||
|
void bypassRequested(const QString &sessionId, const QString &id, const QString &cmd, const QString &cwd);
|
||||||
void rootBackendsLoadedChanged();
|
void rootBackendsLoadedChanged();
|
||||||
void availableBackendsChanged();
|
void availableBackendsChanged();
|
||||||
void rootAgentsLoadedChanged();
|
void rootAgentsLoadedChanged();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue