mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
12
server/proto/mcp.go
Normal 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
159
server/router/control.go
Normal 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
34
server/router/mcp.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
97
server/service/controlmode/activity.go
Normal file
97
server/service/controlmode/activity.go
Normal 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{})
|
||||||
|
}
|
||||||
555
server/service/controlmode/manager.go
Normal file
555
server/service/controlmode/manager.go
Normal 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)
|
||||||
|
}
|
||||||
202
server/service/controlmode/manager_test.go
Normal file
202
server/service/controlmode/manager_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
16
server/service/hid/hid_test.go
Normal file
16
server/service/hid/hid_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
30
server/service/hid/queue.go
Normal file
30
server/service/hid/queue.go
Normal 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()
|
||||||
|
}
|
||||||
245
server/service/hid/queue_test.go
Normal file
245
server/service/hid/queue_test.go
Normal 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()
|
||||||
|
}
|
||||||
48
server/service/hid/release.go
Normal file
48
server/service/hid/release.go
Normal 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...)
|
||||||
|
}
|
||||||
77
server/service/hid/release_test.go
Normal file
77
server/service/hid/release_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
458
server/service/inputcontrol/coordinator.go
Normal file
458
server/service/inputcontrol/coordinator.go
Normal 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)
|
||||||
|
}
|
||||||
397
server/service/inputcontrol/coordinator_test.go
Normal file
397
server/service/inputcontrol/coordinator_test.go
Normal 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()
|
||||||
|
}
|
||||||
51
server/service/mcp/auth.go
Normal file
51
server/service/mcp/auth.go
Normal 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 != ""
|
||||||
|
}
|
||||||
125
server/service/mcp/capture/capture.go
Normal file
125
server/service/mcp/capture/capture.go
Normal 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
|
||||||
|
}
|
||||||
196
server/service/mcp/capture/capture_test.go
Normal file
196
server/service/mcp/capture/capture_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
15
server/service/mcp/capture/kvm/kvm.go
Normal file
15
server/service/mcp/capture/kvm/kvm.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
154
server/service/mcp/config.go
Normal file
154
server/service/mcp/config.go
Normal 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
|
||||||
|
}
|
||||||
262
server/service/mcp/config_test.go
Normal file
262
server/service/mcp/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
server/service/mcp/keyboard.go
Normal file
122
server/service/mcp/keyboard.go
Normal 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
|
||||||
|
}
|
||||||
37
server/service/mcp/keyboard_test.go
Normal file
37
server/service/mcp/keyboard_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
server/service/mcp/mouse.go
Normal file
93
server/service/mcp/mouse.go
Normal 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)
|
||||||
|
}
|
||||||
53
server/service/mcp/mouse_test.go
Normal file
53
server/service/mcp/mouse_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
234
server/service/mcp/remote.go
Normal file
234
server/service/mcp/remote.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
115
server/service/mcp/remote_test.go
Normal file
115
server/service/mcp/remote_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
server/service/mcp/screenshot.go
Normal file
27
server/service/mcp/screenshot.go
Normal 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)
|
||||||
|
}
|
||||||
544
server/service/mcp/server.go
Normal file
544
server/service/mcp/server.go
Normal 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
|
||||||
|
}
|
||||||
396
server/service/mcp/server_test.go
Normal file
396
server/service/mcp/server_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
169
server/service/mcp/service.go
Normal file
169
server/service/mcp/service.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
50
server/service/picoclaw/control_events.go
Normal file
50
server/service/picoclaw/control_events.go
Normal 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
|
||||||
|
}
|
||||||
50
server/service/picoclaw/control_events_test.go
Normal file
50
server/service/picoclaw/control_events_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
267
server/service/picoclaw/control_mode.go
Normal file
267
server/service/picoclaw/control_mode.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
server/service/picoclaw/control_operations.go
Normal file
153
server/service/picoclaw/control_operations.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
79
server/service/picoclaw/control_operations_test.go
Normal file
79
server/service/picoclaw/control_operations_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
128
server/service/picoclaw/mcp_handler_test.go
Normal file
128
server/service/picoclaw/mcp_handler_test.go
Normal 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 + `"}`
|
||||||
|
}
|
||||||
@@ -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{}
|
||||||
|
|||||||
170
server/service/picoclaw/model_config_test.go
Normal file
170
server/service/picoclaw/model_config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
436
server/service/picoclaw/runtime_intent.go
Normal file
436
server/service/picoclaw/runtime_intent.go
Normal 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 = ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
33
server/service/picoclaw/runtime_session_test.go
Normal file
33
server/service/picoclaw/runtime_session_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
171
server/service/picoclaw/runtime_status_test.go
Normal file
171
server/service/picoclaw/runtime_status_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
236
server/service/picoclaw/runtime_stop_test.go
Normal file
236
server/service/picoclaw/runtime_stop_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
60
server/service/picoclaw/session_lock_test.go
Normal file
60
server/service/picoclaw/session_lock_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
112
server/service/ws/client_test.go
Normal file
112
server/service/ws/client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user