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" printf '%s\n' "/root"
} }
setup_runtime_env() { setup_picoclaw_home() {
[ -x "$BIN_PATH" ] || {
echo "picoclaw binary not found: $BIN_PATH"
return 1
}
USER_HOME="$(resolve_user_home)" USER_HOME="$(resolve_user_home)"
PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}" PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}"
HOME="$USER_HOME" HOME="$USER_HOME"
@@ -37,6 +32,8 @@ setup_runtime_env() {
echo "failed to create picoclaw home: $PICOCLAW_HOME" echo "failed to create picoclaw home: $PICOCLAW_HOME"
return 1 return 1
} }
PICOCLAW_PID_FILE="${PICOCLAW_PID_FILE:-$PICOCLAW_HOME/.picoclaw.pid}"
export PICOCLAW_PID_FILE
cd "$PICOCLAW_HOME" || { cd "$PICOCLAW_HOME" || {
echo "failed to enter picoclaw home: $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() { resolve_kvm_control_source() {
for candidate in \ for candidate in \
"/kvmapp/picoclaw/skills/kvm-control" \ "/kvmapp/picoclaw/skills/kvm-control" \
@@ -107,8 +173,8 @@ sync_agent_profile() {
start_service() { start_service() {
setup_runtime_env || return 1 setup_runtime_env || return 1
if pidof "$BIN_NAME" >/dev/null 2>&1; then if pid="$(gateway_running_pid)"; then
echo "picoclaw already running" echo "picoclaw gateway already running (PID: $pid)"
return 0 return 0
fi fi
@@ -118,6 +184,12 @@ start_service() {
fi fi
"$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 & "$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() { onboard_service() {
@@ -128,7 +200,26 @@ onboard_service() {
} }
stop_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 case "$1" in

View File

@@ -64,7 +64,7 @@ turn:
## Compile & Deploy ## 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. 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). 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. ツールチェーンのインストール
1. 以下のリンクからツールチェーンをダウンロードします: [ダウンロードリンク](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz)。 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. 安装工具链
1. 下载工具链:[下载地址](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz) 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 module NanoKVM-Server
go 1.24.0 go 1.25.0
require ( require (
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/gin-gonic/contrib v0.0.0-20240508051311-c1c6bf0061b0 github.com/gin-gonic/contrib v0.0.0-20240508051311-c1c6bf0061b0
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.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/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0 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/dtls/v3 v3.1.2
github.com/pion/rtp v1.8.18 github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1 github.com/pion/webrtc/v4 v4.0.1
@@ -60,6 +62,8 @@ require (
github.com/rs/cors v1.11.0 // indirect github.com/rs/cors v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.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/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/wlynxg/anet v0.0.5 // 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/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.47.0 // 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 golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // 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/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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= 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/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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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/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 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 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 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 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/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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= 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 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= 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/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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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= 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...) return append([]string(nil), picoclawLoopbackHTTPAllowedPaths...)
} }
func picoclawRouter(r *gin.Engine) { func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
service := picoclaw.NewService()
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken()) frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken()) localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())

View File

@@ -5,6 +5,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/picoclaw"
"github.com/gin-gonic/contrib/static" "github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -29,6 +32,9 @@ func web(r *gin.Engine) {
} }
func server(r *gin.Engine) { func server(r *gin.Engine) {
control := controlmode.GetManager()
picoclawService := picoclaw.NewService(control)
authRouter(r) authRouter(r)
applicationRouter(r) applicationRouter(r)
vmRouter(r) vmRouter(r)
@@ -36,7 +42,9 @@ func server(r *gin.Engine) {
storageRouter(r) storageRouter(r)
networkRouter(r) networkRouter(r)
hidRouter(r) hidRouter(r)
picoclawRouter(r) controlRouter(r, control, picoclawService)
mcpRouter(r, control, picoclawService)
picoclawRouter(r, picoclawService)
wsRouter(r) wsRouter(r)
downloadRouter(r) downloadRouter(r)
extensionsRouter(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) { 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) { 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) { 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 { 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) { 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) { func (h *Hid) KeyboardReports(queue <-chan QueuedReport) {
defer h.releaseKeyboard(path) 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 { for event := range queue {
if len(event) != 8 { execute = event.Execute
log.Debugf("invalid keyboard event: %v", event) resetKeyboard = event.ResetKeyboard
if len(event.Data) != 8 {
event.complete(false)
log.Debugf("invalid keyboard event: %v", event.Data)
continue 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 { if dropped := drainHIDQueue(queue); dropped > 0 {
log.Debugf("dropped %d stale keyboard HID reports after write failure", dropped) 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) { func keyboardReportActive(report []byte) bool {
h.writeHIDReport(h.keyboardDevice(path), keyboardReleaseReport()) 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 { func keyboardReleaseReport() []byte {
return []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} return []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
} }
func drainHIDQueue(queue <-chan []byte) int { func drainHIDQueue(queue <-chan QueuedReport) int {
dropped := 0 dropped := 0
for { for {
select { select {
case _, ok := <-queue: case report, ok := <-queue:
if !ok { if !ok {
return dropped return dropped
} }
report.complete(false)
dropped++ dropped++
default: default:
return dropped return dropped

View File

@@ -5,59 +5,153 @@ import (
) )
func (h *Hid) Mouse(queue <-chan []byte) { 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) { func (h *Hid) MouseReports(queue <-chan QueuedReport) {
defer h.releaseRelativeMouse(relativePath) 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 absoluteButtonsActive := false
var absoluteReleaseReport []byte absoluteReleaseReport := absoluteMouseReleaseReport(nil)
defer func() { 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 { 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 { for event := range queue {
switch len(event) { execute = event.Execute
case 4: resetRelativeMouse = event.ResetRelativeMouse
if !h.writeHIDReport(h.relativeMouseDevice(relativePath), event) { resetAbsoluteMouse = event.ResetAbsoluteMouse
cleanupFailure := func(writeErr error) {
log.Errorf("mouse HID write failed: %s", writeErr)
if dropped := drainHIDQueue(queue); dropped > 0 { if dropped := drainHIDQueue(queue); dropped > 0 {
log.Debugf("dropped %d stale mouse HID reports after relative write failure", dropped) log.Debugf("dropped %d stale mouse HID reports after write failure", dropped)
} }
h.releaseRelativeMouse(relativePath)
if len(event.Data) == 4 && event.Data[0] != 0 {
relativeButtonsActive = true
} }
case 6: if len(event.Data) == 6 && event.Data[0] != 0 {
if !h.writeHIDReport(h.absoluteMouseDevice(absolutePath), event) { absoluteButtonsActive = true
if dropped := drainHIDQueue(queue); dropped > 0 { absoluteReleaseReport = absoluteMouseReleaseReport(event.Data)
log.Debugf("dropped %d stale mouse HID reports after absolute write failure", dropped)
} }
if absoluteButtonsActive {
if h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport) { 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()
}
}
}
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 absoluteButtonsActive = false
if resetAbsoluteMouse != nil {
resetAbsoluteMouse()
} }
} else if event[0] != 0 {
h.releaseAbsoluteMouse(absolutePath, absoluteMouseReleaseReport(event))
} }
}
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 continue
} }
absoluteReleaseReport = absoluteMouseReleaseReport(event) absoluteButtonsActive = false
absoluteButtonsActive = event[0] != 0 if resetAbsoluteMouse != nil {
resetAbsoluteMouse()
}
}
if err := event.run(func() error {
return h.writeHID(h.relativeMouseDevice(relativePath), event.Data)
}); err != nil {
cleanupFailure(err)
continue
}
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: 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 { func relativeMouseReleaseReport() []byte {
return []byte{0x00, 0x00, 0x00, 0x00} return []byte{0x00, 0x00, 0x00, 0x00}
} }

View File

@@ -1,12 +1,14 @@
package hid package hid
import ( import (
"context"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto" "NanoKVM-Server/proto"
"NanoKVM-Server/service/inputcontrol"
) )
type Char struct { type Char struct {
@@ -19,6 +21,12 @@ type PasteReq struct {
Langue string `form:"langue"` 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 { func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
// if no language is specified → return base map // if no language is specified → return base map
if lang == "" { if lang == "" {
@@ -171,16 +179,44 @@ func (s *Service) Paste(c *gin.Context) {
return return
} }
if len(req.Content) > 1024 { contentRunes := []rune(req.Content)
if len(contentRunes) > maxPasteContentRunes {
rsp.ErrRsp(c, -2, "content too long") rsp.ErrRsp(c, -2, "content too long")
return return
} }
charMapLocal := LangueSwitch(charMap, req.Langue) 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} 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] key, ok := charMapLocal[char]
if !ok { if !ok {
log.Debugf("unknown key '%c' (rune: %d)", char, char) 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} keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
if err = writeKeyboardReport(keyDown); err != nil {
hid.WriteHid0(keyDown) break
hid.WriteHid0(keyUp) }
time.Sleep(30 * time.Millisecond) 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) 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 { 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 package hid
import (
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/inputcontrol"
)
type Service struct { type Service struct {
hid *Hid hid *Hid
control *controlmode.Manager
coordinator *inputcontrol.Coordinator
} }
func NewService() *Service { func NewService() *Service {
return &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 package hid
import ( import (
"NanoKVM-Server/proto"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -10,6 +9,9 @@ import (
"strings" "strings"
"time" "time"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/inputcontrol"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -91,7 +93,17 @@ func (s *Service) SetHidMode(c *gin.Context) {
func (s *Service) ResetHid(c *gin.Context) { func (s *Service) ResetHid(c *gin.Context) {
var rsp proto.Response 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) log.Errorf("failed to reset hid: %v", err)
rsp.ErrRsp(c, -1, "failed to reset hid") rsp.ErrRsp(c, -1, "failed to reset hid")
return 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 ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"math" "math"
"strings" "strings"
@@ -20,6 +21,16 @@ const (
) )
func (s *Service) Actions(c *gin.Context) { 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) sessionID, sessionErr := s.requireSessionID(c)
if sessionErr != nil { if sessionErr != nil {
writePicoclawError(c, sessionErr) writePicoclawError(c, sessionErr)
@@ -41,7 +52,7 @@ func (s *Service) Actions(c *gin.Context) {
return return
} }
result, execErr := s.executeActions(sessionID, actions) result, execErr := s.executeActions(operationCtx, sessionID, actions)
if execErr != nil { if execErr != nil {
writePicoclawError(c, execErr) writePicoclawError(c, execErr)
return return
@@ -74,7 +85,7 @@ func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) {
return []Action{action}, nil 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() startedAt := time.Now()
if len(actions) == 0 { if len(actions) == 0 {
return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions") return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions")
@@ -88,12 +99,16 @@ func (s *Service) executeActions(sessionID string, actions []Action) (result Act
totalWrites := 0 totalWrites := 0
for idx, action := range actions { 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 { if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
lockErr.Index = &idx lockErr.Index = &idx
return ActionResult{}, lockErr return ActionResult{}, lockErr
} }
writes, execErr := s.executeAction(action) writes, execErr := s.executeAction(ctx, action)
if execErr != nil { if execErr != nil {
execErr.Index = &idx execErr.Index = &idx
return ActionResult{}, execErr return ActionResult{}, execErr
@@ -114,7 +129,11 @@ func (s *Service) executeActions(sessionID string, actions []Action) (result Act
return result, nil 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)) { switch strings.ToLower(strings.TrimSpace(action.Action)) {
case "click": case "click":
x, y, err := normalizedPoint(action.X, action.Y) x, y, err := normalizedPoint(action.X, action.Y)
@@ -129,7 +148,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
writes := 0 writes := 0
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0) writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
writes += s.sendMousePress(x, y, button) writes += s.sendMousePress(x, y, button)
time.Sleep(defaultClickHold) if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil {
return writes, waitErr
}
writes += s.sendMouseRelease(x, y) writes += s.sendMouseRelease(x, y)
return writes, nil return writes, nil
@@ -144,7 +165,12 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
if action.DurationMs < 0 { if action.DurationMs < 0 {
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 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 return 0, nil
case "drag": case "drag":
@@ -165,6 +191,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0) writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0)
writes += s.sendMousePress(fromX, fromY, button) writes += s.sendMousePress(fromX, fromY, button)
for step := 1; step <= defaultDragSteps; step++ { for step := 1; step <= defaultDragSteps; step++ {
if contextErr := controlOperationError(ctx); contextErr != nil {
return writes, contextErr
}
ratio := float64(step) / float64(defaultDragSteps) ratio := float64(step) / float64(defaultDragSteps)
x := fromX + (toX-fromX)*ratio x := fromX + (toX-fromX)*ratio
y := fromY + (toY-fromY)*ratio y := fromY + (toY-fromY)*ratio
@@ -203,9 +232,14 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
writes := 0 writes := 0
for range amount { 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, wheel)
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0) writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
time.Sleep(defaultScrollStep) if waitErr := waitForControlOperation(ctx, defaultScrollStep); waitErr != nil {
return writes, waitErr
}
} }
return writes, nil return writes, nil
@@ -216,6 +250,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
charMap := hid.GetCharMap("") charMap := hid.GetCharMap("")
writes := 0 writes := 0
for _, char := range action.Text { for _, char := range action.Text {
if contextErr := controlOperationError(ctx); contextErr != nil {
return writes, contextErr
}
key, ok := charMap[char] key, ok := charMap[char]
if !ok { if !ok {
return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action") 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{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00})
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 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 return writes, nil
@@ -234,7 +273,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
} }
writes := 0 writes := 0
writes += s.sendKeyboardReport(report) 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}) writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
return writes, nil return writes, nil
} }

View File

@@ -120,6 +120,7 @@ func withAgentProfile(status RuntimeStatus) RuntimeStatus {
} }
func (s *Service) UpdateAgentProfile(c *gin.Context) { func (s *Service) UpdateAgentProfile(c *gin.Context) {
s.ensureDependencies()
var req AgentProfileUpdateRequest var req AgentProfileUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile payload")) writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile payload"))
@@ -132,16 +133,19 @@ func (s *Service) UpdateAgentProfile(c *gin.Context) {
return return
} }
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
if err := applyPicoclawAgentProfile(profile); err != nil { if err := applyPicoclawAgentProfile(profile); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error())) writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
return return
} }
_ = s.syncConfigFromPicoclaw() _ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady() _ = s.ensureRuntimeReadyForLifecycle()
writeSuccess(c, gin.H{ writeSuccess(c, gin.H{
"profile": profile, "profile": profile,
"status": withAgentProfile(s.runtime.Get()), "status": withAgentProfile(s.runtimeStatus()),
}) })
} }

View File

@@ -1,6 +1,7 @@
package picoclaw package picoclaw
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -104,6 +105,53 @@ func (s *Service) syncConfigFromPicoclaw() *PicoclawError {
return nil 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 { type picoclawGatewaySettings struct {
GatewayURL string GatewayURL string
Token string Token string

View File

@@ -3,6 +3,7 @@ package picoclaw
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"os/user" "os/user"
@@ -93,19 +94,55 @@ func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) {
}, nil }, nil
} }
func resolvePicoclawConfigPath() (string, error) { var runPicoclawOnboardForConfig = runPicoclawOnboard
home := os.Getenv("PICOCLAW_HOME")
if home == "" { func loadOrInitializePicoclawConfigDocument() (*picoclawConfigDocument, error) {
currentUser, err := user.Current() doc, err := loadPicoclawConfigDocument()
if err != nil { if err == nil {
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err) return doc, nil
} }
home = filepath.Join(currentUser.HomeDir, ".picoclaw") 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, err := resolvePicoclawHome()
if err != nil {
return "", err
} }
return filepath.Join(home, "config.json"), nil 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 { func expandPicoclawPath(path string) string {
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
if 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

@@ -16,6 +16,10 @@ const (
CodeRuntimeStartFailed = "RUNTIME_START_FAILED" CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
CodeSessionIDMissing = "SESSION_ID_MISSING" CodeSessionIDMissing = "SESSION_ID_MISSING"
CodeSessionIDInvalid = "SESSION_ID_INVALID" CodeSessionIDInvalid = "SESSION_ID_INVALID"
CodeControlModeConflict = "AI_MODE_CONFLICT"
CodeControlRequired = "CONTROL_REQUIRED"
CodeControlOwnedByMCP = "CONTROL_OWNED_BY_MCP"
CodeControlTransitioning = "CONTROL_TRANSITIONING"
) )
type PicoclawError struct { type PicoclawError struct {
@@ -47,6 +51,10 @@ func writeSuccess(c *gin.Context, data interface{}) {
} }
func writePicoclawError(c *gin.Context, err *PicoclawError) { func writePicoclawError(c *gin.Context, err *PicoclawError) {
writePicoclawErrorWithData(c, err, nil)
}
func writePicoclawErrorWithData(c *gin.Context, err *PicoclawError, data interface{}) {
if err == nil { if err == nil {
return return
} }
@@ -55,6 +63,9 @@ func writePicoclawError(c *gin.Context, err *PicoclawError) {
"code": err.Code, "code": err.Code,
"message": err.Message, "message": err.Message,
} }
if data != nil {
payload["data"] = data
}
if err.SessionID != "" { if err.SessionID != "" {
payload["session_id"] = err.SessionID payload["session_id"] = err.SessionID
} }

View File

@@ -10,6 +10,13 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
type gatewayProbeError struct {
status string
configError string
lastError string
message string
}
func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) { func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) {
cfg := s.config.Get() cfg := s.config.Get()
@@ -66,6 +73,69 @@ func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawEr
return upstream, nil 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) { func buildGatewayURL(cfg Config, sessionID string) (string, error) {
parsed, err := url.Parse(cfg.GatewayURL) parsed, err := url.Parse(cfg.GatewayURL)
if err != nil { if err != nil {

View File

@@ -7,6 +7,7 @@ import (
"sync" "sync"
"time" "time"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/stream/mjpeg" "NanoKVM-Server/service/stream/mjpeg"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -30,6 +31,19 @@ type relayResult struct {
} }
func (s *Service) ConnectGateway(c *gin.Context) { 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")) sessionID := strings.TrimSpace(c.Query("session_id"))
if sessionID == "" { if sessionID == "" {
sessionID = uuid.NewString() sessionID = uuid.NewString()
@@ -69,7 +83,7 @@ func (s *Service) ConnectGateway(c *gin.Context) {
if err != nil { if err != nil {
log.Errorf("failed to upgrade gateway websocket: %s", err) log.Errorf("failed to upgrade gateway websocket: %s", err)
_ = upstream.Close() _ = upstream.Close()
ReleaseSession(sessionID) s.releaseGatewaySession(sessionID)
GetSessionManager().SetState(sessionID, SessionStateClosed) GetSessionManager().SetState(sessionID, SessionStateClosed)
GetSessionManager().Remove(sessionID) GetSessionManager().Remove(sessionID)
return return
@@ -92,7 +106,6 @@ func (s *Service) ConnectGateway(c *gin.Context) {
go s.runPingLoop("upstream", session.SessionID, upstream, cfg, &wg) go s.runPingLoop("upstream", session.SessionID, upstream, cfg, &wg)
go s.proxyMessages("downstream", session, downstream, cfg, &wg, results) go s.proxyMessages("downstream", session, downstream, cfg, &wg, results)
go s.proxyMessages("upstream", session, upstream, cfg, &wg, results) go s.proxyMessages("upstream", session, upstream, cfg, &wg, results)
result := <-results result := <-results
closeCode := result.CloseCode closeCode := result.CloseCode
if closeCode == 0 { if closeCode == 0 {
@@ -217,7 +230,7 @@ func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, re
cleanupPicoclawMediaTempDir() cleanupPicoclawMediaTempDir()
} }
ReleaseSession(session.SessionID) s.releaseGatewaySession(session.SessionID)
GetSessionManager().SetState(session.SessionID, SessionStateClosed) GetSessionManager().SetState(session.SessionID, SessionStateClosed)
GetSessionManager().Remove(session.SessionID) GetSessionManager().Remove(session.SessionID)

View File

@@ -30,6 +30,7 @@ type jsonRPCResponse struct {
type jsonRPCError struct { type jsonRPCError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
} }
// MCP tool definitions // MCP tool definitions
@@ -67,7 +68,12 @@ var mcpToolDefinitions = []map[string]interface{}{
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, "keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"direction": map[string]interface{}{"type": "string"}, "direction": map[string]interface{}{"type": "string"},
"amount": map[string]interface{}{"type": "integer"}, "amount": map[string]interface{}{"type": "integer"},
"duration_ms": 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"}}}, "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"}}}, "to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
}, },
@@ -122,6 +128,33 @@ func (s *Service) MCPHandler(c *gin.Context) {
c.JSON(http.StatusOK, resp) 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 { func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse {
return jsonRPCResponse{ return jsonRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
@@ -166,6 +199,22 @@ func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCRespon
case "kvm_screenshot": case "kvm_screenshot":
return s.mcpScreenshot(req, params.Arguments, c) return s.mcpScreenshot(req, params.Arguments, c)
case "kvm_actions": 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) return s.mcpActions(req, params.Arguments, c)
default: default:
return jsonRPCResponse{ return jsonRPCResponse{
@@ -262,7 +311,7 @@ func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Co
sessionID = s.lock.Owner() sessionID = s.lock.Owner()
} }
result, err := s.executeActions(sessionID, params.Actions) result, err := s.executeActions(c.Request.Context(), sessionID, params.Actions)
if err != nil { if err != nil {
return mcpToolError(req, err.Message) 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 ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -20,6 +21,66 @@ func extractPicoclawModelName(model string) string {
return model 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 { func isPicoclawModelConfigured(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool {
if modelName == "" { if modelName == "" {
return false return false
@@ -71,6 +132,7 @@ type ModelConfigUpdateRequest struct {
} }
func (s *Service) UpdateModelConfig(c *gin.Context) { func (s *Service) UpdateModelConfig(c *gin.Context) {
s.ensureDependencies()
var req ModelConfigUpdateRequest var req ModelConfigUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload")) writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload"))
@@ -79,6 +141,33 @@ func (s *Service) UpdateModelConfig(c *gin.Context) {
currentStatus := s.runtime.Get() currentStatus := s.runtime.Get()
shouldRestart := currentStatus.Ready || currentStatus.Status == "ready" 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( modelName, err := updatePicoclawModelConfig(
strings.TrimSpace(req.APIBase), 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)) writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+startErr.Message))
return return
} }
s.setRuntimeIntentDesired(true, "model_config")
} else { } else {
_ = s.syncConfigFromPicoclaw() _ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady() _ = s.ensureRuntimeReadyForLifecycle()
} }
writeSuccess(c, gin.H{ writeSuccess(c, gin.H{
"model_name": modelName, "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") return "", fmt.Errorf("model identifier is required")
} }
modelName := extractPicoclawModelName(model) modelName, err := validatePicoclawModelIdentifier(model)
if modelName == "" {
return "", fmt.Errorf("model identifier is required")
}
doc, err := loadPicoclawConfigDocument()
if err != nil { if err != nil {
return "", err return "", err
} }
doc, err := loadOrInitializePicoclawConfigDocument()
if err != nil {
return "", err
}
doc.raw["version"] = currentPicoclawConfigVersion
modelListValue, ok := doc.raw["model_list"].([]any) modelListValue, ok := doc.raw["model_list"].([]any)
if !ok { if !ok {
modelListValue = []any{} 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 ( const (
picoclawBinaryPath = "/usr/bin/picoclaw" picoclawBinaryPath = "/usr/bin/picoclaw"
picoclawCacheDir = "/root/.picoclaw-cache" picoclawCacheDir = "/root/.picoclaw-cache"
picoclawPIDFileName = ".picoclaw.pid"
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/picoclaw_Linux_riscv64.tar.gz" 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" picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/sha512.txt"
etcInitPicoclawScript = "/etc/init.d/S96picoclaw" etcInitPicoclawScript = "/etc/init.d/S96picoclaw"

View File

@@ -11,6 +11,7 @@ import (
) )
const ( const (
currentPicoclawConfigVersion = 3
defaultPicoclawPingSec = 30 defaultPicoclawPingSec = 30
defaultPicoclawReadSec = 60 defaultPicoclawReadSec = 60
defaultPicoclawWriteSec = 10 defaultPicoclawWriteSec = 10
@@ -90,6 +91,7 @@ func ensurePicoclawPicoChannelEnabled(doc *picoclawConfigDocument) error {
} }
func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error { func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error {
editor.setValue(currentPicoclawConfigVersion, "version")
for _, entry := range picoclawNanoKVMDefaults { for _, entry := range picoclawNanoKVMDefaults {
editor.setValue(entry.value, entry.path...) editor.setValue(entry.value, entry.path...)
} }

View File

@@ -1,44 +1,232 @@
package picoclaw package picoclaw
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
"time" "time"
"NanoKVM-Server/service/controlmode"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
) )
func (s *Service) StartRuntime(c *gin.Context) { func (s *Service) StartRuntime(c *gin.Context) {
command, output, err := s.startRuntime() defer s.recoverRuntimeHandler(c, "start")
if err != nil { s.ensureDependencies()
writePicoclawError(c, err) 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 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{ writeSuccess(c, RuntimeStartResult{
Started: true, Started: true,
Command: command, Command: command,
Output: output, Output: output,
Status: s.runtime.Get(), Status: status,
}) })
} }
func (s *Service) StopRuntime(c *gin.Context) { 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 { 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 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{ writeSuccess(c, RuntimeStartResult{
Started: false, Started: false,
Command: command, Status: status,
Output: output,
Status: s.runtime.Get(),
}) })
} }
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) { func (s *Service) InstallRuntime(c *gin.Context) {
s.ensureDependencies()
output, err := s.installRuntime() output, err := s.installRuntime()
if err != nil { if err != nil {
writePicoclawError(c, err) writePicoclawError(c, err)
@@ -51,11 +239,12 @@ func (s *Service) InstallRuntime(c *gin.Context) {
Binary: picoclawBinaryPath, Binary: picoclawBinaryPath,
Download: picoclawDownloadURL, Download: picoclawDownloadURL,
Output: output, Output: output,
Status: currentStatus, Status: s.runtimeStatus(),
}) })
} }
func (s *Service) UninstallRuntime(c *gin.Context) { func (s *Service) UninstallRuntime(c *gin.Context) {
s.ensureDependencies()
currentStatus := s.runtime.Get() currentStatus := s.runtime.Get()
uninstallOutput := "picoclaw uninstalled successfully" uninstallOutput := "picoclaw uninstalled successfully"
if currentStatus.Installing { if currentStatus.Installing {
@@ -63,17 +252,53 @@ func (s *Service) UninstallRuntime(c *gin.Context) {
return 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() running, err := isRuntimeRunning()
if err != nil { if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status")) writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status"))
return return
} }
if running || currentStatus.Ready || currentStatus.Status == "ready" { if running || currentStatus.Ready || currentStatus.Status == "ready" || isRuntimeLifecycleStatusPending(currentStatus) {
if _, _, stopErr := s.stopRuntime(); stopErr != nil { s.runtime.Update(func(status *RuntimeStatus) {
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Message + ")" 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 { if configPath, err := resolvePicoclawConfigPath(); err == nil {
_ = os.RemoveAll(filepath.Dir(configPath)) _ = os.RemoveAll(filepath.Dir(configPath))
@@ -105,6 +330,6 @@ func (s *Service) UninstallRuntime(c *gin.Context) {
Binary: picoclawBinaryPath, Binary: picoclawBinaryPath,
Download: picoclawDownloadURL, Download: picoclawDownloadURL,
Output: uninstallOutput, Output: uninstallOutput,
Status: s.runtime.Get(), Status: s.runtimeStatus(),
}) })
} }

View File

@@ -19,6 +19,10 @@ import (
) )
func (s *Service) installRuntime() (string, *PicoclawError) { 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) log.Debugf("picoclaw install: start, binary=%s, cache=%s", picoclawBinaryPath, picoclawCacheDir)
currentStatus := s.runtime.Get() currentStatus := s.runtime.Get()
@@ -62,6 +66,7 @@ func (s *Service) installRuntime() (string, *PicoclawError) {
} }
func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) { func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) {
s.ensureDependencies()
defer cancel() defer cancel()
_ = os.RemoveAll(picoclawCacheDir) _ = 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) { func (s *Service) setInstallProgress(stage string, progress int, lastError string) {
s.ensureDependencies()
if progress < 0 { if progress < 0 {
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) { func (s *Service) finishInstallFailure(status string, message string) {
s.ensureDependencies()
s.runtime.Set(RuntimeStatus{ s.runtime.Set(RuntimeStatus{
Ready: false, Ready: false,
Installed: 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) { func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
s.ensureDependencies()
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader)) sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
if sessionID == "" { if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")) 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 { if session, ok := GetSessionManager().Get(sessionID); ok {
s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released") s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released")
} else {
ReleaseSession(sessionID)
} }
status := s.runtime.Get() 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 ( import (
"context" "context"
"fmt" "fmt"
"net"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
) )
func (s *Service) startRuntime() (string, string, *PicoclawError) { 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 { if installed, statErr := isPicoclawInstalled(); statErr != nil {
s.runtime.Update(func(status *RuntimeStatus) { s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false status.Ready = false
@@ -57,6 +65,16 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
}) })
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error()) 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" command := scriptPath + " start"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout) ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
@@ -81,6 +99,22 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
time.Sleep(picoclawStartWaitPeriod) time.Sleep(picoclawStartWaitPeriod)
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil { if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message) 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 return command, trimmedOutput, startErr
} }
@@ -88,6 +122,11 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
} }
func (s *Service) stopRuntime() (string, string, *PicoclawError) { func (s *Service) stopRuntime() (string, string, *PicoclawError) {
if s == nil {
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
}
s.ensureDependencies()
settings, _ := loadPicoclawGatewaySettings() settings, _ := loadPicoclawGatewaySettings()
scriptPath, err := resolvePicoclawStartScript() scriptPath, err := resolvePicoclawStartScript()
if err != nil { if err != nil {
@@ -148,7 +187,8 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
var lastErr *PicoclawError var lastErr *PicoclawError
for { for {
if runtimeErr := s.ensureRuntimeReady(); runtimeErr == nil { runtimeErr := s.ensureRuntimeReadyForLifecycle()
if runtimeErr == nil {
return nil return nil
} else { } else {
lastErr = runtimeErr lastErr = runtimeErr
@@ -168,12 +208,12 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
} }
func resolvePicoclawStartScript() (string, error) { func resolvePicoclawStartScript() (string, error) {
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
return etcInitPicoclawScript, nil
}
if _, err := os.Stat(kvmappPicoclawScript); err == nil { if _, err := os.Stat(kvmappPicoclawScript); err == nil {
return kvmappPicoclawScript, 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) 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) { func isRuntimeRunning() (bool, error) {
binName := filepath.Base(picoclawBinaryPath) pid, err := runtimeGatewayPID()
if binName == "" || binName == "." || binName == string(filepath.Separator) { if err != nil {
return false, fmt.Errorf("invalid picoclaw binary path: %s", picoclawBinaryPath) 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) settings, err := loadPicoclawGatewaySettings()
if err := command.Run(); err != nil { if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 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, nil
} }
return false, err return false, err
} }
return isPicoclawGatewayCmdline(data), nil
return true, 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 package picoclaw
import ( import (
"net"
"net/url"
"os" "os"
"sync" "sync"
"time" "time"
"NanoKVM-Server/common" "NanoKVM-Server/common"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid" "NanoKVM-Server/service/hid"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -20,18 +19,63 @@ var (
configStore *ConfigStore configStore *ConfigStore
runtimeStoreOnce sync.Once runtimeStoreOnce sync.Once
runtimeStore *RuntimeStore runtimeStore *RuntimeStore
runtimeIntentStoreOnce sync.Once
runtimeIntentStore *RuntimeIntentStore
probeLoopOnce sync.Once probeLoopOnce sync.Once
) )
const runtimeStatusRefreshInterval = 2 * time.Second const runtimeStatusRefreshInterval = 2 * time.Second
func NewService() *Service { func NewService(control *controlmode.Manager) *Service {
return &Service{ if control == nil {
control = controlmode.GetManager()
}
service := &Service{
vision: common.GetKvmVision(), vision: common.GetKvmVision(),
hid: hid.GetHid(), hid: hid.GetHid(),
config: getConfigStore(), config: getConfigStore(),
lock: GetSessionLock(), lock: GetSessionLock(),
runtime: getRuntimeStore(), 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 return runtimeStore
} }
func getRuntimeIntentStore() *RuntimeIntentStore {
runtimeIntentStoreOnce.Do(func() {
runtimeIntentStore = NewRuntimeIntentStore(RuntimeIntentFile)
})
return runtimeIntentStore
}
func (s *Service) GetRuntimeStatus(c *gin.Context) { func (s *Service) GetRuntimeStatus(c *gin.Context) {
s.ensureDependencies()
s.startRuntimeProbeLoop() s.startRuntimeProbeLoop()
modeStatus, modeErr := s.control.Status()
if modeErr != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, modeErr.Error()))
return
}
status := s.runtime.Get() status := s.runtime.Get()
if shouldRefreshRuntimeStatus(status) { if shouldRefreshRuntimeStatus(status) {
_ = s.ensureRuntimeReady() _ = s.ensureRuntimeReady()
@@ -78,16 +136,115 @@ func (s *Service) GetRuntimeStatus(c *gin.Context) {
}) })
} }
status = s.applyRuntimeIntentStatus(applyControlModeStatus(status, modeStatus))
writeSuccess(c, withAgentProfile(status)) writeSuccess(c, withAgentProfile(status))
} }
func (s *Service) GetRuntimeSession(c *gin.Context) { func (s *Service) GetRuntimeSession(c *gin.Context) {
s.ensureDependencies()
writeSuccess(c, gin.H{ writeSuccess(c, gin.H{
"current_session": s.lock.Owner(), "current_session": s.lock.Owner(),
"checked_at": time.Now(), "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) { func (s *Service) requireSessionID(c *gin.Context) (string, *PicoclawError) {
sessionID := c.GetHeader(sessionIDHeader) sessionID := c.GetHeader(sessionIDHeader)
if sessionID == "" { if sessionID == "" {
@@ -98,31 +255,69 @@ func (s *Service) requireSessionID(c *gin.Context) (string, *PicoclawError) {
} }
func (s *ConfigStore) Get() Config { func (s *ConfigStore) Get() Config {
if s == nil {
return defaultConfig()
}
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
return s.config return s.config
} }
func (s *ConfigStore) Set(cfg Config) { func (s *ConfigStore) Set(cfg Config) {
if s == nil {
return
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.config = cfg s.config = cfg
} }
func (s *RuntimeStore) Get() RuntimeStatus { func (s *RuntimeStore) Get() RuntimeStatus {
if s == nil {
return RuntimeStatus{
Ready: false,
Installed: false,
InstallPath: picoclawBinaryPath,
Status: "unavailable",
}
}
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
return s.status return s.status
} }
func (s *RuntimeStore) Set(status RuntimeStatus) { func (s *RuntimeStore) Set(status RuntimeStatus) {
if s == nil {
return
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
status.InstallPath = picoclawBinaryPath status.InstallPath = picoclawBinaryPath
s.status = status 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)) { func (s *RuntimeStore) Update(update func(*RuntimeStatus)) {
if s == nil {
return
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
update(&s.status) update(&s.status)
@@ -130,21 +325,76 @@ func (s *RuntimeStore) Update(update func(*RuntimeStatus)) {
} }
func (s *RuntimeStore) UpdateInstallStatus(update func(*RuntimeStatus)) { func (s *RuntimeStore) UpdateInstallStatus(update func(*RuntimeStatus)) {
if s == nil {
return
}
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
update(&s.status) update(&s.status)
s.status.InstallPath = picoclawBinaryPath 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 { 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() currentStatus := s.runtime.Get()
if currentStatus.Installing { if currentStatus.Installing {
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress") 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() installed, statErr := isPicoclawInstalled()
if statErr != nil { if statErr != nil {
s.runtime.Set(RuntimeStatus{ setStatus(RuntimeStatus{
Ready: false, Ready: false,
Installed: false, Installed: false,
Installing: false, Installing: false,
@@ -158,7 +408,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError {
return newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw installation") return newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw installation")
} }
if !installed { if !installed {
s.runtime.Set(RuntimeStatus{ setStatus(RuntimeStatus{
Ready: false, Ready: false,
Installed: false, Installed: false,
Installing: false, Installing: false,
@@ -174,7 +424,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError {
configPath, pathErr := resolvePicoclawConfigPath() configPath, pathErr := resolvePicoclawConfigPath()
if pathErr != nil { if pathErr != nil {
s.runtime.Set(RuntimeStatus{ setStatus(RuntimeStatus{
Ready: false, Ready: false,
Installed: true, Installed: true,
Installing: false, Installing: false,
@@ -191,7 +441,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError {
if _, err := os.Stat(configPath); err != nil { if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
if _, onboardErr := runPicoclawOnboard(); onboardErr != nil { if _, onboardErr := runPicoclawOnboard(); onboardErr != nil {
s.runtime.Set(RuntimeStatus{ setStatus(RuntimeStatus{
Ready: false, Ready: false,
Installed: true, Installed: true,
Installing: false, Installing: false,
@@ -208,7 +458,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError {
if _, statErr := os.Stat(configPath); statErr == nil { if _, statErr := os.Stat(configPath); statErr == nil {
goto configReady goto configReady
} }
s.runtime.Set(RuntimeStatus{ setStatus(RuntimeStatus{
Ready: false, Ready: false,
Installed: true, Installed: true,
Installing: false, Installing: false,
@@ -221,7 +471,7 @@ func (s *Service) ensureRuntimeReady() *PicoclawError {
}) })
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured") return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
} }
s.runtime.Set(RuntimeStatus{ setStatus(RuntimeStatus{
Ready: false, Ready: false,
Installed: true, Installed: true,
Installing: false, Installing: false,
@@ -246,7 +496,7 @@ configReady:
return newPicoclawError(CodeRuntimeUnavailable, settingsErr.Error()) return newPicoclawError(CodeRuntimeUnavailable, settingsErr.Error())
} }
if !settings.ModelConfigured { if !settings.ModelConfigured {
s.runtime.Set(RuntimeStatus{ setStatus(RuntimeStatus{
Ready: false, Ready: false,
Installed: true, Installed: true,
Installing: false, Installing: false,
@@ -261,67 +511,9 @@ configReady:
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured") return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
} }
cfg := s.config.Get() running, runningErr := isRuntimeRunning()
if runningErr != nil {
parsed, err := url.Parse(cfg.GatewayURL) setStatus(RuntimeStatus{
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{
Ready: false, Ready: false,
Installed: true, Installed: true,
Installing: false, Installing: false,
@@ -330,15 +522,48 @@ configReady:
ModelConfigured: true, ModelConfigured: true,
ModelName: settings.ModelName, ModelName: settings.ModelName,
Status: "unavailable", Status: "unavailable",
LastError: err.Error(), LastError: runningErr.Error(),
CheckedAt: time.Now(), CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(), 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, Ready: true,
Installed: true, Installed: true,
Installing: false, Installing: false,
@@ -354,6 +579,7 @@ configReady:
} }
func (s *Service) startRuntimeProbeLoop() { func (s *Service) startRuntimeProbeLoop() {
s.ensureDependencies()
probeLoopOnce.Do(func() { probeLoopOnce.Do(func() {
go func() { go func() {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)

View File

@@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid" "NanoKVM-Server/service/hid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -12,15 +13,35 @@ import (
const picoclawMediaTempDirName = "picoclaw_media" const picoclawMediaTempDirName = "picoclaw_media"
func ReleaseSession(sessionID string) { func ReleaseSession(sessionID string) {
GetSessionLock().Release(sessionID) _, err := releaseOwnedSession(GetSessionLock(), sessionID, hid.ReleaseAllHIDState)
releaseAllHIDState() if err != nil {
log.Errorf("failed to release HID state for PicoClaw session %s: %v", sessionID, err)
}
} }
func releaseAllHIDState() { func (s *Service) releaseGatewaySession(sessionID string) {
h := hid.GetHid() if s == nil {
h.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) ReleaseSession(sessionID)
h.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00}) return
h.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) }
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() { func (s *Service) releaseAllHIDState() {

View File

@@ -108,6 +108,29 @@ func (l *SessionLock) Release(sessionID string) bool {
return true 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) { func (l *SessionLock) ForceTakeover(sessionID string) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() 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() defer m.mu.Unlock()
delete(m.sessions, sessionID) 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" "sync"
"time" "time"
"NanoKVM-Server/service/controlmode"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -26,6 +28,12 @@ type Service struct {
config *ConfigStore config *ConfigStore
lock *SessionLock lock *SessionLock
runtime *RuntimeStore runtime *RuntimeStore
runtimeIntent *RuntimeIntentStore
control *controlmode.Manager
releaseHID func() error
operations *controlOperationTracker
runtimeLifecycleMu sync.Mutex
reconcileOnce sync.Once
} }
type ConfigStore struct { type ConfigStore struct {
@@ -38,6 +46,11 @@ type RuntimeStore struct {
status RuntimeStatus status RuntimeStatus
} }
type RuntimeIntentStore struct {
mu sync.Mutex
path string
}
type Config struct { type Config struct {
GatewayURL string `json:"gateway_url"` GatewayURL string `json:"gateway_url"`
ConnectTimeoutMs int `json:"connect_timeout_ms"` ConnectTimeoutMs int `json:"connect_timeout_ms"`
@@ -64,6 +77,35 @@ type RuntimeStatus struct {
LastError string `json:"last_error,omitempty"` LastError string `json:"last_error,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"` CheckedAt time.Time `json:"checked_at,omitempty"`
CurrentSession string `json:"current_session,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 { type RuntimeStartResult struct {
@@ -191,6 +233,8 @@ const (
CloseCodeAuthFailed = 4003 CloseCodeAuthFailed = 4003
CloseCodePicoclawTakenOver = 4004 CloseCodePicoclawTakenOver = 4004
CloseCodeUpstreamClosed = 4005 CloseCodeUpstreamClosed = 4005
CloseCodeControlModeSwitched = 4006
CloseCodeRuntimeStopped = 4007
) )
type GatewaySession struct { type GatewaySession struct {

View File

@@ -1,11 +1,15 @@
package jiggler package jiggler
import ( import (
"NanoKVM-Server/service/hid" "context"
"os" "os"
"strings" "strings"
"sync" "sync"
"time" "time"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/inputcontrol"
) )
const ( const (
@@ -120,15 +124,46 @@ func (j *Jiggler) GetMode() string {
} }
func move(mode 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() h := hid.GetHid()
if mode == "absolute" { if mode == "absolute" {
h.WriteHid2([]byte{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00}) if err := h.WriteAbsoluteMouseReport([]byte{0x00, 0x00, 0x3f, 0x00, 0x3f, 0x00}); err != nil {
time.Sleep(100 * time.Millisecond) return
h.WriteHid2([]byte{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00}) }
defer func() {
_ = h.WriteAbsoluteMouseReport([]byte{0x00, 0xff, 0x3f, 0xff, 0x3f, 0x00})
}()
_ = waitMove(ctx, 100*time.Millisecond)
} else { } else {
h.WriteHid1([]byte{0x00, 0xa, 0xa, 0x00}) if err := h.WriteRelativeMouseReport([]byte{0x00, 0xa, 0xa, 0x00}); err != nil {
time.Sleep(100 * time.Millisecond) return
h.WriteHid1([]byte{0x00, 0xf6, 0xf6, 0x00}) }
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 package ws
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"time" "time"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid" "NanoKVM-Server/service/hid"
"NanoKVM-Server/service/inputcontrol"
"NanoKVM-Server/service/picoclaw" "NanoKVM-Server/service/picoclaw"
"NanoKVM-Server/service/vm/jiggler" "NanoKVM-Server/service/vm/jiggler"
@@ -18,12 +22,19 @@ const (
MouseEvent MouseEvent
) )
const (
manualPreemptTimeout = 2 * time.Second
clientHeartbeatTimeout = 90 * time.Second
)
func NewClient(ws *websocket.Conn) *Client { func NewClient(ws *websocket.Conn) *Client {
client := &Client{ client := &Client{
ws: ws, ws: ws,
hid: hid.GetHid(), hid: hid.GetHid(),
keyboard: make(chan []byte, 200), manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()),
mouse: make(chan []byte, 200), keyboard: make(chan hid.QueuedReport, 200),
mouse: make(chan hid.QueuedReport, 200),
heartbeatTimeout: clientHeartbeatTimeout,
lastHeartbeat: time.Time{}, lastHeartbeat: time.Time{},
} }
@@ -33,23 +44,33 @@ func NewClient(ws *websocket.Conn) *Client {
} }
func (c *Client) Start() { func (c *Client) Start() {
defer c.Close() c.workers.Add(2)
go func() {
go c.hid.Keyboard(c.keyboard) defer c.workers.Done()
go c.hid.Mouse(c.mouse) c.hid.KeyboardReports(c.keyboard)
}()
go func() {
defer c.workers.Done()
c.hid.MouseReports(c.mouse)
}()
_ = c.Read() _ = c.Read()
c.Close()
} }
func (c *Client) Read() error { func (c *Client) Read() error {
var zeroTime time.Time if err := c.UpdateHeartbeat(); err != nil {
_ = c.ws.SetReadDeadline(zeroTime) return err
}
for { for {
messageType, data, err := c.ws.ReadMessage() messageType, data, err := c.ws.ReadMessage()
if err != nil { if err != nil {
return err return err
} }
if err := c.UpdateHeartbeat(); err != nil {
return err
}
if len(data) == 0 { if len(data) == 0 {
continue continue
@@ -59,23 +80,83 @@ func (c *Client) Read() error {
switch data[0] { switch data[0] {
case Heartbeat: case Heartbeat:
c.UpdateHeartbeat()
case KeyboardEvent: case KeyboardEvent:
if picoclaw.GetSessionLock().BlocksManualInput() { report := data[1:]
log.Debug("manual keyboard input dropped while AI session holds control") if len(report) != 8 {
log.Debugf("invalid manual keyboard report: %v", report)
continue continue
} }
writeQueue(c.keyboard, data[1:]) c.queueManualReport(c.keyboard, inputcontrol.ManualKeyboard, report, keyboardReportHeld(report), true)
case MouseEvent: case MouseEvent:
if picoclaw.GetSessionLock().BlocksManualInput() { report := data[1:]
log.Debug("manual mouse input dropped while AI session holds control") if len(report) != 4 && len(report) != 6 {
log.Debugf("invalid manual mouse report: %v", report)
continue 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 { func (c *Client) Write(event string, data string) error {
message := &Message{ message := &Message{
Type: event, Type: event,
@@ -95,10 +176,17 @@ func (c *Client) Write(event string, data string) error {
return c.ws.WriteMessage(websocket.TextMessage, messageByte) 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() c.mutex.Lock()
defer c.mutex.Unlock() c.lastHeartbeat = now
c.lastHeartbeat = time.Now() c.mutex.Unlock()
return c.ws.SetReadDeadline(now.Add(timeout))
} }
func (c *Client) Close() { func (c *Client) Close() {
@@ -113,21 +201,24 @@ func (c *Client) Close() {
if c.mouse != nil { if c.mouse != nil {
close(c.mouse) close(c.mouse)
} }
log.Debug("websocket disconnected")
}) })
} c.workers.Wait()
if c.manual != nil {
func writeQueue(queue chan []byte, data []byte) { c.manual.Close()
if !sendQueue(queue, data) {
log.Debug("hid event dropped because websocket queue is closed")
return
} }
log.Debug("websocket disconnected")
jiggler.GetJiggler().Update()
} }
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 { if queue == nil {
return false return false
} }
@@ -138,6 +229,6 @@ func sendQueue(queue chan []byte, data []byte) (ok bool) {
} }
}() }()
queue <- data queue <- report
return true 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" "github.com/gorilla/websocket"
"NanoKVM-Server/service/hid" "NanoKVM-Server/service/hid"
"NanoKVM-Server/service/inputcontrol"
) )
type Manager struct { type Manager struct {
@@ -17,11 +18,14 @@ type Manager struct {
type Client struct { type Client struct {
ws *websocket.Conn ws *websocket.Conn
hid *hid.Hid hid *hid.Hid
keyboard chan []byte manual *inputcontrol.ManualSession
mouse chan []byte keyboard chan hid.QueuedReport
mouse chan hid.QueuedReport
heartbeatTimeout time.Duration
lastHeartbeat time.Time lastHeartbeat time.Time
mutex sync.Mutex mutex sync.Mutex
closeOnce sync.Once closeOnce sync.Once
workers sync.WaitGroup
} }
type Message struct { type Message struct {