feat(mcp): add authenticated remote control backend

Add an MCP Streamable HTTP server with token authentication, screenshot capture, keyboard, and mouse tools.

Introduce shared control-mode and input-control coordination so MCP, PicoClaw, and local HID paths serialize ownership safely.

Integrate PicoClaw gateway/runtime control handoff with PID-managed startup and focused unit coverage.
This commit is contained in:
SiYue-ZO
2026-07-20 17:58:41 +08:00
committed by Guoguo
parent 183f8dc983
commit 64ce12cc0d
79 changed files with 8868 additions and 374 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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)。

View File

@@ -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)

View File

@@ -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

View File

@@ -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=

12
server/proto/mcp.go Normal file
View File

@@ -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"`
}

159
server/router/control.go Normal file
View File

@@ -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
}
}

34
server/router/mcp.go Normal file
View File

@@ -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))
}

View File

@@ -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())

View File

@@ -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)

View File

@@ -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{})
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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}
}

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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...)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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 != ""
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
})
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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()),
})
}

View File

@@ -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

View File

@@ -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 == "" {

View File

@@ -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
}

View File

@@ -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"])
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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 + `"}`
}

View File

@@ -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{}

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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...)
}

View File

@@ -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(),
})
}

View File

@@ -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,

View File

@@ -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 = ""
}
})
}

View File

@@ -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()

View File

@@ -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())
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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 {