diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index 8661604..d86eb71 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -463,6 +463,55 @@ func buildSessionChildren( return []byte(status + "\n"), status, nil }, 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.Read(func() ([]byte, error) { a, err := metrics.AggregateSession(s.ID) diff --git a/cmd/olliesrv/internal/session/session.go b/cmd/olliesrv/internal/session/session.go index 9bc59ee..0fb5692 100644 --- a/cmd/olliesrv/internal/session/session.go +++ b/cmd/olliesrv/internal/session/session.go @@ -62,6 +62,12 @@ type Session struct { modelsMu sync.Mutex modelsCache string modelsCacheAt time.Time + + // Bypass request tracking + bypassMu sync.RWMutex + bypassPending *protocol.BypassRequest + bypassDialFn func() *toolclient.ToolsrvConn + bypassSignalCh chan struct{} } 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 if notifyFn != nil { notifyFn((*protocol.BypassRequest)(req), s.ID, s.DialToolServer) @@ -247,6 +256,70 @@ func (s *Session) GoalSignal() <-chan struct{} { 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"). func (s *Session) Workflow() string { s.mu.RLock() diff --git a/kde/gui/ChatPane.qml b/kde/gui/ChatPane.qml index e064ac3..29ee831 100644 --- a/kde/gui/ChatPane.qml +++ b/kde/gui/ChatPane.qml @@ -6,6 +6,7 @@ Item { id: chatPane property bool sidebarVisible: true property int maxVisibleBlocks: 80 + property var pendingBypass: null // {sessionId, id, cmd, cwd} signal toggleSidebar() // Do not opt the chat pane into focus navigation. activeFocusOnTab: false @@ -133,6 +134,78 @@ Item { 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 { id: chatView model: chatModel @@ -512,6 +585,17 @@ Connections { function onActiveAgentIdChanged() { 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 + } + } + } } } diff --git a/kde/gui/ollie9pclient.cpp b/kde/gui/ollie9pclient.cpp index c6dd35c..035b39f 100644 --- a/kde/gui/ollie9pclient.cpp +++ b/kde/gui/ollie9pclient.cpp @@ -250,6 +250,18 @@ void Ollie9pClient::handleEvent(const QString &eventLine) rest == QLatin1String("new") || rest == QLatin1String("kill") || rest == QLatin1String("rename")) { 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; } +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) --- void Ollie9pClient::ensureRootDataLoaded() diff --git a/kde/gui/ollie9pclient.h b/kde/gui/ollie9pclient.h index 44c41a7..63018c0 100644 --- a/kde/gui/ollie9pclient.h +++ b/kde/gui/ollie9pclient.h @@ -90,6 +90,7 @@ public: Q_INVOKABLE bool killAgent(const QString &sessionId, const QString &agentId); 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 resolveBypass(const QString &sessionId, const QString &id, bool approve); Q_INVOKABLE QString lastError() const { return m_9p ? m_9p->lastError() : QString(); } signals: @@ -99,6 +100,7 @@ signals: void activeStateChanged(); void agentStateChanged(const QString &sessionId, const QString &agentId, const QString &state); void chatReceived(const QString &text); + void bypassRequested(const QString &sessionId, const QString &id, const QString &cmd, const QString &cwd); void rootBackendsLoadedChanged(); void availableBackendsChanged(); void rootAgentsLoadedChanged();