From 8923c15bd31ce96daf34f6e22e006555f18feebc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Feb 2026 18:26:25 +0000
Subject: [PATCH 1/3] Initial plan
From b181095f1a9e90b1ec0dc032353832fc367e317e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Feb 2026 18:37:37 +0000
Subject: [PATCH 2/3] Add web-based terminal with WebSocket shell access
- Create terminal package with WebSocket-based shell handler
- Add terminal page with mobile-friendly UI (responsive CSS)
- Register terminal routes in main.go (admin-only access)
- Add terminal navigation entry in sidebar
- Add terminal icon
- Add unit tests for terminal package
Co-authored-by: asim <17530+asim@users.noreply.github.com>
---
app/app.go | 1 +
app/html/terminal.png | Bin 0 -> 204 bytes
main.go | 10 +-
terminal/terminal.go | 399 ++++++++++++++++++++++++++++++++++++++
terminal/terminal_test.go | 78 ++++++++
5 files changed, 487 insertions(+), 1 deletion(-)
create mode 100644 app/html/terminal.png
create mode 100644 terminal/terminal.go
create mode 100644 terminal/terminal_test.go
diff --git a/app/app.go b/app/app.go
index a44c027..2ae6410 100644
--- a/app/app.go
+++ b/app/app.go
@@ -217,6 +217,7 @@ var Template = `
News
Video
Wallet
+
Terminal
diff --git a/app/html/terminal.png b/app/html/terminal.png
new file mode 100644
index 0000000000000000000000000000000000000000..b5da2b194dbd2ce90d37b4aba8cb06f890cbfa2a
GIT binary patch
literal 204
zcmeAS@N?(olHy`uVBq!ia0vp^x**KK1|+Sd9?b$$lRaG=Ln`LHy}Dbl!GMSPLeJz}
ziCGIZ7A{hgSB^}7c`ml=`5fWx&n#FkSMLv0W#-Ln=#6dKywLPQ&J&?Iye79Y7TaGq
zUYWT_^26JY2?w0hq
vr(N@U&o|uPy!fkj
z)t9nM3nEjuTzq=r<^$)1#S>rXe9$!5+^Dp6N}#;pH#G43g|orPgg&ebxsLQ
E0P#pxApigX
literal 0
HcmV?d00001
diff --git a/main.go b/main.go
index 29c020c..ef120fa 100644
--- a/main.go
+++ b/main.go
@@ -24,6 +24,7 @@ import (
"mu/home"
"mu/mail"
"mu/news"
+ "mu/terminal"
"mu/user"
"mu/video"
"mu/wallet"
@@ -110,6 +111,8 @@ func main() {
"/status": false, // Public - server health status
"/docs": false, // Public - documentation
"/about": false, // Public - about page
+
+ "/terminal": true, // Require auth for terminal
}
// Static assets should not require authentication
@@ -197,6 +200,10 @@ func main() {
// presence WebSocket endpoint
http.HandleFunc("/presence", user.PresenceHandler)
+ // terminal - web based shell (admin only)
+ http.HandleFunc("/terminal", terminal.Handler)
+ http.HandleFunc("/terminal/ws", terminal.WSHandler)
+
// presence ping endpoint
http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
_, acc, err := auth.RequireSession(r)
@@ -230,7 +237,8 @@ func main() {
!strings.HasSuffix(r.URL.Path, ".js") &&
!strings.HasSuffix(r.URL.Path, ".png") &&
!strings.HasSuffix(r.URL.Path, ".ico") &&
- !strings.HasPrefix(r.URL.Path, "/chat/ws") {
+ !strings.HasPrefix(r.URL.Path, "/chat/ws") &&
+ !strings.HasPrefix(r.URL.Path, "/terminal/ws") {
app.Log("http", "%s %s %s %v", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
}
}()
diff --git a/terminal/terminal.go b/terminal/terminal.go
new file mode 100644
index 0000000..b1a9fcb
--- /dev/null
+++ b/terminal/terminal.go
@@ -0,0 +1,399 @@
+package terminal
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "mu/app"
+ "mu/auth"
+)
+
+// Template renders the terminal UI page
+var Template = `
+
+
+
+`
+
+// WebSocket upgrader
+var upgrader = websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 4096,
+ CheckOrigin: func(r *http.Request) bool {
+ return true
+ },
+}
+
+// Handler serves the terminal page (admin only)
+func Handler(w http.ResponseWriter, r *http.Request) {
+ // Require admin access
+ _, _, err := auth.RequireAdmin(r)
+ if err != nil {
+ app.Forbidden(w, r, "Admin access required")
+ return
+ }
+
+ if app.WantsJSON(r) {
+ app.RespondJSON(w, map[string]string{"status": "ok"})
+ return
+ }
+
+ html := app.RenderHTMLForRequest("Terminal", "Web terminal", Template, r)
+ w.Write([]byte(html))
+}
+
+// wsMessage represents a WebSocket message
+type wsMessage struct {
+ Type string `json:"type"` // "input", "output", "error", "prompt"
+ Data string `json:"data"`
+}
+
+// WSHandler handles WebSocket connections for the terminal
+func WSHandler(w http.ResponseWriter, r *http.Request) {
+ // Require admin access
+ _, _, err := auth.RequireAdmin(r)
+ if err != nil {
+ http.Error(w, "Admin access required", http.StatusForbidden)
+ return
+ }
+
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ app.Log("terminal", "WebSocket upgrade error: %v", err)
+ return
+ }
+ defer conn.Close()
+
+ app.Log("terminal", "New terminal session")
+
+ shell := getShell()
+
+ // Start the shell process
+ cmd := exec.Command(shell)
+ cmd.Env = append(os.Environ(), "TERM=dumb")
+
+ // Set working directory to user home or /tmp
+ if home, err := os.UserHomeDir(); err == nil {
+ cmd.Dir = home
+ } else {
+ cmd.Dir = "/tmp"
+ }
+
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ sendError(conn, "Failed to create stdin pipe")
+ return
+ }
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ sendError(conn, "Failed to create stdout pipe")
+ return
+ }
+
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ sendError(conn, "Failed to create stderr pipe")
+ return
+ }
+
+ if err := cmd.Start(); err != nil {
+ sendError(conn, fmt.Sprintf("Failed to start shell: %v", err))
+ return
+ }
+
+ var wg sync.WaitGroup
+ done := make(chan struct{})
+
+ // Read stdout and send to WebSocket
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ buf := make([]byte, 4096)
+ for {
+ select {
+ case <-done:
+ return
+ default:
+ n, err := stdout.Read(buf)
+ if n > 0 {
+ sendOutput(conn, string(buf[:n]))
+ }
+ if err != nil {
+ if err != io.EOF {
+ app.Log("terminal", "stdout read error: %v", err)
+ }
+ return
+ }
+ }
+ }
+ }()
+
+ // Read stderr and send to WebSocket
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ buf := make([]byte, 4096)
+ for {
+ select {
+ case <-done:
+ return
+ default:
+ n, err := stderr.Read(buf)
+ if n > 0 {
+ sendError(conn, string(buf[:n]))
+ }
+ if err != nil {
+ if err != io.EOF {
+ app.Log("terminal", "stderr read error: %v", err)
+ }
+ return
+ }
+ }
+ }
+ }()
+
+ // Read WebSocket messages and write to stdin
+ for {
+ var msg wsMessage
+ if err := conn.ReadJSON(&msg); err != nil {
+ if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
+ app.Log("terminal", "WebSocket read error: %v", err)
+ }
+ break
+ }
+
+ if msg.Type == "input" {
+ command := msg.Data + "\n"
+ if _, err := io.WriteString(stdin, command); err != nil {
+ app.Log("terminal", "stdin write error: %v", err)
+ break
+ }
+ }
+ }
+
+ // Cleanup
+ close(done)
+ stdin.Close()
+
+ // Kill the process if still running
+ if cmd.Process != nil {
+ cmd.Process.Kill()
+ }
+
+ // Wait with timeout
+ waitDone := make(chan error, 1)
+ go func() { waitDone <- cmd.Wait() }()
+ select {
+ case <-waitDone:
+ case <-time.After(3 * time.Second):
+ app.Log("terminal", "Shell process did not exit in time")
+ }
+
+ wg.Wait()
+ app.Log("terminal", "Terminal session ended")
+}
+
+// getShell returns the shell to use
+func getShell() string {
+ if runtime.GOOS == "windows" {
+ return "cmd.exe"
+ }
+ // Try common shells
+ for _, sh := range []string{"/bin/bash", "/bin/sh"} {
+ if _, err := os.Stat(sh); err == nil {
+ return sh
+ }
+ }
+ return "sh"
+}
+
+// sendOutput sends output to the WebSocket client
+func sendOutput(conn *websocket.Conn, data string) {
+ msg := wsMessage{Type: "output", Data: data}
+ conn.WriteJSON(msg)
+}
+
+// sendError sends an error message to the WebSocket client
+func sendError(conn *websocket.Conn, data string) {
+ msg := wsMessage{Type: "error", Data: data}
+ conn.WriteJSON(msg)
+}
+
+// RenderPage generates the terminal page HTML (exported for testing)
+func RenderPage() string {
+ var b strings.Builder
+ b.WriteString(Template)
+ return b.String()
+}
diff --git a/terminal/terminal_test.go b/terminal/terminal_test.go
new file mode 100644
index 0000000..359bfc1
--- /dev/null
+++ b/terminal/terminal_test.go
@@ -0,0 +1,78 @@
+package terminal
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestGetShell(t *testing.T) {
+ shell := getShell()
+ if shell == "" {
+ t.Error("getShell() returned empty string")
+ }
+ // Should return a valid shell path
+ validShells := []string{"sh", "bash", "cmd.exe", "/bin/sh", "/bin/bash"}
+ found := false
+ for _, valid := range validShells {
+ if strings.HasSuffix(shell, valid) || shell == valid {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("getShell() returned unexpected shell: %s", shell)
+ }
+}
+
+func TestRenderPage(t *testing.T) {
+ page := RenderPage()
+ if page == "" {
+ t.Error("RenderPage() returned empty string")
+ }
+ // Should contain key terminal elements
+ if !strings.Contains(page, "terminal-container") {
+ t.Error("RenderPage() should contain terminal-container")
+ }
+ if !strings.Contains(page, "terminal-input") {
+ t.Error("RenderPage() should contain terminal-input")
+ }
+ if !strings.Contains(page, "terminal-output") {
+ t.Error("RenderPage() should contain terminal-output")
+ }
+ if !strings.Contains(page, "WebSocket") {
+ t.Error("RenderPage() should contain WebSocket connection code")
+ }
+}
+
+func TestTemplateContainsMobileStyles(t *testing.T) {
+ // Verify mobile-friendly responsive styles are present
+ if !strings.Contains(Template, "@media") {
+ t.Error("Template should contain responsive media queries for mobile support")
+ }
+ if !strings.Contains(Template, "max-width: 600px") {
+ t.Error("Template should contain mobile breakpoint")
+ }
+}
+
+func TestWSMessageTypes(t *testing.T) {
+ // Verify the message type constants are used correctly
+ tests := []struct {
+ name string
+ msgType string
+ expected string
+ }{
+ {"output type", "output", "output"},
+ {"error type", "error", "error"},
+ {"input type", "input", "input"},
+ {"prompt type", "prompt", "prompt"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ msg := wsMessage{Type: tt.msgType, Data: "test"}
+ if msg.Type != tt.expected {
+ t.Errorf("Expected type %q, got %q", tt.expected, msg.Type)
+ }
+ })
+ }
+}
From 8fdf06efd44a682edcd8f6ef130701ede776786a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 9 Feb 2026 18:39:24 +0000
Subject: [PATCH 3/3] Address code review feedback: fix busy-wait, error
handling, cross-platform paths
Co-authored-by: asim <17530+asim@users.noreply.github.com>
---
terminal/terminal.go | 56 +++++++++++++++++++-------------------------
1 file changed, 24 insertions(+), 32 deletions(-)
diff --git a/terminal/terminal.go b/terminal/terminal.go
index b1a9fcb..6cfea6a 100644
--- a/terminal/terminal.go
+++ b/terminal/terminal.go
@@ -243,11 +243,11 @@ func WSHandler(w http.ResponseWriter, r *http.Request) {
cmd := exec.Command(shell)
cmd.Env = append(os.Environ(), "TERM=dumb")
- // Set working directory to user home or /tmp
+ // Set working directory to user home or a temp directory
if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home
} else {
- cmd.Dir = "/tmp"
+ cmd.Dir = os.TempDir()
}
stdin, err := cmd.StdinPipe()
@@ -274,7 +274,6 @@ func WSHandler(w http.ResponseWriter, r *http.Request) {
}
var wg sync.WaitGroup
- done := make(chan struct{})
// Read stdout and send to WebSocket
wg.Add(1)
@@ -282,20 +281,15 @@ func WSHandler(w http.ResponseWriter, r *http.Request) {
defer wg.Done()
buf := make([]byte, 4096)
for {
- select {
- case <-done:
- return
- default:
- n, err := stdout.Read(buf)
- if n > 0 {
- sendOutput(conn, string(buf[:n]))
- }
- if err != nil {
- if err != io.EOF {
- app.Log("terminal", "stdout read error: %v", err)
- }
- return
+ n, err := stdout.Read(buf)
+ if n > 0 {
+ sendOutput(conn, string(buf[:n]))
+ }
+ if err != nil {
+ if err != io.EOF {
+ app.Log("terminal", "stdout read error: %v", err)
}
+ return
}
}
}()
@@ -306,20 +300,15 @@ func WSHandler(w http.ResponseWriter, r *http.Request) {
defer wg.Done()
buf := make([]byte, 4096)
for {
- select {
- case <-done:
- return
- default:
- n, err := stderr.Read(buf)
- if n > 0 {
- sendError(conn, string(buf[:n]))
- }
- if err != nil {
- if err != io.EOF {
- app.Log("terminal", "stderr read error: %v", err)
- }
- return
+ n, err := stderr.Read(buf)
+ if n > 0 {
+ sendError(conn, string(buf[:n]))
+ }
+ if err != nil {
+ if err != io.EOF {
+ app.Log("terminal", "stderr read error: %v", err)
}
+ return
}
}
}()
@@ -344,7 +333,6 @@ func WSHandler(w http.ResponseWriter, r *http.Request) {
}
// Cleanup
- close(done)
stdin.Close()
// Kill the process if still running
@@ -382,13 +370,17 @@ func getShell() string {
// sendOutput sends output to the WebSocket client
func sendOutput(conn *websocket.Conn, data string) {
msg := wsMessage{Type: "output", Data: data}
- conn.WriteJSON(msg)
+ if err := conn.WriteJSON(msg); err != nil {
+ app.Log("terminal", "WebSocket write error: %v", err)
+ }
}
// sendError sends an error message to the WebSocket client
func sendError(conn *websocket.Conn, data string) {
msg := wsMessage{Type: "error", Data: data}
- conn.WriteJSON(msg)
+ if err := conn.WriteJSON(msg); err != nil {
+ app.Log("terminal", "WebSocket write error: %v", err)
+ }
}
// RenderPage generates the terminal page HTML (exported for testing)