diff --git a/kvmapp/system/init.d/S96picoclaw b/kvmapp/system/init.d/S96picoclaw index b8cdce9..c4f7e03 100755 --- a/kvmapp/system/init.d/S96picoclaw +++ b/kvmapp/system/init.d/S96picoclaw @@ -21,12 +21,7 @@ resolve_user_home() { printf '%s\n' "/root" } -setup_runtime_env() { - [ -x "$BIN_PATH" ] || { - echo "picoclaw binary not found: $BIN_PATH" - return 1 - } - +setup_picoclaw_home() { USER_HOME="$(resolve_user_home)" PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}" HOME="$USER_HOME" @@ -37,6 +32,8 @@ setup_runtime_env() { echo "failed to create picoclaw home: $PICOCLAW_HOME" return 1 } + PICOCLAW_PID_FILE="${PICOCLAW_PID_FILE:-$PICOCLAW_HOME/.picoclaw.pid}" + export PICOCLAW_PID_FILE cd "$PICOCLAW_HOME" || { echo "failed to enter picoclaw home: $PICOCLAW_HOME" @@ -44,6 +41,75 @@ setup_runtime_env() { } } +setup_runtime_env() { + [ -x "$BIN_PATH" ] || { + echo "picoclaw binary not found: $BIN_PATH" + return 1 + } + + setup_picoclaw_home || return 1 +} + +read_pid_file() { + [ -f "$PICOCLAW_PID_FILE" ] || return 1 + pid="$(sed -n '1p' "$PICOCLAW_PID_FILE" 2>/dev/null | tr -d '[:space:]')" + case "$pid" in + ''|*[!0-9]*) + return 1 + ;; + esac + printf '%s\n' "$pid" +} + +is_gateway_pid() { + pid="$1" + [ -n "$pid" ] || return 1 + [ -r "/proc/$pid/cmdline" ] || return 1 + + cmd_path="$(tr '\000' '\n' <"/proc/$pid/cmdline" 2>/dev/null | sed -n '1p')" + subcommand="$(tr '\000' '\n' <"/proc/$pid/cmdline" 2>/dev/null | sed -n '2p')" + [ "$(basename "$cmd_path")" = "$BIN_NAME" ] && [ "$subcommand" = "gateway" ] && return 0 + + return 1 +} + +find_gateway_pid() { + for proc_dir in /proc/[0-9]*; do + [ -d "$proc_dir" ] || continue + pid="${proc_dir#/proc/}" + if is_gateway_pid "$pid"; then + printf '%s\n' "$pid" + return 0 + fi + done + + return 1 +} + +cleanup_stale_pid_file() { + [ -f "$PICOCLAW_PID_FILE" ] || return 0 + pid="$(read_pid_file 2>/dev/null)" || { + rm -f "$PICOCLAW_PID_FILE" + return 0 + } + if ! is_gateway_pid "$pid"; then + echo "picoclaw stale pid file removed: $PICOCLAW_PID_FILE" + rm -f "$PICOCLAW_PID_FILE" + fi +} + +gateway_running_pid() { + pid="$(read_pid_file 2>/dev/null)" && is_gateway_pid "$pid" && { + printf '%s\n' "$pid" + return 0 + } + + cleanup_stale_pid_file + pid="$(find_gateway_pid 2>/dev/null)" || return 1 + printf '%s\n' "$pid" >"$PICOCLAW_PID_FILE" 2>/dev/null || true + printf '%s\n' "$pid" +} + resolve_kvm_control_source() { for candidate in \ "/kvmapp/picoclaw/skills/kvm-control" \ @@ -107,8 +173,8 @@ sync_agent_profile() { start_service() { setup_runtime_env || return 1 - if pidof "$BIN_NAME" >/dev/null 2>&1; then - echo "picoclaw already running" + if pid="$(gateway_running_pid)"; then + echo "picoclaw gateway already running (PID: $pid)" return 0 fi @@ -118,6 +184,12 @@ start_service() { fi "$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 & + gateway_pid="$!" + printf '%s\n' "$gateway_pid" >"$PICOCLAW_PID_FILE" || { + echo "failed to write picoclaw pid file: $PICOCLAW_PID_FILE" + return 1 + } + echo "picoclaw gateway started (PID: $gateway_pid)" } onboard_service() { @@ -128,7 +200,26 @@ onboard_service() { } stop_service() { - killall "$BIN_NAME" 2>/dev/null || true + setup_picoclaw_home || return 1 + + pid="$(gateway_running_pid 2>/dev/null)" || { + cleanup_stale_pid_file + return 0 + } + + kill "$pid" 2>/dev/null || true + count=0 + while is_gateway_pid "$pid"; do + count=$((count + 1)) + if [ "$count" -ge 15 ]; then + echo "picoclaw gateway did not stop after TERM, killing PID: $pid" + kill -9 "$pid" 2>/dev/null || true + break + fi + sleep 1 + done + + rm -f "$PICOCLAW_PID_FILE" } case "$1" in diff --git a/server/README.md b/server/README.md index 5435b9c..172fa9c 100644 --- a/server/README.md +++ b/server/README.md @@ -64,7 +64,7 @@ turn: ## Compile & Deploy -Note: Use Linux operating system (x86-64). This build process is not compatible with ARM, Windows or macOS. +Note: Use Linux operating system (x86-64) with Go 1.25 or newer. This build process is not compatible with ARM, Windows or macOS. 1. Install the Toolchain 1. Download the toolchain from the following link: [Download Link](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz). diff --git a/server/README_JA.md b/server/README_JA.md index 92ad8c2..7bbec32 100644 --- a/server/README_JA.md +++ b/server/README_JA.md @@ -65,7 +65,7 @@ turn: ## コンパイルとデプロイ -注意: Linux オペレーティングシステム (x86-64) を使用してください。このビルドプロセスは ARM、Windows、macOS では互換性がありません。 +注意: Linux オペレーティングシステム (x86-64) と Go 1.25 以降を使用してください。このビルドプロセスは ARM、Windows、macOS では互換性がありません。 1. ツールチェーンのインストール 1. 以下のリンクからツールチェーンをダウンロードします: [ダウンロードリンク](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz)。 diff --git a/server/README_ZH.md b/server/README_ZH.md index 94b82c3..d0c07d2 100644 --- a/server/README_ZH.md +++ b/server/README_ZH.md @@ -62,7 +62,7 @@ turn: ## 编译部署 -**注意:请使用 Linux 操作系统(x86-64)。该工具链无法在 ARM、Windows 或 macOS 下使用。** +**注意:请使用 Linux 操作系统(x86-64)和 Go 1.25 或更高版本。该工具链无法在 ARM、Windows 或 macOS 下使用。** 1. 安装工具链 1. 下载工具链:[下载地址](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz); diff --git a/server/go.mod b/server/go.mod index 20a24e5..08af276 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,16 +1,18 @@ module NanoKVM-Server -go 1.24.0 +go 1.25.0 require ( github.com/creack/pty v1.1.24 github.com/gin-gonic/contrib v0.0.0-20240508051311-c1c6bf0061b0 github.com/gin-gonic/gin v1.10.0 github.com/go-playground/validator/v10 v10.20.0 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/pion/dtls/v3 v3.1.2 github.com/pion/rtp v1.8.18 github.com/pion/webrtc/v4 v4.0.1 @@ -60,6 +62,8 @@ require ( github.com/rs/cors v1.11.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -68,12 +72,14 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/wlynxg/anet v0.0.5 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/server/go.sum b/server/go.sum index 0f8bc30..f04e885 100644 --- a/server/go.sum +++ b/server/go.sum @@ -34,11 +34,13 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -65,6 +67,8 @@ github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0 h github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0/go.mod h1:Eb5RMoo9kOQra/2uRiUTGP+LfNuM13Vqm7y7P34+KKo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -119,6 +123,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -154,6 +162,8 @@ github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9O github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= @@ -167,13 +177,17 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/proto/mcp.go b/server/proto/mcp.go new file mode 100644 index 0000000..fad4e4f --- /dev/null +++ b/server/proto/mcp.go @@ -0,0 +1,12 @@ +package proto + +type GetMCPConfigRsp struct { + Enabled bool `json:"enabled"` + APIKey string `json:"apiKey"` + ControlMode string `json:"controlMode"` + Transitioning bool `json:"transitioning"` +} + +type SetMCPConfigReq struct { + Enabled *bool `json:"enabled" form:"enabled" validate:"required"` +} diff --git a/server/router/control.go b/server/router/control.go new file mode 100644 index 0000000..e1f57d2 --- /dev/null +++ b/server/router/control.go @@ -0,0 +1,159 @@ +package router + +import ( + "net/http" + "time" + + "NanoKVM-Server/middleware" + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/inputcontrol" + "NanoKVM-Server/service/picoclaw" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +type setAIControlModeRequest struct { + Mode controlmode.Mode `json:"mode"` +} + +func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) { + group := r.Group("/api/ai/control").Use(middleware.CheckToken()) + group.GET("/status", func(c *gin.Context) { + status, err := control.Status() + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": -1, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": status}) + }) + + group.PUT("/mode", func(c *gin.Context) { + startedAt := time.Now() + var req setAIControlModeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"code": -1, "message": "invalid arguments"}) + return + } + if !validAIControlMode(req.Mode) { + c.JSON(http.StatusOK, gin.H{"code": -1, "message": "invalid control mode"}) + return + } + + previousStatus, previousErr := control.Status() + if previousErr != nil { + log.WithFields(log.Fields{ + "request_mode": string(req.Mode), + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).WithError(previousErr).Warn("AI control mode request failed before switch") + c.JSON(http.StatusOK, gin.H{"code": -2, "message": previousErr.Error()}) + return + } + + preempt := func() error { + inputcontrol.GetCoordinator().CancelMCP() + if req.Mode == controlmode.ModeMCP { + return picoclawService.PreemptControlLeasesForMCP() + } + picoclawService.CancelActiveControlOperations() + return nil + } + cleanup := func() error { + return hid.ReleaseAllHIDStateBestEffort() + } + if req.Mode == controlmode.ModeMCP { + cleanup = func() error { + if err := picoclawService.StopRuntimeForMCP(); err != nil { + return err + } + return hid.ReleaseAllHIDStateBestEffort() + } + } + if err := control.SwitchWithCleanup(req.Mode, preempt, cleanup); err != nil { + if req.Mode == controlmode.ModeOff { + if status, statusErr := control.Status(); statusErr == nil && status.Mode == controlmode.ModeOff { + picoclawService.CancelActiveControlOperations() + picoclawService.PreserveRuntimeForChatOnly("control_release_chat_only") + closedSessions := 0 + picoclawService.PublishControlModeChangedFrom(status, "ai_control_mode") + log.WithFields(log.Fields{ + "request_mode": string(req.Mode), + "previous_mode": string(previousStatus.Mode), + "final_mode": string(status.Mode), + "transitioning": status.Transitioning, + "closed_sessions": closedSessions, + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).WithError(err).Warn("AI control mode request completed with cleanup warning") + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": gin.H{ + "control": status, + "runtime": picoclawService.RuntimeStatus(), + "released": true, + "closed_sessions": closedSessions, + "cleanup_warning": err.Error(), + }, + }) + return + } + } + log.WithFields(log.Fields{ + "request_mode": string(req.Mode), + "previous_mode": string(previousStatus.Mode), + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).WithError(err).Warn("AI control mode request failed") + c.JSON(http.StatusOK, gin.H{"code": -2, "message": err.Error()}) + return + } + + status, err := control.Status() + if err != nil { + log.WithFields(log.Fields{ + "request_mode": string(req.Mode), + "previous_mode": string(previousStatus.Mode), + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).WithError(err).Warn("AI control mode request failed after switch") + c.JSON(http.StatusOK, gin.H{"code": -2, "message": err.Error()}) + return + } + closedSessions := 0 + if req.Mode == controlmode.ModeOff { + picoclawService.CancelActiveControlOperations() + picoclawService.PreserveRuntimeForChatOnly("control_release_chat_only") + } + picoclawService.PublishControlModeChangedFrom(status, "ai_control_mode") + log.WithFields(log.Fields{ + "request_mode": string(req.Mode), + "previous_mode": string(previousStatus.Mode), + "final_mode": string(status.Mode), + "transitioning": status.Transitioning, + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).Info("AI control mode request completed") + if req.Mode == controlmode.ModeOff { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": gin.H{ + "control": status, + "runtime": picoclawService.RuntimeStatus(), + "released": true, + "closed_sessions": closedSessions, + "cleanup_warning": "", + }, + }) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": status}) + }) +} + +func validAIControlMode(mode controlmode.Mode) bool { + switch mode { + case controlmode.ModeOff, controlmode.ModeMCP, controlmode.ModePicoclaw: + return true + default: + return false + } +} diff --git a/server/router/mcp.go b/server/router/mcp.go new file mode 100644 index 0000000..12b885f --- /dev/null +++ b/server/router/mcp.go @@ -0,0 +1,34 @@ +package router + +import ( + "net/http" + + "NanoKVM-Server/middleware" + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/hid" + mcpservice "NanoKVM-Server/service/mcp" + "NanoKVM-Server/service/mcp/capture/kvm" + "NanoKVM-Server/service/picoclaw" + + "github.com/gin-gonic/gin" +) + +func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) { + service := mcpservice.NewServiceWithPreempt( + control, + picoclawService.PreemptControlLeasesForMCP, + picoclawService.StopRuntimeForMCP, + hid.ReleaseAllHIDStateBestEffort, + func(status controlmode.Status) { + picoclawService.PublishControlModeChangedFrom(status, "mcp_config") + }, + ) + management := r.Group("/api/mcp").Use(middleware.CheckToken()) + management.GET("/config", service.GetConfig) + management.POST("/config", service.SetConfig) + management.POST("/key/regenerate", service.RegenerateAPIKey) + + handler := mcpservice.NewMCPHandler(control, kvmcapture.New()) + handler = http.NewCrossOriginProtection().Handler(handler) + r.Any("/api/mcp", mcpservice.APIKeyMiddleware(control), gin.WrapH(handler)) +} diff --git a/server/router/picoclaw.go b/server/router/picoclaw.go index ef77c4b..de0696b 100644 --- a/server/router/picoclaw.go +++ b/server/router/picoclaw.go @@ -38,8 +38,7 @@ func PicoclawLoopbackHTTPAllowedPaths() []string { return append([]string(nil), picoclawLoopbackHTTPAllowedPaths...) } -func picoclawRouter(r *gin.Engine) { - service := picoclaw.NewService() +func picoclawRouter(r *gin.Engine, service *picoclaw.Service) { frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken()) localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken()) diff --git a/server/router/router.go b/server/router/router.go index c731e0c..e2fbea0 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -5,6 +5,9 @@ import ( "os" "path/filepath" + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/picoclaw" + "github.com/gin-gonic/contrib/static" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" @@ -29,6 +32,9 @@ func web(r *gin.Engine) { } func server(r *gin.Engine) { + control := controlmode.GetManager() + picoclawService := picoclaw.NewService(control) + authRouter(r) applicationRouter(r) vmRouter(r) @@ -36,7 +42,9 @@ func server(r *gin.Engine) { storageRouter(r) networkRouter(r) hidRouter(r) - picoclawRouter(r) + controlRouter(r, control, picoclawService) + mcpRouter(r, control, picoclawService) + picoclawRouter(r, picoclawService) wsRouter(r) downloadRouter(r) extensionsRouter(r) diff --git a/server/service/controlmode/activity.go b/server/service/controlmode/activity.go new file mode 100644 index 0000000..5eead02 --- /dev/null +++ b/server/service/controlmode/activity.go @@ -0,0 +1,97 @@ +package controlmode + +import ( + "errors" + "sync" + "time" +) + +var ErrActivityWaitTimeout = errors.New("timed out waiting for active control operations") + +// activityGate allows normal control operations to share the current mode while +// giving mode transitions exclusive access. Unlike sync.RWMutex, exclusive +// acquisition is bounded so an abandoned client cannot leave the manager stuck +// in the transitioning state forever. +type activityGate struct { + mu sync.Mutex + active int + exclusive bool + changed chan struct{} +} + +func (g *activityGate) acquireShared() func() { + for { + g.mu.Lock() + if !g.exclusive { + g.active++ + g.mu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + g.mu.Lock() + if g.active > 0 { + g.active-- + } + g.signalLocked() + g.mu.Unlock() + }) + } + } + + changed := g.changedLocked() + g.mu.Unlock() + <-changed + } +} + +func (g *activityGate) acquireExclusive(timeout time.Duration) (func(), error) { + if timeout <= 0 { + return nil, ErrActivityWaitTimeout + } + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + g.mu.Lock() + if !g.exclusive && g.active == 0 { + g.exclusive = true + g.mu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + g.mu.Lock() + g.exclusive = false + g.signalLocked() + g.mu.Unlock() + }) + }, nil + } + + changed := g.changedLocked() + g.mu.Unlock() + + select { + case <-changed: + case <-timer.C: + return nil, ErrActivityWaitTimeout + } + } +} + +func (g *activityGate) changedLocked() <-chan struct{} { + if g.changed == nil { + g.changed = make(chan struct{}) + } + return g.changed +} + +func (g *activityGate) signalLocked() { + if g.changed == nil { + return + } + close(g.changed) + g.changed = make(chan struct{}) +} diff --git a/server/service/controlmode/manager.go b/server/service/controlmode/manager.go new file mode 100644 index 0000000..d16bebc --- /dev/null +++ b/server/service/controlmode/manager.go @@ -0,0 +1,555 @@ +package controlmode + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +const ModeFile = "/etc/kvm/ai-control.mode" + +const defaultActivityWaitTimeout = 30 * time.Second + +type Mode string + +const ( + ModeOff Mode = "off" + ModeMCP Mode = "mcp" + ModePicoclaw Mode = "picoclaw" +) + +var ErrModeConflict = errors.New("AI control mode conflict") + +type Status struct { + Mode Mode `json:"mode"` + Transitioning bool `json:"transitioning"` + LastError string `json:"last_error,omitempty"` + ChangedAt time.Time `json:"changed_at,omitempty"` +} + +type Manager struct { + transitionMu sync.Mutex + activity activityGate + mu sync.Mutex + path string + defaultMode Mode + activityWaitTimeout time.Duration + loaded bool + mode Mode + modeFileExists bool + modeFileSize int64 + modeFileModTime time.Time + transitioning bool + lastError string + changedAt time.Time +} + +var ( + defaultManagerOnce sync.Once + defaultManager *Manager +) + +func GetManager() *Manager { + defaultManagerOnce.Do(func() { + defaultManager = NewManager(ModeFile, ModePicoclaw) + }) + return defaultManager +} + +func NewManager(path string, defaultMode Mode) *Manager { + if !validMode(defaultMode) { + defaultMode = ModeOff + } + return &Manager{ + path: path, + defaultMode: defaultMode, + activityWaitTimeout: defaultActivityWaitTimeout, + } +} + +func (m *Manager) Status() (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.loadLocked(); err != nil { + return Status{Mode: ModeOff, Transitioning: m.transitioning, LastError: err.Error(), ChangedAt: m.changedAt}, err + } + return m.statusLocked(), nil +} + +func (m *Manager) Current() Mode { + status, err := m.Status() + if err != nil { + return ModeOff + } + return status.Mode +} + +func (m *Manager) RequireWrite(expected Mode) error { + status, err := m.Status() + if err != nil { + return err + } + if status.Transitioning || status.Mode != expected { + return fmt.Errorf("%w: current=%s expected=%s", ErrModeConflict, status.Mode, expected) + } + return nil +} + +func (m *Manager) Require(expected Mode) error { + return m.RequireWrite(expected) +} + +func (m *Manager) RequireMode(expected Mode) error { + status, err := m.Status() + if err != nil { + return err + } + if status.Mode != expected { + return fmt.Errorf("%w: current=%s expected=%s", ErrModeConflict, status.Mode, expected) + } + return nil +} + +func (m *Manager) AcquireWrite(expected Mode) (func(), error) { + if err := m.RequireWrite(expected); err != nil { + return nil, err + } + release := m.activity.acquireShared() + if err := m.RequireWrite(expected); err != nil { + release() + return nil, err + } + return release, nil +} + +func (m *Manager) Acquire(expected Mode) (func(), error) { + return m.AcquireWrite(expected) +} + +// AcquireStable holds the current control mode stable while a non-AI input +// operation is in flight. Mode transitions take the exclusive side of the +// activity gate, so they wait until the returned release function is called. +func (m *Manager) AcquireStable() (Status, func(), error) { + status, err := m.Status() + if err != nil { + return Status{Mode: ModeOff}, nil, err + } + if status.Transitioning { + return status, nil, fmt.Errorf("%w: control mode is transitioning", ErrModeConflict) + } + + release := m.activity.acquireShared() + status, err = m.Status() + if err != nil { + release() + return Status{Mode: ModeOff}, nil, err + } + if status.Transitioning { + release() + return status, nil, fmt.Errorf("%w: control mode is transitioning", ErrModeConflict) + } + + return status, release, nil +} + +func (m *Manager) Switch(next Mode, preempt func() error) error { + return m.SwitchWithCleanup(next, preempt, nil) +} + +func (m *Manager) SwitchWithCleanup(next Mode, preempt func() error, cleanup func() error) error { + startedAt := time.Now() + timings := log.Fields{ + "to": string(next), + } + + m.transitionMu.Lock() + defer m.transitionMu.Unlock() + + stageStartedAt := time.Now() + status, err := m.Status() + timings["load_status_ms"] = elapsedMilliseconds(stageStartedAt) + if err != nil { + logModeSwitchFailure("AI control mode switch failed while loading status", timings, err, startedAt) + return err + } + timings["from"] = string(status.Mode) + if !validMode(next) { + err := fmt.Errorf("invalid AI control mode %q", next) + logModeSwitchFailure("AI control mode switch rejected invalid target", timings, err, startedAt) + return err + } + if status.Mode == next { + timings["total_ms"] = elapsedMilliseconds(startedAt) + log.WithFields(timings).Debug("AI control mode switch skipped") + return nil + } + + m.setTransitioning(true) + defer m.setTransitioning(false) + if preempt != nil { + stageStartedAt = time.Now() + if err := callModeTransitionHook(preempt); err != nil { + timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt) + m.setLastError(err) + logModeSwitchFailure("AI control mode switch preempt failed", timings, err, startedAt) + return err + } + timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt) + } + + stageStartedAt = time.Now() + releaseActivity, err := m.acquireExclusiveActivity() + timings["activity_wait_ms"] = elapsedMilliseconds(stageStartedAt) + if err != nil { + m.setLastError(err) + logModeSwitchFailure("AI control mode switch activity wait failed", timings, err, startedAt) + return err + } + defer releaseActivity() + + if cleanup != nil { + stageStartedAt = time.Now() + if err := callModeTransitionHook(cleanup); err != nil { + timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt) + if rollbackErr := m.save(ModeOff); rollbackErr != nil { + err = errors.Join(err, rollbackErr) + } + m.setLastError(err) + logModeSwitchFailure("AI control mode switch cleanup failed", timings, err, startedAt) + return err + } + timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt) + } + + stageStartedAt = time.Now() + if err := m.save(next); err != nil { + timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt) + m.setLastError(err) + logModeSwitchFailure("AI control mode switch save failed", timings, err, startedAt) + return err + } + timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt) + m.setLastError(nil) + timings["total_ms"] = elapsedMilliseconds(startedAt) + log.WithFields(timings).Info("AI control mode switch completed") + return nil +} + +func (m *Manager) SwitchIf(expected Mode, next Mode, preempt func() error) (bool, error) { + return m.SwitchIfWithCleanup(expected, next, preempt, nil) +} + +func (m *Manager) SwitchIfWithCleanup(expected Mode, next Mode, preempt func() error, cleanup func() error) (bool, error) { + startedAt := time.Now() + timings := log.Fields{ + "expected": string(expected), + "to": string(next), + } + + if !validMode(expected) || !validMode(next) { + err := fmt.Errorf("invalid AI control mode transition %q -> %q", expected, next) + logModeSwitchFailure("conditional AI control mode switch rejected invalid transition", timings, err, startedAt) + return false, err + } + + m.transitionMu.Lock() + defer m.transitionMu.Unlock() + + stageStartedAt := time.Now() + status, err := m.Status() + timings["load_status_ms"] = elapsedMilliseconds(stageStartedAt) + if err != nil { + logModeSwitchFailure("conditional AI control mode switch failed while loading status", timings, err, startedAt) + return false, err + } + timings["from"] = string(status.Mode) + if status.Mode != expected { + timings["matched"] = false + timings["total_ms"] = elapsedMilliseconds(startedAt) + log.WithFields(timings).Debug("conditional AI control mode switch skipped") + return false, nil + } + if status.Mode == next { + timings["matched"] = true + timings["total_ms"] = elapsedMilliseconds(startedAt) + log.WithFields(timings).Debug("conditional AI control mode switch already satisfied") + return true, nil + } + + m.setTransitioning(true) + defer m.setTransitioning(false) + if preempt != nil { + stageStartedAt = time.Now() + if err := callModeTransitionHook(preempt); err != nil { + timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt) + m.setLastError(err) + logModeSwitchFailure("conditional AI control mode switch preempt failed", timings, err, startedAt) + return false, err + } + timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt) + } + + stageStartedAt = time.Now() + releaseActivity, err := m.acquireExclusiveActivity() + timings["activity_wait_ms"] = elapsedMilliseconds(stageStartedAt) + if err != nil { + m.setLastError(err) + logModeSwitchFailure("conditional AI control mode switch activity wait failed", timings, err, startedAt) + return false, err + } + defer releaseActivity() + + if cleanup != nil { + stageStartedAt = time.Now() + if err := callModeTransitionHook(cleanup); err != nil { + timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt) + if rollbackErr := m.save(ModeOff); rollbackErr != nil { + err = errors.Join(err, rollbackErr) + } + m.setLastError(err) + logModeSwitchFailure("conditional AI control mode switch cleanup failed", timings, err, startedAt) + return false, err + } + timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt) + } + + stageStartedAt = time.Now() + if err := m.save(next); err != nil { + timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt) + m.setLastError(err) + logModeSwitchFailure("conditional AI control mode switch save failed", timings, err, startedAt) + return false, err + } + timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt) + m.setLastError(nil) + timings["matched"] = true + timings["total_ms"] = elapsedMilliseconds(startedAt) + log.WithFields(timings).Info("conditional AI control mode switch completed") + return true, nil +} + +func (m *Manager) SwitchToMCP(preempt func() error) error { + return m.Switch(ModeMCP, preempt) +} + +func (m *Manager) SwitchToMCPWithPreempt(preempt func() error) error { + return m.Switch(ModeMCP, preempt) +} + +func (m *Manager) SwitchToPicoclaw(preempt func() error) error { + return m.Switch(ModePicoclaw, preempt) +} + +func (m *Manager) SwitchToPicoclawWithPreempt(preempt func() error) error { + return m.Switch(ModePicoclaw, preempt) +} + +func (m *Manager) SwitchOff(preempt func() error) error { + return m.Switch(ModeOff, preempt) +} + +func (m *Manager) SwitchOffIf(expected Mode, preempt func() error) (bool, error) { + return m.SwitchIf(expected, ModeOff, preempt) +} + +func (m *Manager) SwitchOffIfWithPreempt(expected Mode, preempt func() error) (bool, error) { + return m.SwitchIf(expected, ModeOff, preempt) +} + +func callModeTransitionHook(hook func() error) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("panic: %v", recovered) + } + }() + return hook() +} + +func (m *Manager) setTransitioning(transitioning bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.transitioning = transitioning +} + +func (m *Manager) setLastError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + if err == nil { + m.lastError = "" + return + } + m.lastError = err.Error() +} + +func (m *Manager) statusLocked() Status { + return Status{ + Mode: m.mode, + Transitioning: m.transitioning, + LastError: m.lastError, + ChangedAt: m.changedAt, + } +} + +func (m *Manager) acquireExclusiveActivity() (func(), error) { + timeout := m.activityWaitTimeout + if timeout <= 0 { + timeout = defaultActivityWaitTimeout + } + + release, err := m.activity.acquireExclusive(timeout) + if err != nil { + return nil, fmt.Errorf("wait for active control operations: %w", err) + } + return release, nil +} + +func (m *Manager) save(mode Mode) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.saveLocked(mode) +} + +func (m *Manager) loadLocked() error { + if m.loaded && m.transitioning { + return nil + } + + info, statErr := os.Stat(m.path) + if m.loaded && statErr == nil && m.modeFileExists && info.Size() == m.modeFileSize && info.ModTime().Equal(m.modeFileModTime) { + return nil + } + if m.loaded && errors.Is(statErr, os.ErrNotExist) && !m.modeFileExists { + return nil + } + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("stat AI control mode: %w", statErr) + } + + data, err := os.ReadFile(m.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + m.mode = m.defaultMode + m.loaded = true + m.modeFileExists = false + m.modeFileSize = 0 + m.modeFileModTime = time.Time{} + if m.changedAt.IsZero() { + m.changedAt = time.Now() + } + return nil + } + return fmt.Errorf("read AI control mode: %w", err) + } + + mode := Mode(strings.TrimSpace(string(data))) + if !validMode(mode) { + m.mode = ModeOff + m.loaded = true + m.lastError = fmt.Sprintf("invalid AI control mode %q", strings.TrimSpace(string(data))) + m.cacheModeFileInfoLocked(info, data) + return nil + } + m.mode = mode + m.loaded = true + m.lastError = "" + m.cacheModeFileInfoLocked(info, data) + return nil +} + +func (m *Manager) saveLocked(mode Mode) error { + if !validMode(mode) { + return fmt.Errorf("invalid AI control mode %q", mode) + } + + dir := filepath.Dir(m.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create AI control mode directory: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".ai-control.mode.*") + if err != nil { + return fmt.Errorf("create temporary AI control mode: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("set AI control mode permissions: %w", err) + } + if _, err := tmp.WriteString(string(mode) + "\n"); err != nil { + _ = tmp.Close() + return fmt.Errorf("write AI control mode: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync AI control mode: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close AI control mode: %w", err) + } + if err := os.Rename(tmpPath, m.path); err != nil { + return fmt.Errorf("replace AI control mode: %w", err) + } + m.mode = mode + m.loaded = true + if info, err := os.Stat(m.path); err == nil { + m.cacheModeFileInfoLocked(info, []byte(string(mode)+"\n")) + } else { + m.modeFileExists = true + m.modeFileSize = int64(len(string(mode) + "\n")) + m.modeFileModTime = time.Now() + } + m.changedAt = time.Now() + + directory, err := os.Open(dir) + if err == nil { + if syncErr := directory.Sync(); syncErr != nil { + _ = directory.Close() + return fmt.Errorf("sync AI control mode directory: %w", syncErr) + } + _ = directory.Close() + } + + return nil +} + +func (m *Manager) cacheModeFileInfoLocked(info os.FileInfo, data []byte) { + m.modeFileExists = true + if info != nil { + m.modeFileSize = info.Size() + m.modeFileModTime = info.ModTime() + if m.changedAt.IsZero() || info.ModTime().After(m.changedAt) { + m.changedAt = info.ModTime() + } + return + } + m.modeFileSize = int64(len(data)) + m.modeFileModTime = time.Now() + m.changedAt = m.modeFileModTime +} + +func validMode(mode Mode) bool { + switch mode { + case ModeOff, ModeMCP, ModePicoclaw: + return true + default: + return false + } +} + +func elapsedMilliseconds(startedAt time.Time) int64 { + return time.Since(startedAt).Milliseconds() +} + +func logModeSwitchFailure(message string, fields log.Fields, err error, startedAt time.Time) { + fields["total_ms"] = elapsedMilliseconds(startedAt) + log.WithFields(fields).WithError(err).Warn(message) +} diff --git a/server/service/controlmode/manager_test.go b/server/service/controlmode/manager_test.go new file mode 100644 index 0000000..315e0e0 --- /dev/null +++ b/server/service/controlmode/manager_test.go @@ -0,0 +1,202 @@ +package controlmode + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestManagerDefaultsToPicoclaw(t *testing.T) { + manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw) + if got := manager.Current(); got != ModePicoclaw { + t.Fatalf("mode = %q, want %q", got, ModePicoclaw) + } +} + +func TestSwitchPreemptsBeforeWaitingForActiveWrite(t *testing.T) { + manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModeMCP) + release, err := manager.AcquireWrite(ModeMCP) + if err != nil { + t.Fatal(err) + } + + preempted := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- manager.Switch(ModePicoclaw, func() error { + close(preempted) + return nil + }) + }() + + select { + case <-preempted: + case <-time.After(time.Second): + t.Fatal("preempt callback was not called before waiting") + } + if _, err := manager.AcquireWrite(ModeMCP); err == nil { + t.Fatal("new write acquired control during transition") + } + + release() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("switch did not resume after active write was released") + } + if got := manager.Current(); got != ModePicoclaw { + t.Fatalf("mode = %q, want picoclaw", got) + } +} + +func TestSwitchRunsCleanupAfterActiveWritesDrain(t *testing.T) { + manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw) + release, err := manager.AcquireWrite(ModePicoclaw) + if err != nil { + t.Fatal(err) + } + + cleanupCalled := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- manager.SwitchWithCleanup(ModeMCP, nil, func() error { + close(cleanupCalled) + return nil + }) + }() + + select { + case <-cleanupCalled: + t.Fatal("cleanup ran before active write drained") + case <-time.After(20 * time.Millisecond): + } + + release() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("switch did not finish") + } + select { + case <-cleanupCalled: + default: + t.Fatal("cleanup was not called") + } +} + +func TestSwitchCleanupFailureFailsClosed(t *testing.T) { + path := filepath.Join(t.TempDir(), "mode") + manager := NewManager(path, ModePicoclaw) + wantErr := errors.New("hid release failed") + + if err := manager.SwitchWithCleanup(ModeMCP, nil, func() error { return wantErr }); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if got := manager.Current(); got != ModeOff { + t.Fatalf("mode = %q, want off after cleanup failure", got) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "off\n" { + t.Fatalf("mode file = %q, want off", data) + } +} + +func TestSwitchIfKeepsDifferentMode(t *testing.T) { + manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw) + switched, err := manager.SwitchIf(ModeMCP, ModeOff, nil) + if err != nil { + t.Fatal(err) + } + if switched || manager.Current() != ModePicoclaw { + t.Fatalf("switched=%v mode=%q, want unchanged picoclaw", switched, manager.Current()) + } +} + +func TestSwitchTimeoutDoesNotLeaveManagerTransitioning(t *testing.T) { + manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModeMCP) + manager.activityWaitTimeout = 20 * time.Millisecond + cleanupCalled := false + + status, release, err := manager.AcquireStable() + if err != nil { + t.Fatal(err) + } + if status.Mode != ModeMCP { + t.Fatalf("mode = %q, want MCP", status.Mode) + } + + err = manager.SwitchWithCleanup(ModePicoclaw, nil, func() error { + cleanupCalled = true + return nil + }) + if !errors.Is(err, ErrActivityWaitTimeout) { + t.Fatalf("switch error = %v, want %v", err, ErrActivityWaitTimeout) + } + if cleanupCalled { + t.Fatal("cleanup ran even though exclusive activity lease was not acquired") + } + status, err = manager.Status() + if err != nil { + t.Fatal(err) + } + if status.Mode != ModeMCP || status.Transitioning { + t.Fatalf("status after timeout = %+v, want stable MCP", status) + } + + release() + if err := manager.Switch(ModePicoclaw, nil); err != nil { + t.Fatalf("switch after lease release failed: %v", err) + } +} + +func TestInvalidModeFailsClosed(t *testing.T) { + path := filepath.Join(t.TempDir(), "mode") + if err := os.WriteFile(path, []byte("invalid\n"), 0o600); err != nil { + t.Fatal(err) + } + manager := NewManager(path, ModePicoclaw) + status, err := manager.Status() + if err != nil { + t.Fatal(err) + } + if status.Mode != ModeOff { + t.Fatalf("mode = %q, want off", status.Mode) + } + if !strings.Contains(status.LastError, "invalid") { + t.Fatalf("last_error = %q, want invalid mode detail", status.LastError) + } +} + +func TestStatusReloadsExternallyModifiedModeFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "mode") + if err := os.WriteFile(path, []byte("off\n"), 0o600); err != nil { + t.Fatal(err) + } + manager := NewManager(path, ModePicoclaw) + if got := manager.Current(); got != ModeOff { + t.Fatalf("initial mode = %q, want off", got) + } + + if err := os.WriteFile(path, []byte("mcp\n"), 0o600); err != nil { + t.Fatal(err) + } + modTime := time.Now().Add(time.Second) + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatal(err) + } + if got := manager.Current(); got != ModeMCP { + t.Fatalf("mode after external write = %q, want MCP", got) + } +} diff --git a/server/service/hid/hid.go b/server/service/hid/hid.go index 60185ba..5b5fd27 100644 --- a/server/service/hid/hid.go +++ b/server/service/hid/hid.go @@ -249,15 +249,42 @@ func (h *Hid) Close() { } func (h *Hid) WriteHid0(data []byte) { - h.writeHIDReport(h.keyboardDevice(HID0), data) + if err := h.WriteKeyboardReport(data); err != nil { + log.Errorf("write to %s failed: %s", HID0, err) + } } func (h *Hid) WriteHid1(data []byte) { - h.writeHIDReport(h.relativeMouseDevice(HID1), data) + if err := h.WriteRelativeMouseReport(data); err != nil { + log.Errorf("write to %s failed: %s", HID1, err) + } } func (h *Hid) WriteHid2(data []byte) { - h.writeHIDReport(h.absoluteMouseDevice(HID2), data) + if err := h.WriteAbsoluteMouseReport(data); err != nil { + log.Errorf("write to %s failed: %s", HID2, err) + } +} + +func (h *Hid) WriteKeyboardReport(data []byte) error { + if len(data) != 8 { + return fmt.Errorf("invalid keyboard report length: %d", len(data)) + } + return h.writeHID(h.keyboardDevice(HID0), data) +} + +func (h *Hid) WriteRelativeMouseReport(data []byte) error { + if len(data) != 4 { + return fmt.Errorf("invalid relative mouse report length: %d", len(data)) + } + return h.writeHID(h.relativeMouseDevice(HID1), data) +} + +func (h *Hid) WriteAbsoluteMouseReport(data []byte) error { + if len(data) != 6 { + return fmt.Errorf("invalid absolute mouse report length: %d", len(data)) + } + return h.writeHID(h.absoluteMouseDevice(HID2), data) } func (h *Hid) writeHIDReport(device hidDevice, data []byte) bool { diff --git a/server/service/hid/hid_test.go b/server/service/hid/hid_test.go new file mode 100644 index 0000000..572793e --- /dev/null +++ b/server/service/hid/hid_test.go @@ -0,0 +1,16 @@ +package hid + +import "testing" + +func TestReportLengthValidation(t *testing.T) { + h := &Hid{} + if err := h.WriteKeyboardReport(make([]byte, 7)); err == nil { + t.Fatal("expected keyboard length error") + } + if err := h.WriteRelativeMouseReport(make([]byte, 5)); err == nil { + t.Fatal("expected relative mouse length error") + } + if err := h.WriteAbsoluteMouseReport(make([]byte, 7)); err == nil { + t.Fatal("expected absolute mouse length error") + } +} diff --git a/server/service/hid/keyboard.go b/server/service/hid/keyboard.go index 0cf8318..8a13060 100644 --- a/server/service/hid/keyboard.go +++ b/server/service/hid/keyboard.go @@ -5,43 +5,105 @@ import ( ) func (h *Hid) Keyboard(queue <-chan []byte) { - h.keyboard(queue, HID0) + legacy := make(chan QueuedReport) + go func() { + defer close(legacy) + for report := range queue { + legacy <- QueuedReport{Data: report} + } + }() + h.KeyboardReports(legacy) } -func (h *Hid) keyboard(queue <-chan []byte, path string) { - defer h.releaseKeyboard(path) +func (h *Hid) KeyboardReports(queue <-chan QueuedReport) { + h.keyboardReports(queue, HID0) +} + +func (h *Hid) keyboardReports(queue <-chan QueuedReport, path string) { + var execute func(func() error) error + var resetKeyboard func() + keyboardActive := false + defer func() { + if !keyboardActive { + return + } + if err := runCleanup(execute, func() error { + return h.writeHID(h.keyboardDevice(path), keyboardReleaseReport()) + }); err != nil { + log.Errorf("release keyboard on queue close failed: %s", err) + return + } + if resetKeyboard != nil { + resetKeyboard() + } + }() for event := range queue { - if len(event) != 8 { - log.Debugf("invalid keyboard event: %v", event) + execute = event.Execute + resetKeyboard = event.ResetKeyboard + if len(event.Data) != 8 { + event.complete(false) + log.Debugf("invalid keyboard event: %v", event.Data) continue } - if !h.writeHIDReport(h.keyboardDevice(path), event) { + if err := event.run(func() error { + return h.writeHID(h.keyboardDevice(path), event.Data) + }); err != nil { + log.Errorf("write to %s failed: %s", path, err) if dropped := drainHIDQueue(queue); dropped > 0 { log.Debugf("dropped %d stale keyboard HID reports after write failure", dropped) } - h.releaseKeyboard(path) + cleanupErr := runCleanup(execute, func() error { + return h.writeHID(h.keyboardDevice(path), keyboardReleaseReport()) + }) + if cleanupErr != nil { + log.Errorf("release keyboard after write failure failed: %s", cleanupErr) + keyboardActive = keyboardActive || keyboardReportActive(event.Data) + event.complete(false) + continue + } + keyboardActive = false + if resetKeyboard != nil { + resetKeyboard() + } + event.complete(false) + continue } + + keyboardActive = keyboardReportActive(event.Data) + event.complete(true) } } -func (h *Hid) releaseKeyboard(path string) { - h.writeHIDReport(h.keyboardDevice(path), keyboardReleaseReport()) +func keyboardReportActive(report []byte) bool { + if len(report) != 8 { + return false + } + if report[0] != 0 { + return true + } + for _, key := range report[2:] { + if key != 0 { + return true + } + } + return false } func keyboardReleaseReport() []byte { return []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} } -func drainHIDQueue(queue <-chan []byte) int { +func drainHIDQueue(queue <-chan QueuedReport) int { dropped := 0 for { select { - case _, ok := <-queue: + case report, ok := <-queue: if !ok { return dropped } + report.complete(false) dropped++ default: return dropped diff --git a/server/service/hid/mouse.go b/server/service/hid/mouse.go index d94389e..6b93540 100644 --- a/server/service/hid/mouse.go +++ b/server/service/hid/mouse.go @@ -5,59 +5,153 @@ import ( ) func (h *Hid) Mouse(queue <-chan []byte) { - h.mouse(queue, HID1, HID2) + legacy := make(chan QueuedReport) + go func() { + defer close(legacy) + for report := range queue { + legacy <- QueuedReport{Data: report} + } + }() + h.MouseReports(legacy) } -func (h *Hid) mouse(queue <-chan []byte, relativePath string, absolutePath string) { - defer h.releaseRelativeMouse(relativePath) +func (h *Hid) MouseReports(queue <-chan QueuedReport) { + h.mouseReports(queue, HID1, HID2) +} +func (h *Hid) mouseReports(queue <-chan QueuedReport, relativePath string, absolutePath string) { + var execute func(func() error) error + var resetRelativeMouse func() + var resetAbsoluteMouse func() + relativeButtonsActive := false absoluteButtonsActive := false - var absoluteReleaseReport []byte + absoluteReleaseReport := absoluteMouseReleaseReport(nil) defer func() { + if relativeButtonsActive { + if err := runCleanup(execute, func() error { + return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport()) + }); err != nil { + log.Errorf("release relative mouse on queue close failed: %s", err) + } else if resetRelativeMouse != nil { + resetRelativeMouse() + } + } if absoluteButtonsActive { - h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport) + if err := runCleanup(execute, func() error { + return h.writeHID(h.absoluteMouseDevice(absolutePath), absoluteReleaseReport) + }); err != nil { + log.Errorf("release absolute mouse on queue close failed: %s", err) + } else if resetAbsoluteMouse != nil { + resetAbsoluteMouse() + } } }() for event := range queue { - switch len(event) { - case 4: - if !h.writeHIDReport(h.relativeMouseDevice(relativePath), event) { - if dropped := drainHIDQueue(queue); dropped > 0 { - log.Debugf("dropped %d stale mouse HID reports after relative write failure", dropped) - } - h.releaseRelativeMouse(relativePath) + execute = event.Execute + resetRelativeMouse = event.ResetRelativeMouse + resetAbsoluteMouse = event.ResetAbsoluteMouse + + cleanupFailure := func(writeErr error) { + log.Errorf("mouse HID write failed: %s", writeErr) + if dropped := drainHIDQueue(queue); dropped > 0 { + log.Debugf("dropped %d stale mouse HID reports after write failure", dropped) } - case 6: - if !h.writeHIDReport(h.absoluteMouseDevice(absolutePath), event) { - if dropped := drainHIDQueue(queue); dropped > 0 { - log.Debugf("dropped %d stale mouse HID reports after absolute write failure", dropped) - } - if absoluteButtonsActive { - if h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport) { - absoluteButtonsActive = false + + if len(event.Data) == 4 && event.Data[0] != 0 { + relativeButtonsActive = true + } + if len(event.Data) == 6 && event.Data[0] != 0 { + absoluteButtonsActive = true + absoluteReleaseReport = absoluteMouseReleaseReport(event.Data) + } + + if relativeButtonsActive || len(event.Data) == 4 { + if err := runCleanup(execute, func() error { + return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport()) + }); err != nil { + log.Errorf("release relative mouse after write failure failed: %s", err) + } else { + relativeButtonsActive = false + if resetRelativeMouse != nil { + resetRelativeMouse() } - } else if event[0] != 0 { - h.releaseAbsoluteMouse(absolutePath, absoluteMouseReleaseReport(event)) } + } + + if absoluteButtonsActive || len(event.Data) == 6 { + releaseReport := absoluteReleaseReport + if len(event.Data) == 6 { + releaseReport = absoluteMouseReleaseReport(event.Data) + } + if err := runCleanup(execute, func() error { + return h.writeHID(h.absoluteMouseDevice(absolutePath), releaseReport) + }); err != nil { + log.Errorf("release absolute mouse after write failure failed: %s", err) + } else { + absoluteButtonsActive = false + if resetAbsoluteMouse != nil { + resetAbsoluteMouse() + } + } + } + + event.complete(false) + } + + switch len(event.Data) { + case 4: + if absoluteButtonsActive { + if err := runCleanup(execute, func() error { + return h.writeHID(h.absoluteMouseDevice(absolutePath), absoluteReleaseReport) + }); err != nil { + cleanupFailure(err) + continue + } + absoluteButtonsActive = false + if resetAbsoluteMouse != nil { + resetAbsoluteMouse() + } + } + + if err := event.run(func() error { + return h.writeHID(h.relativeMouseDevice(relativePath), event.Data) + }); err != nil { + cleanupFailure(err) continue } - absoluteReleaseReport = absoluteMouseReleaseReport(event) - absoluteButtonsActive = event[0] != 0 + relativeButtonsActive = event.Data[0] != 0 + event.complete(true) + case 6: + if relativeButtonsActive { + if err := runCleanup(execute, func() error { + return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport()) + }); err != nil { + cleanupFailure(err) + continue + } + relativeButtonsActive = false + if resetRelativeMouse != nil { + resetRelativeMouse() + } + } + + if err := event.run(func() error { + return h.writeHID(h.absoluteMouseDevice(absolutePath), event.Data) + }); err != nil { + cleanupFailure(err) + continue + } + absoluteReleaseReport = absoluteMouseReleaseReport(event.Data) + absoluteButtonsActive = event.Data[0] != 0 + event.complete(true) default: - log.Debugf("invalid mouse event: %v", event) + event.complete(false) + log.Debugf("invalid mouse event: %v", event.Data) } } } -func (h *Hid) releaseRelativeMouse(path string) { - h.writeHIDReport(h.relativeMouseDevice(path), relativeMouseReleaseReport()) -} - -func (h *Hid) releaseAbsoluteMouse(path string, report []byte) bool { - return h.writeHIDReport(h.absoluteMouseDevice(path), report) -} - func relativeMouseReleaseReport() []byte { return []byte{0x00, 0x00, 0x00, 0x00} } diff --git a/server/service/hid/paste.go b/server/service/hid/paste.go index 916e685..3864906 100644 --- a/server/service/hid/paste.go +++ b/server/service/hid/paste.go @@ -1,12 +1,14 @@ package hid import ( + "context" "time" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "NanoKVM-Server/proto" + "NanoKVM-Server/service/inputcontrol" ) type Char struct { @@ -19,6 +21,12 @@ type PasteReq struct { Langue string `form:"langue"` } +const ( + defaultPasteDelay = 30 * time.Millisecond + maxPasteDuration = 30 * time.Second + maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay) +) + func LangueSwitch(base map[rune]Char, lang string) map[rune]Char { // if no language is specified → return base map if lang == "" { @@ -86,15 +94,15 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char { case "fr": // French AZERTY layout // Letters: a↔q swap, z↔w swap, m moved to physical ; position - m['a'] = Char{0, 20} // a is at physical Q key (HID 20) + m['a'] = Char{0, 20} // a is at physical Q key (HID 20) m['A'] = Char{2, 20} - m['q'] = Char{0, 4} // q is at physical A key (HID 4) + m['q'] = Char{0, 4} // q is at physical A key (HID 4) m['Q'] = Char{2, 4} - m['z'] = Char{0, 26} // z is at physical W key (HID 26) + m['z'] = Char{0, 26} // z is at physical W key (HID 26) m['Z'] = Char{2, 26} - m['w'] = Char{0, 29} // w is at physical Z key (HID 29) + m['w'] = Char{0, 29} // w is at physical Z key (HID 29) m['W'] = Char{2, 29} - m['m'] = Char{0, 51} // m is at physical ; key (HID 51) + m['m'] = Char{0, 51} // m is at physical ; key (HID 51) m['M'] = Char{2, 51} // Numbers require Shift on AZERTY @@ -110,53 +118,53 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char { m['0'] = Char{2, 39} // Unshifted number row → French/special characters - m['&'] = Char{0, 30} // & at physical key 1 - m['\u00E9'] = Char{0, 31} // é at physical key 2 - m['"'] = Char{0, 32} // " at physical key 3 - m['\''] = Char{0, 33} // ' at physical key 4 - m['('] = Char{0, 34} // ( at physical key 5 - m['-'] = Char{0, 35} // - at physical key 6 - m['\u00E8'] = Char{0, 36} // è at physical key 7 - m['_'] = Char{0, 37} // _ at physical key 8 - m['\u00E7'] = Char{0, 38} // ç at physical key 9 - m['\u00E0'] = Char{0, 39} // à at physical key 0 + m['&'] = Char{0, 30} // & at physical key 1 + m['\u00E9'] = Char{0, 31} // é at physical key 2 + m['"'] = Char{0, 32} // " at physical key 3 + m['\''] = Char{0, 33} // ' at physical key 4 + m['('] = Char{0, 34} // ( at physical key 5 + m['-'] = Char{0, 35} // - at physical key 6 + m['\u00E8'] = Char{0, 36} // è at physical key 7 + m['_'] = Char{0, 37} // _ at physical key 8 + m['\u00E7'] = Char{0, 38} // ç at physical key 9 + m['\u00E0'] = Char{0, 39} // à at physical key 0 // Physical - key (HID 45) → ) on AZERTY - m[')'] = Char{0, 45} // ) at physical - key - m['\u00B0'] = Char{2, 45} // ° at shift+physical - key + m[')'] = Char{0, 45} // ) at physical - key + m['\u00B0'] = Char{2, 45} // ° at shift+physical - key // Letter-row bracket/special keys - m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47) - m['\u00A8'] = Char{2, 47} // ¨ at shift+[ - m['$'] = Char{0, 48} // $ at physical ] key (HID 48) - m['\u00A3'] = Char{2, 48} // £ at shift+] - m['*'] = Char{0, 49} // * at physical \ key (HID 49) - m['\u00B5'] = Char{2, 49} // µ at shift+\ - m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52) - m['%'] = Char{2, 52} // % at shift+' + m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47) + m['\u00A8'] = Char{2, 47} // ¨ at shift+[ + m['$'] = Char{0, 48} // $ at physical ] key (HID 48) + m['\u00A3'] = Char{2, 48} // £ at shift+] + m['*'] = Char{0, 49} // * at physical \ key (HID 49) + m['\u00B5'] = Char{2, 49} // µ at shift+\ + m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52) + m['%'] = Char{2, 52} // % at shift+' // Bottom row remappings - m[','] = Char{0, 16} // , at physical M key (HID 16) - m['?'] = Char{2, 16} // ? at shift+physical M - m[';'] = Char{0, 54} // ; at physical , key (HID 54) - m['.'] = Char{2, 54} // . at shift+physical , - m[':'] = Char{0, 55} // : at physical . key (HID 55) - m['/'] = Char{2, 55} // / at shift+physical . - m['!'] = Char{0, 56} // ! at physical / key (HID 56) - m['\u00A7'] = Char{2, 56} // § at shift+physical / + m[','] = Char{0, 16} // , at physical M key (HID 16) + m['?'] = Char{2, 16} // ? at shift+physical M + m[';'] = Char{0, 54} // ; at physical , key (HID 54) + m['.'] = Char{2, 54} // . at shift+physical , + m[':'] = Char{0, 55} // : at physical . key (HID 55) + m['/'] = Char{2, 55} // / at shift+physical . + m['!'] = Char{0, 56} // ! at physical / key (HID 56) + m['\u00A7'] = Char{2, 56} // § at shift+physical / // AltGr combinations - m['~'] = Char{0x40, 31} // AltGr+2 - m['#'] = Char{0x40, 32} // AltGr+3 - m['{'] = Char{0x40, 33} // AltGr+4 - m['['] = Char{0x40, 34} // AltGr+5 - m['|'] = Char{0x40, 35} // AltGr+6 - m['`'] = Char{0x40, 36} // AltGr+7 - m['\\'] = Char{0x40, 37} // AltGr+8 - m[']'] = Char{0x40, 45} // AltGr+physical - - m['}'] = Char{0x40, 46} // AltGr+= - m['@'] = Char{0x40, 39} // AltGr+0 - m['\u20AC'] = Char{0x40, 8} // € AltGr+E + m['~'] = Char{0x40, 31} // AltGr+2 + m['#'] = Char{0x40, 32} // AltGr+3 + m['{'] = Char{0x40, 33} // AltGr+4 + m['['] = Char{0x40, 34} // AltGr+5 + m['|'] = Char{0x40, 35} // AltGr+6 + m['`'] = Char{0x40, 36} // AltGr+7 + m['\\'] = Char{0x40, 37} // AltGr+8 + m[']'] = Char{0x40, 45} // AltGr+physical - + m['}'] = Char{0x40, 46} // AltGr+= + m['@'] = Char{0x40, 39} // AltGr+0 + m['\u20AC'] = Char{0x40, 8} // € AltGr+E } return m @@ -171,16 +179,44 @@ func (s *Service) Paste(c *gin.Context) { return } - if len(req.Content) > 1024 { + contentRunes := []rune(req.Content) + if len(contentRunes) > maxPasteContentRunes { rsp.ErrRsp(c, -2, "content too long") return } charMapLocal := LangueSwitch(charMap, req.Langue) + typeableRunes := 0 + for _, char := range contentRunes { + if _, ok := charMapLocal[char]; ok { + typeableRunes++ + } + } + if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration { + rsp.ErrRsp(c, -2, "paste duration exceeds 30s") + return + } keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + manual := s.newManualSession() + defer manual.Close() + reservation, err := manual.Reserve(c.Request.Context(), inputcontrol.ManualKeyboard, false, nil) + if err != nil { + log.Errorf("manual paste failed to acquire HID control: %v", err) + rsp.ErrRsp(c, -3, "HID control is busy") + return + } - for _, char := range req.Content { + writeKeyboardReport := func(report []byte) error { + return manual.Execute(func() error { + return s.hid.WriteKeyboardReport(report) + }) + } + + for _, char := range contentRunes { + if err := context.Cause(c.Request.Context()); err != nil { + break + } key, ok := charMapLocal[char] if !ok { log.Debugf("unknown key '%c' (rune: %d)", char, char) @@ -188,14 +224,40 @@ func (s *Service) Paste(c *gin.Context) { } keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00} - - hid.WriteHid0(keyDown) - hid.WriteHid0(keyUp) - time.Sleep(30 * time.Millisecond) + if err = writeKeyboardReport(keyDown); err != nil { + break + } + if err = writeKeyboardReport(keyUp); err != nil { + break + } + if err = sleepPasteContext(c.Request.Context(), defaultPasteDelay); err != nil { + break + } + } + if err == nil { + err = context.Cause(c.Request.Context()) + } + _ = writeKeyboardReport(keyUp) + reservation.Complete(err == nil) + if err != nil { + log.Errorf("hid paste failed: %v", err) + rsp.ErrRsp(c, -3, "HID paste failed") + return } rsp.OkRsp(c) - log.Debugf("hid paste success, total %d characters processed", len(req.Content)) + log.Debugf("hid paste success, total %d characters processed", len(contentRunes)) +} + +func sleepPasteContext(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return context.Cause(ctx) + case <-timer.C: + return nil + } } func copyMap(src map[rune]Char) map[rune]Char { diff --git a/server/service/hid/queue.go b/server/service/hid/queue.go new file mode 100644 index 0000000..0efc870 --- /dev/null +++ b/server/service/hid/queue.go @@ -0,0 +1,30 @@ +package hid + +type QueuedReport struct { + Data []byte + Execute func(func() error) error + Complete func(bool) + ResetKeyboard func() + ResetRelativeMouse func() + ResetAbsoluteMouse func() +} + +func (r QueuedReport) run(write func() error) error { + if r.Execute != nil { + return r.Execute(write) + } + return write() +} + +func (r QueuedReport) complete(success bool) { + if r.Complete != nil { + r.Complete(success) + } +} + +func runCleanup(execute func(func() error) error, write func() error) error { + if execute != nil { + return execute(write) + } + return write() +} diff --git a/server/service/hid/queue_test.go b/server/service/hid/queue_test.go new file mode 100644 index 0000000..fae8740 --- /dev/null +++ b/server/service/hid/queue_test.go @@ -0,0 +1,245 @@ +package hid + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + "time" +) + +func TestMouseModeSwitchReleasesPreviousDevice(t *testing.T) { + relativeReader, relativeWriter, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer relativeReader.Close() + absoluteReader, absoluteWriter, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer absoluteReader.Close() + + h := &Hid{g1: relativeWriter, g2: absoluteWriter} + queue := make(chan QueuedReport, 2) + completed := make(chan bool, 2) + absoluteReset := make(chan struct{}, 1) + absoluteDown := []byte{1, 0x34, 0x12, 0x78, 0x56, 0} + queue <- QueuedReport{ + Data: absoluteDown, + Complete: func(success bool) { completed <- success }, + ResetAbsoluteMouse: func() { + absoluteReset <- struct{}{} + }, + } + queue <- QueuedReport{ + Data: relativeMouseReleaseReport(), + Complete: func(success bool) { completed <- success }, + ResetAbsoluteMouse: func() { absoluteReset <- struct{}{} }, + } + close(queue) + + done := make(chan struct{}) + go func() { + h.mouseReports(queue, "unused-relative", "unused-absolute") + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("mouse worker did not stop") + } + + for range 2 { + select { + case success := <-completed: + if !success { + t.Fatal("mouse report failed") + } + default: + t.Fatal("missing mouse completion") + } + } + select { + case <-absoluteReset: + default: + t.Fatal("absolute state was not reset during mode switch") + } + + absoluteData := make([]byte, 12) + if _, err := io.ReadFull(absoluteReader, absoluteData); err != nil { + t.Fatal(err) + } + if !bytes.Equal(absoluteData[:6], absoluteDown) { + t.Fatalf("absolute down report = %v", absoluteData[:6]) + } + wantRelease := absoluteMouseReleaseReport(absoluteDown) + if !bytes.Equal(absoluteData[6:], wantRelease) { + t.Fatalf("absolute release report = %v, want %v", absoluteData[6:], wantRelease) + } + relativeData := make([]byte, 4) + if _, err := io.ReadFull(relativeReader, relativeData); err != nil { + t.Fatal(err) + } + if !bytes.Equal(relativeData, relativeMouseReleaseReport()) { + t.Fatalf("relative report = %v", relativeData) + } + h.Close() +} + +func TestKeyboardFailureCompletesAfterCleanup(t *testing.T) { + path := filepath.Join(t.TempDir(), "hidg0") + closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if err := closedFile.Close(); err != nil { + t.Fatal(err) + } + + h := &Hid{g0: closedFile} + queue := make(chan QueuedReport, 1) + cleanupStarted := make(chan struct{}) + allowCleanup := make(chan struct{}) + cleanupFinished := make(chan struct{}) + completed := make(chan bool, 1) + executions := 0 + queue <- QueuedReport{ + Data: []byte{0, 0, 4, 0, 0, 0, 0, 0}, + Execute: func(write func() error) error { + executions++ + if executions == 2 { + close(cleanupStarted) + <-allowCleanup + } + err := write() + if executions == 2 { + close(cleanupFinished) + } + return err + }, + Complete: func(success bool) { completed <- success }, + } + close(queue) + + done := make(chan struct{}) + go func() { + h.keyboardReports(queue, path) + close(done) + }() + + select { + case <-cleanupStarted: + case <-time.After(time.Second): + t.Fatal("cleanup did not start") + } + select { + case success := <-completed: + t.Fatalf("reservation completed before cleanup: success=%v", success) + default: + } + + close(allowCleanup) + select { + case <-cleanupFinished: + case <-time.After(time.Second): + t.Fatal("cleanup did not finish") + } + select { + case success := <-completed: + if success { + t.Fatal("failed write completed successfully") + } + case <-time.After(time.Second): + t.Fatal("reservation was not completed") + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("keyboard worker did not stop") + } + h.Close() +} + +func TestKeyboardFailureCompletesFalseWhenCleanupFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "hidg0") + closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if err := closedFile.Close(); err != nil { + t.Fatal(err) + } + + h := &Hid{g0: closedFile} + queue := make(chan QueuedReport, 1) + completed := make(chan bool, 1) + queue <- QueuedReport{ + Data: []byte{0, 0, 4, 0, 0, 0, 0, 0}, + Complete: func(success bool) { completed <- success }, + } + close(queue) + + done := make(chan struct{}) + go func() { + h.keyboardReports(queue, filepath.Join(t.TempDir(), "missing", "hidg0")) + close(done) + }() + + select { + case success := <-completed: + if success { + t.Fatal("failed keyboard write completed successfully after cleanup failed") + } + case <-time.After(time.Second): + t.Fatal("reservation was not completed") + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("keyboard worker did not stop") + } + h.Close() +} + +func TestMouseFailureCompletesFalseWhenCleanupFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "hidg1") + closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if err := closedFile.Close(); err != nil { + t.Fatal(err) + } + + h := &Hid{g1: closedFile} + queue := make(chan QueuedReport, 1) + completed := make(chan bool, 1) + queue <- QueuedReport{ + Data: []byte{1, 0, 0, 0}, + Complete: func(success bool) { completed <- success }, + } + close(queue) + + done := make(chan struct{}) + go func() { + h.mouseReports(queue, filepath.Join(t.TempDir(), "missing", "hidg1"), filepath.Join(t.TempDir(), "hidg2")) + close(done) + }() + + select { + case success := <-completed: + if success { + t.Fatal("failed mouse write completed successfully after cleanup failed") + } + case <-time.After(time.Second): + t.Fatal("reservation was not completed") + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("mouse worker did not stop") + } + h.Close() +} diff --git a/server/service/hid/release.go b/server/service/hid/release.go new file mode 100644 index 0000000..828505b --- /dev/null +++ b/server/service/hid/release.go @@ -0,0 +1,48 @@ +package hid + +import ( + "errors" + "fmt" + + log "github.com/sirupsen/logrus" +) + +type reportWriter interface { + WriteKeyboardReport([]byte) error + WriteRelativeMouseReport([]byte) error + WriteAbsoluteMouseReport([]byte) error +} + +func ReleaseAllHIDState() error { + return releaseAllHIDState(GetHid()) +} + +func ReleaseAllHIDStateBestEffort() error { + return releaseAllHIDStateBestEffort(GetHid()) +} + +func releaseAllHIDStateBestEffort(writer reportWriter) error { + if err := releaseAllHIDState(writer); err != nil { + log.Warnf("failed to release HID state during control switch: %v", err) + } + return nil +} + +func releaseAllHIDState(writer reportWriter) error { + if writer == nil { + return fmt.Errorf("HID writer is unavailable") + } + + var errs []error + if err := writer.WriteKeyboardReport(keyboardReleaseReport()); err != nil { + errs = append(errs, fmt.Errorf("release keyboard: %w", err)) + } + if err := writer.WriteRelativeMouseReport(relativeMouseReleaseReport()); err != nil { + errs = append(errs, fmt.Errorf("release relative mouse: %w", err)) + } + if err := writer.WriteAbsoluteMouseReport(absoluteMouseReleaseReport(nil)); err != nil { + errs = append(errs, fmt.Errorf("release absolute mouse: %w", err)) + } + + return errors.Join(errs...) +} diff --git a/server/service/hid/release_test.go b/server/service/hid/release_test.go new file mode 100644 index 0000000..5029413 --- /dev/null +++ b/server/service/hid/release_test.go @@ -0,0 +1,77 @@ +package hid + +import ( + "errors" + "strings" + "testing" +) + +type releaseRecordingWriter struct { + keyboardErr error + relativeErr error + absoluteErr error + keyboard int + relative int + absolute int +} + +func (w *releaseRecordingWriter) WriteKeyboardReport([]byte) error { + w.keyboard++ + return w.keyboardErr +} + +func (w *releaseRecordingWriter) WriteRelativeMouseReport([]byte) error { + w.relative++ + return w.relativeErr +} + +func (w *releaseRecordingWriter) WriteAbsoluteMouseReport([]byte) error { + w.absolute++ + return w.absoluteErr +} + +func TestReleaseAllHIDStateAttemptsEveryDevice(t *testing.T) { + writer := &releaseRecordingWriter{ + keyboardErr: errors.New("keyboard failed"), + relativeErr: errors.New("relative failed"), + absoluteErr: errors.New("absolute failed"), + } + + err := releaseAllHIDState(writer) + if err == nil { + t.Fatal("expected joined release error") + } + if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 { + t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute) + } + for _, message := range []string{"release keyboard", "release relative mouse", "release absolute mouse"} { + if !strings.Contains(err.Error(), message) { + t.Fatalf("error %q missing %q", err, message) + } + } +} + +func TestReleaseAllHIDStateSuccess(t *testing.T) { + writer := &releaseRecordingWriter{} + if err := releaseAllHIDState(writer); err != nil { + t.Fatal(err) + } + if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 { + t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute) + } +} + +func TestReleaseAllHIDStateBestEffortSuppressesErrors(t *testing.T) { + writer := &releaseRecordingWriter{ + keyboardErr: errors.New("keyboard failed"), + relativeErr: errors.New("relative failed"), + absoluteErr: errors.New("absolute failed"), + } + + if err := releaseAllHIDStateBestEffort(writer); err != nil { + t.Fatalf("best-effort release error = %v, want nil", err) + } + if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 { + t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute) + } +} diff --git a/server/service/hid/service.go b/server/service/hid/service.go index 211e134..3a9f4e5 100644 --- a/server/service/hid/service.go +++ b/server/service/hid/service.go @@ -1,11 +1,24 @@ package hid +import ( + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/inputcontrol" +) + type Service struct { - hid *Hid + hid *Hid + control *controlmode.Manager + coordinator *inputcontrol.Coordinator } func NewService() *Service { return &Service{ - hid: GetHid(), + hid: GetHid(), + control: controlmode.GetManager(), + coordinator: inputcontrol.GetCoordinator(), } } + +func (s *Service) newManualSession() *inputcontrol.ManualSession { + return inputcontrol.NewManualSession(s.control, s.coordinator) +} diff --git a/server/service/hid/status.go b/server/service/hid/status.go index fc29ac2..c373286 100644 --- a/server/service/hid/status.go +++ b/server/service/hid/status.go @@ -1,7 +1,6 @@ package hid import ( - "NanoKVM-Server/proto" "errors" "fmt" "io" @@ -10,6 +9,9 @@ import ( "strings" "time" + "NanoKVM-Server/proto" + "NanoKVM-Server/service/inputcontrol" + "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" ) @@ -91,7 +93,17 @@ func (s *Service) SetHidMode(c *gin.Context) { func (s *Service) ResetHid(c *gin.Context) { var rsp proto.Response - if err := ResetUSBPHY(); err != nil { + manual := s.newManualSession() + defer manual.Close() + reservation, err := manual.Reserve(c.Request.Context(), inputcontrol.ManualRelativeMouse, false, nil) + if err != nil { + log.Errorf("failed to acquire manual control for HID reset: %v", err) + rsp.ErrRsp(c, -1, "HID control is busy") + return + } + err = manual.Execute(ResetUSBPHY) + reservation.Complete(err == nil) + if err != nil { log.Errorf("failed to reset hid: %v", err) rsp.ErrRsp(c, -1, "failed to reset hid") return diff --git a/server/service/inputcontrol/coordinator.go b/server/service/inputcontrol/coordinator.go new file mode 100644 index 0000000..10f658b --- /dev/null +++ b/server/service/inputcontrol/coordinator.go @@ -0,0 +1,458 @@ +package inputcontrol + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "NanoKVM-Server/service/controlmode" +) + +var ( + ErrMCPBusy = errors.New("MCP remote control is busy") + ErrManualControlActive = errors.New("manual control is active") + ErrManualInputBlocked = errors.New("manual input is blocked") + ErrManualPreempted = errors.New("MCP operation was preempted by manual input") + ErrMCPModeChanged = errors.New("MCP control mode changed") +) + +const defaultManualCooldown = 2 * time.Second + +type OperationKind uint8 + +const ( + OperationHID OperationKind = iota + OperationReadOnly +) + +type ManualReportKind uint8 + +const ( + ManualKeyboard ManualReportKind = iota + ManualRelativeMouse + ManualAbsoluteMouse +) + +type activeOperation struct { + id uint64 + kind OperationKind + cancel context.CancelCauseFunc + done chan struct{} +} + +type Coordinator struct { + mu sync.Mutex + manualWriteMu sync.Mutex + active *activeOperation + nextID uint64 + manualSessions int + manualUntil time.Time + manualCooldown time.Duration + now func() time.Time +} + +type ManualSession struct { + coordinator *Coordinator + control *controlmode.Manager + + mu sync.Mutex + active bool + closed bool + generation uint64 + pending int + keyboardHeld bool + relativeMouseHeld bool + absoluteMouseHeld bool + cooldownOnIdle bool + releaseControl func() +} + +type ManualReservation struct { + once sync.Once + session *ManualSession + generation uint64 + kind ManualReportKind + held bool + startCooldown bool +} + +var defaultCoordinator = newCoordinator(defaultManualCooldown, time.Now) + +func newCoordinator(cooldown time.Duration, now func() time.Time) *Coordinator { + if cooldown < 0 { + cooldown = 0 + } + if now == nil { + now = time.Now + } + return &Coordinator{manualCooldown: cooldown, now: now} +} + +func GetCoordinator() *Coordinator { + return defaultCoordinator +} + +func NewManualSession(control *controlmode.Manager, coordinator *Coordinator) *ManualSession { + if control == nil { + control = controlmode.GetManager() + } + if coordinator == nil { + coordinator = GetCoordinator() + } + return &ManualSession{control: control, coordinator: coordinator} +} + +func (c *Coordinator) BeginMCP(parent context.Context, kind OperationKind) (context.Context, func(), error) { + if parent == nil { + parent = context.Background() + } + + c.mu.Lock() + defer c.mu.Unlock() + if c.active != nil { + return nil, nil, ErrMCPBusy + } + if kind == OperationHID && (c.manualSessions > 0 || c.currentTime().Before(c.manualUntil)) { + return nil, nil, ErrManualControlActive + } + + c.nextID++ + id := c.nextID + ctx, cancel := context.WithCancelCause(parent) + operation := &activeOperation{ + id: id, + kind: kind, + cancel: cancel, + done: make(chan struct{}), + } + c.active = operation + + var once sync.Once + release := func() { + once.Do(func() { + cancel(context.Canceled) + c.mu.Lock() + if c.active != nil && c.active.id == id { + c.active = nil + close(operation.done) + } + c.mu.Unlock() + }) + } + return ctx, release, nil +} + +// BeginBackground reserves the HID lane for a best-effort background action, +// such as the mouse jiggler. Callers should skip the action when it returns a +// busy error rather than delaying manual or MCP input. +func (c *Coordinator) BeginBackground(parent context.Context) (context.Context, func(), error) { + return c.BeginMCP(parent, OperationHID) +} + +func (c *Coordinator) CancelMCP() { + c.CancelMCPWithCause(ErrMCPModeChanged) +} + +func (c *Coordinator) CancelMCPWithCause(cause error) { + if cause == nil { + cause = context.Canceled + } + c.mu.Lock() + operation := c.active + c.mu.Unlock() + if operation != nil { + operation.cancel(cause) + } +} + +func (c *Coordinator) beginManual(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + + c.mu.Lock() + c.manualSessions++ + operation := c.active + if operation != nil && operation.kind == OperationHID { + operation.cancel(ErrManualPreempted) + } else { + operation = nil + } + c.mu.Unlock() + + if operation == nil { + return nil + } + + select { + case <-operation.done: + return nil + case <-ctx.Done(): + c.endManual(false) + return context.Cause(ctx) + } +} + +func (c *Coordinator) endManual(startCooldown bool) { + c.mu.Lock() + defer c.mu.Unlock() + if c.manualSessions > 0 { + c.manualSessions-- + } + if startCooldown && c.manualSessions == 0 { + c.manualUntil = c.currentTime().Add(c.cooldown()) + } +} + +func (c *Coordinator) currentTime() time.Time { + if c.now == nil { + return time.Now() + } + return c.now() +} + +func (c *Coordinator) cooldown() time.Duration { + if c.manualCooldown <= 0 { + return defaultManualCooldown + } + return c.manualCooldown +} + +func (c *Coordinator) executeManual(write func() error) error { + if write == nil { + return nil + } + c.manualWriteMu.Lock() + defer c.manualWriteMu.Unlock() + return write() +} + +func (s *ManualSession) Reserve( + ctx context.Context, + kind ManualReportKind, + held bool, + allow func(controlmode.Mode) bool, +) (*ManualReservation, error) { + return s.reserve(ctx, kind, held, true, allow) +} + +func (s *ManualSession) ReserveWithCooldown( + ctx context.Context, + kind ManualReportKind, + held bool, + startCooldown bool, + allow func(controlmode.Mode) bool, +) (*ManualReservation, error) { + return s.reserve(ctx, kind, held, startCooldown, allow) +} + +func (s *ManualSession) reserve( + ctx context.Context, + kind ManualReportKind, + held bool, + startCooldown bool, + allow func(controlmode.Mode) bool, +) (*ManualReservation, error) { + if s == nil || s.coordinator == nil || s.control == nil { + return nil, fmt.Errorf("manual input coordinator is unavailable") + } + + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil, fmt.Errorf("manual input session is closed") + } + if s.active { + status, err := s.control.Status() + if err != nil { + s.mu.Unlock() + return nil, err + } + allowed := !status.Transitioning && (allow == nil || allow(status.Mode)) + if !allowed && !s.isReleaseReportLocked(kind, held) { + s.mu.Unlock() + return nil, ErrManualInputBlocked + } + s.pending++ + startCooldown = startCooldown || s.isReleaseReportLocked(kind, held) + reservation := &ManualReservation{ + session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown, + } + s.mu.Unlock() + return reservation, nil + } + s.mu.Unlock() + + status, releaseControl, err := s.control.AcquireStable() + if err != nil { + return nil, err + } + if allow != nil && !allow(status.Mode) { + releaseControl() + return nil, ErrManualInputBlocked + } + if err := s.coordinator.beginManual(ctx); err != nil { + releaseControl() + return nil, err + } + + s.mu.Lock() + if s.closed { + s.mu.Unlock() + s.coordinator.endManual(false) + releaseControl() + return nil, fmt.Errorf("manual input session is closed") + } + if s.active { + // Reserve calls are serialized for a WebSocket client, but keep this path + // safe for callers that share a session concurrently. + s.pending++ + startCooldown = startCooldown || s.isReleaseReportLocked(kind, held) + reservation := &ManualReservation{ + session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown, + } + s.mu.Unlock() + s.coordinator.endManual(false) + releaseControl() + return reservation, nil + } + + s.active = true + s.generation++ + s.pending = 1 + s.releaseControl = releaseControl + reservation := &ManualReservation{ + session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown, + } + s.mu.Unlock() + return reservation, nil +} + +func (s *ManualSession) isReleaseReportLocked(kind ManualReportKind, held bool) bool { + if held { + return false + } + switch kind { + case ManualKeyboard: + return s.keyboardHeld + case ManualRelativeMouse, ManualAbsoluteMouse: + return s.relativeMouseHeld || s.absoluteMouseHeld + default: + return false + } +} + +func (r *ManualReservation) Complete(success bool) { + if r == nil || r.session == nil { + return + } + r.once.Do(func() { + r.session.complete(r.generation, r.kind, r.held, success, r.startCooldown) + }) +} + +func (s *ManualSession) Execute(write func() error) error { + if s == nil || s.coordinator == nil { + return fmt.Errorf("manual input coordinator is unavailable") + } + return s.coordinator.executeManual(write) +} + +func (s *ManualSession) Reset(kind ManualReportKind) { + if s == nil { + return + } + + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return + } + switch kind { + case ManualKeyboard: + s.keyboardHeld = false + case ManualRelativeMouse: + s.relativeMouseHeld = false + case ManualAbsoluteMouse: + s.absoluteMouseHeld = false + } + releaseControl, end, _ := s.finishIfIdleLocked() + s.mu.Unlock() + s.finish(releaseControl, end, true) +} + +func (s *ManualSession) Close() { + if s == nil { + return + } + + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + s.pending = 0 + s.keyboardHeld = false + s.relativeMouseHeld = false + s.absoluteMouseHeld = false + s.cooldownOnIdle = false + releaseControl := s.releaseControl + end := s.active + s.active = false + s.releaseControl = nil + s.generation++ + s.mu.Unlock() + s.finish(releaseControl, end, true) +} + +func (s *ManualSession) complete(generation uint64, kind ManualReportKind, held bool, success bool, startCooldown bool) { + s.mu.Lock() + if !s.active || generation != s.generation { + s.mu.Unlock() + return + } + if s.pending > 0 { + s.pending-- + } + if startCooldown { + s.cooldownOnIdle = true + } + if success { + switch kind { + case ManualKeyboard: + s.keyboardHeld = held + case ManualRelativeMouse: + s.relativeMouseHeld = held + case ManualAbsoluteMouse: + s.absoluteMouseHeld = held + } + } + releaseControl, end, startCooldown := s.finishIfIdleLocked() + s.mu.Unlock() + s.finish(releaseControl, end, startCooldown) +} + +func (s *ManualSession) finishIfIdleLocked() (func(), bool, bool) { + if !s.active || s.pending > 0 || s.keyboardHeld || s.relativeMouseHeld || s.absoluteMouseHeld { + return nil, false, false + } + releaseControl := s.releaseControl + startCooldown := s.cooldownOnIdle + s.active = false + s.releaseControl = nil + s.cooldownOnIdle = false + return releaseControl, true, startCooldown +} + +func (s *ManualSession) finish(releaseControl func(), end bool, startCooldown bool) { + if !end { + return + } + if releaseControl != nil { + releaseControl() + } + s.coordinator.endManual(startCooldown) +} diff --git a/server/service/inputcontrol/coordinator_test.go b/server/service/inputcontrol/coordinator_test.go new file mode 100644 index 0000000..ca46d52 --- /dev/null +++ b/server/service/inputcontrol/coordinator_test.go @@ -0,0 +1,397 @@ +package inputcontrol + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "NanoKVM-Server/service/controlmode" +) + +func TestCoordinatorRejectsConcurrentMCP(t *testing.T) { + coordinator := newCoordinator(defaultManualCooldown, time.Now) + ctx, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatal(err) + } + defer release() + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrMCPBusy) { + t.Fatalf("error = %v, want %v", err, ErrMCPBusy) + } + coordinator.CancelMCP() + if err := context.Cause(ctx); !errors.Is(err, ErrMCPModeChanged) { + t.Fatalf("context cause = %v, want %v", err, ErrMCPModeChanged) + } +} + +func TestCoordinatorReleaseAllowsNextMCP(t *testing.T) { + coordinator := newCoordinator(defaultManualCooldown, time.Now) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatal(err) + } + release() + + _, nextRelease, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatal(err) + } + nextRelease() +} + +func TestManualInputPreemptsMCPWithoutSwitchingMode(t *testing.T) { + now := time.Unix(100, 0) + coordinator := newCoordinator(2*time.Second, func() time.Time { return now }) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + operationCtx, releaseOperation, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatal(err) + } + releaseMode, err := control.Acquire(controlmode.ModeMCP) + if err != nil { + t.Fatal(err) + } + + manual := NewManualSession(control, coordinator) + type result struct { + reservation *ManualReservation + err error + } + done := make(chan result, 1) + go func() { + reservation, err := manual.Reserve(context.Background(), ManualKeyboard, false, nil) + done <- result{reservation: reservation, err: err} + }() + + select { + case <-operationCtx.Done(): + if cause := context.Cause(operationCtx); !errors.Is(cause, ErrManualPreempted) { + t.Fatalf("context cause = %v, want %v", cause, ErrManualPreempted) + } + case <-time.After(time.Second): + t.Fatal("manual input did not preempt active MCP operation") + } + releaseOperation() + releaseMode() + + var reservation *ManualReservation + select { + case got := <-done: + if got.err != nil { + t.Fatal(got.err) + } + reservation = got.reservation + case <-time.After(time.Second): + t.Fatal("manual input did not acquire control") + } + if got := control.Current(); got != controlmode.ModeMCP { + t.Fatalf("mode = %q, want MCP to remain enabled", got) + } + + reservation.Complete(true) + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive) + } + now = now.Add(3 * time.Second) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatalf("MCP did not resume after cooldown: %v", err) + } + release() + manual.Close() +} + +func TestHeldManualInputBlocksMCPUntilReleaseAndCooldown(t *testing.T) { + now := time.Unix(200, 0) + coordinator := newCoordinator(time.Second, func() time.Time { return now }) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + down, err := manual.Reserve(context.Background(), ManualKeyboard, true, nil) + if err != nil { + t.Fatal(err) + } + down.Complete(true) + now = now.Add(10 * time.Second) + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("held input error = %v, want %v", err, ErrManualControlActive) + } + + up, err := manual.Reserve(context.Background(), ManualKeyboard, false, nil) + if err != nil { + t.Fatal(err) + } + up.Complete(true) + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive) + } + now = now.Add(2 * time.Second) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatal(err) + } + release() +} + +func TestFailedHeldManualInputDoesNotRemainHeld(t *testing.T) { + now := time.Unix(250, 0) + coordinator := newCoordinator(time.Second, func() time.Time { return now }) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + down, err := manual.Reserve(context.Background(), ManualKeyboard, true, nil) + if err != nil { + t.Fatal(err) + } + down.Complete(false) + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive) + } + now = now.Add(2 * time.Second) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatalf("failed held input remained active after cooldown: %v", err) + } + release() +} + +func TestPointerMoveWithoutButtonsDoesNotStartCooldown(t *testing.T) { + coordinator := newCoordinator(time.Second, time.Now) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + move, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, false, nil) + if err != nil { + t.Fatal(err) + } + move.Complete(true) + + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatalf("pointer move without buttons blocked MCP: %v", err) + } + release() +} + +func TestReleaseReportForcesCooldown(t *testing.T) { + now := time.Unix(275, 0) + coordinator := newCoordinator(time.Second, func() time.Time { return now }) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + down, err := manual.ReserveWithCooldown(context.Background(), ManualRelativeMouse, true, true, nil) + if err != nil { + t.Fatal(err) + } + down.Complete(true) + + up, err := manual.ReserveWithCooldown(context.Background(), ManualRelativeMouse, false, false, nil) + if err != nil { + t.Fatal(err) + } + up.Complete(true) + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive) + } + now = now.Add(2 * time.Second) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatal(err) + } + release() +} + +func TestCooldownAggregatesAcrossPendingReports(t *testing.T) { + now := time.Unix(285, 0) + coordinator := newCoordinator(time.Second, func() time.Time { return now }) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + move, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, false, nil) + if err != nil { + t.Fatal(err) + } + wheel, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, true, nil) + if err != nil { + t.Fatal(err) + } + + wheel.Complete(true) + move.Complete(true) + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive) + } + now = now.Add(2 * time.Second) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatal(err) + } + release() +} + +func TestRelativeAndAbsoluteMouseHeldStateIsIndependent(t *testing.T) { + now := time.Unix(300, 0) + coordinator := newCoordinator(time.Second, func() time.Time { return now }) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + relativeDown, err := manual.Reserve(context.Background(), ManualRelativeMouse, true, nil) + if err != nil { + t.Fatal(err) + } + relativeDown.Complete(true) + + absoluteMove, err := manual.Reserve(context.Background(), ManualAbsoluteMouse, false, nil) + if err != nil { + t.Fatal(err) + } + absoluteMove.Complete(true) + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("relative button was cleared by absolute report: %v", err) + } + + relativeUp, err := manual.Reserve(context.Background(), ManualRelativeMouse, false, nil) + if err != nil { + t.Fatal(err) + } + relativeUp.Complete(true) + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive) + } + now = now.Add(2 * time.Second) + _, release, err := coordinator.BeginMCP(context.Background(), OperationHID) + if err != nil { + t.Fatal(err) + } + release() +} + +func TestAbsoluteMouseHeldStateSurvivesRelativeReport(t *testing.T) { + coordinator := newCoordinator(defaultManualCooldown, time.Now) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + absoluteDown, err := manual.Reserve(context.Background(), ManualAbsoluteMouse, true, nil) + if err != nil { + t.Fatal(err) + } + absoluteDown.Complete(true) + + relativeMove, err := manual.Reserve(context.Background(), ManualRelativeMouse, false, nil) + if err != nil { + t.Fatal(err) + } + relativeMove.Complete(true) + + if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) { + t.Fatalf("absolute button was cleared by relative report: %v", err) + } +} + +func TestBlockedActiveSessionStillAllowsReleaseReport(t *testing.T) { + coordinator := newCoordinator(defaultManualCooldown, time.Now) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true }) + if err != nil { + t.Fatal(err) + } + down.Complete(true) + + if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) { + t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked) + } + + up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false }) + if err != nil { + t.Fatalf("release report was blocked: %v", err) + } + up.Complete(true) +} + +func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) { + coordinator := newCoordinator(defaultManualCooldown, time.Now) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + manual := NewManualSession(control, coordinator) + defer manual.Close() + + reservation, err := manual.Reserve(context.Background(), ManualRelativeMouse, true, nil) + if err != nil { + t.Fatal(err) + } + reservation.Complete(true) + + _, release, err := coordinator.BeginMCP(context.Background(), OperationReadOnly) + if err != nil { + t.Fatalf("read-only MCP operation was blocked by manual control: %v", err) + } + release() +} + +func TestManualWritesAreSerialized(t *testing.T) { + coordinator := newCoordinator(defaultManualCooldown, time.Now) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + first := NewManualSession(control, coordinator) + second := NewManualSession(control, coordinator) + defer first.Close() + defer second.Close() + + firstReservation, err := first.Reserve(context.Background(), ManualKeyboard, false, nil) + if err != nil { + t.Fatal(err) + } + secondReservation, err := second.Reserve(context.Background(), ManualRelativeMouse, false, nil) + if err != nil { + t.Fatal(err) + } + + entered := make(chan struct{}) + releaseFirst := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _ = first.Execute(func() error { + close(entered) + <-releaseFirst + return nil + }) + firstReservation.Complete(true) + }() + <-entered + + secondEntered := make(chan struct{}) + go func() { + defer wg.Done() + _ = second.Execute(func() error { + close(secondEntered) + return nil + }) + secondReservation.Complete(true) + }() + select { + case <-secondEntered: + t.Fatal("second manual write entered before the first completed") + case <-time.After(20 * time.Millisecond): + } + close(releaseFirst) + wg.Wait() +} diff --git a/server/service/mcp/auth.go b/server/service/mcp/auth.go new file mode 100644 index 0000000..6d282cf --- /dev/null +++ b/server/service/mcp/auth.go @@ -0,0 +1,51 @@ +package mcpservice + +import ( + "crypto/subtle" + "net/http" + "strings" + + "NanoKVM-Server/service/controlmode" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +func APIKeyMiddleware(control *controlmode.Manager) gin.HandlerFunc { + if control == nil { + control = controlmode.GetManager() + } + return func(c *gin.Context) { + if err := control.Require(controlmode.ModeMCP); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "MCP service is disabled"}) + c.Abort() + return + } + + cfg, err := loadConfig() + if err != nil { + log.Errorf("failed to load MCP config for authentication: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "MCP auth unavailable"}) + c.Abort() + return + } + token, ok := extractBearer(c.GetHeader("Authorization")) + if !ok || cfg.APIKey == "" || subtle.ConstantTimeCompare([]byte(token), []byte(cfg.APIKey)) != 1 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid MCP API key"}) + c.Abort() + return + } + + c.Next() + } +} + +func extractBearer(header string) (string, bool) { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "", false + } + + token := strings.TrimSpace(strings.TrimPrefix(header, prefix)) + return token, token != "" +} diff --git a/server/service/mcp/capture/capture.go b/server/service/mcp/capture/capture.go new file mode 100644 index 0000000..b78a98a --- /dev/null +++ b/server/service/mcp/capture/capture.go @@ -0,0 +1,125 @@ +package mcpcapture + +import ( + "bytes" + "context" + "fmt" + "image/jpeg" + "time" + + mcpservice "NanoKVM-Server/service/mcp" +) + +const ( + defaultQuality = 85 + screenshotRetryDelay = 100 * time.Millisecond + maxTimeoutMS = 30_000 +) + +type VisionReader interface { + ReadMjpeg(width uint16, height uint16, quality uint16) ([]byte, int) +} + +type ScreenReader func() (width uint16, height uint16) + +type Snapshotter struct { + vision VisionReader + readScreen ScreenReader + captureSlot chan struct{} + retryDelay time.Duration +} + +func New(vision VisionReader, readScreen ScreenReader) *Snapshotter { + return &Snapshotter{ + vision: vision, + readScreen: readScreen, + captureSlot: make(chan struct{}, 1), + retryDelay: screenshotRetryDelay, + } +} + +func (s *Snapshotter) Capture(ctx context.Context, req mcpservice.SnapshotRequest) (mcpservice.Snapshot, error) { + if req.X != 0 || req.Y != 0 || req.W != 0 || req.H != 0 { + return mcpservice.Snapshot{}, fmt.Errorf("screenshot cropping is not supported") + } + if s.vision == nil || s.readScreen == nil { + return mcpservice.Snapshot{}, fmt.Errorf("screenshot capture is unavailable") + } + + quality := req.Quality + if quality == 0 { + quality = defaultQuality + } + quality = clamp(quality, 1, 100) + + timeoutMS := 1000 + if req.TimeoutMS != nil { + timeoutMS = clamp(*req.TimeoutMS, 0, maxTimeoutMS) + } + + select { + case s.captureSlot <- struct{}{}: + defer func() { <-s.captureSlot }() + case <-ctx.Done(): + return mcpservice.Snapshot{}, ctx.Err() + } + + deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond) + width, height := s.readScreen() + for { + if err := ctx.Err(); err != nil { + return mcpservice.Snapshot{}, err + } + if timeoutMS > 0 && !time.Now().Before(deadline) { + return mcpservice.Snapshot{Message: "screenshot capture timed out"}, nil + } + + data, result := s.vision.ReadMjpeg(width, height, uint16(quality)) + snapshot := mcpservice.Snapshot{ + RetCode: result, + Width: int(width), + Height: int(height), + JPEG: data, + } + switch { + case result >= 0 && result != 5 && len(data) > 0: + config, err := jpeg.DecodeConfig(bytes.NewReader(data)) + if err != nil { + snapshot.Message = "captured data is not a valid JPEG" + return snapshot, nil + } + snapshot.OK = true + snapshot.Width = config.Width + snapshot.Height = config.Height + return snapshot, nil + case result == 5: + snapshot.Message = "no HDMI signal or frame unavailable" + case result == -3 || result == -4 || result == -5: + snapshot.Message = "screenshot capture is temporarily unavailable" + case result < 0 || len(data) == 0: + snapshot.Message = "failed to capture screenshot" + return snapshot, nil + } + + if timeoutMS == 0 || time.Now().Add(s.retryDelay).After(deadline) { + return snapshot, nil + } + timer := time.NewTimer(s.retryDelay) + select { + case <-ctx.Done(): + timer.Stop() + return mcpservice.Snapshot{}, ctx.Err() + case <-timer.C: + } + } +} + +func clamp(value int, minValue int, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} diff --git a/server/service/mcp/capture/capture_test.go b/server/service/mcp/capture/capture_test.go new file mode 100644 index 0000000..a1da85f --- /dev/null +++ b/server/service/mcp/capture/capture_test.go @@ -0,0 +1,196 @@ +package mcpcapture + +import ( + "bytes" + "context" + "errors" + "image" + "image/color" + "image/jpeg" + "sync" + "testing" + "time" + + mcpservice "NanoKVM-Server/service/mcp" +) + +func testJPEG(t *testing.T, width int, height int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, width, height)) + img.Set(0, 0, color.White) + var output bytes.Buffer + if err := jpeg.Encode(&output, img, nil); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +type visionResponse struct { + data []byte + result int +} + +type fakeVision struct { + mu sync.Mutex + responses []visionResponse + calls int + quality uint16 + active int + maxActive int + delay time.Duration +} + +func (v *fakeVision) ReadMjpeg(_ uint16, _ uint16, quality uint16) ([]byte, int) { + v.mu.Lock() + v.calls++ + v.quality = quality + v.active++ + if v.active > v.maxActive { + v.maxActive = v.active + } + index := v.calls - 1 + response := visionResponse{result: -1} + if index < len(v.responses) { + response = v.responses[index] + } else if len(v.responses) > 0 { + response = v.responses[len(v.responses)-1] + } + v.mu.Unlock() + + time.Sleep(v.delay) + + v.mu.Lock() + v.active-- + v.mu.Unlock() + return response.data, response.result +} + +func TestCaptureSuccessAndQualityBounds(t *testing.T) { + vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 1920, 1080), result: 0}}} + snapshotter := New(vision, func() (uint16, uint16) { return 1920, 1080 }) + + snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{}) + if err != nil { + t.Fatal(err) + } + if !snapshot.OK || snapshot.Width != 1920 || snapshot.Height != 1080 || vision.quality != defaultQuality { + t.Fatalf("snapshot=%+v quality=%d", snapshot, vision.quality) + } + + _, err = snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{Quality: 200}) + if err != nil || vision.quality != 100 { + t.Fatalf("quality clamp=%d err=%v", vision.quality, err) + } +} + +func TestCaptureRetriesNoSignal(t *testing.T) { + vision := &fakeVision{responses: []visionResponse{{result: 5}, {result: -5}, {data: testJPEG(t, 1280, 720), result: 0}}} + snapshotter := New(vision, func() (uint16, uint16) { return 1280, 720 }) + snapshotter.retryDelay = 0 + + snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{}) + if err != nil || !snapshot.OK || vision.calls != 3 { + t.Fatalf("snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err) + } + + timeout := 0 + vision = &fakeVision{responses: []visionResponse{{result: 5}}} + snapshotter = New(vision, func() (uint16, uint16) { return 1280, 720 }) + snapshot, err = snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{TimeoutMS: &timeout}) + if err != nil || snapshot.RetCode != 5 || vision.calls != 1 { + t.Fatalf("timeout snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err) + } +} + +func TestCaptureRejectsCropAndEmptyData(t *testing.T) { + vision := &fakeVision{responses: []visionResponse{{result: 0}}} + snapshotter := New(vision, func() (uint16, uint16) { return 800, 600 }) + if _, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{W: 10}); err == nil { + t.Fatal("expected crop error") + } + if vision.calls != 0 { + t.Fatalf("vision called for rejected crop: %d", vision.calls) + } + + snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{}) + if err != nil || snapshot.OK || snapshot.Message == "" { + t.Fatalf("snapshot=%+v err=%v", snapshot, err) + } +} + +func TestCaptureSerializesConcurrentCalls(t *testing.T) { + vision := &fakeVision{ + responses: []visionResponse{{data: testJPEG(t, 800, 600), result: 0}}, + delay: 10 * time.Millisecond, + } + snapshotter := New(vision, func() (uint16, uint16) { return 800, 600 }) + + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{}) + }() + } + wg.Wait() + if vision.maxActive != 1 { + t.Fatalf("max concurrent captures = %d", vision.maxActive) + } +} + +func TestCaptureUsesJPEGDimensionsForAutomaticResolution(t *testing.T) { + vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 640, 480), result: 0}}} + snapshotter := New(vision, func() (uint16, uint16) { return 0, 0 }) + + snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{}) + if err != nil { + t.Fatal(err) + } + if !snapshot.OK || snapshot.Width != 640 || snapshot.Height != 480 { + t.Fatalf("snapshot=%+v", snapshot) + } +} + +func TestCaptureCancellationWhileWaitingForSlot(t *testing.T) { + snapshotter := New(&fakeVision{}, func() (uint16, uint16) { return 0, 0 }) + snapshotter.captureSlot <- struct{}{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := snapshotter.Capture(ctx, mcpservice.SnapshotRequest{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error=%v, want canceled", err) + } + <-snapshotter.captureSlot +} + +func TestCaptureTimeoutStartsAfterSlotIsAcquired(t *testing.T) { + vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 320, 240), result: 0}}} + snapshotter := New(vision, func() (uint16, uint16) { return 320, 240 }) + snapshotter.captureSlot <- struct{}{} + + timeout := 1 + done := make(chan struct { + snapshot mcpservice.Snapshot + err error + }, 1) + go func() { + snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{TimeoutMS: &timeout}) + done <- struct { + snapshot mcpservice.Snapshot + err error + }{snapshot: snapshot, err: err} + }() + + time.Sleep(5 * time.Millisecond) + <-snapshotter.captureSlot + select { + case result := <-done: + if result.err != nil || !result.snapshot.OK { + t.Fatalf("snapshot=%+v err=%v, want success after queue wait", result.snapshot, result.err) + } + case <-time.After(time.Second): + t.Fatal("capture did not complete after slot release") + } +} diff --git a/server/service/mcp/capture/kvm/kvm.go b/server/service/mcp/capture/kvm/kvm.go new file mode 100644 index 0000000..233407d --- /dev/null +++ b/server/service/mcp/capture/kvm/kvm.go @@ -0,0 +1,15 @@ +package kvmcapture + +import ( + "NanoKVM-Server/common" + mcpservice "NanoKVM-Server/service/mcp" + "NanoKVM-Server/service/mcp/capture" +) + +func New() mcpservice.Snapshotter { + return mcpcapture.New(common.GetKvmVision(), func() (uint16, uint16) { + screen := common.GetScreen() + common.CheckScreen() + return screen.Width, screen.Height + }) +} diff --git a/server/service/mcp/config.go b/server/service/mcp/config.go new file mode 100644 index 0000000..a8162d6 --- /dev/null +++ b/server/service/mcp/config.go @@ -0,0 +1,154 @@ +package mcpservice + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +const ( + ConfigFile = "/etc/kvm/mcp.json" + apiKeyPrefix = "nag_mcp_" + apiKeyBytes = 32 +) + +var ( + configMu sync.Mutex + configFilePath = ConfigFile +) + +type Config struct { + APIKey string `json:"apiKey"` +} + +func loadConfig() (Config, error) { + configMu.Lock() + defer configMu.Unlock() + + return loadConfigFromPath(configFilePath) +} + +func updateConfig(update func(Config) (Config, error)) (Config, error) { + configMu.Lock() + defer configMu.Unlock() + + cfg, err := loadConfigFromPath(configFilePath) + if err != nil { + return Config{}, err + } + + updated, err := update(cfg) + if err != nil { + return Config{}, err + } + if err := saveConfigToPath(configFilePath, updated); err != nil { + return Config{}, err + } + + return updated, nil +} + +func loadConfigFromPath(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Config{}, nil + } + return Config{}, err + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("decode MCP config: %w", err) + } + return cfg, nil +} + +func saveConfigToPath(path string, cfg Config) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create MCP config directory: %w", err) + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("encode MCP config: %w", err) + } + data = append(data, '\n') + + tmp, err := os.CreateTemp(dir, ".mcp.json.*") + if err != nil { + return fmt.Errorf("create temporary MCP config: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("set temporary MCP config permissions: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temporary MCP config: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temporary MCP config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary MCP config: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replace MCP config: %w", err) + } + if err := os.Chmod(path, 0o600); err != nil { + return fmt.Errorf("set MCP config permissions: %w", err) + } + + directory, err := os.Open(dir) + if err == nil { + if syncErr := directory.Sync(); syncErr != nil { + _ = directory.Close() + return fmt.Errorf("sync MCP config directory: %w", syncErr) + } + _ = directory.Close() + } + + return nil +} + +func ensureAPIKey(cfg Config) (Config, error) { + if cfg.APIKey != "" { + return cfg, nil + } + + key, err := generateAPIKey() + if err != nil { + return Config{}, err + } + cfg.APIKey = key + return cfg, nil +} + +func regenerateAPIKey(cfg Config) (Config, error) { + key, err := generateAPIKey() + if err != nil { + return Config{}, err + } + cfg.APIKey = key + return cfg, nil +} + +func generateAPIKey() (string, error) { + raw := make([]byte, apiKeyBytes) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate MCP API key: %w", err) + } + + return apiKeyPrefix + base64.RawURLEncoding.EncodeToString(raw), nil +} diff --git a/server/service/mcp/config_test.go b/server/service/mcp/config_test.go new file mode 100644 index 0000000..2634ce4 --- /dev/null +++ b/server/service/mcp/config_test.go @@ -0,0 +1,262 @@ +package mcpservice + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "NanoKVM-Server/service/controlmode" + + "github.com/gin-gonic/gin" +) + +func useTestConfig(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "mcp.json") + oldPath := configFilePath + configFilePath = path + t.Cleanup(func() { configFilePath = oldPath }) + return path +} + +func TestConfigLifecycle(t *testing.T) { + path := useTestConfig(t) + + cfg, err := loadConfig() + if err != nil || cfg.APIKey != "" { + t.Fatalf("unexpected missing config result: cfg=%+v err=%v", cfg, err) + } + + cfg, err = updateConfig(func(cfg Config) (Config, error) { + cfg, err = ensureAPIKey(cfg) + return cfg, err + }) + if err != nil || !strings.HasPrefix(cfg.APIKey, apiKeyPrefix) { + t.Fatalf("enable MCP: cfg=%+v err=%v", cfg, err) + } + firstKey := cfg.APIKey + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("config permissions = %o, want 600", info.Mode().Perm()) + } + + cfg, err = updateConfig(ensureAPIKey) + if err != nil || cfg.APIKey != firstKey { + t.Fatalf("ensure existing key changed it: cfg=%+v err=%v", cfg, err) + } + cfg, err = updateConfig(regenerateAPIKey) + if err != nil || cfg.APIKey == firstKey { + t.Fatalf("regenerate did not replace key: cfg=%+v err=%v", cfg, err) + } +} + +func TestLoadConfigRejectsCorruptJSON(t *testing.T) { + path := useTestConfig(t) + if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadConfig(); err == nil { + t.Fatal("expected corrupt config error") + } +} + +func TestAPIKeyMiddleware(t *testing.T) { + useTestConfig(t) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeOff) + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/api/mcp", APIKeyMiddleware(control), func(c *gin.Context) { c.Status(http.StatusNoContent) }) + + writeConfig := func(cfg Config) { + if _, err := updateConfig(func(Config) (Config, error) { return cfg, nil }); err != nil { + t.Fatal(err) + } + } + request := func(header string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/mcp", nil) + if header != "" { + req.Header.Set("Authorization", header) + } + router.ServeHTTP(recorder, req) + return recorder + } + + writeConfig(Config{APIKey: "secret"}) + if got := request("Bearer secret").Code; got != http.StatusForbidden { + t.Fatalf("disabled status = %d", got) + } + if err := control.SwitchToMCP(nil); err != nil { + t.Fatal(err) + } + for _, header := range []string{"", "secret", "Basic secret", "Bearer wrong"} { + if got := request(header).Code; got != http.StatusUnauthorized { + t.Fatalf("header %q status = %d", header, got) + } + } + if got := request("Bearer secret").Code; got != http.StatusNoContent { + t.Fatalf("valid key status = %d", got) + } + writeConfig(Config{APIKey: "rotated"}) + if got := request("Bearer secret").Code; got != http.StatusUnauthorized { + t.Fatalf("old key after rotation status = %d", got) + } + if got := request("Bearer rotated").Code; got != http.StatusNoContent { + t.Fatalf("rotated key status = %d", got) + } +} + +func TestSetConfigAPI(t *testing.T) { + useTestConfig(t) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + preemptLeasesCalled := false + stopRuntimeCalled := false + releaseCalled := false + gin.SetMode(gin.TestMode) + service := NewServiceWithPreempt(control, func() error { + preemptLeasesCalled = true + return nil + }, func() error { + stopRuntimeCalled = true + return nil + }, func() error { + releaseCalled = true + return nil + }, nil) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`)) + c.Request.Header.Set("Content-Type", "application/json") + service.SetConfig(c) + if got := recorder.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + + var response struct { + Code int `json:"code"` + Data struct { + Enabled bool `json:"enabled"` + APIKey string `json:"apiKey"` + ControlMode string `json:"controlMode"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Code != 0 || !response.Data.Enabled || response.Data.APIKey == "" || response.Data.ControlMode != string(controlmode.ModeMCP) || !preemptLeasesCalled || !stopRuntimeCalled || !releaseCalled { + t.Fatalf("unexpected response: %+v", response) + } + + recorder = httptest.NewRecorder() + c, _ = gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":false}`)) + c.Request.Header.Set("Content-Type", "application/json") + service.SetConfig(c) + if control.Current() != controlmode.ModeOff { + t.Fatalf("mode after disabling MCP = %q, want off", control.Current()) + } +} + +func TestSetConfigEnablePreemptFailureDoesNotEnableMCP(t *testing.T) { + useTestConfig(t) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + wantErr := errors.New("picoclaw stop failed") + service := NewServiceWithPreempt(control, func() error { + return wantErr + }, func() error { + t.Fatal("runtime stop should not run when soft preempt fails") + return nil + }, func() error { + t.Fatal("release should not run when preempt fails") + return nil + }, nil) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`)) + c.Request.Header.Set("Content-Type", "application/json") + service.SetConfig(c) + + if control.Current() != controlmode.ModePicoclaw { + t.Fatalf("mode after failed preempt = %q, want picoclaw", control.Current()) + } + var response struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Code != -2 || !strings.Contains(response.Msg, wantErr.Error()) { + t.Fatalf("response = %+v, want preempt failure", response) + } +} + +func TestSetConfigEnableCleanupFailureFailsClosed(t *testing.T) { + useTestConfig(t) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + wantErr := errors.New("picoclaw stop failed") + preemptCalled := false + releaseCalled := false + service := NewServiceWithPreempt(control, func() error { + preemptCalled = true + return nil + }, func() error { + return wantErr + }, func() error { + releaseCalled = true + return nil + }, nil) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`)) + c.Request.Header.Set("Content-Type", "application/json") + service.SetConfig(c) + + if control.Current() != controlmode.ModeOff { + t.Fatalf("mode after failed destructive cleanup = %q, want off", control.Current()) + } + if !preemptCalled || !releaseCalled { + t.Fatalf("preemptCalled=%v releaseCalled=%v, want both true", preemptCalled, releaseCalled) + } + var response struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if response.Code != -2 || !strings.Contains(response.Msg, wantErr.Error()) { + t.Fatalf("response = %+v, want cleanup failure", response) + } +} + +func TestSetConfigEnableReleaseFailureFailsClosed(t *testing.T) { + useTestConfig(t) + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + service := NewService(control, func() error { return errors.New("release failed") }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`)) + c.Request.Header.Set("Content-Type", "application/json") + service.SetConfig(c) + + if control.Current() != controlmode.ModeOff { + t.Fatalf("mode after failed enable = %q, want off", control.Current()) + } + cfg, err := loadConfig() + if err != nil || cfg.APIKey == "" { + t.Fatalf("API key should be persisted before switching: cfg=%+v err=%v", cfg, err) + } +} diff --git a/server/service/mcp/keyboard.go b/server/service/mcp/keyboard.go new file mode 100644 index 0000000..79b9123 --- /dev/null +++ b/server/service/mcp/keyboard.go @@ -0,0 +1,122 @@ +package mcpservice + +import ( + "fmt" + "sort" + + "NanoKVM-Server/service/hid" +) + +const maxKeyboardKeys = 6 + +var keyUpReport = []byte{0, 0, 0, 0, 0, 0, 0, 0} + +var modifierMap = map[string]byte{ + "ControlLeft": 1 << 0, + "ShiftLeft": 1 << 1, + "AltLeft": 1 << 2, + "MetaLeft": 1 << 3, + "ControlRight": 1 << 4, + "ShiftRight": 1 << 5, + "AltRight": 1 << 6, + "MetaRight": 1 << 7, +} + +var keyCodeMap = map[string]byte{ + "KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, "KeyE": 0x08, + "KeyF": 0x09, "KeyG": 0x0a, "KeyH": 0x0b, "KeyI": 0x0c, "KeyJ": 0x0d, + "KeyK": 0x0e, "KeyL": 0x0f, "KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12, + "KeyP": 0x13, "KeyQ": 0x14, "KeyR": 0x15, "KeyS": 0x16, "KeyT": 0x17, + "KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1a, "KeyX": 0x1b, "KeyY": 0x1c, + "KeyZ": 0x1d, + + "Digit1": 0x1e, "Digit2": 0x1f, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22, + "Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27, + + "Enter": 0x28, "Escape": 0x29, "Backspace": 0x2a, "Tab": 0x2b, "Space": 0x2c, + "Minus": 0x2d, "Equal": 0x2e, "BracketLeft": 0x2f, "BracketRight": 0x30, + "Backslash": 0x31, "IntlHash": 0x32, "Semicolon": 0x33, "Quote": 0x34, + "Backquote": 0x35, "Comma": 0x36, "Period": 0x37, "Slash": 0x38, + "CapsLock": 0x39, + + "F1": 0x3a, "F2": 0x3b, "F3": 0x3c, "F4": 0x3d, "F5": 0x3e, "F6": 0x3f, + "F7": 0x40, "F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45, + + "PrintScreen": 0x46, "ScrollLock": 0x47, "Pause": 0x48, "Insert": 0x49, + "Home": 0x4a, "PageUp": 0x4b, "Delete": 0x4c, "End": 0x4d, + "PageDown": 0x4e, "ArrowRight": 0x4f, "ArrowLeft": 0x50, + "ArrowDown": 0x51, "ArrowUp": 0x52, + + "NumLock": 0x53, "NumpadDivide": 0x54, "NumpadMultiply": 0x55, + "NumpadSubtract": 0x56, "NumpadAdd": 0x57, "NumpadEnter": 0x58, + "Numpad1": 0x59, "Numpad2": 0x5a, "Numpad3": 0x5b, "Numpad4": 0x5c, + "Numpad5": 0x5d, "Numpad6": 0x5e, "Numpad7": 0x5f, "Numpad8": 0x60, + "Numpad9": 0x61, "Numpad0": 0x62, "NumpadDecimal": 0x63, + "IntlBackslash": 0x64, "ContextMenu": 0x65, +} + +func supportedKeyNames() []any { + names := make([]string, 0, len(modifierMap)+len(keyCodeMap)) + for name := range modifierMap { + names = append(names, name) + } + for name := range keyCodeMap { + names = append(names, name) + } + sort.Strings(names) + + values := make([]any, len(names)) + for index, name := range names { + values[index] = name + } + return values +} + +func buildKeyComboReport(keys []string) ([]byte, error) { + report := make([]byte, 8) + seenKeys := make(map[byte]struct{}) + keyIndex := 2 + + for _, key := range keys { + if modifier, ok := modifierMap[key]; ok { + report[0] |= modifier + continue + } + + code, ok := keyCodeMap[key] + if !ok { + return nil, fmt.Errorf("unknown key: %s", key) + } + if _, ok := seenKeys[code]; ok { + continue + } + if keyIndex >= 2+maxKeyboardKeys { + return nil, fmt.Errorf("too many non-modifier keys") + } + seenKeys[code] = struct{}{} + report[keyIndex] = code + keyIndex++ + } + + return report, nil +} + +func buildTypeReports(text string) ([][]byte, []rune) { + charMap := hid.GetCharMap("") + reports := make([][]byte, 0, len(text)*2) + skipped := make([]rune, 0) + + for _, char := range text { + key, ok := charMap[char] + if !ok { + skipped = append(skipped, char) + continue + } + reports = append(reports, + []byte{byte(key.Modifiers), 0, byte(key.Code), 0, 0, 0, 0, 0}, + append([]byte(nil), keyUpReport...), + ) + } + + return reports, skipped +} diff --git a/server/service/mcp/keyboard_test.go b/server/service/mcp/keyboard_test.go new file mode 100644 index 0000000..71c081d --- /dev/null +++ b/server/service/mcp/keyboard_test.go @@ -0,0 +1,37 @@ +package mcpservice + +import "testing" + +func TestBuildKeyComboReport(t *testing.T) { + report, err := buildKeyComboReport([]string{"ControlLeft", "AltLeft", "Delete"}) + if err != nil { + t.Fatal(err) + } + if report[0] != 0x05 || report[2] != 0x4c { + t.Fatalf("unexpected combo report: %v", report) + } + if _, err := buildKeyComboReport([]string{"Unknown"}); err == nil { + t.Fatal("expected unknown key error") + } + if _, err := buildKeyComboReport([]string{"KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG"}); err == nil { + t.Fatal("expected six-key limit error") + } +} + +func TestBuildTypeReportsReturnsSkippedRunes(t *testing.T) { + reports, skipped := buildTypeReports("A界") + if len(reports) != 2 || string(skipped) != "界" { + t.Fatalf("reports=%d skipped=%q", len(reports), string(skipped)) + } + if reports[0][0] != 2 || reports[0][2] != 4 || string(reports[1]) != string(keyUpReport) { + t.Fatalf("unexpected reports: %v", reports) + } +} + +func TestSupportedKeyNamesMatchKeyboardDescriptor(t *testing.T) { + for name, code := range keyCodeMap { + if code > 0x65 { + t.Fatalf("key %s uses unsupported descriptor code %#x", name, code) + } + } +} diff --git a/server/service/mcp/mouse.go b/server/service/mcp/mouse.go new file mode 100644 index 0000000..441b8c2 --- /dev/null +++ b/server/service/mcp/mouse.go @@ -0,0 +1,93 @@ +package mcpservice + +import ( + "fmt" + "math" +) + +const ( + maxRelativeMouseReports = 1024 + maxRelativeMouseDelta = 127 * maxRelativeMouseReports +) + +func absoluteCoordinate(value float64) uint16 { + value = math.Max(0, math.Min(1, value)) + return uint16(math.Floor(0x7fff*value)) + 1 +} + +func buildAbsolutePointerReport(x uint16, y uint16, buttons byte, wheel int) []byte { + return []byte{ + buttons, + byte(x), byte(x >> 8), + byte(y), byte(y >> 8), + byte(int8(clampInt(wheel, -127, 127))), + } +} + +func buildRelativeMouseReport(deltaX int, deltaY int, buttons byte, wheel int) []byte { + return []byte{ + buttons, + byte(int8(clampInt(deltaX, -127, 127))), + byte(int8(clampInt(deltaY, -127, 127))), + byte(int8(clampInt(wheel, -127, 127))), + } +} + +func buildRelativeMoveReports(deltaX int, deltaY int) ([][]byte, error) { + if relativeReportCount(deltaX, deltaY) > maxRelativeMouseReports { + return nil, fmt.Errorf("relative movement exceeds %d reports", maxRelativeMouseReports) + } + + reports := make([][]byte, 0) + for deltaX != 0 || deltaY != 0 { + x := clampInt(deltaX, -127, 127) + y := clampInt(deltaY, -127, 127) + reports = append(reports, buildRelativeMouseReport(x, y, 0, 0)) + deltaX -= x + deltaY -= y + } + return reports, nil +} + +func mouseButtonBit(button string) (byte, error) { + switch button { + case "left": + return 1 << 0, nil + case "right": + return 1 << 1, nil + case "middle": + return 1 << 2, nil + default: + return 0, fmt.Errorf("unknown mouse button: %s", button) + } +} + +func clampInt(value int, minValue int, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func relativeReportCount(deltaX int, deltaY int) int { + xReports := reportsForDelta(deltaX) + yReports := reportsForDelta(deltaY) + if xReports > yReports { + return xReports + } + return yReports +} + +func reportsForDelta(delta int) int { + value := int64(delta) + if value == 0 { + return 0 + } + if value < 0 { + return int((-(value + 1))/127 + 1) + } + return int((value-1)/127 + 1) +} diff --git a/server/service/mcp/mouse_test.go b/server/service/mcp/mouse_test.go new file mode 100644 index 0000000..8c4ecae --- /dev/null +++ b/server/service/mcp/mouse_test.go @@ -0,0 +1,53 @@ +package mcpservice + +import "testing" + +func TestAbsoluteCoordinateUsesNanoKVMRange(t *testing.T) { + tests := []struct { + value float64 + want uint16 + }{ + {-1, 1}, + {0, 1}, + {0.5, 0x4000}, + {1, 0x8000}, + {2, 0x8000}, + } + for _, test := range tests { + if got := absoluteCoordinate(test.value); got != test.want { + t.Fatalf("absoluteCoordinate(%v) = %#x, want %#x", test.value, got, test.want) + } + } +} + +func TestAbsoluteReportIsSixBytes(t *testing.T) { + report := buildAbsolutePointerReport(0x1234, 0x5678, 1, -2) + want := []byte{1, 0x34, 0x12, 0x78, 0x56, 0xfe} + if string(report) != string(want) { + t.Fatalf("report = %v, want %v", report, want) + } +} + +func TestRelativeMovementSplitsReports(t *testing.T) { + reports, err := buildRelativeMoveReports(300, -300) + if err != nil { + t.Fatal(err) + } + if len(reports) != 3 { + t.Fatalf("report count = %d, want 3", len(reports)) + } + if reports[0][1] != byte(int8(127)) || int8(reports[0][2]) != -127 { + t.Fatalf("first report = %v", reports[0]) + } + if _, err := buildRelativeMoveReports(127*(maxRelativeMouseReports+1), 0); err == nil { + t.Fatal("expected oversized movement error") + } +} + +func TestUnsupportedAuxiliaryMouseButtons(t *testing.T) { + for _, button := range []string{"back", "forward"} { + if _, err := mouseButtonBit(button); err == nil { + t.Fatalf("button %q should be rejected", button) + } + } +} diff --git a/server/service/mcp/remote.go b/server/service/mcp/remote.go new file mode 100644 index 0000000..e824bc1 --- /dev/null +++ b/server/service/mcp/remote.go @@ -0,0 +1,234 @@ +package mcpservice + +import ( + "context" + "fmt" + "sync" + "time" + + "NanoKVM-Server/service/hid" +) + +type HIDWriter interface { + WriteKeyboardReport([]byte) error + WriteRelativeMouseReport([]byte) error + WriteAbsoluteMouseReport([]byte) error +} + +type Remote struct { + hid HIDWriter + hidMu sync.Mutex +} + +func NewRemote() *Remote { + return &Remote{hid: hid.GetHid()} +} + +func newRemoteWithHID(writer HIDWriter) *Remote { + return &Remote{hid: writer} +} + +func (r *Remote) TypeText(ctx context.Context, text string, delay time.Duration) ([]rune, error) { + reports, skipped := buildTypeReports(text) + r.hidMu.Lock() + defer r.hidMu.Unlock() + + keyDown := false + for index, report := range reports { + if err := contextError(ctx); err != nil { + if keyDown { + _ = r.hid.WriteKeyboardReport(keyUpReport) + } + return skipped, err + } + if err := r.hid.WriteKeyboardReport(report); err != nil { + _ = r.hid.WriteKeyboardReport(keyUpReport) + return skipped, err + } + keyDown = index%2 == 0 + if !keyDown && index+1 < len(reports) { + if err := sleepContext(ctx, delay); err != nil { + return skipped, err + } + } + } + return skipped, nil +} + +func (r *Remote) PressKeys(ctx context.Context, keys []string, hold time.Duration) error { + report, err := buildKeyComboReport(keys) + if err != nil { + return err + } + + r.hidMu.Lock() + defer r.hidMu.Unlock() + + if err := contextError(ctx); err != nil { + return err + } + if err := r.hid.WriteKeyboardReport(report); err != nil { + _ = r.hid.WriteKeyboardReport(keyUpReport) + return err + } + if err := sleepContext(ctx, hold); err != nil { + _ = r.hid.WriteKeyboardReport(keyUpReport) + return err + } + return r.hid.WriteKeyboardReport(keyUpReport) +} + +func (r *Remote) MoveAbsolute(ctx context.Context, x float64, y float64) error { + if x < 0 || x > 1 || y < 0 || y > 1 { + return fmt.Errorf("absolute coordinates must be between 0 and 1") + } + report := buildAbsolutePointerReport(absoluteCoordinate(x), absoluteCoordinate(y), 0, 0) + r.hidMu.Lock() + defer r.hidMu.Unlock() + if err := contextError(ctx); err != nil { + return err + } + return r.hid.WriteAbsoluteMouseReport(report) +} + +func (r *Remote) TouchAbsoluteButton(ctx context.Context, x float64, y float64, button string, hold time.Duration) error { + if x < 0 || x > 1 || y < 0 || y > 1 { + return fmt.Errorf("absolute coordinates must be between 0 and 1") + } + bit, err := mouseButtonBit(button) + if err != nil { + return err + } + hidX := absoluteCoordinate(x) + hidY := absoluteCoordinate(y) + release := buildAbsolutePointerReport(hidX, hidY, 0, 0) + + r.hidMu.Lock() + defer r.hidMu.Unlock() + + if err := contextError(ctx); err != nil { + return err + } + if err := r.hid.WriteAbsoluteMouseReport(buildAbsolutePointerReport(hidX, hidY, bit, 0)); err != nil { + _ = r.hid.WriteAbsoluteMouseReport(release) + return err + } + if err := sleepContext(ctx, hold); err != nil { + _ = r.hid.WriteAbsoluteMouseReport(release) + return err + } + return r.hid.WriteAbsoluteMouseReport(release) +} + +func (r *Remote) MoveRelative(ctx context.Context, deltaX int, deltaY int) error { + reports, err := buildRelativeMoveReports(deltaX, deltaY) + if err != nil { + return err + } + + r.hidMu.Lock() + defer r.hidMu.Unlock() + for _, report := range reports { + if err := contextError(ctx); err != nil { + _ = r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, 0, 0)) + return err + } + if err := r.hid.WriteRelativeMouseReport(report); err != nil { + _ = r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, 0, 0)) + return err + } + } + return nil +} + +func (r *Remote) Click(ctx context.Context, button string, clicks int, delay time.Duration) error { + bit, err := mouseButtonBit(button) + if err != nil { + return err + } + clicks = clampInt(clicks, 1, 5) + release := buildRelativeMouseReport(0, 0, 0, 0) + + r.hidMu.Lock() + defer r.hidMu.Unlock() + for i := 0; i < clicks; i++ { + if err := contextError(ctx); err != nil { + _ = r.hid.WriteRelativeMouseReport(release) + return err + } + if err := r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, bit, 0)); err != nil { + _ = r.hid.WriteRelativeMouseReport(release) + return err + } + if err := sleepContext(ctx, delay); err != nil { + _ = r.hid.WriteRelativeMouseReport(release) + return err + } + if err := r.hid.WriteRelativeMouseReport(release); err != nil { + _ = r.hid.WriteRelativeMouseReport(release) + return err + } + if i+1 < clicks { + if err := sleepContext(ctx, delay); err != nil { + return err + } + } + } + return nil +} + +func (r *Remote) Scroll(ctx context.Context, deltaY int) error { + if reportsForDelta(deltaY) > maxRelativeMouseReports { + return fmt.Errorf("scroll exceeds %d reports", maxRelativeMouseReports) + } + + x := absoluteCoordinate(0.5) + y := absoluteCoordinate(0.5) + release := buildAbsolutePointerReport(x, y, 0, 0) + + r.hidMu.Lock() + defer r.hidMu.Unlock() + for deltaY != 0 { + if err := contextError(ctx); err != nil { + _ = r.hid.WriteAbsoluteMouseReport(release) + return err + } + wheel := clampInt(deltaY, -127, 127) + if err := r.hid.WriteAbsoluteMouseReport(buildAbsolutePointerReport(x, y, 0, wheel)); err != nil { + _ = r.hid.WriteAbsoluteMouseReport(release) + return err + } + if err := r.hid.WriteAbsoluteMouseReport(release); err != nil { + _ = r.hid.WriteAbsoluteMouseReport(release) + return err + } + deltaY -= wheel + } + return nil +} + +func sleepContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return contextError(ctx) + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return context.Cause(ctx) + case <-timer.C: + return nil + } +} + +func contextError(ctx context.Context) error { + if ctx == nil { + return nil + } + select { + case <-ctx.Done(): + return context.Cause(ctx) + default: + return nil + } +} diff --git a/server/service/mcp/remote_test.go b/server/service/mcp/remote_test.go new file mode 100644 index 0000000..0d2ec6e --- /dev/null +++ b/server/service/mcp/remote_test.go @@ -0,0 +1,115 @@ +package mcpservice + +import ( + "context" + "errors" + "testing" +) + +type recordingHID struct { + keyboard [][]byte + relative [][]byte + absolute [][]byte + failAt int + writes int + onWrite func(int) +} + +func (h *recordingHID) record(target *[][]byte, report []byte) error { + h.writes++ + if h.onWrite != nil { + h.onWrite(h.writes) + } + *target = append(*target, append([]byte(nil), report...)) + if h.failAt > 0 && h.writes == h.failAt { + return errors.New("write failed") + } + return nil +} + +func (h *recordingHID) WriteKeyboardReport(report []byte) error { + return h.record(&h.keyboard, report) +} + +func (h *recordingHID) WriteRelativeMouseReport(report []byte) error { + return h.record(&h.relative, report) +} + +func (h *recordingHID) WriteAbsoluteMouseReport(report []byte) error { + return h.record(&h.absolute, report) +} + +func TestRemotePressKeysAlwaysReleases(t *testing.T) { + hid := &recordingHID{} + remote := newRemoteWithHID(hid) + if err := remote.PressKeys(context.Background(), []string{"ShiftLeft", "KeyA"}, 0); err != nil { + t.Fatal(err) + } + if len(hid.keyboard) != 2 || string(hid.keyboard[1]) != string(keyUpReport) { + t.Fatalf("keyboard reports: %v", hid.keyboard) + } + + failing := &recordingHID{failAt: 1} + if err := newRemoteWithHID(failing).PressKeys(context.Background(), []string{"KeyA"}, 0); err == nil { + t.Fatal("expected write error") + } + if len(failing.keyboard) != 2 || string(failing.keyboard[1]) != string(keyUpReport) { + t.Fatalf("release not attempted after error: %v", failing.keyboard) + } +} + +func TestRemoteClickReleasesButton(t *testing.T) { + hid := &recordingHID{} + if err := newRemoteWithHID(hid).Click(context.Background(), "left", 1, 0); err != nil { + t.Fatal(err) + } + if len(hid.relative) != 2 || hid.relative[0][0] != 1 || hid.relative[1][0] != 0 { + t.Fatalf("relative reports: %v", hid.relative) + } +} + +func TestRemoteScrollUsesAbsoluteReportsAndReleasesWheel(t *testing.T) { + hid := &recordingHID{} + if err := newRemoteWithHID(hid).Scroll(context.Background(), 130); err != nil { + t.Fatal(err) + } + + if len(hid.relative) != 0 { + t.Fatalf("relative reports = %v, want none", hid.relative) + } + if len(hid.absolute) != 4 { + t.Fatalf("absolute report count = %d, want 4: %v", len(hid.absolute), hid.absolute) + } + + x := absoluteCoordinate(0.5) + y := absoluteCoordinate(0.5) + want := [][]byte{ + buildAbsolutePointerReport(x, y, 0, 127), + buildAbsolutePointerReport(x, y, 0, 0), + buildAbsolutePointerReport(x, y, 0, 3), + buildAbsolutePointerReport(x, y, 0, 0), + } + for i := range want { + if string(hid.absolute[i]) != string(want[i]) { + t.Fatalf("absolute[%d] = %v, want %v", i, hid.absolute[i], want[i]) + } + } +} + +func TestRemoteTypeTextCancellationReleasesKeyboard(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + hid := &recordingHID{} + hid.onWrite = func(write int) { + if write == 1 { + cancel() + } + } + + _, err := newRemoteWithHID(hid).TypeText(ctx, "A", 0) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want canceled", err) + } + if len(hid.keyboard) != 2 || string(hid.keyboard[1]) != string(keyUpReport) { + t.Fatalf("keyboard was not released after cancellation: %v", hid.keyboard) + } +} diff --git a/server/service/mcp/screenshot.go b/server/service/mcp/screenshot.go new file mode 100644 index 0000000..ea7a75e --- /dev/null +++ b/server/service/mcp/screenshot.go @@ -0,0 +1,27 @@ +package mcpservice + +import "context" + +const snapshotTimeoutMaxMS = 30_000 + +type SnapshotRequest struct { + Quality int + TimeoutMS *int + X int + Y int + W int + H int +} + +type Snapshot struct { + OK bool + RetCode int + Message string + Width int + Height int + JPEG []byte +} + +type Snapshotter interface { + Capture(context.Context, SnapshotRequest) (Snapshot, error) +} diff --git a/server/service/mcp/server.go b/server/service/mcp/server.go new file mode 100644 index 0000000..bfc5e88 --- /dev/null +++ b/server/service/mcp/server.go @@ -0,0 +1,544 @@ +package mcpservice + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/inputcontrol" + + "github.com/google/jsonschema-go/jsonschema" + protocol "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + mcpServerName = "nanokvm-cube-remote-control" + typeTextToolName = "cube_type_text" + pressKeysToolName = "cube_press_keys" + moveMouseToolName = "cube_move_mouse" + clickMouseToolName = "cube_click_mouse" + scrollMouseToolName = "cube_scroll_mouse" + screenshotToolName = "cube_screenshot" + defaultTypeDelayMS = 30 + defaultHoldMS = 50 + defaultClickDelayMS = 50 + maxPressKeyItems = maxKeyboardKeys + 8 + maxTypeTextDuration = 30 * time.Second + maxTypeTextRunes = int(maxTypeTextDuration / (defaultTypeDelayMS * time.Millisecond)) + maxRequestBodyBytes = 1 << 20 +) + +type TypeTextParams struct { + Text string `json:"text" jsonschema:"text to type on the remote host"` + DelayMS *int `json:"delayMs,omitempty" jsonschema:"delay between typed characters in milliseconds"` +} + +type TypeTextResult struct { + Typed int `json:"typed"` + Skipped string `json:"skipped,omitempty"` + SkippedCount int `json:"skippedCount"` +} + +type PressKeysParams struct { + Keys []string `json:"keys" jsonschema:"KeyboardEvent.code key names"` + HoldMS *int `json:"holdMs,omitempty" jsonschema:"hold time in milliseconds"` +} + +type MoveMouseParams struct { + Mode string `json:"mode" jsonschema:"absolute or relative"` + X *float64 `json:"x,omitempty" jsonschema:"absolute normalized x"` + Y *float64 `json:"y,omitempty" jsonschema:"absolute normalized y"` + DeltaX int `json:"deltaX,omitempty" jsonschema:"relative x movement"` + DeltaY int `json:"deltaY,omitempty" jsonschema:"relative y movement"` +} + +type ClickMouseParams struct { + Button string `json:"button" jsonschema:"left, right, or middle"` + Clicks int `json:"clicks,omitempty" jsonschema:"number of clicks"` + Mode string `json:"mode,omitempty" jsonschema:"absolute or relative"` + X *float64 `json:"x,omitempty" jsonschema:"absolute normalized x"` + Y *float64 `json:"y,omitempty" jsonschema:"absolute normalized y"` +} + +type ScrollMouseParams struct { + DeltaY int `json:"deltaY" jsonschema:"vertical wheel movement"` +} + +type ScreenshotParams struct { + Quality int `json:"quality,omitempty"` + TimeoutMS *int `json:"timeoutMs,omitempty"` +} + +type ScreenshotResult struct { + OK bool `json:"ok"` + RetCode int `json:"retCode"` + Message string `json:"message"` + Width int `json:"width"` + Height int `json:"height"` + Size int `json:"size"` +} + +type ToolOKResult struct { + OK bool `json:"ok"` +} + +type toolExecutor struct { + control *controlmode.Manager + coordinator *inputcontrol.Coordinator +} + +func NewMCPHandler(control *controlmode.Manager, snapshotter Snapshotter) http.Handler { + return newMCPHandler(control, inputcontrol.GetCoordinator(), NewRemote(), snapshotter) +} + +func newMCPHandler(control *controlmode.Manager, coordinator *inputcontrol.Coordinator, remote *Remote, snapshotter Snapshotter) http.Handler { + executor := &toolExecutor{control: control, coordinator: coordinator} + server := protocol.NewServer(&protocol.Implementation{ + Name: mcpServerName, + Version: "v1.0.0", + }, nil) + registerTools(server, executor, remote, snapshotter) + handler := protocol.NewStreamableHTTPHandler( + func(*http.Request) *protocol.Server { return server }, + &protocol.StreamableHTTPOptions{Stateless: true}, + ) + return http.MaxBytesHandler(handler, maxRequestBodyBytes) +} + +func registerTools(server *protocol.Server, executor *toolExecutor, remote *Remote, snapshotter Snapshotter) { + protocol.AddTool(server, &protocol.Tool{Name: typeTextToolName, Description: "Type short text into the remote host", InputSchema: typeTextSchema()}, typeTextHandler(executor, remote)) + protocol.AddTool(server, &protocol.Tool{Name: pressKeysToolName, Description: "Press one keyboard shortcut on the remote host", InputSchema: pressKeysSchema()}, pressKeysHandler(executor, remote)) + protocol.AddTool(server, &protocol.Tool{Name: moveMouseToolName, Description: "Move the remote mouse pointer", InputSchema: moveMouseSchema()}, moveMouseHandler(executor, remote)) + protocol.AddTool(server, &protocol.Tool{Name: clickMouseToolName, Description: "Click a remote mouse button", InputSchema: clickMouseSchema()}, clickMouseHandler(executor, remote)) + protocol.AddTool(server, &protocol.Tool{Name: scrollMouseToolName, Description: "Scroll the remote mouse wheel", InputSchema: scrollMouseSchema()}, scrollMouseHandler(executor, remote)) + protocol.AddTool(server, &protocol.Tool{Name: screenshotToolName, Description: "Capture a JPEG screenshot from the remote display", InputSchema: screenshotSchema()}, screenshotHandler(executor, snapshotter)) +} + +func typeTextHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[TypeTextParams, TypeTextResult] { + return func(ctx context.Context, _ *protocol.CallToolRequest, input TypeTextParams) (*protocol.CallToolResult, TypeTextResult, error) { + textRunes := []rune(input.Text) + if len(textRunes) > maxTypeTextRunes { + return nil, TypeTextResult{}, fmt.Errorf("text exceeds maximum length of %d runes", maxTypeTextRunes) + } + delay := time.Duration(resolveDelayMS(input.DelayMS, defaultTypeDelayMS)) * time.Millisecond + reports, _ := buildTypeReports(input.Text) + typeableRunes := len(reports) / 2 + if time.Duration(typeableRunes)*delay > maxTypeTextDuration { + return nil, TypeTextResult{}, fmt.Errorf("typing duration exceeds %s for %d typeable runes at %s delay", maxTypeTextDuration, typeableRunes, delay) + } + operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID) + if err != nil { + return nil, TypeTextResult{}, err + } + defer release() + + skipped, err := remote.TypeText(operationCtx, input.Text, delay) + if err != nil { + return nil, TypeTextResult{}, err + } + + return nil, TypeTextResult{ + Typed: len(textRunes) - len(skipped), + Skipped: string(skipped), + SkippedCount: len(skipped), + }, nil + } +} + +func pressKeysHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[PressKeysParams, ToolOKResult] { + return func(ctx context.Context, _ *protocol.CallToolRequest, input PressKeysParams) (*protocol.CallToolResult, ToolOKResult, error) { + if len(input.Keys) == 0 { + return nil, ToolOKResult{}, fmt.Errorf("keys must not be empty") + } + operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID) + if err != nil { + return nil, ToolOKResult{}, err + } + defer release() + if err := remote.PressKeys(operationCtx, input.Keys, time.Duration(resolveDelayMS(input.HoldMS, defaultHoldMS))*time.Millisecond); err != nil { + return nil, ToolOKResult{}, err + } + return nil, ToolOKResult{OK: true}, nil + } +} + +func moveMouseHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[MoveMouseParams, ToolOKResult] { + return func(ctx context.Context, _ *protocol.CallToolRequest, input MoveMouseParams) (*protocol.CallToolResult, ToolOKResult, error) { + mode := normalizedMode(input.Mode, "absolute") + if err := validateMoveMouseInput(mode, input); err != nil { + return nil, ToolOKResult{}, err + } + operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID) + if err != nil { + return nil, ToolOKResult{}, err + } + defer release() + switch mode { + case "absolute": + x, y, err := normalizedCoordinates(input.X, input.Y) + if err != nil { + return nil, ToolOKResult{}, err + } + if err := remote.MoveAbsolute(operationCtx, x, y); err != nil { + return nil, ToolOKResult{}, err + } + case "relative": + if err := remote.MoveRelative(operationCtx, input.DeltaX, input.DeltaY); err != nil { + return nil, ToolOKResult{}, err + } + default: + return nil, ToolOKResult{}, fmt.Errorf("unknown mouse mode: %s", input.Mode) + } + return nil, ToolOKResult{OK: true}, nil + } +} + +func clickMouseHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[ClickMouseParams, ToolOKResult] { + return func(ctx context.Context, _ *protocol.CallToolRequest, input ClickMouseParams) (*protocol.CallToolResult, ToolOKResult, error) { + button := strings.ToLower(strings.TrimSpace(input.Button)) + if button == "" { + button = "left" + } + clicks, err := normalizeClickCount(input.Clicks) + if err != nil { + return nil, ToolOKResult{}, err + } + mode := normalizedMode(input.Mode, "relative") + if err := validateClickMouseInput(mode, button, input); err != nil { + return nil, ToolOKResult{}, err + } + operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID) + if err != nil { + return nil, ToolOKResult{}, err + } + defer release() + switch mode { + case "absolute": + x, y, err := normalizedCoordinates(input.X, input.Y) + if err != nil { + return nil, ToolOKResult{}, err + } + for i := 0; i < clicks; i++ { + if err := remote.TouchAbsoluteButton(operationCtx, x, y, button, defaultClickDelayMS*time.Millisecond); err != nil { + return nil, ToolOKResult{}, err + } + if i+1 < clicks { + if err := sleepContext(operationCtx, defaultClickDelayMS*time.Millisecond); err != nil { + return nil, ToolOKResult{}, err + } + } + } + case "relative": + if err := remote.Click(operationCtx, button, clicks, defaultClickDelayMS*time.Millisecond); err != nil { + return nil, ToolOKResult{}, err + } + default: + return nil, ToolOKResult{}, fmt.Errorf("unknown mouse mode: %s", input.Mode) + } + + return nil, ToolOKResult{OK: true}, nil + } +} + +func scrollMouseHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[ScrollMouseParams, ToolOKResult] { + return func(ctx context.Context, _ *protocol.CallToolRequest, input ScrollMouseParams) (*protocol.CallToolResult, ToolOKResult, error) { + operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID) + if err != nil { + return nil, ToolOKResult{}, err + } + defer release() + if err := remote.Scroll(operationCtx, input.DeltaY); err != nil { + return nil, ToolOKResult{}, err + } + return nil, ToolOKResult{OK: true}, nil + } +} + +func screenshotHandler(executor *toolExecutor, snapshotter Snapshotter) protocol.ToolHandlerFor[ScreenshotParams, ScreenshotResult] { + return func(ctx context.Context, _ *protocol.CallToolRequest, input ScreenshotParams) (*protocol.CallToolResult, ScreenshotResult, error) { + if snapshotter == nil { + return toolError("screenshot capture is unavailable"), ScreenshotResult{Message: "screenshot capture is unavailable"}, nil + } + + snapshot, err := snapshotter.Capture(ctx, SnapshotRequest{ + Quality: input.Quality, TimeoutMS: input.TimeoutMS, + }) + output := ScreenshotResult{ + OK: snapshot.OK, RetCode: snapshot.RetCode, Message: snapshot.Message, + Width: snapshot.Width, Height: snapshot.Height, Size: len(snapshot.JPEG), + } + if err != nil { + output.Message = err.Error() + return toolError(err.Error()), output, nil + } + if !snapshot.OK || len(snapshot.JPEG) == 0 { + message := snapshot.Message + if message == "" { + message = "screenshot capture failed" + } + return toolError(message), output, nil + } + + return &protocol.CallToolResult{Content: []protocol.Content{ + &protocol.ImageContent{Data: snapshot.JPEG, MIMEType: "image/jpeg"}, + }}, output, nil + } +} + +func resolveDelayMS(value *int, fallback int) int { + if value == nil { + return fallback + } + return clampInt(*value, 0, 1000) +} + +func normalizeClickCount(value int) (int, error) { + if value == 0 { + return 1, nil + } + if value < 1 || value > 5 { + return 0, fmt.Errorf("clicks must be between 1 and 5") + } + return value, nil +} + +func normalizedCoordinates(x *float64, y *float64) (float64, float64, error) { + if x == nil || y == nil { + return 0, 0, fmt.Errorf("absolute mode requires both x and y") + } + if *x < 0 || *x > 1 || *y < 0 || *y > 1 { + return 0, 0, fmt.Errorf("absolute coordinates must be between 0 and 1") + } + return *x, *y, nil +} + +func validateMoveMouseInput(mode string, input MoveMouseParams) error { + switch mode { + case "absolute": + if input.DeltaX != 0 || input.DeltaY != 0 { + return fmt.Errorf("absolute mode does not accept relative deltas") + } + _, _, err := normalizedCoordinates(input.X, input.Y) + return err + case "relative": + if input.X != nil || input.Y != nil { + return fmt.Errorf("relative mode does not accept absolute coordinates") + } + if input.DeltaX == 0 && input.DeltaY == 0 { + return fmt.Errorf("relative mode requires deltaX or deltaY") + } + return nil + default: + return fmt.Errorf("unknown mouse mode: %s", mode) + } +} + +func validateClickMouseInput(mode string, button string, input ClickMouseParams) error { + switch mode { + case "absolute": + if button != "left" && button != "right" { + return fmt.Errorf("absolute mouse clicks only support left and right buttons") + } + _, _, err := normalizedCoordinates(input.X, input.Y) + return err + case "relative": + if input.X != nil || input.Y != nil { + return fmt.Errorf("relative mouse clicks do not accept absolute coordinates") + } + if button != "left" && button != "right" && button != "middle" { + return fmt.Errorf("relative mouse clicks only support left, right, and middle buttons") + } + return nil + default: + return fmt.Errorf("unknown mouse mode: %s", mode) + } +} + +func (e *toolExecutor) begin(ctx context.Context, kind inputcontrol.OperationKind) (context.Context, func(), error) { + if ctx == nil { + ctx = context.Background() + } + if e == nil { + return ctx, func() {}, nil + } + + var releaseMode func() + if e.control != nil && kind == inputcontrol.OperationHID { + var err error + releaseMode, err = e.control.AcquireWrite(controlmode.ModeMCP) + if err != nil { + return nil, nil, fmt.Errorf("MCP service is disabled: %w", err) + } + } + + if e.coordinator == nil { + return ctx, func() { + if releaseMode != nil { + releaseMode() + } + }, nil + } + + operationCtx, releaseOperation, err := e.coordinator.BeginMCP(ctx, kind) + if err != nil { + if releaseMode != nil { + releaseMode() + } + return nil, nil, err + } + return operationCtx, func() { + releaseOperation() + if releaseMode != nil { + releaseMode() + } + }, nil +} + +func typeTextSchema() *jsonschema.Schema { + return objectSchema(map[string]*jsonschema.Schema{ + "text": {Type: "string", Description: "Text to type on the remote host", MaxLength: jsonschema.Ptr(maxTypeTextRunes)}, + "delayMs": integerSchema(fmt.Sprintf("Delay between typed characters in milliseconds; defaults to %d when omitted, and 0 disables the delay", defaultTypeDelayMS), 0, 1000), + }, "text") +} + +func pressKeysSchema() *jsonschema.Schema { + return objectSchema(map[string]*jsonschema.Schema{ + "keys": { + Type: "array", + Description: "KeyboardEvent.code key names", + Items: &jsonschema.Schema{Type: "string", Enum: supportedKeyNames()}, + MinItems: jsonschema.Ptr(1), + MaxItems: jsonschema.Ptr(maxPressKeyItems), + }, + "holdMs": integerSchema(fmt.Sprintf("Hold time in milliseconds; defaults to %d when omitted, and 0 releases immediately", defaultHoldMS), 0, 1000), + }, "keys") +} + +func moveMouseSchema() *jsonschema.Schema { + mode := &jsonschema.Schema{Type: "string", Enum: []any{"absolute", "relative"}} + coordinate := func(description string) *jsonschema.Schema { + return numberSchema(description, 0, 1) + } + schema := objectSchema(map[string]*jsonschema.Schema{ + "mode": mode, + "x": coordinate("Absolute normalized x coordinate"), + "y": coordinate("Absolute normalized y coordinate"), + "deltaX": integerSchema("Relative x movement", -maxRelativeMouseDelta, maxRelativeMouseDelta), + "deltaY": integerSchema("Relative y movement", -maxRelativeMouseDelta, maxRelativeMouseDelta), + }) + schema.OneOf = []*jsonschema.Schema{ + { + Type: "object", + Properties: map[string]*jsonschema.Schema{"mode": {Type: "string", Enum: []any{"absolute"}}}, + Required: []string{"x", "y"}, + Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{ + {Type: "object", Required: []string{"deltaX"}}, + {Type: "object", Required: []string{"deltaY"}}, + }}, + }, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{"mode": {Type: "string", Enum: []any{"relative"}}}, + Required: []string{"mode"}, + AnyOf: []*jsonschema.Schema{ + {Type: "object", Required: []string{"deltaX"}}, + {Type: "object", Required: []string{"deltaY"}}, + }, + Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{ + {Type: "object", Required: []string{"x"}}, + {Type: "object", Required: []string{"y"}}, + }}, + }, + } + return schema +} + +func clickMouseSchema() *jsonschema.Schema { + schema := objectSchema(map[string]*jsonschema.Schema{ + "button": {Type: "string", Enum: []any{"left", "right", "middle"}}, + "clicks": integerSchema("Number of clicks", 1, 5), + "mode": {Type: "string", Enum: []any{"absolute", "relative"}}, + "x": numberSchema("Absolute normalized x coordinate", 0, 1), + "y": numberSchema("Absolute normalized y coordinate", 0, 1), + }) + schema.OneOf = []*jsonschema.Schema{ + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "mode": {Type: "string", Enum: []any{"absolute"}}, + "button": {Type: "string", Enum: []any{"left", "right"}}, + }, + Required: []string{"mode", "x", "y"}, + }, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "mode": {Type: "string", Enum: []any{"relative"}}, + "button": {Type: "string", Enum: []any{"left", "right", "middle"}}, + }, + Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{ + {Type: "object", Required: []string{"x"}}, + {Type: "object", Required: []string{"y"}}, + }}, + }, + } + return schema +} + +func scrollMouseSchema() *jsonschema.Schema { + return objectSchema(map[string]*jsonschema.Schema{ + "deltaY": integerSchema("Vertical wheel movement", -maxRelativeMouseDelta, maxRelativeMouseDelta), + }, "deltaY") +} + +func screenshotSchema() *jsonschema.Schema { + return objectSchema(map[string]*jsonschema.Schema{ + "quality": integerSchema("JPEG quality", 1, 100), + "timeoutMs": integerSchema("Capture timeout in milliseconds", 0, 30000), + }) +} + +func objectSchema(properties map[string]*jsonschema.Schema, required ...string) *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Properties: properties, + Required: required, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + } +} + +func integerSchema(description string, minimum int, maximum int) *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "integer", + Description: description, + Minimum: jsonschema.Ptr(float64(minimum)), + Maximum: jsonschema.Ptr(float64(maximum)), + } +} + +func numberSchema(description string, minimum float64, maximum float64) *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "number", + Description: description, + Minimum: jsonschema.Ptr(minimum), + Maximum: jsonschema.Ptr(maximum), + } +} + +func toolError(message string) *protocol.CallToolResult { + return &protocol.CallToolResult{ + Content: []protocol.Content{&protocol.TextContent{Text: message}}, + IsError: true, + } +} + +func normalizedMode(mode string, fallback string) string { + normalized := strings.ToLower(strings.TrimSpace(mode)) + if normalized == "" { + return fallback + } + return normalized +} diff --git a/server/service/mcp/server_test.go b/server/service/mcp/server_test.go new file mode 100644 index 0000000..e74f4fa --- /dev/null +++ b/server/service/mcp/server_test.go @@ -0,0 +1,396 @@ +package mcpservice + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/inputcontrol" +) + +type fakeSnapshotter struct { + snapshot Snapshot + err error +} + +func (s fakeSnapshotter) Capture(context.Context, SnapshotRequest) (Snapshot, error) { + return s.snapshot, s.err +} + +func TestScreenshotHandlerReturnsImage(t *testing.T) { + handler := screenshotHandler(nil, fakeSnapshotter{snapshot: Snapshot{ + OK: true, Width: 800, Height: 600, JPEG: []byte{0xff, 0xd8, 0xff}, + }}) + result, output, err := handler(context.Background(), nil, ScreenshotParams{}) + if err != nil { + t.Fatal(err) + } + if result == nil || result.IsError || len(result.Content) != 1 || !output.OK || output.Size != 3 { + t.Fatalf("result=%+v output=%+v", result, output) + } +} + +func TestResolveDelayMSDefaultsOnlyWhenOmitted(t *testing.T) { + if got := resolveDelayMS(nil, defaultTypeDelayMS); got != defaultTypeDelayMS { + t.Fatalf("omitted delay = %d, want %d", got, defaultTypeDelayMS) + } + + zero := 0 + if got := resolveDelayMS(&zero, defaultTypeDelayMS); got != 0 { + t.Fatalf("explicit zero delay = %d, want 0", got) + } +} + +func TestTypeTextSchemaMatchesDurationBudget(t *testing.T) { + if maxTypeTextRunes != 1000 { + t.Fatalf("maxTypeTextRunes = %d, want 1000", maxTypeTextRunes) + } + schema := typeTextSchema() + maxLength := schema.Properties["text"].MaxLength + if maxLength == nil || *maxLength != maxTypeTextRunes { + t.Fatalf("schema maxLength = %v, want %d", maxLength, maxTypeTextRunes) + } + + tooLong := strings.Repeat("a", maxTypeTextRunes+1) + handler := typeTextHandler(nil, nil) + if _, _, err := handler(context.Background(), nil, TypeTextParams{Text: tooLong}); err == nil || !strings.Contains(err.Error(), "maximum length") { + t.Fatalf("too-long error = %v, want maximum length", err) + } + + delayMS := defaultTypeDelayMS + 1 + maxLengthText := strings.Repeat("a", maxTypeTextRunes) + if _, _, err := handler(context.Background(), nil, TypeTextParams{Text: maxLengthText, DelayMS: &delayMS}); err == nil || !strings.Contains(err.Error(), "typing duration exceeds") { + t.Fatalf("duration error = %v, want duration budget", err) + } +} + +func TestMoveMouseHandlerDoesNotRateLimitRapidMoves(t *testing.T) { + hid := &recordingHID{} + handler := moveMouseHandler(nil, newRemoteWithHID(hid)) + x, y := 0.5, 0.5 + + for i := 0; i < 2; i++ { + if _, _, err := handler(context.Background(), nil, MoveMouseParams{Mode: "absolute", X: &x, Y: &y}); err != nil { + t.Fatalf("move %d error = %v", i+1, err) + } + } + if len(hid.absolute) != 2 { + t.Fatalf("absolute writes = %d, want 2", len(hid.absolute)) + } +} + +func TestMCPInitializeAndToolsList(t *testing.T) { + hid := &recordingHID{} + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + handler := newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(hid), fakeSnapshotter{snapshot: Snapshot{ + OK: true, Width: 800, Height: 600, JPEG: []byte{0xff, 0xd8, 0xff}, + }}) + + request := func(method string, body string, sessionID string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(method, "/api/mcp", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + if sessionID != "" { + req.Header.Set("Mcp-Session-Id", sessionID) + req.Header.Set("Mcp-Protocol-Version", "2025-03-26") + } + handler.ServeHTTP(recorder, req) + return recorder + } + + initialize := request(http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}`, "") + if initialize.Code != http.StatusOK { + t.Fatalf("initialize status=%d body=%s", initialize.Code, initialize.Body.String()) + } + sessionID := initialize.Header().Get("Mcp-Session-Id") + if sessionID == "" || !strings.Contains(initialize.Body.String(), "nanokvm-cube-remote-control") { + t.Fatalf("session=%q body=%s", sessionID, initialize.Body.String()) + } + + initialized := request(http.MethodPost, `{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}`, sessionID) + if initialized.Code != http.StatusAccepted { + t.Fatalf("initialized status=%d body=%s", initialized.Code, initialized.Body.String()) + } + + tools := request(http.MethodPost, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, sessionID) + if tools.Code != http.StatusOK { + t.Fatalf("tools/list status=%d body=%s", tools.Code, tools.Body.String()) + } + for _, name := range []string{"cube_type_text", "cube_press_keys", "cube_move_mouse", "cube_click_mouse", "cube_scroll_mouse", "cube_screenshot"} { + if !strings.Contains(tools.Body.String(), `"name":"`+name+`"`) { + t.Fatalf("tools/list missing %s: %s", name, tools.Body.String()) + } + } + for _, name := range []string{"type_text", "press_keys", "move_mouse", "click_mouse", "scroll_mouse", "screenshot"} { + if strings.Contains(tools.Body.String(), `"name":"`+name+`"`) { + t.Fatalf("tools/list exposes unprefixed tool %s: %s", name, tools.Body.String()) + } + } + + calls := []struct { + id int + name string + arguments string + }{ + {3, "cube_type_text", `{"text":"A","delayMs":1}`}, + {4, "cube_press_keys", `{"keys":["ControlLeft","AltLeft","KeyA","KeyB","KeyC","KeyD","KeyE","KeyF"],"holdMs":1}`}, + {5, "cube_move_mouse", `{"mode":"absolute","x":0.5,"y":0.5}`}, + {6, "cube_click_mouse", `{"button":"left","clicks":1}`}, + {7, "cube_scroll_mouse", `{"deltaY":1}`}, + {8, "cube_screenshot", `{"quality":75}`}, + } + for _, call := range calls { + body := `{"jsonrpc":"2.0","id":` + fmt.Sprint(call.id) + `,"method":"tools/call","params":{"name":"` + call.name + `","arguments":` + call.arguments + `}}` + response := request(http.MethodPost, body, sessionID) + if response.Code != http.StatusOK || strings.Contains(response.Body.String(), `"isError":true`) { + t.Fatalf("tools/call %s status=%d body=%s", call.name, response.Code, response.Body.String()) + } + if call.name == "cube_screenshot" && !strings.Contains(response.Body.String(), "image/jpeg") { + t.Fatalf("screenshot response missing image: %s", response.Body.String()) + } + } + + invalidCalls := []struct { + id int + name string + arguments string + }{ + {9, "cube_move_mouse", `{"mode":"absolute"}`}, + {10, "cube_move_mouse", `{"mode":"absolute","x":1.1,"y":0.5}`}, + {11, "cube_click_mouse", `{"mode":"absolute","button":"left"}`}, + {12, "cube_screenshot", `{"x":10}`}, + {13, "cube_move_mouse", `{"mode":"relative","x":0.5,"y":0.5}`}, + {14, "cube_move_mouse", `{"mode":"relative"}`}, + {15, "cube_click_mouse", `{"mode":"absolute","button":"middle","x":0.5,"y":0.5}`}, + {16, "cube_click_mouse", `{"button":"back"}`}, + {17, "cube_press_keys", `{"keys":["MediaPlayPause"]}`}, + } + for _, call := range invalidCalls { + body := `{"jsonrpc":"2.0","id":` + fmt.Sprint(call.id) + `,"method":"tools/call","params":{"name":"` + call.name + `","arguments":` + call.arguments + `}}` + response := request(http.MethodPost, body, sessionID) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"isError":true`) { + t.Fatalf("invalid tools/call %s status=%d body=%s", call.name, response.Code, response.Body.String()) + } + } + + closed := request(http.MethodDelete, "", sessionID) + if closed.Code != http.StatusNoContent { + t.Fatalf("delete status=%d body=%s", closed.Code, closed.Body.String()) + } +} + +func TestMCPHandlerRejectsCrossOriginBrowserRequests(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + handler := http.NewCrossOriginProtection().Handler(newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(&recordingHID{}), nil)) + + request := func(origin string) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "http://nanokvm.local/api/mcp", strings.NewReader(`{}`)) + req.Header.Set("Origin", origin) + handler.ServeHTTP(recorder, req) + return recorder + } + + if got := request("https://evil.example").Code; got != http.StatusForbidden { + t.Fatalf("cross-origin status = %d, want 403", got) + } + if got := request("http://nanokvm.local").Code; got == http.StatusForbidden { + t.Fatalf("same-origin request was rejected with status %d", got) + } +} + +func TestMCPHandlerRejectsOversizedRequestBody(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + handler := newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(&recordingHID{}), nil) + recorder := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/mcp", + strings.NewReader(strings.Repeat(" ", maxRequestBodyBytes+1)), + ) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + handler.ServeHTTP(recorder, req) + if recorder.Code == http.StatusOK { + t.Fatalf("oversized request was accepted: status=%d", recorder.Code) + } + if !strings.Contains(recorder.Body.String(), "failed to read body") { + t.Fatalf("unexpected oversized response: status=%d body=%q", recorder.Code, recorder.Body.String()) + } +} + +func TestToolExecutorRejectsConcurrentControl(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + coordinator := &inputcontrol.Coordinator{} + executor := &toolExecutor{control: control, coordinator: coordinator} + + _, release, err := executor.begin(context.Background(), inputcontrol.OperationHID) + if err != nil { + t.Fatal(err) + } + defer release() + + if _, _, err := executor.begin(context.Background(), inputcontrol.OperationHID); !errors.Is(err, inputcontrol.ErrMCPBusy) { + t.Fatalf("error = %v, want busy", err) + } +} + +func TestManualControlBlocksHIDButAllowsScreenshot(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + coordinator := &inputcontrol.Coordinator{} + manual := inputcontrol.NewManualSession(control, coordinator) + defer manual.Close() + + reservation, err := manual.Reserve(context.Background(), inputcontrol.ManualRelativeMouse, true, nil) + if err != nil { + t.Fatal(err) + } + reservation.Complete(true) + + hid := &recordingHID{} + executor := &toolExecutor{control: control, coordinator: coordinator} + x, y := 0.5, 0.5 + _, _, err = moveMouseHandler(executor, newRemoteWithHID(hid))( + context.Background(), nil, MoveMouseParams{Mode: "absolute", X: &x, Y: &y}, + ) + if !errors.Is(err, inputcontrol.ErrManualControlActive) { + t.Fatalf("move error = %v, want manual-control busy", err) + } + if hid.writes != 0 { + t.Fatalf("manual-control rejection wrote %d HID reports", hid.writes) + } + + result, output, err := screenshotHandler(executor, fakeSnapshotter{snapshot: Snapshot{ + OK: true, Width: 800, Height: 600, JPEG: []byte{0xff, 0xd8, 0xff}, + }})(context.Background(), nil, ScreenshotParams{}) + if err != nil { + t.Fatalf("screenshot was blocked by manual control: %v", err) + } + if result == nil || !output.OK { + t.Fatalf("result=%+v output=%+v", result, output) + } + + manual.Reset(inputcontrol.ManualRelativeMouse) + if got := control.Current(); got != controlmode.ModeMCP { + t.Fatalf("mode = %q, want MCP to remain enabled", got) + } +} + +func TestModeSwitchCancelsActiveMCPTool(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + coordinator := &inputcontrol.Coordinator{} + hid := &recordingHID{} + started := make(chan struct{}, 1) + hid.onWrite = func(write int) { + if write == 1 { + started <- struct{}{} + } + } + handler := typeTextHandler( + &toolExecutor{control: control, coordinator: coordinator}, + newRemoteWithHID(hid), + ) + + toolDone := make(chan error, 1) + go func() { + delayMS := 1000 + _, _, err := handler(context.Background(), nil, TypeTextParams{Text: "AB", DelayMS: &delayMS}) + toolDone <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("MCP tool did not start") + } + + switched, err := control.SwitchIf(controlmode.ModeMCP, controlmode.ModeOff, func() error { + coordinator.CancelMCP() + return nil + }) + if err != nil || !switched { + t.Fatalf("switched=%v err=%v", switched, err) + } + select { + case err := <-toolDone: + if !errors.Is(err, inputcontrol.ErrMCPModeChanged) { + t.Fatalf("tool error=%v, want mode-change cancellation", err) + } + case <-time.After(time.Second): + t.Fatal("canceled MCP tool did not return") + } +} + +func TestManualInputCancelsActiveMCPToolAndKeepsModeEnabled(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + coordinator := &inputcontrol.Coordinator{} + hid := &recordingHID{} + started := make(chan struct{}, 1) + hid.onWrite = func(write int) { + if write == 1 { + started <- struct{}{} + } + } + handler := typeTextHandler( + &toolExecutor{control: control, coordinator: coordinator}, + newRemoteWithHID(hid), + ) + + toolDone := make(chan error, 1) + go func() { + delayMS := 1000 + _, _, err := handler(context.Background(), nil, TypeTextParams{Text: "AB", DelayMS: &delayMS}) + toolDone <- err + }() + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("MCP tool did not start") + } + + manual := inputcontrol.NewManualSession(control, coordinator) + defer manual.Close() + reservationDone := make(chan *inputcontrol.ManualReservation, 1) + reservationErr := make(chan error, 1) + go func() { + reservation, err := manual.Reserve(context.Background(), inputcontrol.ManualKeyboard, false, nil) + if err != nil { + reservationErr <- err + return + } + reservationDone <- reservation + }() + + select { + case err := <-toolDone: + if !errors.Is(err, inputcontrol.ErrManualPreempted) { + t.Fatalf("tool error=%v, want manual preemption", err) + } + case <-time.After(time.Second): + t.Fatal("manual input did not cancel MCP tool") + } + + select { + case err := <-reservationErr: + t.Fatal(err) + case reservation := <-reservationDone: + reservation.Complete(true) + case <-time.After(time.Second): + t.Fatal("manual input did not acquire control after MCP cleanup") + } + if got := control.Current(); got != controlmode.ModeMCP { + t.Fatalf("mode = %q, want MCP to remain enabled", got) + } + if len(hid.keyboard) < 2 || string(hid.keyboard[len(hid.keyboard)-1]) != string(keyUpReport) { + t.Fatalf("keyboard was not released before manual takeover: %v", hid.keyboard) + } +} diff --git a/server/service/mcp/service.go b/server/service/mcp/service.go new file mode 100644 index 0000000..00e7aa5 --- /dev/null +++ b/server/service/mcp/service.go @@ -0,0 +1,169 @@ +package mcpservice + +import ( + "errors" + + "NanoKVM-Server/proto" + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/inputcontrol" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +type Service struct { + control *controlmode.Manager + preemptPicoclawLeases func() error + stopPicoclawForMCP func() error + releaseHID func() error + onControlChange func(controlmode.Status) +} + +func NewService(control *controlmode.Manager, releaseHID func() error) *Service { + return NewServiceWithPreempt(control, nil, nil, releaseHID, nil) +} + +func NewServiceWithPreempt( + control *controlmode.Manager, + preemptPicoclawLeases func() error, + stopPicoclawForMCP func() error, + releaseHID func() error, + onControlChange func(controlmode.Status), +) *Service { + if control == nil { + control = controlmode.GetManager() + } + if releaseHID == nil { + releaseHID = hid.ReleaseAllHIDState + } + return &Service{ + control: control, + preemptPicoclawLeases: preemptPicoclawLeases, + stopPicoclawForMCP: stopPicoclawForMCP, + releaseHID: releaseHID, + onControlChange: onControlChange, + } +} + +func (s *Service) GetConfig(c *gin.Context) { + var rsp proto.Response + c.Header("Cache-Control", "no-store") + + cfg, err := loadConfig() + if err != nil { + log.Errorf("failed to load MCP config: %v", err) + rsp.ErrRsp(c, -1, "get MCP config failed") + return + } + status, err := s.control.Status() + if err != nil { + log.Errorf("failed to load AI control mode: %v", err) + rsp.ErrRsp(c, -1, "get MCP config failed") + return + } + + rsp.OkRspWithData(c, mcpConfigResponse(cfg, status)) +} + +func (s *Service) SetConfig(c *gin.Context) { + var req proto.SetMCPConfigReq + var rsp proto.Response + c.Header("Cache-Control", "no-store") + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -1, "invalid arguments") + return + } + + var cfg Config + var err error + cancelMCP := func() error { + inputcontrol.GetCoordinator().CancelMCP() + return nil + } + preemptForEnable := func() error { + if err := cancelMCP(); err != nil { + return err + } + if s.preemptPicoclawLeases != nil { + return s.preemptPicoclawLeases() + } + return nil + } + cleanupForEnable := func() error { + var errs []error + if s.stopPicoclawForMCP != nil { + errs = append(errs, s.stopPicoclawForMCP()) + } + if s.releaseHID != nil { + errs = append(errs, s.releaseHID()) + } + return errors.Join(errs...) + } + if *req.Enabled { + cfg, err = updateConfig(ensureAPIKey) + if err == nil { + err = s.control.SwitchWithCleanup( + controlmode.ModeMCP, + preemptForEnable, + cleanupForEnable, + ) + } + } else { + cfg, err = loadConfig() + if err == nil { + _, err = s.control.SwitchIfWithCleanup( + controlmode.ModeMCP, + controlmode.ModeOff, + cancelMCP, + s.releaseHID, + ) + } + } + if err != nil { + log.Errorf("failed to switch MCP control mode: %v", err) + rsp.ErrRsp(c, -2, "operation failed: "+err.Error()) + return + } + status, err := s.control.Status() + if err != nil { + log.Errorf("failed to load AI control mode: %v", err) + rsp.ErrRsp(c, -2, "operation failed: "+err.Error()) + return + } + if s.onControlChange != nil { + s.onControlChange(status) + } + + rsp.OkRspWithData(c, mcpConfigResponse(cfg, status)) +} + +func (s *Service) RegenerateAPIKey(c *gin.Context) { + var rsp proto.Response + c.Header("Cache-Control", "no-store") + + cfg, err := updateConfig(regenerateAPIKey) + if err != nil { + log.Errorf("failed to regenerate MCP API key: %v", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + status, err := s.control.Status() + if err != nil { + log.Errorf("failed to load AI control mode: %v", err) + rsp.ErrRsp(c, -1, "operation failed") + return + } + + rsp.OkRspWithData(c, mcpConfigResponse(cfg, status)) +} + +func mcpConfigResponse(cfg Config, status controlmode.Status) *proto.GetMCPConfigRsp { + return &proto.GetMCPConfigRsp{ + Enabled: status.Mode == controlmode.ModeMCP && !status.Transitioning, + APIKey: cfg.APIKey, + ControlMode: string(status.Mode), + Transitioning: status.Transitioning, + } +} diff --git a/server/service/picoclaw/actions.go b/server/service/picoclaw/actions.go index 439735d..3d5f755 100644 --- a/server/service/picoclaw/actions.go +++ b/server/service/picoclaw/actions.go @@ -2,6 +2,7 @@ package picoclaw import ( "bytes" + "context" "encoding/json" "math" "strings" @@ -20,6 +21,16 @@ const ( ) func (s *Service) Actions(c *gin.Context) { + releaseMode, modeErr := s.acquireControlMode() + if modeErr != nil { + writePicoclawError(c, modeErr) + return + } + defer releaseMode() + + operationCtx, releaseOperation := s.beginControlOperation(c.Request.Context()) + defer releaseOperation() + sessionID, sessionErr := s.requireSessionID(c) if sessionErr != nil { writePicoclawError(c, sessionErr) @@ -41,7 +52,7 @@ func (s *Service) Actions(c *gin.Context) { return } - result, execErr := s.executeActions(sessionID, actions) + result, execErr := s.executeActions(operationCtx, sessionID, actions) if execErr != nil { writePicoclawError(c, execErr) return @@ -74,7 +85,7 @@ func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) { return []Action{action}, nil } -func (s *Service) executeActions(sessionID string, actions []Action) (result ActionResult, err *PicoclawError) { +func (s *Service) executeActions(ctx context.Context, sessionID string, actions []Action) (result ActionResult, err *PicoclawError) { startedAt := time.Now() if len(actions) == 0 { return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions") @@ -88,12 +99,16 @@ func (s *Service) executeActions(sessionID string, actions []Action) (result Act totalWrites := 0 for idx, action := range actions { + if contextErr := controlOperationError(ctx); contextErr != nil { + contextErr.Index = &idx + return ActionResult{}, contextErr + } if lockErr := s.lock.Ensure(sessionID); lockErr != nil { lockErr.Index = &idx return ActionResult{}, lockErr } - writes, execErr := s.executeAction(action) + writes, execErr := s.executeAction(ctx, action) if execErr != nil { execErr.Index = &idx return ActionResult{}, execErr @@ -114,7 +129,11 @@ func (s *Service) executeActions(sessionID string, actions []Action) (result Act return result, nil } -func (s *Service) executeAction(action Action) (int, *PicoclawError) { +func (s *Service) executeAction(ctx context.Context, action Action) (int, *PicoclawError) { + if err := controlOperationError(ctx); err != nil { + return 0, err + } + switch strings.ToLower(strings.TrimSpace(action.Action)) { case "click": x, y, err := normalizedPoint(action.X, action.Y) @@ -129,7 +148,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) { writes := 0 writes += s.sendMouseMoveWithButton(x, y, 0x00, 0) writes += s.sendMousePress(x, y, button) - time.Sleep(defaultClickHold) + if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil { + return writes, waitErr + } writes += s.sendMouseRelease(x, y) return writes, nil @@ -144,7 +165,12 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) { if action.DurationMs < 0 { return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 0") } - time.Sleep(time.Duration(action.DurationMs) * time.Millisecond) + if action.DurationMs > maxWaitDurationMS { + return 0, newPicoclawError(CodeInvalidAction, "wait duration must be <= 30000 milliseconds") + } + if waitErr := waitForControlOperation(ctx, time.Duration(action.DurationMs)*time.Millisecond); waitErr != nil { + return 0, waitErr + } return 0, nil case "drag": @@ -165,6 +191,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) { writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0) writes += s.sendMousePress(fromX, fromY, button) for step := 1; step <= defaultDragSteps; step++ { + if contextErr := controlOperationError(ctx); contextErr != nil { + return writes, contextErr + } ratio := float64(step) / float64(defaultDragSteps) x := fromX + (toX-fromX)*ratio y := fromY + (toY-fromY)*ratio @@ -203,9 +232,14 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) { writes := 0 for range amount { + if contextErr := controlOperationError(ctx); contextErr != nil { + return writes, contextErr + } writes += s.sendMouseMoveWithButton(x, y, 0x00, wheel) writes += s.sendMouseMoveWithButton(x, y, 0x00, 0) - time.Sleep(defaultScrollStep) + if waitErr := waitForControlOperation(ctx, defaultScrollStep); waitErr != nil { + return writes, waitErr + } } return writes, nil @@ -216,6 +250,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) { charMap := hid.GetCharMap("") writes := 0 for _, char := range action.Text { + if contextErr := controlOperationError(ctx); contextErr != nil { + return writes, contextErr + } key, ok := charMap[char] if !ok { return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action") @@ -223,7 +260,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) { writes += s.sendKeyboardReport([]byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}) writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) - time.Sleep(defaultKeyDelay) + if waitErr := waitForControlOperation(ctx, defaultKeyDelay); waitErr != nil { + return writes, waitErr + } } return writes, nil @@ -234,7 +273,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) { } writes := 0 writes += s.sendKeyboardReport(report) - time.Sleep(defaultClickHold) + if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil { + return writes, waitErr + } writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) return writes, nil } diff --git a/server/service/picoclaw/agent_profile.go b/server/service/picoclaw/agent_profile.go index 954abfe..272f323 100644 --- a/server/service/picoclaw/agent_profile.go +++ b/server/service/picoclaw/agent_profile.go @@ -120,6 +120,7 @@ func withAgentProfile(status RuntimeStatus) RuntimeStatus { } func (s *Service) UpdateAgentProfile(c *gin.Context) { + s.ensureDependencies() var req AgentProfileUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile payload")) @@ -132,16 +133,19 @@ func (s *Service) UpdateAgentProfile(c *gin.Context) { return } + unlockLifecycle := s.lockRuntimeLifecycle() + defer unlockLifecycle() + if err := applyPicoclawAgentProfile(profile); err != nil { writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error())) return } _ = s.syncConfigFromPicoclaw() - _ = s.ensureRuntimeReady() + _ = s.ensureRuntimeReadyForLifecycle() writeSuccess(c, gin.H{ "profile": profile, - "status": withAgentProfile(s.runtime.Get()), + "status": withAgentProfile(s.runtimeStatus()), }) } diff --git a/server/service/picoclaw/config.go b/server/service/picoclaw/config.go index a7c0d92..3ac77ee 100644 --- a/server/service/picoclaw/config.go +++ b/server/service/picoclaw/config.go @@ -1,6 +1,7 @@ package picoclaw import ( + "errors" "fmt" "os" "strings" @@ -104,6 +105,53 @@ func (s *Service) syncConfigFromPicoclaw() *PicoclawError { return nil } +// syncRuntimeConfigMetadataFromPicoclaw refreshes the model metadata exposed +// by RuntimeStatus without changing PicoClaw's config or probing its gateway. +// This is used while PicoClaw does not own the control mode. +func (s *Service) syncRuntimeConfigMetadataFromPicoclaw() *PicoclawError { + doc, err := loadPicoclawConfigDocument() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + s.runtime.Update(func(status *RuntimeStatus) { + status.ModelConfigured = false + status.ModelName = "" + status.ConfigError = "" + if status.Status == "config_error" { + status.Status = "checking" + status.LastError = "" + } + status.CheckedAt = time.Now() + }) + return nil + } + + s.runtime.Update(func(status *RuntimeStatus) { + status.ModelConfigured = false + status.ModelName = "" + status.Status = "config_error" + status.ConfigError = err.Error() + status.LastError = err.Error() + status.CheckedAt = time.Now() + }) + return newPicoclawError(CodeRuntimeUnavailable, err.Error()) + } + + modelName := resolvePicoclawTargetModelName(doc.config) + modelConfigured := isPicoclawModelConfigured(doc.config, doc.security, modelName) + s.runtime.Update(func(status *RuntimeStatus) { + status.ModelConfigured = modelConfigured + status.ModelName = modelName + status.ConfigError = "" + if status.Status == "config_error" || (modelConfigured && status.Status == "model_not_configured") { + status.Status = "checking" + status.LastError = "" + } + status.CheckedAt = time.Now() + }) + + return nil +} + type picoclawGatewaySettings struct { GatewayURL string Token string diff --git a/server/service/picoclaw/config_files.go b/server/service/picoclaw/config_files.go index 239390f..5e7ff80 100644 --- a/server/service/picoclaw/config_files.go +++ b/server/service/picoclaw/config_files.go @@ -3,6 +3,7 @@ package picoclaw import ( "bytes" "encoding/json" + "errors" "fmt" "os" "os/user" @@ -93,19 +94,55 @@ func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) { }, nil } +var runPicoclawOnboardForConfig = runPicoclawOnboard + +func loadOrInitializePicoclawConfigDocument() (*picoclawConfigDocument, error) { + doc, err := loadPicoclawConfigDocument() + if err == nil { + return doc, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + if _, onboardErr := runPicoclawOnboardForConfig(); onboardErr != nil { + return nil, fmt.Errorf("failed to initialize PicoClaw config before saving model config: %w", onboardErr) + } + + doc, err = loadPicoclawConfigDocument() + if err != nil { + return nil, fmt.Errorf("failed to load PicoClaw config after initialization: %w", err) + } + return doc, nil +} + func resolvePicoclawConfigPath() (string, error) { - home := os.Getenv("PICOCLAW_HOME") - if home == "" { - currentUser, err := user.Current() - if err != nil { - return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err) - } - home = filepath.Join(currentUser.HomeDir, ".picoclaw") + home, err := resolvePicoclawHome() + if err != nil { + return "", err } return filepath.Join(home, "config.json"), nil } +func resolvePicoclawHome() (string, error) { + if home := strings.TrimSpace(os.Getenv("PICOCLAW_HOME")); home != "" { + return home, nil + } + + currentUser, err := user.Current() + if err == nil && currentUser.HomeDir != "" { + return filepath.Join(currentUser.HomeDir, ".picoclaw"), nil + } + if home := strings.TrimSpace(os.Getenv("HOME")); home != "" { + return filepath.Join(home, ".picoclaw"), nil + } + if err != nil { + return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err) + } + return filepath.Join("/root", ".picoclaw"), nil +} + func expandPicoclawPath(path string) string { path = strings.TrimSpace(path) if path == "" { diff --git a/server/service/picoclaw/control_events.go b/server/service/picoclaw/control_events.go new file mode 100644 index 0000000..e75109f --- /dev/null +++ b/server/service/picoclaw/control_events.go @@ -0,0 +1,50 @@ +package picoclaw + +import ( + "time" + + "NanoKVM-Server/service/controlmode" +) + +func (s *Service) PublishControlModeChanged(status controlmode.Status) { + s.PublishControlModeChangedFrom(status, "") +} + +func (s *Service) PublishControlModeChangedFrom(status controlmode.Status, source string) { + if s == nil { + return + } + s.ensureDependencies() + + payload := controlModeChangedPayload(status, source) + message := picoGatewayMessage{ + Type: "control.mode_changed", + Timestamp: time.Now().UnixMilli(), + Payload: payload, + } + cfg := s.config.Get() + for _, session := range GetSessionManager().Snapshot() { + if session == nil || session.State != SessionStateActive || session.Downstream == nil { + continue + } + _ = session.writeDownstreamJSON(cfg, message) + } +} + +func controlModeChangedPayload(status controlmode.Status, source string) map[string]any { + payload := map[string]any{ + "mode": string(status.Mode), + "transitioning": status.Transitioning, + "can_control": status.Mode == controlmode.ModePicoclaw && !status.Transitioning, + } + if status.LastError != "" { + payload["last_error"] = status.LastError + } + if !status.ChangedAt.IsZero() { + payload["changed_at"] = status.ChangedAt + } + if source != "" { + payload["source"] = source + } + return payload +} diff --git a/server/service/picoclaw/control_events_test.go b/server/service/picoclaw/control_events_test.go new file mode 100644 index 0000000..cf3c7b2 --- /dev/null +++ b/server/service/picoclaw/control_events_test.go @@ -0,0 +1,50 @@ +package picoclaw + +import ( + "testing" + "time" + + "NanoKVM-Server/service/controlmode" +) + +func TestControlModeChangedPayloadIncludesControlMetadata(t *testing.T) { + changedAt := time.Now().UTC() + payload := controlModeChangedPayload(controlmode.Status{ + Mode: controlmode.ModeMCP, + Transitioning: true, + LastError: "switch failed", + ChangedAt: changedAt, + }, "mcp_config") + + if payload["mode"] != string(controlmode.ModeMCP) { + t.Fatalf("mode = %v, want %q", payload["mode"], controlmode.ModeMCP) + } + if payload["transitioning"] != true { + t.Fatalf("transitioning = %v, want true", payload["transitioning"]) + } + if payload["can_control"] != false { + t.Fatalf("can_control = %v, want false", payload["can_control"]) + } + if payload["last_error"] != "switch failed" { + t.Fatalf("last_error = %v, want switch failed", payload["last_error"]) + } + if payload["changed_at"] != changedAt { + t.Fatalf("changed_at = %v, want %v", payload["changed_at"], changedAt) + } + if payload["source"] != "mcp_config" { + t.Fatalf("source = %v, want mcp_config", payload["source"]) + } +} + +func TestControlModeChangedPayloadAllowsPicoclawControlWhenStable(t *testing.T) { + payload := controlModeChangedPayload(controlmode.Status{ + Mode: controlmode.ModePicoclaw, + }, "") + + if payload["can_control"] != true { + t.Fatalf("can_control = %v, want true", payload["can_control"]) + } + if _, ok := payload["source"]; ok { + t.Fatalf("source = %v, want omitted", payload["source"]) + } +} diff --git a/server/service/picoclaw/control_mode.go b/server/service/picoclaw/control_mode.go new file mode 100644 index 0000000..6a697fa --- /dev/null +++ b/server/service/picoclaw/control_mode.go @@ -0,0 +1,267 @@ +package picoclaw + +import ( + "errors" + "fmt" + "time" + + log "github.com/sirupsen/logrus" +) + +func (s *Service) PreemptForMCP() error { + if err := s.PreemptControlLeasesForMCP(); err != nil { + return err + } + return s.StopRuntimeForMCP() +} + +func (s *Service) PreemptControlLeasesForMCP() error { + startedAt := time.Now() + if s == nil { + return fmt.Errorf("picoclaw service is unavailable") + } + s.ensureDependencies() + + activeOperations := s.CancelActiveControlOperations() + closedSessions := s.ReleaseControlSessions( + CloseCodeControlModeSwitched, + "external MCP enabled", + ) + log.WithFields(log.Fields{ + "active_operations": activeOperations, + "closed_sessions": closedSessions, + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).Info("picoclaw control leases preempted for MCP") + return nil +} + +func (s *Service) StopRuntimeForMCP() error { + startedAt := time.Now() + if s == nil { + return fmt.Errorf("picoclaw service is unavailable") + } + s.ensureDependencies() + + currentStatus := s.runtime.Get() + statusRequiresStop := + currentStatus.Ready || + currentStatus.Status == "ready" || + isRuntimeLifecycleStatusPending(currentStatus) + running, runningErr := isRuntimeRunning() + if runningErr != nil { + if !statusRequiresStop { + log.WithFields(log.Fields{ + "status": currentStatus.Status, + "ready": currentStatus.Ready, + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).WithError(runningErr).Warn("skipping PicoClaw runtime stop for MCP because runtime is not active") + s.setRuntimeIntentDesired(false, "mcp_preempt") + return nil + } + return fmt.Errorf("check PicoClaw runtime for MCP: %w", runningErr) + } + if !running && !statusRequiresStop { + s.setRuntimeIntentDesired(false, "mcp_preempt") + log.WithFields(log.Fields{ + "status": currentStatus.Status, + "ready": currentStatus.Ready, + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).Info("PicoClaw runtime stop skipped for MCP because runtime is not active") + return nil + } + + unlockLifecycle := s.lockRuntimeLifecycle() + defer unlockLifecycle() + + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + status.Status = "stopping" + status.LastError = "" + status.CheckedAt = time.Now() + }) + if err := s.stopRuntimeAndVerify(false); err != nil { + log.WithFields(log.Fields{ + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).WithError(err).Warn("picoclaw stop runtime for MCP failed") + return fmt.Errorf("stop PicoClaw runtime for MCP: %w", err) + } + s.setRuntimeIntentDesired(false, "mcp_preempt") + log.WithFields(log.Fields{ + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).Info("picoclaw runtime stopped for MCP") + return nil +} + +func (s *Service) CompleteControlRelease(source string, closeCode int, closeReason string) int { + if s == nil { + return 0 + } + s.ensureDependencies() + unlockLifecycle := s.lockRuntimeLifecycle() + defer unlockLifecycle() + + s.CancelActiveControlOperations() + s.setRuntimeIntentDesired(false, source) + return s.ReleaseControlSessions(closeCode, closeReason) +} + +func (s *Service) PreserveRuntimeForChatOnly(source string) { + if s == nil { + return + } + s.ensureDependencies() + + status := s.runtime.Get() + if !status.Ready && len(GetSessionManager().Snapshot()) == 0 { + return + } + s.setRuntimeIntentDesired(true, source) +} + +func (s *Service) RuntimeStatus() RuntimeStatus { + if s == nil { + return RuntimeStatus{ + Ready: false, + Installed: false, + InstallPath: picoclawBinaryPath, + Status: "unavailable", + } + } + return s.runtimeStatus() +} + +func (s *Service) stopForRuntimeStop() error { + s.ensureDependencies() + return s.stopRuntimeAndCloseSessions( + CloseCodeRuntimeStopped, + "PicoClaw runtime stopped", + ) +} + +func (s *Service) stopRuntimeAndCloseSessions(closeCode int, closeReason string) error { + if s == nil { + return nil + } + s.ensureDependencies() + startedAt := time.Now() + sessions := GetSessionManager().Snapshot() + for _, session := range sessions { + s.closeGatewaySession(session, closeCode, closeReason) + } + closeElapsed := time.Since(startedAt) + if s.lock != nil { + s.lock.Release("") + } + + stopStartedAt := time.Now() + if err := s.stopRuntimeAndVerify(false); err != nil { + log.WithFields(log.Fields{ + "session_count": len(sessions), + "close_code": closeCode, + "close_sessions_ms": closeElapsed.Milliseconds(), + "stop_runtime_ms": time.Since(stopStartedAt).Milliseconds(), + "total_ms": time.Since(startedAt).Milliseconds(), + }).WithError(err).Warn("picoclaw stop runtime and close sessions failed") + return err + } + + log.WithFields(log.Fields{ + "session_count": len(sessions), + "close_code": closeCode, + "close_sessions_ms": closeElapsed.Milliseconds(), + "stop_runtime_ms": time.Since(stopStartedAt).Milliseconds(), + "total_ms": time.Since(startedAt).Milliseconds(), + }).Info("picoclaw runtime stopped and gateway sessions closed") + return nil +} + +func (s *Service) ReleaseControlSessions(closeCode int, closeReason string) int { + if s == nil { + return 0 + } + s.ensureDependencies() + startedAt := time.Now() + sessions := GetSessionManager().Snapshot() + for _, session := range sessions { + s.closeGatewaySession(session, closeCode, closeReason) + } + if s.lock != nil { + s.lock.Release("") + } + log.WithFields(log.Fields{ + "session_count": len(sessions), + "close_code": closeCode, + "elapsed_ms": time.Since(startedAt).Milliseconds(), + }).Info("picoclaw gateway sessions closed for control release") + return len(sessions) +} + +func (s *Service) stopRuntimeAndVerify(forceStop bool) error { + if s == nil { + return fmt.Errorf("picoclaw service is unavailable") + } + s.ensureDependencies() + err := stopRuntimeProcessAndVerify( + forceStop, + isRuntimeRunning, + func() error { + _, _, stopErr := s.stopRuntime() + return stopErr + }, + picoclawStopTimeout, + 100*time.Millisecond, + ) + if err != nil { + return err + } + + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Status = "stopped" + status.CurrentSession = "" + status.CheckedAt = time.Now() + }) + return nil +} + +func stopRuntimeProcessAndVerify( + forceStop bool, + isRunning func() (bool, error), + stop func() error, + timeout time.Duration, + pollInterval time.Duration, +) error { + if !forceStop { + running, err := isRunning() + if err != nil { + return fmt.Errorf("check PicoClaw runtime: %w", err) + } + if !running { + return nil + } + } + + var stopErr error + if err := stop(); err != nil { + stopErr = fmt.Errorf("stop PicoClaw runtime: %w", err) + } + + deadline := time.Now().Add(timeout) + for { + running, err := isRunning() + if err != nil { + return errors.Join(stopErr, fmt.Errorf("verify PicoClaw stopped: %w", err)) + } + if !running { + if stopErr != nil { + log.Warnf("PicoClaw stop command returned an error after the runtime stopped: %v", stopErr) + } + return nil + } + if !time.Now().Before(deadline) { + return errors.Join(stopErr, fmt.Errorf("PicoClaw runtime is still running")) + } + time.Sleep(pollInterval) + } +} diff --git a/server/service/picoclaw/control_operations.go b/server/service/picoclaw/control_operations.go new file mode 100644 index 0000000..43bd025 --- /dev/null +++ b/server/service/picoclaw/control_operations.go @@ -0,0 +1,153 @@ +package picoclaw + +import ( + "context" + "errors" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + maxControlOperationDuration = 35 * time.Second + maxWaitDurationMS = 30_000 +) + +var ( + errControlModeSwitch = errors.New("PicoClaw control operation canceled by control mode switch") + errControlTimeout = errors.New("PicoClaw control operation timed out") +) + +type controlOperationTracker struct { + mu sync.Mutex + nextID uint64 + active map[uint64]context.CancelCauseFunc +} + +func newControlOperationTracker() *controlOperationTracker { + return &controlOperationTracker{active: make(map[uint64]context.CancelCauseFunc)} +} + +func (t *controlOperationTracker) begin(parent context.Context) (context.Context, func()) { + if parent == nil { + parent = context.Background() + } + if t == nil { + return parent, func() {} + } + + ctx, cancel := context.WithCancelCause(parent) + t.mu.Lock() + if t.active == nil { + t.active = make(map[uint64]context.CancelCauseFunc) + } + t.nextID++ + id := t.nextID + t.active[id] = cancel + t.mu.Unlock() + + var once sync.Once + release := func() { + once.Do(func() { + cancel(context.Canceled) + t.mu.Lock() + delete(t.active, id) + t.mu.Unlock() + }) + } + return ctx, release +} + +func (t *controlOperationTracker) cancelAll(cause error) int { + if t == nil { + return 0 + } + if cause == nil { + cause = context.Canceled + } + + t.mu.Lock() + cancels := make([]context.CancelCauseFunc, 0, len(t.active)) + for _, cancel := range t.active { + cancels = append(cancels, cancel) + } + t.mu.Unlock() + + for _, cancel := range cancels { + cancel(cause) + } + return len(cancels) +} + +func (s *Service) beginControlOperation(parent context.Context) (context.Context, func()) { + if parent == nil { + parent = context.Background() + } + + timedCtx, cancelTimeout := context.WithTimeoutCause( + parent, + maxControlOperationDuration, + errControlTimeout, + ) + if s == nil || s.operations == nil { + return timedCtx, cancelTimeout + } + + operationCtx, releaseOperation := s.operations.begin(timedCtx) + var once sync.Once + return operationCtx, func() { + once.Do(func() { + releaseOperation() + cancelTimeout() + }) + } +} + +func (s *Service) CancelActiveControlOperations() int { + if s == nil || s.operations == nil { + return 0 + } + count := s.operations.cancelAll(errControlModeSwitch) + log.WithFields(log.Fields{ + "active_operations": count, + }).Info("picoclaw control operations canceled for control mode switch") + return count +} + +func controlOperationError(ctx context.Context) *PicoclawError { + if ctx == nil || ctx.Err() == nil { + return nil + } + + cause := context.Cause(ctx) + switch { + case errors.Is(cause, errControlModeSwitch): + return newPicoclawError(CodeControlModeConflict, "PicoClaw control operation canceled because the control mode is switching") + case errors.Is(cause, errControlTimeout), errors.Is(cause, context.DeadlineExceeded): + return newPicoclawError(CodeInvalidAction, "PicoClaw control operation exceeded the 35 second limit") + default: + return newPicoclawError(CodeInvalidAction, "PicoClaw control operation was canceled") + } +} + +func waitForControlOperation(ctx context.Context, delay time.Duration) *PicoclawError { + if ctx == nil { + ctx = context.Background() + } + if err := controlOperationError(ctx); err != nil { + return err + } + if delay <= 0 { + return nil + } + + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return controlOperationError(ctx) + case <-timer.C: + return nil + } +} diff --git a/server/service/picoclaw/control_operations_test.go b/server/service/picoclaw/control_operations_test.go new file mode 100644 index 0000000..9fe417f --- /dev/null +++ b/server/service/picoclaw/control_operations_test.go @@ -0,0 +1,79 @@ +package picoclaw + +import ( + "context" + "path/filepath" + "testing" + "time" + + "NanoKVM-Server/service/controlmode" +) + +func TestControlModeSwitchCancelsActiveWait(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + service := &Service{ + control: control, + operations: newControlOperationTracker(), + } + + operationCtx, releaseOperation := service.beginControlOperation(context.Background()) + releaseMode, modeErr := service.acquireControlMode() + if modeErr != nil { + t.Fatal(modeErr) + } + + actionStarted := make(chan struct{}) + actionDone := make(chan *PicoclawError, 1) + go func() { + close(actionStarted) + _, actionErr := service.executeAction(operationCtx, Action{ + Action: "wait", + DurationMs: maxWaitDurationMS, + }) + releaseMode() + releaseOperation() + actionDone <- actionErr + }() + <-actionStarted + + switchDone := make(chan error, 1) + go func() { + switchDone <- control.Switch(controlmode.ModeMCP, func() error { + service.CancelActiveControlOperations() + return nil + }) + }() + + select { + case err := <-switchDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("control mode switch did not cancel the active PicoClaw wait") + } + + select { + case actionErr := <-actionDone: + if actionErr == nil || actionErr.Code != CodeControlModeConflict { + t.Fatalf("action error = %+v, want %s", actionErr, CodeControlModeConflict) + } + case <-time.After(time.Second): + t.Fatal("canceled PicoClaw action did not return") + } + + if got := control.Current(); got != controlmode.ModeMCP { + t.Fatalf("mode = %q, want %q", got, controlmode.ModeMCP) + } +} + +func TestWaitDurationIsBounded(t *testing.T) { + service := &Service{} + _, err := service.executeAction(context.Background(), Action{ + Action: "wait", + DurationMs: maxWaitDurationMS + 1, + }) + if err == nil || err.Code != CodeInvalidAction { + t.Fatalf("error = %+v, want %s", err, CodeInvalidAction) + } +} diff --git a/server/service/picoclaw/errors.go b/server/service/picoclaw/errors.go index 7f0c1de..3539af2 100644 --- a/server/service/picoclaw/errors.go +++ b/server/service/picoclaw/errors.go @@ -7,15 +7,19 @@ import ( ) const ( - CodePicoclawLockHeld = "AI_LOCK_HELD" - CodeScreenshotFailed = "SCREENSHOT_FAILED" - CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL" - CodeHIDWriteFailed = "HID_WRITE_FAILED" - CodeInvalidAction = "INVALID_ACTION" - CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE" - CodeRuntimeStartFailed = "RUNTIME_START_FAILED" - CodeSessionIDMissing = "SESSION_ID_MISSING" - CodeSessionIDInvalid = "SESSION_ID_INVALID" + CodePicoclawLockHeld = "AI_LOCK_HELD" + CodeScreenshotFailed = "SCREENSHOT_FAILED" + CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL" + CodeHIDWriteFailed = "HID_WRITE_FAILED" + CodeInvalidAction = "INVALID_ACTION" + CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE" + CodeRuntimeStartFailed = "RUNTIME_START_FAILED" + CodeSessionIDMissing = "SESSION_ID_MISSING" + CodeSessionIDInvalid = "SESSION_ID_INVALID" + CodeControlModeConflict = "AI_MODE_CONFLICT" + CodeControlRequired = "CONTROL_REQUIRED" + CodeControlOwnedByMCP = "CONTROL_OWNED_BY_MCP" + CodeControlTransitioning = "CONTROL_TRANSITIONING" ) type PicoclawError struct { @@ -47,6 +51,10 @@ func writeSuccess(c *gin.Context, data interface{}) { } func writePicoclawError(c *gin.Context, err *PicoclawError) { + writePicoclawErrorWithData(c, err, nil) +} + +func writePicoclawErrorWithData(c *gin.Context, err *PicoclawError, data interface{}) { if err == nil { return } @@ -55,6 +63,9 @@ func writePicoclawError(c *gin.Context, err *PicoclawError) { "code": err.Code, "message": err.Message, } + if data != nil { + payload["data"] = data + } if err.SessionID != "" { payload["session_id"] = err.SessionID } diff --git a/server/service/picoclaw/gateway_client.go b/server/service/picoclaw/gateway_client.go index 9e3157f..2567c90 100644 --- a/server/service/picoclaw/gateway_client.go +++ b/server/service/picoclaw/gateway_client.go @@ -10,6 +10,13 @@ import ( "github.com/gorilla/websocket" ) +type gatewayProbeError struct { + status string + configError string + lastError string + message string +} + func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) { cfg := s.config.Get() @@ -66,6 +73,69 @@ func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawEr return upstream, nil } +func probePicoclawGateway(cfg Config) *gatewayProbeError { + gatewayURL, err := buildGatewayURL(cfg, "runtime-probe") + if err != nil { + return &gatewayProbeError{ + status: "config_error", + configError: err.Error(), + lastError: err.Error(), + message: "gateway config is invalid", + } + } + + header := http.Header{} + if cfg.Token != "" { + header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Token)) + } + + timeout := time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond + if timeout <= 0 { + timeout = 10 * time.Second + } + dialer := websocket.Dialer{ + HandshakeTimeout: timeout, + NetDialContext: (&net.Dialer{ + Timeout: timeout, + }).DialContext, + } + + conn, response, err := dialer.Dial(gatewayURL, header) + if err == nil { + _ = conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "runtime probe complete"), + time.Now().Add(time.Second), + ) + _ = conn.Close() + return nil + } + + probeErr := &gatewayProbeError{ + status: "unavailable", + lastError: err.Error(), + message: "gateway websocket is unavailable", + } + if response == nil { + return probeErr + } + + switch response.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + probeErr.status = "config_error" + probeErr.configError = "gateway authentication failed" + probeErr.message = "gateway authentication failed" + case http.StatusNotFound: + probeErr.lastError = "gateway pico channel is unavailable" + probeErr.message = "gateway pico channel is unavailable" + default: + probeErr.lastError = fmt.Sprintf("gateway websocket handshake failed: HTTP %d", response.StatusCode) + probeErr.message = "gateway websocket handshake failed" + } + + return probeErr +} + func buildGatewayURL(cfg Config, sessionID string) (string, error) { parsed, err := url.Parse(cfg.GatewayURL) if err != nil { diff --git a/server/service/picoclaw/gateway_proxy.go b/server/service/picoclaw/gateway_proxy.go index b4e4e01..c2ca48b 100644 --- a/server/service/picoclaw/gateway_proxy.go +++ b/server/service/picoclaw/gateway_proxy.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "NanoKVM-Server/service/controlmode" "NanoKVM-Server/service/stream/mjpeg" "github.com/gin-gonic/gin" @@ -30,6 +31,19 @@ type relayResult struct { } func (s *Service) ConnectGateway(c *gin.Context) { + s.ensureDependencies() + modeStatus, modeErr := s.control.Status() + if modeErr != nil { + writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, modeErr.Error())) + return + } + if modeStatus.Transitioning || modeStatus.Mode == controlmode.ModeMCP { + controlErr := s.controlWriteError(controlmode.ModePicoclaw, nil) + controlErr.StatusCode = http.StatusConflict + writePicoclawError(c, controlErr) + return + } + sessionID := strings.TrimSpace(c.Query("session_id")) if sessionID == "" { sessionID = uuid.NewString() @@ -69,7 +83,7 @@ func (s *Service) ConnectGateway(c *gin.Context) { if err != nil { log.Errorf("failed to upgrade gateway websocket: %s", err) _ = upstream.Close() - ReleaseSession(sessionID) + s.releaseGatewaySession(sessionID) GetSessionManager().SetState(sessionID, SessionStateClosed) GetSessionManager().Remove(sessionID) return @@ -92,7 +106,6 @@ func (s *Service) ConnectGateway(c *gin.Context) { go s.runPingLoop("upstream", session.SessionID, upstream, cfg, &wg) go s.proxyMessages("downstream", session, downstream, cfg, &wg, results) go s.proxyMessages("upstream", session, upstream, cfg, &wg, results) - result := <-results closeCode := result.CloseCode if closeCode == 0 { @@ -217,7 +230,7 @@ func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, re cleanupPicoclawMediaTempDir() } - ReleaseSession(session.SessionID) + s.releaseGatewaySession(session.SessionID) GetSessionManager().SetState(session.SessionID, SessionStateClosed) GetSessionManager().Remove(session.SessionID) diff --git a/server/service/picoclaw/mcp_handler.go b/server/service/picoclaw/mcp_handler.go index 7dec412..6266e7a 100644 --- a/server/service/picoclaw/mcp_handler.go +++ b/server/service/picoclaw/mcp_handler.go @@ -28,8 +28,9 @@ type jsonRPCResponse struct { } type jsonRPCError struct { - Code int `json:"code"` - Message string `json:"message"` + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` } // MCP tool definitions @@ -59,17 +60,22 @@ var mcpToolDefinitions = []map[string]interface{}{ "items": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "action": map[string]interface{}{"type": "string"}, - "x": map[string]interface{}{"type": "number"}, - "y": map[string]interface{}{"type": "number"}, - "button": map[string]interface{}{"type": "string"}, - "text": map[string]interface{}{"type": "string"}, - "keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, - "direction": map[string]interface{}{"type": "string"}, - "amount": map[string]interface{}{"type": "integer"}, - "duration_ms": map[string]interface{}{"type": "integer"}, - "from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}}, - "to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}}, + "action": map[string]interface{}{"type": "string"}, + "x": map[string]interface{}{"type": "number"}, + "y": map[string]interface{}{"type": "number"}, + "button": map[string]interface{}{"type": "string"}, + "text": map[string]interface{}{"type": "string"}, + "keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, + "direction": map[string]interface{}{"type": "string"}, + "amount": map[string]interface{}{"type": "integer"}, + "duration_ms": map[string]interface{}{ + "type": "integer", + "minimum": 0, + "maximum": maxWaitDurationMS, + "description": "Wait duration in milliseconds, up to 30000", + }, + "from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}}, + "to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}}, }, "required": []string{"action"}, }, @@ -122,6 +128,33 @@ func (s *Service) MCPHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func mcpModeConflictResponse(req jsonRPCRequest, err *PicoclawError, status controlStatusForMCP) jsonRPCResponse { + message := "PicoClaw control mode is not active" + reason := CodeControlRequired + if err != nil && err.Message != "" { + message = err.Message + reason = err.Code + } + return jsonRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Error: &jsonRPCError{ + Code: -32003, + Message: message, + Data: map[string]interface{}{ + "reason": reason, + "control_mode": status.Mode, + "transitioning": status.Transitioning, + }, + }, + } +} + +type controlStatusForMCP struct { + Mode string + Transitioning bool +} + func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse { return jsonRPCResponse{ JSONRPC: "2.0", @@ -166,6 +199,22 @@ func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCRespon case "kvm_screenshot": return s.mcpScreenshot(req, params.Arguments, c) case "kvm_actions": + releaseMode, modeErr := s.acquireControlMode() + if modeErr != nil { + controlStatus := controlStatusForMCP{} + if status, err := s.control.Status(); err == nil { + controlStatus = controlStatusForMCP{ + Mode: string(status.Mode), + Transitioning: status.Transitioning, + } + } + return mcpModeConflictResponse(req, modeErr, controlStatus) + } + defer releaseMode() + + operationCtx, releaseOperation := s.beginControlOperation(c.Request.Context()) + defer releaseOperation() + c.Request = c.Request.WithContext(operationCtx) return s.mcpActions(req, params.Arguments, c) default: return jsonRPCResponse{ @@ -262,7 +311,7 @@ func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Co sessionID = s.lock.Owner() } - result, err := s.executeActions(sessionID, params.Actions) + result, err := s.executeActions(c.Request.Context(), sessionID, params.Actions) if err != nil { return mcpToolError(req, err.Message) } diff --git a/server/service/picoclaw/mcp_handler_test.go b/server/service/picoclaw/mcp_handler_test.go new file mode 100644 index 0000000..5aabae2 --- /dev/null +++ b/server/service/picoclaw/mcp_handler_test.go @@ -0,0 +1,128 @@ +package picoclaw + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "NanoKVM-Server/service/controlmode" + + "github.com/gin-gonic/gin" +) + +func TestMCPBootstrapMethodsAllowedInAllControlModes(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, mode := range []controlmode.Mode{controlmode.ModeOff, controlmode.ModeMCP, controlmode.ModePicoclaw} { + t.Run(string(mode), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "mode") + if err := os.WriteFile(path, []byte(string(mode)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + service := &Service{control: controlmode.NewManager(path, controlmode.ModePicoclaw)} + + for _, method := range []string{"initialize", "tools/list", "ping"} { + response := performMCPRequest(service, jsonRPCBody(method)) + if response.Code != http.StatusOK { + t.Fatalf("%s status = %d, body = %s", method, response.Code, response.Body.String()) + } + if strings.Contains(response.Body.String(), `"error"`) { + t.Fatalf("%s returned error: %s", method, response.Body.String()) + } + } + }) + } +} + +func TestMCPBootstrapAllowedDuringControlTransition(t *testing.T) { + gin.SetMode(gin.TestMode) + manager := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeOff) + service := &Service{control: manager} + + err := manager.Switch(controlmode.ModePicoclaw, func() error { + response := performMCPRequest(service, jsonRPCBody("initialize")) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if strings.Contains(response.Body.String(), `"error"`) { + t.Fatalf("bootstrap method returned error: %s", response.Body.String()) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestMCPActionsReturnStructuredControlErrors(t *testing.T) { + gin.SetMode(gin.TestMode) + + for _, test := range []struct { + mode controlmode.Mode + reason string + }{ + {mode: controlmode.ModeOff, reason: CodeControlRequired}, + {mode: controlmode.ModeMCP, reason: CodeControlOwnedByMCP}, + } { + t.Run(string(test.mode), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "mode") + if err := os.WriteFile(path, []byte(string(test.mode)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + service := &Service{control: controlmode.NewManager(path, controlmode.ModePicoclaw)} + response := performMCPRequest( + service, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kvm_actions","arguments":{"actions":[]}}}`, + ) + body := response.Body.String() + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, body) + } + if !strings.Contains(body, `"code":-32003`) || !strings.Contains(body, `"reason":"`+test.reason+`"`) { + t.Fatalf("body = %s, want structured %s error", body, test.reason) + } + }) + } +} + +func TestMCPActionsRejectedDuringControlTransition(t *testing.T) { + gin.SetMode(gin.TestMode) + manager := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw) + service := &Service{control: manager} + + err := manager.Switch(controlmode.ModeMCP, func() error { + response := performMCPRequest( + service, + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kvm_actions","arguments":{"actions":[]}}}`, + ) + body := response.Body.String() + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, body) + } + if !strings.Contains(body, `"reason":"`+CodeControlTransitioning+`"`) { + t.Fatalf("body = %s, want transitioning control error", body) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func performMCPRequest(service *Service, body string) *httptest.ResponseRecorder { + router := gin.New() + router.POST("/", service.MCPHandler) + + request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func jsonRPCBody(method string) string { + return `{"jsonrpc":"2.0","id":1,"method":"` + method + `"}` +} diff --git a/server/service/picoclaw/model_config.go b/server/service/picoclaw/model_config.go index 4ce174d..da92684 100644 --- a/server/service/picoclaw/model_config.go +++ b/server/service/picoclaw/model_config.go @@ -2,6 +2,7 @@ package picoclaw import ( "fmt" + "regexp" "strings" "github.com/gin-gonic/gin" @@ -20,6 +21,66 @@ func extractPicoclawModelName(model string) string { return model } +var ( + picoclawProviderPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`) + picoclawKnownProviders = map[string]struct{}{ + "anthropic": {}, + "azure": {}, + "azure_openai": {}, + "baichuan": {}, + "bedrock": {}, + "cerebras": {}, + "cohere": {}, + "dashscope": {}, + "deepseek": {}, + "fireworks_ai": {}, + "gemini": {}, + "google": {}, + "groq": {}, + "lmstudio": {}, + "mistral": {}, + "moonshot": {}, + "ollama": {}, + "openai": {}, + "openai_compatible": {}, + "openrouter": {}, + "qwen": {}, + "siliconflow": {}, + "together_ai": {}, + "vertex_ai": {}, + "vllm": {}, + "volcengine": {}, + "xai": {}, + "zhipu": {}, + } +) + +func validatePicoclawModelIdentifier(model string) (string, error) { + model = strings.TrimSpace(model) + provider, modelRef, ok := strings.Cut(model, "/") + provider = strings.ToLower(strings.TrimSpace(provider)) + modelRef = strings.TrimSpace(modelRef) + + if !ok || provider == "" || modelRef == "" { + return "", fmt.Errorf("model identifier must use provider/model format") + } + if !picoclawProviderPattern.MatchString(provider) { + return "", fmt.Errorf("model provider %q is invalid", provider) + } + if provider == "openao" { + return "", fmt.Errorf("model provider %q is invalid; did you mean openai?", provider) + } + if _, ok := picoclawKnownProviders[provider]; !ok { + return "", fmt.Errorf("model provider %q is not supported by this PicoClaw integration", provider) + } + + modelName := extractPicoclawModelName(model) + if modelName == "" { + return "", fmt.Errorf("model identifier must include a model name") + } + return modelName, nil +} + func isPicoclawModelConfigured(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool { if modelName == "" { return false @@ -71,6 +132,7 @@ type ModelConfigUpdateRequest struct { } func (s *Service) UpdateModelConfig(c *gin.Context) { + s.ensureDependencies() var req ModelConfigUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload")) @@ -79,6 +141,33 @@ func (s *Service) UpdateModelConfig(c *gin.Context) { currentStatus := s.runtime.Get() shouldRestart := currentStatus.Ready || currentStatus.Status == "ready" + var releaseControl func() + if shouldRestart { + // Restart follows StartRuntime's lock order: stable PicoClaw control + // lease first, then the runtime lifecycle lock around config write and + // stop/start. + var controlErr *PicoclawError + releaseControl, controlErr = s.acquireControlMode() + if controlErr != nil { + writePicoclawErrorWithData(c, controlErr, gin.H{"status": s.runtimeStatus()}) + return + } + defer releaseControl() + } + + unlockLifecycle := s.lockRuntimeLifecycle() + defer unlockLifecycle() + currentStatus = s.runtime.Get() + if !shouldRestart && (currentStatus.Ready || currentStatus.Status == "ready") { + var controlErr *PicoclawError + releaseControl, controlErr = s.acquireControlMode() + if controlErr != nil { + writePicoclawErrorWithData(c, controlErr, gin.H{"status": s.runtimeStatus()}) + return + } + defer releaseControl() + shouldRestart = true + } modelName, err := updatePicoclawModelConfig( strings.TrimSpace(req.APIBase), @@ -103,14 +192,15 @@ func (s *Service) UpdateModelConfig(c *gin.Context) { writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+startErr.Message)) return } + s.setRuntimeIntentDesired(true, "model_config") } else { _ = s.syncConfigFromPicoclaw() - _ = s.ensureRuntimeReady() + _ = s.ensureRuntimeReadyForLifecycle() } writeSuccess(c, gin.H{ "model_name": modelName, - "status": s.runtime.Get(), + "status": s.runtimeStatus(), }) } @@ -125,16 +215,18 @@ func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (str return "", fmt.Errorf("model identifier is required") } - modelName := extractPicoclawModelName(model) - if modelName == "" { - return "", fmt.Errorf("model identifier is required") - } - - doc, err := loadPicoclawConfigDocument() + modelName, err := validatePicoclawModelIdentifier(model) if err != nil { return "", err } + doc, err := loadOrInitializePicoclawConfigDocument() + if err != nil { + return "", err + } + + doc.raw["version"] = currentPicoclawConfigVersion + modelListValue, ok := doc.raw["model_list"].([]any) if !ok { modelListValue = []any{} diff --git a/server/service/picoclaw/model_config_test.go b/server/service/picoclaw/model_config_test.go new file mode 100644 index 0000000..ad39e4a --- /dev/null +++ b/server/service/picoclaw/model_config_test.go @@ -0,0 +1,170 @@ +package picoclaw + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdatePicoclawModelConfigInitializesMissingConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + previousOnboard := runPicoclawOnboardForConfig + onboardCalled := false + runPicoclawOnboardForConfig = func() (string, *PicoclawError) { + onboardCalled = true + configPath := filepath.Join(home, "config.json") + err := os.WriteFile(configPath, []byte(`{ + "agents": { + "defaults": {} + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "model_list": [], + "channel_list": {} +}`), 0o600) + if err != nil { + return "", newPicoclawError(CodeRuntimeUnavailable, err.Error()) + } + return "initialized", nil + } + t.Cleanup(func() { + runPicoclawOnboardForConfig = previousOnboard + }) + + modelName, err := updatePicoclawModelConfig( + "https://api.example.invalid", + "secret-key", + "openai/test-model", + ) + if err != nil { + t.Fatal(err) + } + if !onboardCalled { + t.Fatal("missing config did not trigger PicoClaw onboard") + } + if modelName != "test-model" { + t.Fatalf("model name = %q, want test-model", modelName) + } + + doc, err := loadPicoclawConfigDocument() + if err != nil { + t.Fatal(err) + } + if doc.config.Agents.Defaults.ModelName != "test-model" { + t.Fatalf("default model = %q, want test-model", doc.config.Agents.Defaults.ModelName) + } + if version, ok := doc.raw["version"].(float64); !ok || int(version) != currentPicoclawConfigVersion { + t.Fatalf("config version = %v, want %d", doc.raw["version"], currentPicoclawConfigVersion) + } + if !isPicoclawModelConfigured(doc.config, doc.security, "test-model") { + t.Fatalf("model was not configured: config=%+v security=%+v", doc.config.ModelList, doc.security.ModelList) + } + if len(doc.config.ModelList) != 1 || doc.config.ModelList[0].APIKey != "" || len(doc.config.ModelList[0].APIKeys) != 0 { + t.Fatalf("model API key leaked into config.json: %+v", doc.config.ModelList) + } +} + +func TestUpdatePicoclawModelConfigReportsOnboardFailure(t *testing.T) { + t.Setenv("PICOCLAW_HOME", t.TempDir()) + + previousOnboard := runPicoclawOnboardForConfig + runPicoclawOnboardForConfig = func() (string, *PicoclawError) { + return "", newPicoclawError(CodeRuntimeUnavailable, "onboard boom") + } + t.Cleanup(func() { + runPicoclawOnboardForConfig = previousOnboard + }) + + _, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "openai/test-model") + if err == nil { + t.Fatal("expected onboard failure") + } + if !strings.Contains(err.Error(), "failed to initialize PicoClaw config before saving model config") { + t.Fatalf("error = %v, want initialization context", err) + } +} + +func TestUpdatePicoclawModelConfigMigratesVersionAndKeepsUnknownFields(t *testing.T) { + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configPath := filepath.Join(home, "config.json") + if err := os.WriteFile(configPath, []byte(`{ + "version": 4, + "unknown_top_level": { + "keep": true + }, + "agents": { + "defaults": { + "model_name": "old-model" + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "model_list": [ + { + "model_name": "old-model", + "model": "openai/old-model", + "api_base": "https://api.example.invalid" + } + ], + "channel_list": {} +}`), 0o600); err != nil { + t.Fatal(err) + } + + modelName, err := updatePicoclawModelConfig( + "https://api.example.invalid", + "secret-key", + "openai/new-model", + ) + if err != nil { + t.Fatal(err) + } + if modelName != "new-model" { + t.Fatalf("model name = %q, want new-model", modelName) + } + + doc, err := loadPicoclawConfigDocument() + if err != nil { + t.Fatal(err) + } + if version, ok := doc.raw["version"].(float64); !ok || int(version) != currentPicoclawConfigVersion { + t.Fatalf("config version = %v, want %d", doc.raw["version"], currentPicoclawConfigVersion) + } + unknown, ok := doc.raw["unknown_top_level"].(map[string]any) + if !ok || unknown["keep"] != true { + t.Fatalf("unknown fields were not preserved: %#v", doc.raw["unknown_top_level"]) + } +} + +func TestUpdatePicoclawModelConfigRejectsInvalidProvider(t *testing.T) { + t.Setenv("PICOCLAW_HOME", t.TempDir()) + + _, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "openao/deepseek-v4-flash") + if err == nil { + t.Fatal("expected invalid provider error") + } + if !strings.Contains(err.Error(), "did you mean openai") { + t.Fatalf("error = %v, want provider hint", err) + } +} + +func TestUpdatePicoclawModelConfigRequiresProviderModelFormat(t *testing.T) { + t.Setenv("PICOCLAW_HOME", t.TempDir()) + + _, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "deepseek-v4-flash") + if err == nil { + t.Fatal("expected provider/model format error") + } + if !strings.Contains(err.Error(), "provider/model") { + t.Fatalf("error = %v, want provider/model format hint", err) + } +} diff --git a/server/service/picoclaw/runtime_constants.go b/server/service/picoclaw/runtime_constants.go index 6b82e48..2a3522f 100644 --- a/server/service/picoclaw/runtime_constants.go +++ b/server/service/picoclaw/runtime_constants.go @@ -7,6 +7,7 @@ import ( const ( picoclawBinaryPath = "/usr/bin/picoclaw" picoclawCacheDir = "/root/.picoclaw-cache" + picoclawPIDFileName = ".picoclaw.pid" picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/picoclaw_Linux_riscv64.tar.gz" picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/sha512.txt" etcInitPicoclawScript = "/etc/init.d/S96picoclaw" diff --git a/server/service/picoclaw/runtime_defaults.go b/server/service/picoclaw/runtime_defaults.go index 74b1d25..089080e 100644 --- a/server/service/picoclaw/runtime_defaults.go +++ b/server/service/picoclaw/runtime_defaults.go @@ -11,10 +11,11 @@ import ( ) const ( - defaultPicoclawPingSec = 30 - defaultPicoclawReadSec = 60 - defaultPicoclawWriteSec = 10 - defaultPicoclawMaxConns = 100 + currentPicoclawConfigVersion = 3 + defaultPicoclawPingSec = 30 + defaultPicoclawReadSec = 60 + defaultPicoclawWriteSec = 10 + defaultPicoclawMaxConns = 100 ) type picoclawConfigDefault struct { @@ -90,6 +91,7 @@ func ensurePicoclawPicoChannelEnabled(doc *picoclawConfigDocument) error { } func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error { + editor.setValue(currentPicoclawConfigVersion, "version") for _, entry := range picoclawNanoKVMDefaults { editor.setValue(entry.value, entry.path...) } diff --git a/server/service/picoclaw/runtime_handlers.go b/server/service/picoclaw/runtime_handlers.go index 38ce184..2ccb0ee 100644 --- a/server/service/picoclaw/runtime_handlers.go +++ b/server/service/picoclaw/runtime_handlers.go @@ -1,44 +1,232 @@ package picoclaw import ( + "fmt" "os" "path/filepath" + "runtime/debug" "time" + "NanoKVM-Server/service/controlmode" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" ) func (s *Service) StartRuntime(c *gin.Context) { - command, output, err := s.startRuntime() - if err != nil { - writePicoclawError(c, err) + defer s.recoverRuntimeHandler(c, "start") + s.ensureDependencies() + log.Info("picoclaw runtime start requested") + + // Lock order: hold a stable PicoClaw control lease before entering the + // runtime lifecycle section, so MCP/off transitions wait instead of racing + // the start script and desired-running commit. + releaseControl, controlErr := s.acquireControlMode() + if controlErr != nil { + status := s.runtimeStatus() + log.WithFields(log.Fields{ + "code": controlErr.Code, + "runtimeStatus": status.Status, + "ready": status.Ready, + "controlMode": status.ControlMode, + }).Warn("picoclaw runtime start rejected by control mode") + writePicoclawErrorWithData(c, controlErr, gin.H{"status": status}) + return + } + defer releaseControl() + + unlockLifecycle := s.lockRuntimeLifecycle() + defer unlockLifecycle() + + if currentStatus := s.runtime.Get(); currentStatus.Installing { + runtimeErr := newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress") + writePicoclawErrorWithData(c, runtimeErr, gin.H{"status": s.runtimeStatus()}) return } + if readyErr := s.ensureRuntimeReadyForLifecycle(); readyErr == nil { + s.setRuntimeIntentDesired(true, "web") + status := s.runtimeStatus() + log.WithFields(log.Fields{ + "runtimeStatus": status.Status, + "ready": status.Ready, + "controlMode": status.ControlMode, + }).Info("picoclaw runtime start skipped because runtime is already ready") + writeSuccess(c, RuntimeStartResult{ + Started: true, + Status: status, + }) + return + } + + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + status.Status = "starting" + status.LastError = "" + status.CheckedAt = time.Now() + }) + + command, output, startErr := s.startRuntime() + if startErr != nil { + s.setRuntimeIntentError(startErr.Message) + status := s.runtimeStatus() + log.WithFields(log.Fields{ + "code": startErr.Code, + "runtimeStatus": status.Status, + "ready": status.Ready, + "controlMode": status.ControlMode, + }).Warn("picoclaw runtime start returning structured error") + writePicoclawErrorWithData(c, startErr, gin.H{ + "command": command, + "output": output, + "status": status, + }) + return + } + + if err := s.control.RequireWrite(controlmode.ModePicoclaw); err != nil { + controlErr := s.controlWriteError(controlmode.ModePicoclaw, err) + s.setRuntimeIntentError(controlErr.Message) + status := s.runtimeStatus() + log.WithFields(log.Fields{ + "code": controlErr.Code, + "runtimeStatus": status.Status, + "ready": status.Ready, + "controlMode": status.ControlMode, + }).Warn("picoclaw runtime start lost control before intent commit") + writePicoclawErrorWithData(c, controlErr, gin.H{ + "command": command, + "output": output, + "status": status, + }) + return + } + + s.setRuntimeIntentDesired(true, "web") + status := s.runtimeStatus() + log.WithFields(log.Fields{ + "runtimeStatus": status.Status, + "ready": status.Ready, + "controlMode": status.ControlMode, + }).Info("picoclaw runtime start returning success") writeSuccess(c, RuntimeStartResult{ Started: true, Command: command, Output: output, - Status: s.runtime.Get(), + Status: status, }) } func (s *Service) StopRuntime(c *gin.Context) { - command, output, err := s.stopRuntime() + defer s.recoverRuntimeHandler(c, "stop") + s.ensureDependencies() + log.Info("picoclaw runtime stop requested") + + switched, err := s.control.SwitchIfWithCleanup( + controlmode.ModePicoclaw, + controlmode.ModeOff, + func() error { + s.CancelActiveControlOperations() + return nil + }, + s.releaseHID, + ) if err != nil { - writePicoclawError(c, err) + status := s.runtimeStatus() + picoclawErr := newPicoclawError(CodeRuntimeStartFailed, err.Error()) + log.WithFields(log.Fields{ + "code": picoclawErr.Code, + "runtimeStatus": status.Status, + "ready": status.Ready, + "controlMode": status.ControlMode, + }).Warn("picoclaw runtime stop returning structured error") + writePicoclawErrorWithData(c, picoclawErr, gin.H{"status": status}) + return + } + if switched { + if status, statusErr := s.control.Status(); statusErr == nil { + s.PublishControlModeChangedFrom(status, "runtime_stop") + } + } + + unlockLifecycle := s.lockRuntimeLifecycle() + defer unlockLifecycle() + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + status.Status = "stopping" + status.LastError = "" + status.CheckedAt = time.Now() + }) + s.setRuntimeIntentDesired(false, "web") + + err = s.stopForRuntimeStop() + if err != nil { + s.setRuntimeIntentError(err.Error()) + status := s.runtimeStatus() + picoclawErr := newPicoclawError(CodeRuntimeStartFailed, err.Error()) + log.WithFields(log.Fields{ + "code": picoclawErr.Code, + "runtimeStatus": status.Status, + "ready": status.Ready, + "controlMode": status.ControlMode, + }).Warn("picoclaw runtime stop returning structured error") + writePicoclawErrorWithData(c, picoclawErr, gin.H{"status": status}) return } + status := s.runtimeStatus() + log.WithFields(log.Fields{ + "runtimeStatus": status.Status, + "ready": status.Ready, + "controlMode": status.ControlMode, + }).Info("picoclaw runtime stop returning success") writeSuccess(c, RuntimeStartResult{ Started: false, - Command: command, - Output: output, - Status: s.runtime.Get(), + Status: status, }) } +func (s *Service) recoverRuntimeHandler(c *gin.Context, operation string) { + if recovered := recover(); recovered != nil { + message := fmt.Sprintf("picoclaw runtime %s panicked: %v", operation, recovered) + log.Errorf("%s\n%s", message, debug.Stack()) + if c.Writer.Written() { + return + } + writePicoclawErrorWithData( + c, + newPicoclawError(CodeRuntimeStartFailed, message), + gin.H{"status": s.safeRuntimeStatusForRecovery()}, + ) + } +} + +func (s *Service) safeRuntimeStatusForRecovery() (status RuntimeStatus) { + defer func() { + if recover() != nil { + status = RuntimeStatus{ + Ready: false, + Installed: false, + InstallPath: picoclawBinaryPath, + Status: "error", + } + } + }() + if s == nil { + return RuntimeStatus{ + Ready: false, + Installed: false, + InstallPath: picoclawBinaryPath, + Status: "error", + } + } + return s.runtimeStatus() +} + func (s *Service) InstallRuntime(c *gin.Context) { + s.ensureDependencies() output, err := s.installRuntime() if err != nil { writePicoclawError(c, err) @@ -51,11 +239,12 @@ func (s *Service) InstallRuntime(c *gin.Context) { Binary: picoclawBinaryPath, Download: picoclawDownloadURL, Output: output, - Status: currentStatus, + Status: s.runtimeStatus(), }) } func (s *Service) UninstallRuntime(c *gin.Context) { + s.ensureDependencies() currentStatus := s.runtime.Get() uninstallOutput := "picoclaw uninstalled successfully" if currentStatus.Installing { @@ -63,17 +252,53 @@ func (s *Service) UninstallRuntime(c *gin.Context) { return } + switched, switchErr := s.control.SwitchIfWithCleanup( + controlmode.ModePicoclaw, + controlmode.ModeOff, + func() error { + s.CancelActiveControlOperations() + return nil + }, + s.releaseHID, + ) + if switchErr != nil { + status := s.runtimeStatus() + writePicoclawErrorWithData(c, newPicoclawError(CodeRuntimeStartFailed, "control release failed before uninstall: "+switchErr.Error()), gin.H{"status": status}) + return + } + if switched { + if status, statusErr := s.control.Status(); statusErr == nil { + s.PublishControlModeChangedFrom(status, "runtime_uninstall") + } + } + + unlockLifecycle := s.lockRuntimeLifecycle() + defer unlockLifecycle() + + currentStatus = s.runtime.Get() + if currentStatus.Installing { + writePicoclawError(c, newPicoclawError(CodeRuntimeStartFailed, "cannot uninstall while installation is in progress")) + return + } running, err := isRuntimeRunning() if err != nil { writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status")) return } - if running || currentStatus.Ready || currentStatus.Status == "ready" { - if _, _, stopErr := s.stopRuntime(); stopErr != nil { - uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Message + ")" + if running || currentStatus.Ready || currentStatus.Status == "ready" || isRuntimeLifecycleStatusPending(currentStatus) { + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + status.Status = "stopping" + status.LastError = "" + status.CheckedAt = time.Now() + }) + if stopErr := s.stopRuntimeAndCloseSessions(CloseCodeRuntimeStopped, "PicoClaw runtime stopped"); stopErr != nil { + uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Error() + ")" } } + s.setRuntimeIntentDesired(false, "web") if configPath, err := resolvePicoclawConfigPath(); err == nil { _ = os.RemoveAll(filepath.Dir(configPath)) @@ -105,6 +330,6 @@ func (s *Service) UninstallRuntime(c *gin.Context) { Binary: picoclawBinaryPath, Download: picoclawDownloadURL, Output: uninstallOutput, - Status: s.runtime.Get(), + Status: s.runtimeStatus(), }) } diff --git a/server/service/picoclaw/runtime_install.go b/server/service/picoclaw/runtime_install.go index 78d3fa7..0e5a1c9 100644 --- a/server/service/picoclaw/runtime_install.go +++ b/server/service/picoclaw/runtime_install.go @@ -19,6 +19,10 @@ import ( ) func (s *Service) installRuntime() (string, *PicoclawError) { + if s == nil { + return "", newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable") + } + s.ensureDependencies() log.Debugf("picoclaw install: start, binary=%s, cache=%s", picoclawBinaryPath, picoclawCacheDir) currentStatus := s.runtime.Get() @@ -62,6 +66,7 @@ func (s *Service) installRuntime() (string, *PicoclawError) { } func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) { + s.ensureDependencies() defer cancel() _ = os.RemoveAll(picoclawCacheDir) @@ -243,6 +248,7 @@ func copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader, total i } func (s *Service) setInstallProgress(stage string, progress int, lastError string) { + s.ensureDependencies() if progress < 0 { progress = 0 } @@ -263,6 +269,7 @@ func (s *Service) setInstallProgress(stage string, progress int, lastError strin } func (s *Service) finishInstallFailure(status string, message string) { + s.ensureDependencies() s.runtime.Set(RuntimeStatus{ Ready: false, Installed: false, diff --git a/server/service/picoclaw/runtime_intent.go b/server/service/picoclaw/runtime_intent.go new file mode 100644 index 0000000..73d7cc9 --- /dev/null +++ b/server/service/picoclaw/runtime_intent.go @@ -0,0 +1,436 @@ +package picoclaw + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "NanoKVM-Server/service/controlmode" + + log "github.com/sirupsen/logrus" +) + +const RuntimeIntentFile = "/etc/kvm/picoclaw-runtime.json" + +func NewRuntimeIntentStore(path string) *RuntimeIntentStore { + if path == "" { + path = RuntimeIntentFile + } + return &RuntimeIntentStore{path: path} +} + +func (s *RuntimeIntentStore) Load() (RuntimeIntentStatus, error) { + if s == nil { + return RuntimeIntentStatus{}, nil + } + s.mu.Lock() + defer s.mu.Unlock() + return s.loadLocked() +} + +func (s *RuntimeIntentStore) SetDesiredRunning(desired bool, updatedBy string) error { + if s == nil { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + intent, err := s.loadLocked() + if err != nil { + intent = RuntimeIntentStatus{ + DesiredRunning: false, + LastError: err.Error(), + } + } + + now := time.Now().UTC().Format(time.RFC3339) + intent.DesiredRunning = desired + intent.UpdatedAt = now + intent.UpdatedBy = updatedBy + intent.LastError = "" + if desired { + intent.LastStartedAt = now + } else { + intent.LastStoppedAt = now + } + + return s.saveLocked(intent) +} + +func (s *RuntimeIntentStore) SetLastError(message string) error { + if s == nil { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + intent, err := s.loadLocked() + if err != nil { + intent = RuntimeIntentStatus{ + DesiredRunning: false, + } + } + + intent.LastError = message + intent.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + if intent.UpdatedBy == "" { + intent.UpdatedBy = "system" + } + + return s.saveLocked(intent) +} + +func (s *RuntimeIntentStore) loadLocked() (RuntimeIntentStatus, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return RuntimeIntentStatus{DesiredRunning: false}, nil + } + return RuntimeIntentStatus{DesiredRunning: false}, fmt.Errorf("read PicoClaw runtime intent: %w", err) + } + + var intent RuntimeIntentStatus + if err := json.Unmarshal(data, &intent); err != nil { + return RuntimeIntentStatus{ + DesiredRunning: false, + LastError: err.Error(), + }, fmt.Errorf("parse PicoClaw runtime intent: %w", err) + } + + return intent, nil +} + +func (s *RuntimeIntentStore) saveLocked(intent RuntimeIntentStatus) error { + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create PicoClaw runtime intent directory: %w", err) + } + + data, err := json.MarshalIndent(intent, "", " ") + if err != nil { + return fmt.Errorf("encode PicoClaw runtime intent: %w", err) + } + data = append(data, '\n') + + tmp, err := os.CreateTemp(dir, ".picoclaw-runtime.*") + if err != nil { + return fmt.Errorf("create temporary PicoClaw runtime intent: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("set PicoClaw runtime intent permissions: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write PicoClaw runtime intent: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync PicoClaw runtime intent: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close PicoClaw runtime intent: %w", err) + } + if err := os.Rename(tmpPath, s.path); err != nil { + return fmt.Errorf("replace PicoClaw runtime intent: %w", err) + } + + directory, err := os.Open(dir) + if err == nil { + if syncErr := directory.Sync(); syncErr != nil { + _ = directory.Close() + return fmt.Errorf("sync PicoClaw runtime intent directory: %w", syncErr) + } + _ = directory.Close() + } + + return nil +} + +func (s *Service) setRuntimeIntentDesired(desired bool, updatedBy string) { + if s == nil { + return + } + s.ensureDependencies() + if err := s.runtimeIntent.SetDesiredRunning(desired, updatedBy); err != nil { + log.WithFields(log.Fields{ + "desired_running": desired, + "updated_by": updatedBy, + }).WithError(err).Warn("failed to persist PicoClaw runtime intent") + } +} + +func (s *Service) SetRuntimeIntentDesired(desired bool, updatedBy string) { + s.setRuntimeIntentDesired(desired, updatedBy) +} + +func (s *Service) setRuntimeIntentError(message string) { + if s == nil || message == "" { + return + } + s.ensureDependencies() + if err := s.runtimeIntent.SetLastError(message); err != nil { + log.WithError(err).Warn("failed to persist PicoClaw runtime intent error") + } +} + +func (s *Service) startRuntimeIntentReconcile() { + if s == nil { + return + } + s.ensureDependencies() + s.reconcileOnce.Do(func() { + go s.reconcileRuntimeIntent("startup") + }) +} + +func (s *Service) reconcileRuntimeIntent(source string) { + startedAt := time.Now() + s.ensureDependencies() + + unlockLifecycle := s.lockRuntimeLifecycle() + defer unlockLifecycle() + + intent, intentErr := s.runtimeIntent.Load() + if intentErr != nil { + s.setRuntimeIntentError(intentErr.Error()) + log.WithError(intentErr).Warn("PicoClaw runtime restore skipped because intent is invalid") + return + } + + modeStatus, modeErr := s.control.Status() + if modeErr != nil { + s.setRuntimeIntentError(modeErr.Error()) + log.WithError(modeErr).Warn("PicoClaw runtime restore skipped because control mode is unavailable") + return + } + + fields := log.Fields{ + "source": source, + "desired_running": intent.DesiredRunning, + "control_mode": string(modeStatus.Mode), + } + + if !intent.DesiredRunning { + s.reconcileDisabledRuntimeIntent(source) + log.WithFields(fields).Info("PicoClaw runtime restore skipped because desired_running is false") + return + } + if modeStatus.Mode == controlmode.ModeMCP { + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Status = "blocked_by_mcp" + status.CheckedAt = time.Now() + }) + log.WithFields(fields).Info("PicoClaw runtime restore skipped because MCP owns device control") + return + } + if modeStatus.Mode != controlmode.ModePicoclaw { + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Status = "stopped" + status.CheckedAt = time.Now() + }) + log.WithFields(fields).Info("PicoClaw runtime restore skipped because PicoClaw does not own device control") + return + } + + releaseControl, controlErr := s.acquireControlMode() + if controlErr != nil { + s.setRuntimeIntentError(controlErr.Message) + log.WithFields(fields).Warn("PicoClaw runtime restore skipped because PicoClaw control is not stable") + return + } + defer releaseControl() + + intent, intentErr = s.runtimeIntent.Load() + if intentErr != nil { + s.setRuntimeIntentError(intentErr.Error()) + log.WithError(intentErr).Warn("PicoClaw runtime restore skipped because intent changed to an invalid state") + return + } + if !intent.DesiredRunning { + fields["desired_running"] = intent.DesiredRunning + log.WithFields(fields).Info("PicoClaw runtime restore skipped because desired_running changed while waiting") + return + } + + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = true + status.Status = "restoring" + status.LastError = "" + status.CheckedAt = time.Now() + }) + + if readyErr := s.ensureRuntimeReadyForLifecycle(); readyErr == nil { + fields["elapsed_ms"] = time.Since(startedAt).Milliseconds() + s.runtime.Update(func(status *RuntimeStatus) { + status.Restoring = false + status.Status = "ready" + status.CheckedAt = time.Now() + }) + log.WithFields(fields).Info("PicoClaw runtime restore found an already-ready runtime") + return + } + + currentStatus := s.runtime.Get() + switch currentStatus.Status { + case "not_installed", "model_not_configured", "config_error": + message := currentStatus.LastError + if message == "" { + message = currentStatus.ConfigError + } + if message == "" { + message = "PicoClaw runtime restore prerequisites are not satisfied" + } + s.setRuntimeIntentError(message) + s.runtime.Update(func(status *RuntimeStatus) { + status.Restoring = false + status.LastError = message + status.CheckedAt = time.Now() + }) + fields["elapsed_ms"] = time.Since(startedAt).Milliseconds() + fields["runtime_status"] = currentStatus.Status + log.WithFields(fields).Warn("PicoClaw runtime restore skipped because prerequisites are not satisfied") + return + } + + command, output, startErr := s.startRuntime() + fields["elapsed_ms"] = time.Since(startedAt).Milliseconds() + fields["command"] = command + if output != "" { + fields["output"] = output + } + if startErr != nil { + s.setRuntimeIntentError(startErr.Message) + s.runtime.Update(func(status *RuntimeStatus) { + status.Restoring = false + if status.Status == "restoring" { + status.Status = "unavailable" + } + status.LastError = startErr.Message + status.CheckedAt = time.Now() + }) + log.WithFields(fields).WithError(errors.New(startErr.Message)).Warn("PicoClaw runtime restore failed") + return + } + + intent, intentErr = s.runtimeIntent.Load() + if intentErr != nil { + s.setRuntimeIntentError(intentErr.Error()) + log.WithError(intentErr).Warn("PicoClaw runtime restore result discarded because intent became invalid") + return + } + if !intent.DesiredRunning { + log.WithFields(fields).Info("PicoClaw runtime restore result discarded because desired_running is now false") + return + } + if err := s.control.RequireWrite(controlmode.ModePicoclaw); err != nil { + controlErr := s.controlWriteError(controlmode.ModePicoclaw, err) + s.setRuntimeIntentError(controlErr.Message) + log.WithFields(fields).Warn("PicoClaw runtime restore result discarded because control changed") + return + } + + s.setRuntimeIntentDesired(true, "restore") + s.runtime.Update(func(status *RuntimeStatus) { + status.Restoring = false + status.CheckedAt = time.Now() + }) + log.WithFields(fields).Info("PicoClaw runtime restored from persisted intent") +} + +func (s *Service) reconcileDisabledRuntimeIntent(source string) { + status := s.runtime.Get() + running, err := isRuntimeRunning() + fields := log.Fields{ + "source": source, + "runtime_status": status.Status, + "ready": status.Ready, + } + if err != nil { + s.setRuntimeIntentError(err.Error()) + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + status.Status = "unavailable" + status.LastError = err.Error() + status.CheckedAt = time.Now() + }) + log.WithFields(fields).WithError(err).Warn("PicoClaw disabled runtime intent could not check runtime") + return + } + + if !running && !status.Ready && !isRuntimeLifecycleStatusPending(status) { + s.applyDisabledRuntimeIntentStatus() + log.WithFields(fields).Info("PicoClaw disabled runtime intent kept runtime stopped") + return + } + + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + status.Status = "stopping" + status.LastError = "" + status.CheckedAt = time.Now() + }) + if err := s.stopRuntimeAndCloseSessions(CloseCodeRuntimeStopped, "PicoClaw runtime disabled"); err != nil { + s.setRuntimeIntentError(err.Error()) + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + status.Status = "unavailable" + status.LastError = err.Error() + status.CheckedAt = time.Now() + }) + log.WithFields(fields).WithError(err).Warn("PicoClaw disabled runtime intent failed to stop runtime") + return + } + + s.applyDisabledRuntimeIntentStatus() + log.WithFields(fields).Info("PicoClaw disabled runtime intent stopped runtime") +} + +func (s *Service) applyDisabledRuntimeIntentStatus() { + installed, installedKnown := picoclawInstalledState() + settings, settingsErr := loadPicoclawGatewaySettings() + + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Restoring = false + status.CurrentSession = "" + status.CheckedAt = time.Now() + if installedKnown { + status.Installed = installed + } + if installedKnown && !installed { + status.ModelConfigured = false + status.ModelName = "" + status.Status = "not_installed" + status.LastError = "" + return + } + if settingsErr == nil { + status.ModelConfigured = settings.ModelConfigured + status.ModelName = settings.ModelName + if !settings.ModelConfigured { + status.ModelName = settings.TargetModelName + status.Status = "model_not_configured" + return + } + } + status.Status = "stopped" + if status.LastError == "picoclaw runtime is stopped" { + status.LastError = "" + } + }) +} diff --git a/server/service/picoclaw/runtime_session.go b/server/service/picoclaw/runtime_session.go index f54626b..038522c 100644 --- a/server/service/picoclaw/runtime_session.go +++ b/server/service/picoclaw/runtime_session.go @@ -8,6 +8,7 @@ import ( ) func (s *Service) ReleaseRuntimeSession(c *gin.Context) { + s.ensureDependencies() sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader)) if sessionID == "" { writePicoclawError(c, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")) @@ -16,8 +17,6 @@ func (s *Service) ReleaseRuntimeSession(c *gin.Context) { if session, ok := GetSessionManager().Get(sessionID); ok { s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released") - } else { - ReleaseSession(sessionID) } status := s.runtime.Get() diff --git a/server/service/picoclaw/runtime_session_test.go b/server/service/picoclaw/runtime_session_test.go new file mode 100644 index 0000000..b3c637b --- /dev/null +++ b/server/service/picoclaw/runtime_session_test.go @@ -0,0 +1,33 @@ +package picoclaw + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "NanoKVM-Server/service/controlmode" + + "github.com/gin-gonic/gin" +) + +func TestReleaseRuntimeSessionAllowsMCPMode(t *testing.T) { + gin.SetMode(gin.TestMode) + service := &Service{ + control: controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP), + } + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodDelete, "/api/picoclaw/runtime/session", nil) + c.Request.Header.Set(sessionIDHeader, "stale-session") + + service.ReleaseRuntimeSession(c) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + if strings.Contains(recorder.Body.String(), CodeControlModeConflict) { + t.Fatalf("body = %s, did not expect control conflict", recorder.Body.String()) + } +} diff --git a/server/service/picoclaw/runtime_start_stop.go b/server/service/picoclaw/runtime_start_stop.go index fb7b2dd..c1f547e 100644 --- a/server/service/picoclaw/runtime_start_stop.go +++ b/server/service/picoclaw/runtime_start_stop.go @@ -3,14 +3,22 @@ package picoclaw import ( "context" "fmt" + "net" + "net/url" "os" "os/exec" "path/filepath" + "strconv" "strings" "time" ) func (s *Service) startRuntime() (string, string, *PicoclawError) { + if s == nil { + return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable") + } + s.ensureDependencies() + if installed, statErr := isPicoclawInstalled(); statErr != nil { s.runtime.Update(func(status *RuntimeStatus) { status.Ready = false @@ -57,6 +65,16 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) { }) return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error()) } + if err := s.detectGatewayPortConflict(); err != nil { + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Installed = true + status.Status = "unavailable" + status.LastError = err.Error() + status.CheckedAt = time.Now() + }) + return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error()) + } command := scriptPath + " start" ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout) @@ -81,6 +99,22 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) { time.Sleep(picoclawStartWaitPeriod) if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil { startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message) + failureStatus := "unavailable" + if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil { + failureStatus = "error" + startErr.Message = fmt.Sprintf( + "%s; failed to stop partially started runtime: %v", + startErr.Message, + cleanupErr, + ) + } + s.runtime.Update(func(status *RuntimeStatus) { + status.Ready = false + status.Status = failureStatus + status.LastError = startErr.Message + status.CurrentSession = "" + status.CheckedAt = time.Now() + }) return command, trimmedOutput, startErr } @@ -88,6 +122,11 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) { } func (s *Service) stopRuntime() (string, string, *PicoclawError) { + if s == nil { + return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable") + } + s.ensureDependencies() + settings, _ := loadPicoclawGatewaySettings() scriptPath, err := resolvePicoclawStartScript() if err != nil { @@ -148,7 +187,8 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError { var lastErr *PicoclawError for { - if runtimeErr := s.ensureRuntimeReady(); runtimeErr == nil { + runtimeErr := s.ensureRuntimeReadyForLifecycle() + if runtimeErr == nil { return nil } else { lastErr = runtimeErr @@ -168,12 +208,12 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError { } func resolvePicoclawStartScript() (string, error) { - if _, err := os.Stat(etcInitPicoclawScript); err == nil { - return etcInitPicoclawScript, nil - } if _, err := os.Stat(kvmappPicoclawScript); err == nil { return kvmappPicoclawScript, nil } + if _, err := os.Stat(etcInitPicoclawScript); err == nil { + return etcInitPicoclawScript, nil + } return "", fmt.Errorf("picoclaw start script not found: %s or %s", etcInitPicoclawScript, kvmappPicoclawScript) } @@ -211,18 +251,162 @@ func isPicoclawInstalled() (bool, error) { } func isRuntimeRunning() (bool, error) { - binName := filepath.Base(picoclawBinaryPath) - if binName == "" || binName == "." || binName == string(filepath.Separator) { - return false, fmt.Errorf("invalid picoclaw binary path: %s", picoclawBinaryPath) + pid, err := runtimeGatewayPID() + if err != nil { + return false, err + } + return pid > 0, nil +} + +func (s *Service) detectGatewayPortConflict() error { + running, err := isRuntimeRunning() + if err != nil { + return fmt.Errorf("check PicoClaw gateway process: %w", err) + } + if running { + return nil } - command := exec.Command("pidof", binName) - if err := command.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + settings, err := loadPicoclawGatewaySettings() + if err != nil { + return nil + } + parsed, err := url.Parse(settings.GatewayURL) + if err != nil { + return nil + } + hostPort, err := gatewayHostPort(parsed) + if err != nil { + return nil + } + + timeout := 2 * time.Second + if s != nil && s.config != nil { + if configured := time.Duration(s.config.Get().ConnectTimeoutMs) * time.Millisecond; configured > 0 { + timeout = configured + } + } + conn, err := net.DialTimeout("tcp", hostPort, timeout) + if err != nil { + return nil + } + _ = conn.Close() + + return fmt.Errorf("gateway port %s is already in use by another process", hostPort) +} + +func resolvePicoclawPIDPath() (string, error) { + home, err := resolvePicoclawHome() + if err != nil { + return "", err + } + return filepath.Join(home, picoclawPIDFileName), nil +} + +func runtimeGatewayPID() (int, error) { + pidPath, err := resolvePicoclawPIDPath() + if err != nil { + return 0, err + } + + pid, err := readRuntimePIDFile(pidPath) + if err == nil { + running, processErr := isPicoclawGatewayProcess(pid) + if processErr != nil { + return 0, processErr + } + if running { + return pid, nil + } + _ = os.Remove(pidPath) + } else if !os.IsNotExist(err) { + _ = os.Remove(pidPath) + } + + pid, err = findPicoclawGatewayProcess() + if err != nil { + return 0, err + } + if pid > 0 { + _ = os.MkdirAll(filepath.Dir(pidPath), 0o755) + _ = os.WriteFile(pidPath, []byte(strconv.Itoa(pid)+"\n"), 0o600) + } + return pid, nil +} + +func readRuntimePIDFile(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, err + } + pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data))) + if parseErr != nil || pid <= 0 { + return 0, fmt.Errorf("invalid PicoClaw pid file: %s", path) + } + return pid, nil +} + +func isPicoclawGatewayProcess(pid int) (bool, error) { + if pid <= 0 { + return false, nil + } + cmdlinePath := filepath.Join("/proc", strconv.Itoa(pid), "cmdline") + data, err := os.ReadFile(cmdlinePath) + if err != nil { + if os.IsNotExist(err) { return false, nil } return false, err } - - return true, nil + return isPicoclawGatewayCmdline(data), nil +} + +func isPicoclawGatewayCmdline(data []byte) bool { + parts := strings.Split(string(data), "\x00") + args := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + args = append(args, part) + } + } + if len(args) < 2 { + return false + } + + binName := filepath.Base(picoclawBinaryPath) + if binName == "" || binName == "." || binName == string(filepath.Separator) { + return false + } + + return filepath.Base(args[0]) == binName && args[1] == "gateway" +} + +func findPicoclawGatewayProcess() (int, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pid, err := strconv.Atoi(entry.Name()) + if err != nil || pid <= 0 { + continue + } + running, err := isPicoclawGatewayProcess(pid) + if err != nil { + continue + } + if running { + return pid, nil + } + } + + return 0, nil } diff --git a/server/service/picoclaw/runtime_status_test.go b/server/service/picoclaw/runtime_status_test.go new file mode 100644 index 0000000..d6b3f9e --- /dev/null +++ b/server/service/picoclaw/runtime_status_test.go @@ -0,0 +1,171 @@ +package picoclaw + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "NanoKVM-Server/service/controlmode" +) + +func TestSyncRuntimeConfigMetadataFromPicoclawReadsPersistedModelWithoutMutation(t *testing.T) { + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + + configData := []byte(`{ + "agents": { + "defaults": { + "model_name": "test-model" + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 18790 + }, + "model_list": [ + { + "model_name": "test-model", + "model": "provider/test-model", + "api_base": "https://example.invalid" + } + ], + "channel_list": { + "pico": { + "type": "pico", + "enabled": false, + "settings": {} + } + } +}`) + configPath := filepath.Join(home, "config.json") + if err := os.WriteFile(configPath, configData, 0o600); err != nil { + t.Fatal(err) + } + securityData := []byte("model_list:\n test-model:\n api_keys:\n - test-key\n") + if err := os.WriteFile(filepath.Join(home, ".security.yml"), securityData, 0o600); err != nil { + t.Fatal(err) + } + + service := &Service{ + runtime: &RuntimeStore{status: RuntimeStatus{ + Status: "model_not_configured", + LastError: "stale model error", + }}, + } + if syncErr := service.syncRuntimeConfigMetadataFromPicoclaw(); syncErr != nil { + t.Fatal(syncErr) + } + + status := service.runtime.Get() + if !status.ModelConfigured || status.ModelName != "test-model" { + t.Fatalf("runtime model metadata = configured:%v name:%q", status.ModelConfigured, status.ModelName) + } + if status.CheckedAt.IsZero() { + t.Fatal("runtime metadata refresh did not update checked_at") + } + if status.Status != "checking" || status.LastError != "" { + t.Fatalf("runtime kept stale model status: %+v", status) + } + + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, configData) { + t.Fatalf("passive metadata refresh mutated config:\n%s", after) + } + + for _, test := range []struct { + mode controlmode.Mode + wantStatus string + }{ + {mode: controlmode.ModeOff, wantStatus: "checking"}, + {mode: controlmode.ModeMCP, wantStatus: "checking"}, + } { + rendered := applyControlModeStatus(status, controlmode.Status{Mode: test.mode}) + if !rendered.ModelConfigured || rendered.ModelName != "test-model" { + t.Fatalf("mode %q lost model metadata: %+v", test.mode, rendered) + } + if rendered.Status != test.wantStatus { + t.Fatalf("mode %q status = %q, want %q", test.mode, rendered.Status, test.wantStatus) + } + if rendered.Ready { + t.Fatalf("mode %q unexpectedly changed ready to true", test.mode) + } + if rendered.Capabilities.DeviceWrite { + t.Fatalf("mode %q allows PicoClaw device writes", test.mode) + } + } + + ready := status + ready.Ready = true + ready.Installed = true + ready.Status = "ready" + for _, test := range []struct { + name string + status controlmode.Status + wantChat bool + wantReadOnlyTools bool + wantDeviceWrite bool + }{ + { + name: "picoclaw", + status: controlmode.Status{Mode: controlmode.ModePicoclaw}, + wantChat: true, + wantReadOnlyTools: true, + wantDeviceWrite: true, + }, + {name: "mcp", status: controlmode.Status{Mode: controlmode.ModeMCP}}, + { + name: "off", + status: controlmode.Status{Mode: controlmode.ModeOff}, + wantChat: true, + wantReadOnlyTools: true, + }, + { + name: "transitioning", + status: controlmode.Status{Mode: controlmode.ModePicoclaw, Transitioning: true}, + }, + } { + rendered := applyControlModeStatus(ready, test.status) + if !rendered.Ready || rendered.Status != "ready" { + t.Fatalf("%s changed runtime readiness: %+v", test.name, rendered) + } + if rendered.Capabilities.Chat != test.wantChat || + rendered.Capabilities.ReadOnlyTools != test.wantReadOnlyTools || + rendered.Capabilities.DeviceWrite != test.wantDeviceWrite { + t.Fatalf("%s capabilities = %+v, want chat=%v readOnlyTools=%v deviceWrite=%v", + test.name, + rendered.Capabilities, + test.wantChat, + test.wantReadOnlyTools, + test.wantDeviceWrite, + ) + } + } +} + +func TestSyncRuntimeConfigMetadataFromPicoclawTreatsMissingConfigAsUnconfigured(t *testing.T) { + t.Setenv("PICOCLAW_HOME", t.TempDir()) + service := &Service{ + runtime: &RuntimeStore{status: RuntimeStatus{ + ModelConfigured: true, + ModelName: "stale-model", + Status: "config_error", + ConfigError: "stale config error", + LastError: "stale config error", + }}, + } + + if syncErr := service.syncRuntimeConfigMetadataFromPicoclaw(); syncErr != nil { + t.Fatal(syncErr) + } + status := service.runtime.Get() + if status.ModelConfigured || status.ModelName != "" { + t.Fatalf("missing config kept stale model metadata: %+v", status) + } + if status.Status == "config_error" || status.ConfigError != "" || status.LastError != "" { + t.Fatalf("missing config reported a parse error: %+v", status) + } +} diff --git a/server/service/picoclaw/runtime_stop_test.go b/server/service/picoclaw/runtime_stop_test.go new file mode 100644 index 0000000..f8d3e88 --- /dev/null +++ b/server/service/picoclaw/runtime_stop_test.go @@ -0,0 +1,236 @@ +package picoclaw + +import ( + "errors" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestStopRuntimeProcessAndVerifyForceStopRunsStopBeforeChecking(t *testing.T) { + stopCalled := false + err := stopRuntimeProcessAndVerify( + true, + func() (bool, error) { + if !stopCalled { + return false, errors.New("runtime checked before compensating stop") + } + return false, nil + }, + func() error { + stopCalled = true + return nil + }, + time.Second, + time.Millisecond, + ) + if err != nil { + t.Fatal(err) + } + if !stopCalled { + t.Fatal("compensating stop was not called") + } +} + +func TestStopRuntimeProcessAndVerifyWaitsForExit(t *testing.T) { + checks := 0 + stopCalled := false + err := stopRuntimeProcessAndVerify( + false, + func() (bool, error) { + checks++ + switch checks { + case 1: + return true, nil + case 2: + if !stopCalled { + return false, errors.New("runtime verified before stop") + } + return true, nil + default: + return false, nil + } + }, + func() error { + stopCalled = true + return nil + }, + time.Second, + 0, + ) + if err != nil { + t.Fatal(err) + } + if !stopCalled || checks != 3 { + t.Fatalf("stopCalled=%v checks=%d, want true and 3", stopCalled, checks) + } +} + +func TestStopRuntimeProcessAndVerifyIgnoresStopErrorAfterExit(t *testing.T) { + checks := 0 + wantStopErr := errors.New("stop command failed after cleanup") + err := stopRuntimeProcessAndVerify( + false, + func() (bool, error) { + checks++ + return checks == 1, nil + }, + func() error { return wantStopErr }, + time.Second, + 0, + ) + if err != nil { + t.Fatalf("error = %v, want nil after runtime exits", err) + } + if checks != 2 { + t.Fatalf("checks = %d, want 2", checks) + } +} + +func TestStopRuntimeProcessAndVerifyReportsStopAndVerificationFailures(t *testing.T) { + wantStopErr := errors.New("stop command failed") + err := stopRuntimeProcessAndVerify( + true, + func() (bool, error) { return true, nil }, + func() error { return wantStopErr }, + 0, + 0, + ) + if !errors.Is(err, wantStopErr) { + t.Fatalf("error = %v, want wrapped stop error", err) + } + if !strings.Contains(err.Error(), "PicoClaw runtime is still running") { + t.Fatalf("error = %v, want verification failure", err) + } +} + +func TestStopRuntimeCloseSessionsDoesNotRequireHIDRelease(t *testing.T) { + t.Setenv("PICOCLAW_HOME", t.TempDir()) + service := &Service{ + runtime: getRuntimeStore(), + releaseHID: func() error { + return errors.New("hid unavailable") + }, + } + + if err := service.stopRuntimeAndCloseSessions(CloseCodeRuntimeStopped, "test stop"); err != nil { + t.Fatalf("stopRuntimeAndCloseSessions error = %v, want nil", err) + } +} + +func TestStopRuntimeCloseSessionsReleasesStaleLock(t *testing.T) { + t.Setenv("PICOCLAW_HOME", t.TempDir()) + lock := &SessionLock{} + lock.ForceTakeover("stale-session") + service := &Service{ + lock: lock, + runtime: getRuntimeStore(), + } + + if err := service.stopRuntimeAndCloseSessions(CloseCodeRuntimeStopped, "test stop"); err != nil { + t.Fatalf("stopRuntimeAndCloseSessions error = %v, want nil", err) + } + if owner := lock.Owner(); owner != "" { + t.Fatalf("lock owner = %q, want released", owner) + } +} + +func TestPreemptControlLeasesForMCPDoesNotStopRuntimeOrChangeIntent(t *testing.T) { + t.Setenv("PICOCLAW_HOME", t.TempDir()) + intentStore := NewRuntimeIntentStore(filepath.Join(t.TempDir(), "picoclaw-runtime.json")) + if err := intentStore.SetDesiredRunning(true, "test"); err != nil { + t.Fatal(err) + } + + lock := &SessionLock{} + lock.ForceTakeover("session-1") + service := &Service{ + lock: lock, + runtime: &RuntimeStore{status: RuntimeStatus{Ready: true, Status: "ready"}}, + runtimeIntent: intentStore, + operations: newControlOperationTracker(), + } + + if err := service.PreemptControlLeasesForMCP(); err != nil { + t.Fatal(err) + } + if owner := lock.Owner(); owner != "" { + t.Fatalf("lock owner = %q, want released", owner) + } + status := service.runtime.Get() + if !status.Ready || status.Status != "ready" { + t.Fatalf("runtime status changed during soft preempt: %+v", status) + } + intent, err := intentStore.Load() + if err != nil { + t.Fatal(err) + } + if !intent.DesiredRunning || intent.UpdatedBy != "test" { + t.Fatalf("runtime intent changed during soft preempt: %+v", intent) + } +} + +func TestStopRuntimeForMCPStopsRuntimeAndDisablesIntent(t *testing.T) { + t.Setenv("PICOCLAW_HOME", t.TempDir()) + intentStore := NewRuntimeIntentStore(filepath.Join(t.TempDir(), "picoclaw-runtime.json")) + if err := intentStore.SetDesiredRunning(true, "test"); err != nil { + t.Fatal(err) + } + + service := &Service{ + runtime: &RuntimeStore{status: RuntimeStatus{Ready: true, Status: "ready"}}, + runtimeIntent: intentStore, + } + + if err := service.StopRuntimeForMCP(); err != nil { + t.Fatal(err) + } + status := service.runtime.Get() + if status.Ready || status.Status != "stopped" { + t.Fatalf("runtime status = %+v, want stopped", status) + } + intent, err := intentStore.Load() + if err != nil { + t.Fatal(err) + } + if intent.DesiredRunning || intent.UpdatedBy != "mcp_preempt" { + t.Fatalf("runtime intent = %+v, want disabled by mcp_preempt", intent) + } +} + +func TestEnsureDependenciesInitializesRuntimeFields(t *testing.T) { + service := &Service{} + service.ensureDependencies() + + if service.config == nil { + t.Fatal("config store was not initialized") + } + if service.lock == nil { + t.Fatal("session lock was not initialized") + } + if service.runtime == nil { + t.Fatal("runtime store was not initialized") + } + if service.control == nil { + t.Fatal("control manager was not initialized") + } + if service.operations == nil { + t.Fatal("control operation tracker was not initialized") + } + if service.releaseHID == nil { + t.Fatal("HID release callback was not initialized") + } +} + +func TestIsPicoclawGatewayCmdlineOnlyMatchesGatewaySubcommand(t *testing.T) { + if !isPicoclawGatewayCmdline([]byte("/usr/bin/picoclaw\x00gateway\x00")) { + t.Fatal("gateway command was not recognized") + } + if isPicoclawGatewayCmdline([]byte("/usr/bin/picoclaw\x00agent\x00")) { + t.Fatal("agent command was recognized as gateway") + } + if isPicoclawGatewayCmdline([]byte("/usr/bin/other\x00gateway\x00")) { + t.Fatal("other binary was recognized as picoclaw gateway") + } +} diff --git a/server/service/picoclaw/service.go b/server/service/picoclaw/service.go index 03055fa..37e5e52 100644 --- a/server/service/picoclaw/service.go +++ b/server/service/picoclaw/service.go @@ -1,13 +1,12 @@ package picoclaw import ( - "net" - "net/url" "os" "sync" "time" "NanoKVM-Server/common" + "NanoKVM-Server/service/controlmode" "NanoKVM-Server/service/hid" "github.com/gin-gonic/gin" @@ -16,22 +15,67 @@ import ( const sessionIDHeader = "X-PicoClaw-Session-ID" var ( - configStoreOnce sync.Once - configStore *ConfigStore - runtimeStoreOnce sync.Once - runtimeStore *RuntimeStore - probeLoopOnce sync.Once + configStoreOnce sync.Once + configStore *ConfigStore + runtimeStoreOnce sync.Once + runtimeStore *RuntimeStore + runtimeIntentStoreOnce sync.Once + runtimeIntentStore *RuntimeIntentStore + probeLoopOnce sync.Once ) const runtimeStatusRefreshInterval = 2 * time.Second -func NewService() *Service { - return &Service{ - vision: common.GetKvmVision(), - hid: hid.GetHid(), - config: getConfigStore(), - lock: GetSessionLock(), - runtime: getRuntimeStore(), +func NewService(control *controlmode.Manager) *Service { + if control == nil { + control = controlmode.GetManager() + } + service := &Service{ + vision: common.GetKvmVision(), + hid: hid.GetHid(), + config: getConfigStore(), + lock: GetSessionLock(), + runtime: getRuntimeStore(), + runtimeIntent: getRuntimeIntentStore(), + control: control, + releaseHID: hid.ReleaseAllHIDStateBestEffort, + operations: newControlOperationTracker(), + } + service.ensureDependencies() + service.startRuntimeIntentReconcile() + return service +} + +func (s *Service) ensureDependencies() { + if s == nil { + return + } + if s.vision == nil { + s.vision = common.GetKvmVision() + } + if s.hid == nil { + s.hid = hid.GetHid() + } + if s.config == nil { + s.config = getConfigStore() + } + if s.lock == nil { + s.lock = GetSessionLock() + } + if s.runtime == nil { + s.runtime = getRuntimeStore() + } + if s.runtimeIntent == nil { + s.runtimeIntent = getRuntimeIntentStore() + } + if s.control == nil { + s.control = controlmode.GetManager() + } + if s.releaseHID == nil { + s.releaseHID = hid.ReleaseAllHIDStateBestEffort + } + if s.operations == nil { + s.operations = newControlOperationTracker() } } @@ -63,8 +107,22 @@ func getRuntimeStore() *RuntimeStore { return runtimeStore } +func getRuntimeIntentStore() *RuntimeIntentStore { + runtimeIntentStoreOnce.Do(func() { + runtimeIntentStore = NewRuntimeIntentStore(RuntimeIntentFile) + }) + + return runtimeIntentStore +} + func (s *Service) GetRuntimeStatus(c *gin.Context) { + s.ensureDependencies() s.startRuntimeProbeLoop() + modeStatus, modeErr := s.control.Status() + if modeErr != nil { + writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, modeErr.Error())) + return + } status := s.runtime.Get() if shouldRefreshRuntimeStatus(status) { _ = s.ensureRuntimeReady() @@ -78,16 +136,115 @@ func (s *Service) GetRuntimeStatus(c *gin.Context) { }) } + status = s.applyRuntimeIntentStatus(applyControlModeStatus(status, modeStatus)) writeSuccess(c, withAgentProfile(status)) } func (s *Service) GetRuntimeSession(c *gin.Context) { + s.ensureDependencies() writeSuccess(c, gin.H{ "current_session": s.lock.Owner(), "checked_at": time.Now(), }) } +func (s *Service) runtimeStatus() RuntimeStatus { + s.ensureDependencies() + status := s.runtime.Get() + modeStatus, err := s.control.Status() + if err != nil { + status.ControlMode = string(controlmode.ModeOff) + return status + } + return s.applyRuntimeIntentStatus(applyControlModeStatus(status, modeStatus)) +} + +func (s *Service) applyRuntimeIntentStatus(status RuntimeStatus) RuntimeStatus { + if s == nil { + return status + } + s.ensureDependencies() + intent, err := s.runtimeIntent.Load() + status.RuntimeIntent = intent + if err != nil && status.RuntimeIntent.LastError == "" { + status.RuntimeIntent.LastError = err.Error() + } + status.Restoring = status.Restoring || status.Status == "restoring" + return status +} + +func applyControlModeStatus(status RuntimeStatus, modeStatus controlmode.Status) RuntimeStatus { + status.ControlMode = string(modeStatus.Mode) + status.Transitioning = modeStatus.Transitioning + canControl := modeStatus.Mode == controlmode.ModePicoclaw && !modeStatus.Transitioning + runtimeUsable := status.Ready && status.Installed && status.ModelConfigured && !status.Installing + chat := runtimeUsable && modeStatus.Mode != controlmode.ModeMCP && !modeStatus.Transitioning + status.Control = ControlStatus{ + Mode: string(modeStatus.Mode), + Transitioning: modeStatus.Transitioning, + CanControl: canControl, + LastError: modeStatus.LastError, + ChangedAt: modeStatus.ChangedAt, + } + status.Capabilities = RuntimeCapabilities{ + Chat: chat, + ReadOnlyTools: chat, + DeviceWrite: canControl, + } + return status +} + +func (s *Service) requireControlMode() *PicoclawError { + s.ensureDependencies() + if err := s.control.RequireWrite(controlmode.ModePicoclaw); err != nil { + return s.controlWriteError(controlmode.ModePicoclaw, err) + } + return nil +} + +func (s *Service) requireControlModeForBootstrap() *PicoclawError { + return nil +} + +func (s *Service) acquireControlMode() (func(), *PicoclawError) { + s.ensureDependencies() + release, err := s.control.AcquireWrite(controlmode.ModePicoclaw) + if err != nil { + return nil, s.controlWriteError(controlmode.ModePicoclaw, err) + } + return release, nil +} + +func (s *Service) controlWriteError(expected controlmode.Mode, err error) *PicoclawError { + status, statusErr := s.control.Status() + if statusErr != nil { + return newPicoclawError(CodeRuntimeUnavailable, statusErr.Error()) + } + + message := "PicoClaw does not own device control" + code := CodeControlRequired + if status.Transitioning { + code = CodeControlTransitioning + message = "device control is switching" + } else if expected == controlmode.ModePicoclaw { + switch status.Mode { + case controlmode.ModeMCP: + code = CodeControlOwnedByMCP + message = "external MCP owns device control" + case controlmode.ModeOff: + code = CodeControlRequired + message = "PicoClaw device control is not enabled" + default: + code = CodeControlModeConflict + } + } + controlErr := newPicoclawError(code, message) + if err != nil && controlErr.Message == "" { + controlErr.Message = err.Error() + } + return controlErr +} + func (s *Service) requireSessionID(c *gin.Context) (string, *PicoclawError) { sessionID := c.GetHeader(sessionIDHeader) if sessionID == "" { @@ -98,31 +255,69 @@ func (s *Service) requireSessionID(c *gin.Context) (string, *PicoclawError) { } func (s *ConfigStore) Get() Config { + if s == nil { + return defaultConfig() + } s.mu.RLock() defer s.mu.RUnlock() return s.config } func (s *ConfigStore) Set(cfg Config) { + if s == nil { + return + } s.mu.Lock() defer s.mu.Unlock() s.config = cfg } func (s *RuntimeStore) Get() RuntimeStatus { + if s == nil { + return RuntimeStatus{ + Ready: false, + Installed: false, + InstallPath: picoclawBinaryPath, + Status: "unavailable", + } + } s.mu.RLock() defer s.mu.RUnlock() return s.status } func (s *RuntimeStore) Set(status RuntimeStatus) { + if s == nil { + return + } s.mu.Lock() defer s.mu.Unlock() status.InstallPath = picoclawBinaryPath s.status = status } +func (s *RuntimeStore) SetFromProbe(status RuntimeStatus) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + + if isRuntimeLifecycleStatusPending(s.status) && !status.Ready { + s.status.CheckedAt = status.CheckedAt + s.status.CurrentSession = status.CurrentSession + s.status.InstallPath = picoclawBinaryPath + return + } + + status.InstallPath = picoclawBinaryPath + s.status = status +} + func (s *RuntimeStore) Update(update func(*RuntimeStatus)) { + if s == nil { + return + } s.mu.Lock() defer s.mu.Unlock() update(&s.status) @@ -130,21 +325,76 @@ func (s *RuntimeStore) Update(update func(*RuntimeStatus)) { } func (s *RuntimeStore) UpdateInstallStatus(update func(*RuntimeStatus)) { + if s == nil { + return + } s.mu.Lock() defer s.mu.Unlock() update(&s.status) s.status.InstallPath = picoclawBinaryPath } +func isRuntimeLifecycleStatusPending(status RuntimeStatus) bool { + if status.Restoring { + return true + } + switch status.Status { + case "starting", "restoring", "stopping": + return true + default: + return false + } +} + +func (s *Service) lockRuntimeLifecycle() func() { + s.ensureDependencies() + s.runtimeLifecycleMu.Lock() + return func() { + s.runtimeLifecycleMu.Unlock() + } +} + func (s *Service) ensureRuntimeReady() *PicoclawError { + return s.ensureRuntimeReadyWithProbeProtection(false) +} + +func (s *Service) ensureRuntimeReadyForLifecycle() *PicoclawError { + return s.ensureRuntimeReadyWithProbeProtection(true) +} + +func (s *Service) ensureRuntimeReadyWithProbeProtection(allowLifecycleOverwrite bool) *PicoclawError { + if s == nil { + return newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable") + } + s.ensureDependencies() + setStatus := func(status RuntimeStatus) { + if allowLifecycleOverwrite { + s.runtime.Set(status) + return + } + s.runtime.SetFromProbe(status) + } currentStatus := s.runtime.Get() if currentStatus.Installing { return newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress") } + if !allowLifecycleOverwrite && isRuntimeLifecycleStatusPending(currentStatus) { + return newPicoclawError(CodeRuntimeUnavailable, "picoclaw runtime lifecycle operation is pending") + } + if !allowLifecycleOverwrite { + intent, intentErr := s.runtimeIntent.Load() + if intentErr != nil || !intent.DesiredRunning { + s.applyDisabledRuntimeIntentStatus() + if intentErr != nil { + return newPicoclawError(CodeRuntimeUnavailable, intentErr.Error()) + } + return newPicoclawError(CodeRuntimeUnavailable, "picoclaw runtime is disabled") + } + } installed, statErr := isPicoclawInstalled() if statErr != nil { - s.runtime.Set(RuntimeStatus{ + setStatus(RuntimeStatus{ Ready: false, Installed: false, Installing: false, @@ -158,7 +408,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError { return newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw installation") } if !installed { - s.runtime.Set(RuntimeStatus{ + setStatus(RuntimeStatus{ Ready: false, Installed: false, Installing: false, @@ -174,7 +424,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError { configPath, pathErr := resolvePicoclawConfigPath() if pathErr != nil { - s.runtime.Set(RuntimeStatus{ + setStatus(RuntimeStatus{ Ready: false, Installed: true, Installing: false, @@ -191,7 +441,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError { if _, err := os.Stat(configPath); err != nil { if os.IsNotExist(err) { if _, onboardErr := runPicoclawOnboard(); onboardErr != nil { - s.runtime.Set(RuntimeStatus{ + setStatus(RuntimeStatus{ Ready: false, Installed: true, Installing: false, @@ -208,7 +458,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError { if _, statErr := os.Stat(configPath); statErr == nil { goto configReady } - s.runtime.Set(RuntimeStatus{ + setStatus(RuntimeStatus{ Ready: false, Installed: true, Installing: false, @@ -221,7 +471,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError { }) return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured") } - s.runtime.Set(RuntimeStatus{ + setStatus(RuntimeStatus{ Ready: false, Installed: true, Installing: false, @@ -246,7 +496,7 @@ configReady: return newPicoclawError(CodeRuntimeUnavailable, settingsErr.Error()) } if !settings.ModelConfigured { - s.runtime.Set(RuntimeStatus{ + setStatus(RuntimeStatus{ Ready: false, Installed: true, Installing: false, @@ -261,67 +511,9 @@ configReady: return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured") } - cfg := s.config.Get() - - parsed, err := url.Parse(cfg.GatewayURL) - if err != nil { - s.runtime.Set(RuntimeStatus{ - Ready: false, - Installed: true, - Installing: false, - InstallProgress: 0, - InstallPath: picoclawBinaryPath, - ModelConfigured: true, - ModelName: settings.ModelName, - Status: "config_error", - ConfigError: "invalid gateway url", - LastError: err.Error(), - CheckedAt: time.Now(), - CurrentSession: s.lock.Owner(), - }) - return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid") - } - if parsed.Scheme != "ws" && parsed.Scheme != "wss" { - s.runtime.Set(RuntimeStatus{ - Ready: false, - Installed: true, - Installing: false, - InstallProgress: 0, - InstallPath: picoclawBinaryPath, - ModelConfigured: true, - ModelName: settings.ModelName, - Status: "config_error", - ConfigError: "invalid gateway url scheme", - LastError: parsed.Scheme, - CheckedAt: time.Now(), - CurrentSession: s.lock.Owner(), - }) - return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid") - } - - timeout := time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond - hostPort, err := gatewayHostPort(parsed) - if err != nil { - s.runtime.Set(RuntimeStatus{ - Ready: false, - Installed: true, - Installing: false, - InstallProgress: 0, - InstallPath: picoclawBinaryPath, - ModelConfigured: true, - ModelName: settings.ModelName, - Status: "config_error", - ConfigError: err.Error(), - LastError: err.Error(), - CheckedAt: time.Now(), - CurrentSession: s.lock.Owner(), - }) - return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid") - } - - conn, err := net.DialTimeout("tcp", hostPort, timeout) - if err != nil { - s.runtime.Set(RuntimeStatus{ + running, runningErr := isRuntimeRunning() + if runningErr != nil { + setStatus(RuntimeStatus{ Ready: false, Installed: true, Installing: false, @@ -330,15 +522,48 @@ configReady: ModelConfigured: true, ModelName: settings.ModelName, Status: "unavailable", - LastError: err.Error(), + LastError: runningErr.Error(), CheckedAt: time.Now(), CurrentSession: s.lock.Owner(), }) - return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable") + return newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime") + } + if !running { + setStatus(RuntimeStatus{ + Ready: false, + Installed: true, + Installing: false, + InstallProgress: 0, + InstallPath: picoclawBinaryPath, + ModelConfigured: true, + ModelName: settings.ModelName, + Status: "stopped", + CheckedAt: time.Now(), + CurrentSession: s.lock.Owner(), + }) + return newPicoclawError(CodeRuntimeUnavailable, "picoclaw runtime is stopped") } - _ = conn.Close() - s.runtime.Set(RuntimeStatus{ + cfg := s.config.Get() + if probeErr := probePicoclawGateway(cfg); probeErr != nil { + setStatus(RuntimeStatus{ + Ready: false, + Installed: true, + Installing: false, + InstallProgress: 0, + InstallPath: picoclawBinaryPath, + ModelConfigured: true, + ModelName: settings.ModelName, + Status: probeErr.status, + ConfigError: probeErr.configError, + LastError: probeErr.lastError, + CheckedAt: time.Now(), + CurrentSession: s.lock.Owner(), + }) + return newPicoclawError(CodeRuntimeUnavailable, probeErr.message) + } + + setStatus(RuntimeStatus{ Ready: true, Installed: true, Installing: false, @@ -354,6 +579,7 @@ configReady: } func (s *Service) startRuntimeProbeLoop() { + s.ensureDependencies() probeLoopOnce.Do(func() { go func() { ticker := time.NewTicker(30 * time.Second) diff --git a/server/service/picoclaw/session_cleanup.go b/server/service/picoclaw/session_cleanup.go index 883710a..9048244 100644 --- a/server/service/picoclaw/session_cleanup.go +++ b/server/service/picoclaw/session_cleanup.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" + "NanoKVM-Server/service/controlmode" "NanoKVM-Server/service/hid" log "github.com/sirupsen/logrus" @@ -12,15 +13,35 @@ import ( const picoclawMediaTempDirName = "picoclaw_media" func ReleaseSession(sessionID string) { - GetSessionLock().Release(sessionID) - releaseAllHIDState() + _, err := releaseOwnedSession(GetSessionLock(), sessionID, hid.ReleaseAllHIDState) + if err != nil { + log.Errorf("failed to release HID state for PicoClaw session %s: %v", sessionID, err) + } } -func releaseAllHIDState() { - h := hid.GetHid() - h.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) - h.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00}) - h.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) +func (s *Service) releaseGatewaySession(sessionID string) { + if s == nil { + ReleaseSession(sessionID) + return + } + s.ensureDependencies() + releaseHID := s.releaseHID + if s.control.Current() != controlmode.ModePicoclaw { + releaseHID = nil + } + if _, err := releaseOwnedSession(s.lock, sessionID, releaseHID); err != nil { + log.Errorf("failed to release HID state for PicoClaw session %s: %v", sessionID, err) + } +} + +func releaseOwnedSession(lock *SessionLock, sessionID string, releaseHID func() error) (bool, error) { + if lock == nil || !lock.ReleaseOwned(sessionID) { + return false, nil + } + if releaseHID == nil { + return true, nil + } + return true, releaseHID() } func (s *Service) releaseAllHIDState() { diff --git a/server/service/picoclaw/session_lock.go b/server/service/picoclaw/session_lock.go index 30fb8a6..b110ef7 100644 --- a/server/service/picoclaw/session_lock.go +++ b/server/service/picoclaw/session_lock.go @@ -108,6 +108,29 @@ func (l *SessionLock) Release(sessionID string) bool { return true } +// ReleaseOwned releases the lock only when sessionID is the current owner. +// Unlike Release, an already-empty lock is not considered a successful +// release. This distinction prevents stale session cleanup from releasing HID +// state that may now belong to another controller. +func (l *SessionLock) ReleaseOwned(sessionID string) bool { + if sessionID == "" { + return false + } + + l.mu.Lock() + defer l.mu.Unlock() + + l.clearExpiredLocked(time.Now()) + if l.ownerSessionID != sessionID { + return false + } + + l.ownerSessionID = "" + l.acquiredAt = time.Time{} + l.expiresAt = time.Time{} + return true +} + func (l *SessionLock) ForceTakeover(sessionID string) { l.mu.Lock() defer l.mu.Unlock() diff --git a/server/service/picoclaw/session_lock_test.go b/server/service/picoclaw/session_lock_test.go new file mode 100644 index 0000000..880e0bd --- /dev/null +++ b/server/service/picoclaw/session_lock_test.go @@ -0,0 +1,60 @@ +package picoclaw + +import "testing" + +func TestReleaseOwnedRejectsStaleSession(t *testing.T) { + lock := &SessionLock{} + if err := lock.Ensure("current"); err != nil { + t.Fatal(err) + } + + if lock.ReleaseOwned("stale") { + t.Fatal("stale session released the active lock") + } + if got := lock.Owner(); got != "current" { + t.Fatalf("owner = %q, want current", got) + } + + if !lock.ReleaseOwned("current") { + t.Fatal("current owner failed to release the lock") + } + if got := lock.Owner(); got != "" { + t.Fatalf("owner = %q, want empty", got) + } + if lock.ReleaseOwned("current") { + t.Fatal("empty lock was treated as an owned release") + } +} + +func TestStaleSessionDoesNotReleaseHID(t *testing.T) { + lock := &SessionLock{} + if err := lock.Ensure("current"); err != nil { + t.Fatal(err) + } + + releaseCalls := 0 + released, err := releaseOwnedSession(lock, "stale", func() error { + releaseCalls++ + return nil + }) + if err != nil { + t.Fatal(err) + } + if released || releaseCalls != 0 { + t.Fatalf("released=%v HID releases=%d, want false and 0", released, releaseCalls) + } + if got := lock.Owner(); got != "current" { + t.Fatalf("owner = %q, want current", got) + } + + released, err = releaseOwnedSession(lock, "current", func() error { + releaseCalls++ + return nil + }) + if err != nil { + t.Fatal(err) + } + if !released || releaseCalls != 1 { + t.Fatalf("released=%v HID releases=%d, want true and 1", released, releaseCalls) + } +} diff --git a/server/service/picoclaw/session_manager.go b/server/service/picoclaw/session_manager.go index 57b3ef9..6b98f39 100644 --- a/server/service/picoclaw/session_manager.go +++ b/server/service/picoclaw/session_manager.go @@ -92,3 +92,14 @@ func (m *SessionManager) Remove(sessionID string) { defer m.mu.Unlock() delete(m.sessions, sessionID) } + +func (m *SessionManager) Snapshot() []*GatewaySession { + m.mu.RLock() + defer m.mu.RUnlock() + + sessions := make([]*GatewaySession, 0, len(m.sessions)) + for _, session := range m.sessions { + sessions = append(sessions, session) + } + return sessions +} diff --git a/server/service/picoclaw/types.go b/server/service/picoclaw/types.go index c928714..375decc 100644 --- a/server/service/picoclaw/types.go +++ b/server/service/picoclaw/types.go @@ -7,6 +7,8 @@ import ( "sync" "time" + "NanoKVM-Server/service/controlmode" + "github.com/gorilla/websocket" ) @@ -21,11 +23,17 @@ type HIDWriter interface { } type Service struct { - vision VisionReader - hid HIDWriter - config *ConfigStore - lock *SessionLock - runtime *RuntimeStore + vision VisionReader + hid HIDWriter + config *ConfigStore + lock *SessionLock + runtime *RuntimeStore + runtimeIntent *RuntimeIntentStore + control *controlmode.Manager + releaseHID func() error + operations *controlOperationTracker + runtimeLifecycleMu sync.Mutex + reconcileOnce sync.Once } type ConfigStore struct { @@ -38,6 +46,11 @@ type RuntimeStore struct { status RuntimeStatus } +type RuntimeIntentStore struct { + mu sync.Mutex + path string +} + type Config struct { GatewayURL string `json:"gateway_url"` ConnectTimeoutMs int `json:"connect_timeout_ms"` @@ -50,20 +63,49 @@ type Config struct { } type RuntimeStatus struct { - Ready bool `json:"ready"` - Installed bool `json:"installed"` - Installing bool `json:"installing"` - InstallProgress int `json:"install_progress,omitempty"` - InstallStage string `json:"install_stage,omitempty"` - InstallPath string `json:"install_path,omitempty"` - AgentProfile string `json:"agent_profile,omitempty"` - ModelConfigured bool `json:"model_configured"` - ModelName string `json:"model_name,omitempty"` - Status string `json:"status"` - ConfigError string `json:"config_error,omitempty"` - LastError string `json:"last_error,omitempty"` - CheckedAt time.Time `json:"checked_at,omitempty"` - CurrentSession string `json:"current_session,omitempty"` + Ready bool `json:"ready"` + Installed bool `json:"installed"` + Installing bool `json:"installing"` + InstallProgress int `json:"install_progress,omitempty"` + InstallStage string `json:"install_stage,omitempty"` + InstallPath string `json:"install_path,omitempty"` + AgentProfile string `json:"agent_profile,omitempty"` + ModelConfigured bool `json:"model_configured"` + ModelName string `json:"model_name,omitempty"` + Status string `json:"status"` + ConfigError string `json:"config_error,omitempty"` + LastError string `json:"last_error,omitempty"` + CheckedAt time.Time `json:"checked_at,omitempty"` + CurrentSession string `json:"current_session,omitempty"` + Restoring bool `json:"restoring,omitempty"` + RuntimeIntent RuntimeIntentStatus `json:"runtime_intent"` + ControlMode string `json:"control_mode"` + Transitioning bool `json:"transitioning,omitempty"` + Control ControlStatus `json:"control"` + Capabilities RuntimeCapabilities `json:"capabilities"` +} + +type RuntimeIntentStatus struct { + DesiredRunning bool `json:"desired_running"` + UpdatedAt string `json:"updated_at,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + LastStartedAt string `json:"last_started_at,omitempty"` + LastStoppedAt string `json:"last_stopped_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +type ControlStatus struct { + Mode string `json:"mode"` + Transitioning bool `json:"transitioning"` + CanControl bool `json:"can_control"` + LastError string `json:"last_error,omitempty"` + ChangedAt time.Time `json:"changed_at,omitempty"` +} + +type RuntimeCapabilities struct { + Chat bool `json:"chat"` + ReadOnlyTools bool `json:"read_only_tools"` + DeviceWrite bool `json:"device_write"` } type RuntimeStartResult struct { @@ -186,11 +228,13 @@ const ( ) const ( - CloseCodePicoclawLockHeld = 4001 - CloseCodeRuntimeUnavailable = 4002 - CloseCodeAuthFailed = 4003 - CloseCodePicoclawTakenOver = 4004 - CloseCodeUpstreamClosed = 4005 + CloseCodePicoclawLockHeld = 4001 + CloseCodeRuntimeUnavailable = 4002 + CloseCodeAuthFailed = 4003 + CloseCodePicoclawTakenOver = 4004 + CloseCodeUpstreamClosed = 4005 + CloseCodeControlModeSwitched = 4006 + CloseCodeRuntimeStopped = 4007 ) type GatewaySession struct { diff --git a/server/service/vm/jiggler/jiggler.go b/server/service/vm/jiggler/jiggler.go index 4cac43f..b88c1fc 100644 --- a/server/service/vm/jiggler/jiggler.go +++ b/server/service/vm/jiggler/jiggler.go @@ -1,11 +1,15 @@ package jiggler import ( - "NanoKVM-Server/service/hid" + "context" "os" "strings" "sync" "time" + + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/inputcontrol" ) const ( @@ -120,15 +124,46 @@ func (j *Jiggler) GetMode() string { } func move(mode string) { + _, releaseMode, err := controlmode.GetManager().AcquireStable() + if err != nil { + return + } + defer releaseMode() + + ctx, release, err := inputcontrol.GetCoordinator().BeginBackground(context.Background()) + if err != nil { + return + } + defer release() + h := hid.GetHid() if mode == "absolute" { - h.WriteHid2([]byte{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00}) - time.Sleep(100 * time.Millisecond) - h.WriteHid2([]byte{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00}) + if err := h.WriteAbsoluteMouseReport([]byte{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00}); err != nil { + return + } + defer func() { + _ = h.WriteAbsoluteMouseReport([]byte{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00}) + }() + _ = waitMove(ctx, 100*time.Millisecond) } else { - h.WriteHid1([]byte{0x00, 0xa, 0xa, 0x00}) - time.Sleep(100 * time.Millisecond) - h.WriteHid1([]byte{0x00, 0xf6, 0xf6, 0x00}) + if err := h.WriteRelativeMouseReport([]byte{0x00, 0xa, 0xa, 0x00}); err != nil { + return + } + defer func() { + _ = h.WriteRelativeMouseReport([]byte{0x00, 0xf6, 0xf6, 0x00}) + }() + _ = waitMove(ctx, 100*time.Millisecond) + } +} + +func waitMove(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true } } diff --git a/server/service/ws/client.go b/server/service/ws/client.go index f443c62..97f0ce2 100644 --- a/server/service/ws/client.go +++ b/server/service/ws/client.go @@ -1,10 +1,14 @@ package ws import ( + "context" "encoding/json" + "errors" "time" + "NanoKVM-Server/service/controlmode" "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/inputcontrol" "NanoKVM-Server/service/picoclaw" "NanoKVM-Server/service/vm/jiggler" @@ -18,13 +22,20 @@ const ( MouseEvent ) +const ( + manualPreemptTimeout = 2 * time.Second + clientHeartbeatTimeout = 90 * time.Second +) + func NewClient(ws *websocket.Conn) *Client { client := &Client{ - ws: ws, - hid: hid.GetHid(), - keyboard: make(chan []byte, 200), - mouse: make(chan []byte, 200), - lastHeartbeat: time.Time{}, + ws: ws, + hid: hid.GetHid(), + manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()), + keyboard: make(chan hid.QueuedReport, 200), + mouse: make(chan hid.QueuedReport, 200), + heartbeatTimeout: clientHeartbeatTimeout, + lastHeartbeat: time.Time{}, } client.hid.Open() @@ -33,23 +44,33 @@ func NewClient(ws *websocket.Conn) *Client { } func (c *Client) Start() { - defer c.Close() - - go c.hid.Keyboard(c.keyboard) - go c.hid.Mouse(c.mouse) + c.workers.Add(2) + go func() { + defer c.workers.Done() + c.hid.KeyboardReports(c.keyboard) + }() + go func() { + defer c.workers.Done() + c.hid.MouseReports(c.mouse) + }() _ = c.Read() + c.Close() } func (c *Client) Read() error { - var zeroTime time.Time - _ = c.ws.SetReadDeadline(zeroTime) + if err := c.UpdateHeartbeat(); err != nil { + return err + } for { messageType, data, err := c.ws.ReadMessage() if err != nil { return err } + if err := c.UpdateHeartbeat(); err != nil { + return err + } if len(data) == 0 { continue @@ -59,23 +80,83 @@ func (c *Client) Read() error { switch data[0] { case Heartbeat: - c.UpdateHeartbeat() case KeyboardEvent: - if picoclaw.GetSessionLock().BlocksManualInput() { - log.Debug("manual keyboard input dropped while AI session holds control") + report := data[1:] + if len(report) != 8 { + log.Debugf("invalid manual keyboard report: %v", report) continue } - writeQueue(c.keyboard, data[1:]) + c.queueManualReport(c.keyboard, inputcontrol.ManualKeyboard, report, keyboardReportHeld(report), true) case MouseEvent: - if picoclaw.GetSessionLock().BlocksManualInput() { - log.Debug("manual mouse input dropped while AI session holds control") + report := data[1:] + if len(report) != 4 && len(report) != 6 { + log.Debugf("invalid manual mouse report: %v", report) continue } - writeQueue(c.mouse, data[1:]) + kind := inputcontrol.ManualRelativeMouse + if len(report) == 6 { + kind = inputcontrol.ManualAbsoluteMouse + } + c.queueManualReport(c.mouse, kind, report, report[0] != 0, mouseReportStartsCooldown(report)) } } } +func (c *Client) queueManualReport(queue chan hid.QueuedReport, kind inputcontrol.ManualReportKind, report []byte, held bool, startCooldown bool) { + ctx, cancel := context.WithTimeout(context.Background(), manualPreemptTimeout) + defer cancel() + + reservation, err := c.manual.ReserveWithCooldown(ctx, kind, held, startCooldown, func(mode controlmode.Mode) bool { + return mode != controlmode.ModePicoclaw || !picoclaw.GetSessionLock().BlocksManualInput() + }) + if err != nil { + if errors.Is(err, inputcontrol.ErrManualInputBlocked) { + log.Debug("manual HID input dropped while PicoClaw session holds control") + } else { + log.Errorf("manual HID input failed to acquire control: %s", err) + } + return + } + + queued := hid.QueuedReport{ + Data: append([]byte(nil), report...), + Execute: c.manual.Execute, + Complete: reservation.Complete, + ResetKeyboard: func() { c.manual.Reset(inputcontrol.ManualKeyboard) }, + ResetRelativeMouse: func() { c.manual.Reset(inputcontrol.ManualRelativeMouse) }, + ResetAbsoluteMouse: func() { c.manual.Reset(inputcontrol.ManualAbsoluteMouse) }, + } + if !writeQueue(queue, queued) { + return + } + jiggler.GetJiggler().Update() +} + +func keyboardReportHeld(report []byte) bool { + if len(report) != 8 { + return false + } + if report[0] != 0 { + return true + } + for _, key := range report[2:] { + if key != 0 { + return true + } + } + return false +} + +func mouseReportStartsCooldown(report []byte) bool { + if len(report) != 4 && len(report) != 6 { + return true + } + if report[0] != 0 { + return true + } + return report[len(report)-1] != 0 +} + func (c *Client) Write(event string, data string) error { message := &Message{ Type: event, @@ -95,10 +176,17 @@ func (c *Client) Write(event string, data string) error { return c.ws.WriteMessage(websocket.TextMessage, messageByte) } -func (c *Client) UpdateHeartbeat() { +func (c *Client) UpdateHeartbeat() error { + now := time.Now() + timeout := c.heartbeatTimeout + if timeout <= 0 { + timeout = clientHeartbeatTimeout + } c.mutex.Lock() - defer c.mutex.Unlock() - c.lastHeartbeat = time.Now() + c.lastHeartbeat = now + c.mutex.Unlock() + + return c.ws.SetReadDeadline(now.Add(timeout)) } func (c *Client) Close() { @@ -113,21 +201,24 @@ func (c *Client) Close() { if c.mouse != nil { close(c.mouse) } - - log.Debug("websocket disconnected") }) -} - -func writeQueue(queue chan []byte, data []byte) { - if !sendQueue(queue, data) { - log.Debug("hid event dropped because websocket queue is closed") - return + c.workers.Wait() + if c.manual != nil { + c.manual.Close() } - - jiggler.GetJiggler().Update() + log.Debug("websocket disconnected") } -func sendQueue(queue chan []byte, data []byte) (ok bool) { +func writeQueue(queue chan hid.QueuedReport, report hid.QueuedReport) bool { + if !sendQueue(queue, report) { + report.Complete(false) + log.Debug("hid event dropped because websocket queue is closed") + return false + } + return true +} + +func sendQueue(queue chan hid.QueuedReport, report hid.QueuedReport) (ok bool) { if queue == nil { return false } @@ -138,6 +229,6 @@ func sendQueue(queue chan []byte, data []byte) (ok bool) { } }() - queue <- data + queue <- report return true } diff --git a/server/service/ws/client_test.go b/server/service/ws/client_test.go new file mode 100644 index 0000000..7aba3a0 --- /dev/null +++ b/server/service/ws/client_test.go @@ -0,0 +1,112 @@ +package ws + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "NanoKVM-Server/service/controlmode" + "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/inputcontrol" + + "github.com/gorilla/websocket" +) + +func TestHeartbeatTimeoutReleasesManualLease(t *testing.T) { + control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP) + coordinator := &inputcontrol.Coordinator{} + manual := inputcontrol.NewManualSession(control, coordinator) + defer manual.Close() + + reservation, err := manual.Reserve(context.Background(), inputcontrol.ManualKeyboard, true, nil) + if err != nil { + t.Fatal(err) + } + reservation.Complete(true) + + connected := make(chan struct{}) + serverDone := make(chan struct{}) + upgrade := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrade.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade websocket: %v", err) + return + } + client := &Client{ + ws: conn, + hid: hid.GetHid(), + manual: manual, + keyboard: make(chan hid.QueuedReport, 1), + mouse: make(chan hid.QueuedReport, 1), + heartbeatTimeout: 30 * time.Millisecond, + } + close(connected) + client.Start() + close(serverDone) + })) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + select { + case <-connected: + case <-time.After(time.Second): + t.Fatal("websocket client did not connect") + } + + switchDone := make(chan error, 1) + go func() { + switchDone <- control.SwitchToPicoclaw(nil) + }() + + select { + case err := <-switchDone: + if err != nil { + t.Fatalf("switch after heartbeat timeout failed: %v", err) + } + case <-time.After(time.Second): + t.Fatal("heartbeat timeout did not release the manual control lease") + } + if got := control.Current(); got != controlmode.ModePicoclaw { + t.Fatalf("mode = %q, want picoclaw", got) + } + + select { + case <-serverDone: + case <-time.After(time.Second): + t.Fatal("websocket client did not close after heartbeat timeout") + } +} + +func TestMouseReportStartsCooldown(t *testing.T) { + tests := []struct { + name string + report []byte + want bool + }{ + {name: "relative move", report: []byte{0, 10, 0, 0}, want: false}, + {name: "relative wheel", report: []byte{0, 0, 0, 1}, want: true}, + {name: "relative button", report: []byte{1, 0, 0, 0}, want: true}, + {name: "absolute move", report: []byte{0, 1, 0, 1, 0, 0}, want: false}, + {name: "absolute wheel", report: []byte{0, 1, 0, 1, 0, 0xff}, want: true}, + {name: "absolute button", report: []byte{1, 1, 0, 1, 0, 0}, want: true}, + {name: "invalid", report: []byte{0, 1}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mouseReportStartsCooldown(tt.report); got != tt.want { + t.Fatalf("mouseReportStartsCooldown(%v) = %v, want %v", tt.report, got, tt.want) + } + }) + } +} diff --git a/server/service/ws/types.go b/server/service/ws/types.go index 0a1a0c7..fa79295 100644 --- a/server/service/ws/types.go +++ b/server/service/ws/types.go @@ -7,6 +7,7 @@ import ( "github.com/gorilla/websocket" "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/inputcontrol" ) type Manager struct { @@ -15,13 +16,16 @@ type Manager struct { } type Client struct { - ws *websocket.Conn - hid *hid.Hid - keyboard chan []byte - mouse chan []byte - lastHeartbeat time.Time - mutex sync.Mutex - closeOnce sync.Once + ws *websocket.Conn + hid *hid.Hid + manual *inputcontrol.ManualSession + keyboard chan hid.QueuedReport + mouse chan hid.QueuedReport + heartbeatTimeout time.Duration + lastHeartbeat time.Time + mutex sync.Mutex + closeOnce sync.Once + workers sync.WaitGroup } type Message struct {