Compare commits

...

31 Commits
2.4.2 ... 2.5.0

Author SHA1 Message Date
Guoguo
40bbeab8ae ci: split package, tag, and release workflows (#855) 2026-08-04 16:33:56 +08:00
肆月
eb20fb41aa feat: show remote keyboard lock status (#838)
* feat: show remote keyboard lock status

* fix(keyboard-led-status): eliminate polling and improve delivery

* fix(keyboard-led-status): simplify indicator labels
2026-08-04 09:28:14 +08:00
watermeko
21e834b4c8 perf: reduce latency in 60 Hz mode (#844)
- Add decode-driven flow control and bounded GOP-aware queues to prevent stale H.264 frames from accumulating in TCP and WebSocket buffers.
- Isolate slow clients with dedicated writer goroutines, write deadlines, and safer connection lifecycle handling.
- Move the Direct H.264 socket into the worker, simplify the low-latency decode path, and improve reconnect behavior.
2026-07-31 19:08:07 +08:00
916BGAI
8773605715 Merge pull request #845 from imguoguo/ci/release-package
ci: release package
2026-07-31 19:00:18 +08:00
Guoguo
b23eb33611 ci: build identifiable NanoKVM release packages 2026-07-31 03:47:54 -07:00
Guoguo
ef432da2fe build: allow overriding the builder image and install patchelf
Groundwork for building outside an interactive terminal.

- IMAGE_NAME becomes overridable so a prebuilt image can be used instead
  of building one locally
- docker run no longer hardcodes -it on the app and support recipes:
  allocating a TTY fails where there is none. shell stays interactive
- install patchelf, which server/build.sh needs to set the RPATH on the
  server binary
2026-07-30 19:55:30 -07:00
Guoguo
96c86f6098 fix(build): correct exit codes in support/sg2002/build
The script has no error handling of its own, so these all pass silently.

- update_lib exited 1 after succeeding, so callers could not chain it
- a failed maixcdk build printed "Build Error!" and exited 0, letting a
  caller carry on with no artifacts
- add_to_kvmapp did the same when the source did not exist
- the copies in update_lib were unchecked; since update_lib is what keeps
  the SDK components in sync with the source tree, a silent failure there
  yields libraries built from stale sources
2026-07-30 19:52:34 -07:00
watermeko
a7d7574e70 fix: warn about mixed H264 stream modes (#843) 2026-07-31 10:48:52 +08:00
watermeko
b587b9f912 fix: stop HDMI capture when logged out (#841)
* fix: stop HDMI capture when logged out

* feat: stop HDMI capture when viewers are idle

* fix: keep HDMI capture active for consumers
2026-07-30 17:29:07 +08:00
watermeko
004fd59093 fix(sg2002): harden VI state sharing 2026-07-30 16:08:48 +08:00
watermeko
774d314ce9 fix(sg2002): share VI debug state between processes 2026-07-30 16:08:48 +08:00
watermeko
e4d23014ba fix(sg2002): read VI debug state without shell pipelines 2026-07-30 16:08:48 +08:00
BeaconCat
dc050d1f19 fix(usb): assign deterministic gadget MAC so the host NIC stays stable (#828)
The USB network gadget (rndis.usb0 / ncm.usb0) is created without dev_addr or
host_addr, so the kernel picks a random MAC for both the device side and the
host side on every bind. S10uuid later pins the device-side usb0 via
'ip link set', but the host_addr -- the MAC the attached PC's RNDIS/NCM adapter
uses -- stays random and changes on every re-enumeration/reboot. The host then
registers a brand-new network adapter each time, piling up stale adapters/MACs
(e.g. ~10 after a few reboots) and breaking anything that keys off the MAC.

Derive both MACs from the chip UID (same scheme S10uuid already uses for eth0
and usb0), before binding the gadget:
  dev_addr  = 48:da:35:6e:<uid>
  host_addr = 48:da:35:6d:<uid>

Verified on a NanoKVM Cube: host_addr went from random a6:3f:5d:8a:7d:52 to a
stable 48:da:35:6d:26:62, identical across two reboots.

Fixes #740

Co-authored-by: BeaconCat <BeaconCat@users.noreply.github.com>
2026-07-29 16:29:53 +08:00
SiYue-ZO
57e1f6a4d6 fix(picoclaw): cancel runtime lifecycle on mode switch
Cancel PicoClaw start/readiness waits when MCP takes control, clear held manual input state after failed release reports, and keep paste duration below the control-mode wait budget.

Also add MCP copy failure translations and avoid reconnect effect churn when the locale changes.
2026-07-29 15:16:06 +08:00
SiYue-ZO
d578c63036 i18n(picoclaw): add MCP control translations
Add locale coverage for MCP settings, AI-control ownership, PicoClaw runtime operations, and related conflict states.
2026-07-29 15:16:06 +08:00
SiYue-ZO
6b98f5415f feat(web): add MCP settings and control handoff UI
Add the MCP settings view and frontend API bindings for configuring remote-control access.

Expose AI-control ownership state in PicoClaw, lock conflicting keyboard input paths, and preserve chat state when control is released.
2026-07-29 15:16:06 +08:00
SiYue-ZO
64ce12cc0d 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.
2026-07-29 15:16:06 +08:00
watermeko
183f8dc983 fix: reuse existing container users and groups 2026-07-27 10:28:46 +08:00
watermeko
7eee77a33d fix: fix container slow caused by uid/gid 2026-07-27 10:28:46 +08:00
watermeko
c1986acf65 fix(web): keep direct H264 playback low latency 2026-07-24 15:41:02 +08:00
watermeko
c999a883bd perf(sg2002): integrate zero-copy H.264 capture
Move frame ownership and zero-copy VENC handling into the tracked MMF
and Vision sources instead of relying on a preload hook. Keep the sensor
name mapping aligned with middleware enums and release deferred VI frames
safely across encoder lifecycle changes.
2026-07-24 15:41:02 +08:00
肆月
70f8503cbb fix: prevent OLED sleep duration truncation (#834)
OLED sleep settings are stored as seconds, but the kvm_system reader parsed the value into a uint8_t before assigning it to the uint16_t state field. Values above 255 seconds wrapped, so 5 min, 10 min, 30 min, and 1 hour did not behave as selected.

Parse the setting into a uint16_t-sized temporary value and terminate the read buffer before atoi so all configured durations up to 3600 seconds are preserved.

Closes #826
2026-07-22 17:46:39 +08:00
Guoguo
5b0a7d2a91 feat: add sha256sum check (#832)
* feat(download): add sha256sum check and cancellable downloads

+ server/proto/download.go
	+ add DownloadImageReq and sha256 argument

server/router/download.go
	+ add cancel download router

server/service/download/service.go
	+ sha256 decode and check
	+ remove HEAD request
	+ cancel download

* refactor(download): synchronize image and application transfers

sovel the the problem that image downloading and app downloading would conflict
at same time

* fix: optimized UI of image download and locale issue

* fix: locale
2026-07-17 18:17:04 +08:00
watermeko
4fee758ff0 fix: fix api/storage/image/mounted error when using HID-Only mode (#833) 2026-07-17 18:02:07 +08:00
watermeko
736259baa3 fix: locale 2026-07-17 07:47:25 +00:00
watermeko
83b600bbee fix: optimized UI of image download and locale issue 2026-07-16 11:16:36 +00:00
watermeko
6071a6f57c refactor(download): synchronize image and application transfers
sovel the the problem that image downloading and app downloading would conflict
at same time
2026-07-16 11:03:07 +00:00
watermeko
69f5da6fc7 feat(download): add sha256sum check and cancellable downloads
+ server/proto/download.go
	+ add DownloadImageReq and sha256 argument

server/router/download.go
	+ add cancel download router

server/service/download/service.go
	+ sha256 decode and check
	+ remove HEAD request
	+ cancel download
2026-07-16 11:03:07 +00:00
916BGAI
3b2ba7c0c1 docs: add 2.4.3 changelog entries 2026-06-09 18:06:03 +08:00
916BGAI
208998c13a support lt6911d 2026-06-09 18:00:32 +08:00
wenjie
3de4a18eb4 chore: update js-cookie for security fix 2026-05-22 10:43:17 +08:00
224 changed files with 20288 additions and 1893 deletions

132
.github/workflows/builder-image.yml vendored Normal file
View File

@@ -0,0 +1,132 @@
name: Builder Image
# Builds the nanokvm-builder image (riscv64 musl toolchain, Go, and a MaixCDK
# checkout patched with support/sg2002/additional) and publishes it to GHCR so
# the release workflow does not have to rebuild the SDK on every tag.
#
# The image is deliberately not rebuilt on every push: it only carries the
# toolchain. Release builds re-sync support/sg2002/additional into MaixCDK and
# recompile from the checked-out source, so a slightly stale image is harmless.
on:
workflow_dispatch:
push:
branches:
- main
paths:
- docker/**
- .github/workflows/builder-image.yml
concurrency:
group: builder-image
cancel-in-progress: false
jobs:
build:
name: Build and publish nanokvm-builder
runs-on: ubuntu-latest
timeout-minutes: 240
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Free up disk space
run: |
echo "Before:"
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/share/boost /usr/local/share/powershell
sudo docker image prune -af
echo "After:"
df -h /
- name: Resolve image reference
id: image
env:
OWNER: ${{ github.repository_owner }}
run: |
# GHCR only accepts lowercase repository paths.
owner=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
echo "ref=ghcr.io/$owner/nanokvm-builder" >> "$GITHUB_OUTPUT"
# Logging in before the build means a credential or registry problem fails
# in seconds instead of after a full SDK build.
- name: Log in to GHCR
env:
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ok=0
for attempt in 1 2 3; do
if printf '%s' "$GHCR_TOKEN" \
| docker login ghcr.io -u "$GHCR_USER" --password-stdin; then
ok=1
break
fi
echo "login attempt $attempt failed, retrying in 15s"
sleep 15
done
if [ "$ok" -ne 1 ]; then
echo "::error::could not log in to ghcr.io after 3 attempts"
exit 1
fi
- name: Build image
run: |
# The Dockerfile chowns MaixCDK to DOCKER_UID/DOCKER_GID and the
# entrypoint gosu's to the caller's ids, so bake in the ids the
# release job will actually run as. Otherwise the SDK tree belongs to
# 1000 and syncing components into it fails with permission denied.
docker build \
--build-arg DOCKER_UID="$(id -u)" \
--build-arg DOCKER_GID="$(id -g)" \
--tag "${{ steps.image.outputs.ref }}:latest" \
--tag "${{ steps.image.outputs.ref }}:${{ github.sha }}" \
--file docker/Dockerfile \
.
- name: Verify toolchain
run: |
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" go version
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" \
riscv64-unknown-linux-musl-gcc --version | head -1
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" patchelf --version
- name: Push image
env:
IMAGE_REF: ${{ steps.image.outputs.ref }}
SHA: ${{ github.sha }}
run: |
# ghcr.io occasionally times out; a retry is cheaper than rebuilding.
# Push the immutable sha tag first so a failure there cannot leave
# :latest — which release.yml pulls — already moved.
for tag in "$SHA" latest; do
ok=0
for attempt in 1 2 3; do
if docker push "$IMAGE_REF:$tag"; then
ok=1
break
fi
echo "push of $tag attempt $attempt failed, retrying in 15s"
sleep 15
done
if [ "$ok" -ne 1 ]; then
echo "::error::could not push $IMAGE_REF:$tag after 3 attempts"
exit 1
fi
done
- name: Summary
run: |
{
echo "### Builder image published"
echo
echo '```'
echo "${{ steps.image.outputs.ref }}:latest"
echo "${{ steps.image.outputs.ref }}:${{ github.sha }}"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

108
.github/workflows/create-tag.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: NanoKVM Create Tag
on:
workflow_dispatch:
inputs:
version:
description: Numeric version to tag, e.g. 2.5.0
required: true
type: string
commit_sha:
description: Full commit SHA on main (empty uses latest main)
required: false
default: ''
type: string
concurrency:
group: create-tag-${{ inputs.version }}
cancel-in-progress: false
jobs:
create-tag:
name: Create annotated tag
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout main
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Validate target
id: target
env:
COMMIT_INPUT: ${{ inputs.commit_sha }}
VERSION_INPUT: ${{ inputs.version }}
run: |
VERSION="$VERSION_INPUT"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::invalid version '$VERSION', expected MAJOR.MINOR.PATCH"
exit 1
fi
git fetch --force --tags origin main
if [ -n "$COMMIT_INPUT" ]; then
if ! echo "$COMMIT_INPUT" | grep -qE '^[0-9a-fA-F]{40}$'; then
echo "::error::commit_sha must be a full 40-character hexadecimal SHA"
exit 1
fi
TARGET_SHA=$(git rev-parse "${COMMIT_INPUT}^{commit}" 2>/dev/null) || {
echo "::error::commit '$COMMIT_INPUT' does not exist"
exit 1
}
else
TARGET_SHA=$(git rev-parse 'origin/main^{commit}')
fi
if ! git merge-base --is-ancestor "$TARGET_SHA" origin/main; then
echo "::error::commit '$TARGET_SHA' is not reachable from origin/main"
exit 1
fi
set +e
git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null
TAG_STATUS=$?
set -e
case "$TAG_STATUS" in
0)
echo "::error::tag '$VERSION' already exists"
exit 1
;;
2) ;;
*)
echo "::error::failed to check whether tag '$VERSION' exists"
exit 1
;;
esac
echo "sha=$TARGET_SHA" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Create and push tag
env:
TARGET_SHA: ${{ steps.target.outputs.sha }}
VERSION: ${{ steps.target.outputs.version }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git tag -a "$VERSION" "$TARGET_SHA" -m "Release $VERSION"
git push origin "refs/tags/$VERSION"
./scripts/verify-release-tag.sh "$VERSION" "$TARGET_SHA" >/dev/null
- name: Summary
env:
TARGET_SHA: ${{ steps.target.outputs.sha }}
VERSION: ${{ steps.target.outputs.version }}
run: |
{
echo '### NanoKVM tag created'
echo
echo "- Tag: \`$VERSION\`"
echo "- Commit: \`$TARGET_SHA\`"
echo
echo 'No package, GitHub Release, or CDN object was created.'
} >> "$GITHUB_STEP_SUMMARY"

291
.github/workflows/package.yml vendored Normal file
View File

@@ -0,0 +1,291 @@
name: NanoKVM Package
# Builds nanokvm_<version>.tar.gz and the latest.json manifest that the
# on-device updater consumes (server/service/application/). Pull requests get a
# uniquely identified, short-lived test artifact. Release publication is a
# separate, explicit workflow that calls this one for an existing tag.
#
# This workflow does NOT publish to cdn.sipeed.com. Uploading latest.json is what
# actually offers the update to every device in the field, so that step stays
# manual and deliberate.
on:
pull_request:
branches:
- main
paths:
- .github/workflows/create-tag.yml
- .github/workflows/package.yml
- .github/workflows/release.yml
- kvmapp/**
- scripts/**
- server/**
- support/**
- tools/nanokvm_update_edid/**
- web/**
- Makefile
workflow_dispatch:
inputs:
version:
description: Version to package, e.g. 2.4.4
required: true
type: string
workflow_call:
inputs:
version:
description: Numeric version to package
required: true
type: string
ref:
description: Existing tag or commit to check out
required: true
type: string
outputs:
artifact_name:
description: Uploaded Actions artifact name
value: ${{ jobs.package.outputs.artifact_name }}
version:
description: Packaged version
value: ${{ jobs.package.outputs.version }}
concurrency:
group: package-${{ github.event.pull_request.number || inputs.ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
package:
name: Build package
runs-on: ubuntu-latest
timeout-minutes: 180
outputs:
artifact_name: ${{ steps.version.outputs.artifact_name }}
version: ${{ steps.version.outputs.version }}
permissions:
contents: read
packages: read
steps:
- name: Checkout event ref
if: inputs.ref == ''
uses: actions/checkout@v4
with:
# package.sh derives SOURCE_DATE_EPOCH from the commit date.
fetch-depth: 0
# Build scripts from a pull request must not inherit checkout's token.
persist-credentials: false
- name: Checkout requested ref
if: inputs.ref != ''
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ inputs.ref }}
- name: Resolve version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
INPUT_VERSION: ${{ inputs.version }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REF_NAME: ${{ github.ref_name }}
run: |
BUILD_SHA=$(git rev-parse HEAD)
if [ "$EVENT_NAME" = "pull_request" ]; then
VERSION="0.${PR_NUMBER}.${GITHUB_RUN_NUMBER}"
SOURCE_SHA="$HEAD_SHA"
elif [ -n "$INPUT_VERSION" ]; then
VERSION="$INPUT_VERSION"
SOURCE_SHA="$BUILD_SHA"
else
VERSION="$REF_NAME"
SOURCE_SHA="$BUILD_SHA"
fi
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::invalid version '$VERSION', expected MAJOR.MINOR.PATCH"
exit 1
fi
if [ -z "$SOURCE_SHA" ]; then
echo "::error::could not resolve source commit"
exit 1
fi
SHORT_SHA=$(printf '%s' "$SOURCE_SHA" | cut -c1-12)
if [ "$EVENT_NAME" = "pull_request" ]; then
ARTIFACT_NAME="nanokvm-pr-${PR_NUMBER}-${SHORT_SHA}-run-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}"
else
ARTIFACT_NAME="nanokvm-${VERSION}-${SHORT_SHA}-run-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}"
fi
{
echo "artifact_name=$ARTIFACT_NAME"
echo "build_sha=$BUILD_SHA"
echo "source_sha=$SOURCE_SHA"
echo "version=$VERSION"
} >> "$GITHUB_OUTPUT"
echo "Packaging version $VERSION as $ARTIFACT_NAME"
- name: Free up disk space
run: |
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Set up pnpm
run: npm install --global pnpm@11
- name: Build frontend
run: make web
- name: Log in to GHCR
if: github.event_name != 'pull_request'
env:
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ok=0
for attempt in 1 2 3; do
if printf '%s' "$GHCR_TOKEN" \
| docker login ghcr.io -u "$GHCR_USER" --password-stdin; then
ok=1
break
fi
echo "login attempt $attempt failed, retrying in 15s"
sleep 15
done
if [ "$ok" -ne 1 ]; then
echo "::error::could not log in to ghcr.io after 3 attempts"
exit 1
fi
- name: Pull builder image
id: image
env:
OWNER: ${{ github.repository_owner }}
run: |
# GHCR only accepts lowercase repository paths.
# shellcheck disable=SC2153 # OWNER is supplied through the step environment.
owner=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
IMAGE_REPO="ghcr.io/$owner/nanokvm-builder"
TAGGED_REF="${IMAGE_REPO}:latest"
ok=0
for attempt in 1 2 3; do
if docker pull "$TAGGED_REF"; then
ok=1
break
fi
echo "pull attempt $attempt failed, retrying in 15s"
sleep 15
done
if [ "$ok" -ne 1 ]; then
echo "::error::could not pull $TAGGED_REF - run the 'Builder Image' workflow first and ensure PR builds can pull it without credentials"
exit 1
fi
RESOLVED_REF=$(docker image inspect --format='{{index .RepoDigests 0}}' "$TAGGED_REF")
case "$RESOLVED_REF" in
"$IMAGE_REPO"@sha256:*) ;;
*)
echo "::error::could not resolve immutable digest for $TAGGED_REF (got '$RESOLVED_REF')"
exit 1
;;
esac
echo "Pulled $RESOLVED_REF"
echo "ref=$RESOLVED_REF" >> "$GITHUB_OUTPUT"
- name: Build riscv64 artifacts
run: |
make release-build \
DOCKER_TTY= \
IMAGE_NAME="${{ steps.image.outputs.ref }}"
- name: Assemble package
run: make package VERSION="${{ steps.version.outputs.version }}"
- name: Compare against the published release
# Informational only: highlights what changed relative to what devices
# are currently running. Never blocks the build.
continue-on-error: true
run: |
./scripts/compare-release.sh \
"build/release/nanokvm_${{ steps.version.outputs.version }}.tar.gz"
- name: Write build provenance
env:
ARTIFACT_NAME: ${{ steps.version.outputs.artifact_name }}
BUILDER_IMAGE: ${{ steps.image.outputs.ref }}
BUILD_SHA: ${{ steps.version.outputs.build_sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
SOURCE_SHA: ${{ steps.version.outputs.source_sha }}
VERSION: ${{ steps.version.outputs.version }}
run: |
TARBALL="build/release/nanokvm_${VERSION}.tar.gz"
TARBALL_NAME=$(basename "$TARBALL")
SHA256_HEX=$(sha256sum "$TARBALL" | cut -d' ' -f1)
SHA512_HEX=$(sha512sum "$TARBALL" | cut -d' ' -f1)
SHA512_BASE64=$(jq -er '.sha512 | select(type == "string" and length > 0)' \
build/release/latest.json)
ACTUAL_BASE64=$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)
if [ "$SHA512_BASE64" != "$ACTUAL_BASE64" ]; then
echo "::error::latest.json sha512 does not match $TARBALL"
exit 1
fi
printf '%s %s\n' "$SHA256_HEX" "$TARBALL_NAME" > build/release/sha256.txt
{
echo "artifact=${ARTIFACT_NAME}"
echo "version=${VERSION}"
echo "event=${GITHUB_EVENT_NAME}"
echo "pull_request=${PR_NUMBER}"
echo "source_sha=${SOURCE_SHA}"
echo "build_sha=${BUILD_SHA}"
echo "builder_image=${BUILDER_IMAGE}"
echo "run_id=${GITHUB_RUN_ID}"
echo "run_attempt=${GITHUB_RUN_ATTEMPT}"
echo "run_url=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "tarball=${TARBALL_NAME}"
echo "sha256_hex=${SHA256_HEX}"
echo "sha512_hex=${SHA512_HEX}"
echo "sha512_base64=${SHA512_BASE64}"
} > build/release/BUILD_INFO.txt
- name: Summary
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
{
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
echo "> [!WARNING]"
echo "> PR TEST ONLY — DO NOT PUBLISH TO CDN"
echo
fi
echo "### nanokvm_${VERSION}.tar.gz"
echo
echo '```text'
cat build/release/BUILD_INFO.txt
echo '```'
echo
echo '```json'
cat build/release/latest.json
echo '```'
echo
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
echo "Upload only the inner tarball through NanoKVM's manual offline update UI."
else
echo "Publishing to \`cdn.sipeed.com/nanokvm/\` is a separate manual step."
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ steps.version.outputs.artifact_name }}
path: |
build/release/nanokvm_${{ steps.version.outputs.version }}.tar.gz
build/release/latest.json
build/release/sha256.txt
build/release/BUILD_INFO.txt
if-no-files-found: error
retention-days: ${{ github.event_name == 'pull_request' && 7 || 90 }}

244
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,244 @@
name: NanoKVM Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing numeric tag to release, e.g. 2.5.0
required: true
type: string
release_action:
description: Publish a release or promote a tested prerelease
required: true
type: choice
options:
- publish-prerelease
- publish-stable
- promote-stable
# Latest is repository-global, so all Release changes must be serialized.
concurrency:
group: nanokvm-github-release
cancel-in-progress: false
jobs:
validate:
name: Resolve release tag
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
source_sha: ${{ steps.tag.outputs.source_sha }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag
id: tag
env:
TAG: ${{ inputs.tag }}
run: |
SOURCE_SHA=$(./scripts/verify-release-tag.sh "$TAG")
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
package:
name: Build tag package
needs: validate
if: inputs.release_action != 'promote-stable'
permissions:
contents: read
packages: read
uses: ./.github/workflows/package.yml
with:
ref: ${{ needs.validate.outputs.source_sha }}
version: ${{ inputs.tag }}
publish:
name: Publish GitHub release
needs:
- validate
- package
if: inputs.release_action != 'promote-stable'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout release tooling
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Download package artifact
uses: actions/download-artifact@v4
with:
name: ${{ needs.package.outputs.artifact_name }}
path: build/release
- name: Publish release
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ACTION: ${{ inputs.release_action }}
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
TAG: ${{ inputs.tag }}
run: |
./scripts/verify-release-assets.sh build/release "$TAG"
case "$RELEASE_ACTION" in
publish-prerelease) IS_PRERELEASE=true ;;
publish-stable) IS_PRERELEASE=false ;;
*)
echo "::error::invalid publish action '$RELEASE_ACTION'"
exit 1
;;
esac
ENDPOINT="repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"
set +e
HEADERS=$(gh api --include --silent "$ENDPOINT" 2>&1)
API_STATUS=$?
set -e
HTTP_STATUS=$(printf '%s\n' "$HEADERS" | awk '
$1 ~ /^HTTP\// { status = $2 }
END { print status }
')
if [ "$API_STATUS" -eq 0 ] && [ "$HTTP_STATUS" = 200 ]; then
echo "::error::release or draft '$TAG' already exists"
echo "Publishing is one-shot. Inspect it at https://github.com/${GITHUB_REPOSITORY}/releases."
exit 1
fi
if [ "$API_STATUS" -eq 0 ] || [ "$HTTP_STATUS" != 404 ]; then
echo "::error::failed to query release '$TAG' (HTTP status: ${HTTP_STATUS:-unknown})"
printf '%s\n' "$HEADERS" >&2
exit 1
fi
TARBALL="build/release/nanokvm_${TAG}.tar.gz"
SHA256=$(sha256sum "$TARBALL" | cut -d' ' -f1)
SHA512=$(jq -er '.sha512' build/release/latest.json)
# Backticks below are intentional Markdown, not command substitution.
# shellcheck disable=SC2016
NOTES=$(printf '%s\n' \
"Application package for NanoKVM ${TAG}." \
'' \
'### Checksums' \
'' \
'`SHA-256` (hex):' \
'' \
" ${SHA256}" \
'' \
'`SHA-512` (base64, as expected by the on-device updater):' \
'' \
" ${SHA512}" \
'' \
'To offer this build over OTA, publish it through the separate CDN workflow.')
STATE_ARGS=(--prerelease="$IS_PRERELEASE")
if [ "$IS_PRERELEASE" = true ]; then
STATE_ARGS+=(--latest=false)
fi
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
gh release create "$TAG" \
--verify-tag \
--title "$TAG" \
--notes "$NOTES" \
"${STATE_ARGS[@]}" \
"$TARBALL" build/release/latest.json build/release/sha256.txt
- name: Summary
env:
RELEASE_ACTION: ${{ inputs.release_action }}
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
TAG: ${{ inputs.tag }}
run: |
{
echo '### NanoKVM GitHub Release published'
echo
echo "- Tag: \`$TAG\`"
echo "- Commit: \`$SOURCE_SHA\`"
echo "- Action: \`$RELEASE_ACTION\`"
echo
echo 'No CDN object was uploaded.'
} >> "$GITHUB_STEP_SUMMARY"
promote:
name: Promote verified prerelease
needs: validate
if: inputs.release_action == 'promote-stable'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout release tooling
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Verify and promote release
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
TAG: ${{ inputs.tag }}
run: |
RELEASE_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")
if [ "$(jq -r '.draft' <<< "$RELEASE_JSON")" != false ]; then
echo "::error::release '$TAG' is a draft"
exit 1
fi
if [ "$(jq -r '.prerelease' <<< "$RELEASE_JSON")" != true ]; then
echo "::error::release '$TAG' is not a prerelease"
exit 1
fi
CURRENT_ASSETS=$(jq -r '.assets[].name' <<< "$RELEASE_JSON" | LC_ALL=C sort)
EXPECTED_ASSETS=$(printf '%s\n' \
latest.json "nanokvm_${TAG}.tar.gz" sha256.txt | LC_ALL=C sort)
if [ "$CURRENT_ASSETS" != "$EXPECTED_ASSETS" ]; then
echo "::error::release '$TAG' does not have the exact expected asset set"
exit 1
fi
mkdir -p build/release
gh release download "$TAG" --dir build/release \
--pattern "nanokvm_${TAG}.tar.gz" \
--pattern latest.json \
--pattern sha256.txt
./scripts/verify-release-assets.sh build/release "$TAG"
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
gh release edit "$TAG" --prerelease=false --latest
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" --jq '.prerelease')" != false ]; then
echo "::error::release '$TAG' was not promoted"
exit 1
fi
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name')" != "$TAG" ]; then
echo "::error::release '$TAG' was not marked latest"
exit 1
fi
- name: Summary
env:
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
TAG: ${{ inputs.tag }}
run: |
{
echo '### NanoKVM prerelease promoted'
echo
echo "- Tag: \`$TAG\`"
echo "- Commit: \`$SOURCE_SHA\`"
echo '- Assets: unchanged and checksum-verified'
echo
echo 'No package was rebuilt and no CDN object was uploaded.'
} >> "$GITHUB_STEP_SUMMARY"

3
.gitignore vendored
View File

@@ -29,3 +29,6 @@ support/sg2002/kvm_vision_test/CMakeLists.txt
support/sg2002/additional/original
kvmapp/server/dl_lib
kvmapp/kvm_system/kvm_system
# Release packages assembled by scripts/package.sh
/build/

View File

@@ -1,3 +1,13 @@
## 2.4.3 (2026-06-09)
### Features
* Added LT6911D support
### Bug Fixes
* Improved USB network adapter compatibility on Windows
## 2.4.2 (2026-05-20)
### Features

View File

@@ -1,19 +1,30 @@
# Makefile for NanoKVM Project
# Configuration
IMAGE_NAME := nanokvm-builder
UID := $(shell id -u)
GID := $(shell id -g)
# ?= so a prebuilt image can be used instead, e.g. a GHCR-cached builder in CI.
IMAGE_NAME ?= nanokvm-builder-local-$(UID)-$(GID)
PWD := $(shell pwd)
VERSION ?=
# Docker run common parameters
# Docker run common parameters. Allocating a TTY breaks in environments without
# one, so it can be overridden with DOCKER_TTY=
DOCKER_TTY ?= -it
DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/NanoKVM --rm
# Build the image with the host user's identity so entrypoint does not need
# to recursively rewrite the MaixCDK home directory at container startup.
DOCKER_BUILD_ARGS := --build-arg DOCKER_UID=$(UID) --build-arg DOCKER_GID=$(GID)
# Build commands
GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build
SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp
VISION_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_vision && ./build kvm_vision add_to_kvmapp
RELEASE_BUILD_CMD := /home/build/NanoKVM/scripts/build-in-container.sh
.PHONY: help check-root builder-image rebuild-image check-image shell app support all clean
.PHONY: help check-root builder-image rebuild-image check-image shell app support vision \
web release-build package release all clean
# Default target
all: app support
@@ -29,8 +40,13 @@ help:
@echo " rebuild-image - Force rebuild Docker image"
@echo " shell - Enter interactive builder environment"
@echo " app - Build Go application server"
@echo " support - Build hardware support libraries"
@echo " support - Build kvm_system daemon"
@echo " vision - Build video libraries (libkvm.so)"
@echo " web - Build the frontend into web/dist"
@echo " all - Build both app and support (default)"
@echo " release-build - Build every riscv64 release artifact in one pass"
@echo " package - Assemble nanokvm_<VERSION>.tar.gz + latest.json"
@echo " release - release-build + web + package (needs VERSION=x.y.z)"
@echo " clean - Clean build artifacts"
@echo ""
@echo "Prerequisites:"
@@ -58,7 +74,7 @@ check-image: check-root
builder-image: check-root
@if ! docker image inspect $(IMAGE_NAME) >/dev/null 2>&1; then \
echo "Building Docker image..."; \
docker build -t $(IMAGE_NAME) -f docker/Dockerfile ./; \
docker build $(DOCKER_BUILD_ARGS) -t $(IMAGE_NAME) -f docker/Dockerfile ./; \
else \
echo "Docker image $(IMAGE_NAME) already exists."; \
fi
@@ -66,7 +82,7 @@ builder-image: check-root
# Force rebuild Docker image
rebuild-image: check-root
@echo "Force rebuilding Docker image..."
@docker build --no-cache -t $(IMAGE_NAME) -f docker/Dockerfile ./
@docker build --no-cache $(DOCKER_BUILD_ARGS) -t $(IMAGE_NAME) -f docker/Dockerfile ./
# Enter interactive shell (equivalent to build.sh with no arguments)
shell: check-root builder-image
@@ -76,12 +92,43 @@ shell: check-root builder-image
# Build Go application
app: check-root builder-image
@echo "Building app..."
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(GO_BUILD_CMD)'
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(GO_BUILD_CMD)'
# Build hardware support libraries
support: check-root builder-image
@echo "Building support..."
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)'
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)'
# Build video libraries (libkvm.so) into kvmapp/server/dl_lib
vision: check-root builder-image
@echo "Building vision..."
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(VISION_BUILD_CMD)'
# Build every riscv64 release artifact in one container pass
release-build: check-root builder-image
@echo "Building release artifacts..."
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(RELEASE_BUILD_CMD)'
# Build the frontend (runs on the host; the builder image has no Node)
web:
@echo "Building web..."
@cd web && pnpm install --frozen-lockfile && pnpm build
# Assemble the release package and its manifest
package:
@if [ -z "$(VERSION)" ]; then \
echo "VERSION is required, e.g. make package VERSION=2.4.4"; \
exit 1; \
fi
@scripts/package.sh "$(VERSION)"
# Full local release: riscv64 artifacts + frontend + package.
# Invoked as sub-makes rather than prerequisites: packaging asserts on what the
# earlier steps produce, so the order matters even under make -j.
release:
@$(MAKE) release-build
@$(MAKE) web
@$(MAKE) package VERSION="$(VERSION)"
# Clean build artifacts
clean:
@@ -94,4 +141,12 @@ clean:
rm -rf support/sg2002/build; \
echo "Removed support/sg2002/build"; \
fi
@echo "Clean completed."
@if [ -d build/release ]; then \
rm -rf build/release; \
echo "Removed build/release"; \
fi
@if [ -d web/dist ]; then \
rm -rf web/dist; \
echo "Removed web/dist"; \
fi
@echo "Clean completed."

View File

@@ -87,6 +87,7 @@ Start with the guide that matches the part of NanoKVM you want to work on:
- **System support modules:** Build and update the low-level hardware support components in [support/sg2002/README.md](support/sg2002/README.md).
- **Backend service:** Set up, build, and understand the Go service in [server/README.md](server/README.md).
- **Frontend UI:** Develop, lint, and build the React interface in [web/README.md](web/README.md).
- **Release packaging:** Assemble the `nanokvm_<version>.tar.gz` update package in [scripts/README.md](scripts/README.md).
> Backend compilation and runtime validation require the target toolchain or a NanoKVM device. See the module-specific guides above for the latest development workflow.

View File

@@ -3,11 +3,24 @@ FROM ubuntu:24.04 AS base_apt
# RUN echo 'Acquire::http::Proxy "http://cache.lan:3142";' > /etc/apt/apt.conf.d/00cacher
RUN userdel -r ubuntu
RUN apt update \
&& apt install wget git cmake build-essential python3 python3-venv python3-pip autoconf automake libtool gosu -y
&& apt install wget git cmake build-essential python3 python3-venv python3-pip autoconf automake libtool gosu patchelf -y
ARG DOCKER_USER=build
RUN useradd -m "$DOCKER_USER"
USER $DOCKER_USER
ARG DOCKER_UID=1000
ARG DOCKER_GID=1000
# Reuse existing accounts for numeric IDs that are already present in the
# base image. All later ownership and user switches use numeric IDs, so a
# dedicated build user/group is only needed when the ID does not exist.
RUN set -eux; \
if ! getent group "$DOCKER_GID" >/dev/null; then \
groupadd --gid "$DOCKER_GID" "$DOCKER_USER"; \
fi; \
if ! getent passwd "$DOCKER_UID" >/dev/null; then \
useradd --uid "$DOCKER_UID" --gid "$DOCKER_GID" --no-create-home \
--home-dir "/home/$DOCKER_USER" --shell /bin/bash "$DOCKER_USER"; \
fi; \
install -d -o "$DOCKER_UID" -g "$DOCKER_GID" "/home/$DOCKER_USER"
USER $DOCKER_UID:$DOCKER_GID
FROM base_apt AS host_tools
@@ -39,6 +52,8 @@ RUN cd /tmp/go_cache \
FROM base_apt AS sdk
ENV HOME="/home/$DOCKER_USER"
RUN cd ~ \
&& git clone https://github.com/Sipeed/MaixCDK \
&& cd MaixCDK \
@@ -46,7 +61,7 @@ RUN cd ~ \
&& . ./bin/activate \
&& pip install -U -r requirements.txt
COPY --chown=$DOCKER_USER . /home/$DOCKER_USER/NanoKVM
COPY --chown=$DOCKER_UID:$DOCKER_GID . /home/$DOCKER_USER/NanoKVM
RUN cd ~/MaixCDK \
&& . ./bin/activate \
@@ -57,7 +72,7 @@ FROM base_apt
# Install golang
COPY --from=golang /usr/local/go /usr/local/go
COPY --chown=$DOCKER_USER --from=golang /root/go /home/$DOCKER_USER/go
COPY --chown=$DOCKER_UID:$DOCKER_GID --from=golang /root/go /home/$DOCKER_USER/go
ENV PATH="$PATH:/usr/local/go/bin"
# Install host tools
@@ -65,7 +80,7 @@ COPY --from=host_tools /usr/local/host-tools /usr/local/host-tools
ENV PATH="$PATH:/usr/local/host-tools/gcc/riscv64-linux-musl-x86_64/bin"
# Install SDK
COPY --from=sdk /home/$DOCKER_USER/MaixCDK /home/$DOCKER_USER/MaixCDK
COPY --chown=$DOCKER_UID:$DOCKER_GID --from=sdk /home/$DOCKER_USER/MaixCDK /home/$DOCKER_USER/MaixCDK
COPY --chmod=+x docker/entrypoint /entrypoint
RUN echo "Verify build tools" \
@@ -76,4 +91,4 @@ USER root
RUN chmod 0755 /entrypoint \
&& sed "s/\$DOCKER_USER/$DOCKER_USER/g" -i /entrypoint
ENTRYPOINT ["/entrypoint"]
ENTRYPOINT ["/entrypoint"]

10
docker/entrypoint Normal file → Executable file
View File

@@ -12,11 +12,9 @@ fi
if [ "$UID" != 0 ]
then
usermod -u "$UID" "$DOCKER_USER" 2>/dev/null && {
groupmod -g "$GID" "$DOCKER_USER" 2>/dev/null ||
usermod -a -G "$GID" "$DOCKER_USER"
}
set -- gosu "${UID}:${GID}" "${@}"
set -- gosu "${UID}:${GID}" env HOME="/home/$DOCKER_USER" "${@}"
else
export HOME=/root
fi
exec "$@"
exec "$@"

View File

@@ -1,5 +1,5 @@
#!/bin/sh
# kvmhwd Rev2.1
# kvmhwd Rev2.2
start_usb_dev(){
. /etc/profile
@@ -23,6 +23,11 @@ start_usb_dev(){
else
echo 0x1009 > idProduct
fi
echo 0xEF > bDeviceClass
echo 0x02 > bDeviceSubClass
echo 0x01 > bDeviceProtocol
mkdir strings/0x409
echo '0123456789ABCDEF' > strings/0x409/serialnumber
echo 'sipeed' > strings/0x409/manufacturer
@@ -34,18 +39,48 @@ start_usb_dev(){
mkdir configs/c.1/strings/0x409
echo "NanoKVM" > configs/c.1/strings/0x409/configuration
# Deterministic gadget MACs from the chip UID so the host-side RNDIS/NCM
# interface keeps a STABLE MAC across re-enumeration/reboot. Without this
# the kernel assigns a random dev_addr/host_addr on every bind, so the
# attached PC sees a brand-new network adapter (and MAC) each time.
usb_uid=$(sha512sum /sys/class/cvi-base/base_uid | head -c 4)
if [ -n "$usb_uid" ]
then
usb_dev_mac="48:da:35:6e:${usb_uid%??}:${usb_uid#??}"
usb_host_mac="48:da:35:6d:${usb_uid%??}:${usb_uid#??}"
fi
if [ -e /boot/usb.ncm ]
then
mkdir functions/ncm.usb0
[ -n "$usb_dev_mac" ] && echo "$usb_dev_mac" > functions/ncm.usb0/dev_addr
[ -n "$usb_host_mac" ] && echo "$usb_host_mac" > functions/ncm.usb0/host_addr
echo WINNCM > functions/ncm.usb0/os_desc/interface.ncm/compatible_id
ln -s functions/ncm.usb0 configs/c.1/
else
if [ -e /boot/usb.rndis0 ]
then
mkdir functions/rndis.usb0
[ -n "$usb_dev_mac" ] && echo "$usb_dev_mac" > functions/rndis.usb0/dev_addr
[ -n "$usb_host_mac" ] && echo "$usb_host_mac" > functions/rndis.usb0/host_addr
echo e0 > functions/rndis.usb0/class
echo 01 > functions/rndis.usb0/subclass
echo 03 > functions/rndis.usb0/protocol
echo RNDIS > functions/rndis.usb0/os_desc/interface.rndis/compatible_id
echo 5162001 > functions/rndis.usb0/os_desc/interface.rndis/sub_compatible_id
ln -s functions/rndis.usb0 configs/c.1/
fi
fi
if [ -e /boot/usb.ncm ] || [ -e /boot/usb.rndis0 ]
then
# Microsoft OS 2.0 Descriptor
echo 1 > os_desc/use
echo 0xCD > os_desc/b_vendor_code
echo MSFT100 > os_desc/qw_sign
ln -s configs/c.1 os_desc
fi
if [ ! -e /boot/disable_hid ]
then
# keyboard

View File

@@ -1,5 +1,5 @@
#!/bin/sh
# kvmhwd Rev2.4
# kvmhwd Rev2.5
Alpha_OLED_RST_Pin=371
Beta_OLED_RST_Pin=502
@@ -25,7 +25,7 @@ init_alpha_hw(){
devmem 0x03001060 32 0x3 # GPIOA24
devmem 0x03001054 32 0x3 # GPIOA25
devmem 0x03001058 32 0x3 # GPIOA27
devmem 0x03001068 32 0x6 # GPIOA 18 UART1 RX
devmem 0x03001064 32 0x6 # GPIOA 19 UART1 TX
devmem 0x03001070 32 0x2 # GPIOA 28 UART2 TX
@@ -146,19 +146,7 @@ kvm_hw_detect(){
insmod /mnt/system/ko/i2c-algo-bit.ko
insmod /mnt/system/ko/i2c-gpio.ko
kvm_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
if [ -n "$kvm_tmp" ]
then
echo "c" > /etc/kvm/hdmi_version
else
kvm_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
if [ -n "$kvm_tmp1" ]
then
echo "ux" > /etc/kvm/hdmi_version
else
echo "ue" > /etc/kvm/hdmi_version
fi
fi
kvm_hdmi_version_detect
kvm_tmp=$(i2cdetect -ry 5 0x3c 0x3c | grep 3c)
if [ -n "$kvm_tmp" ]
@@ -199,20 +187,19 @@ kvm_hw_init(){
fi
}
kvm_hdmi_version_detect(){
hdmi_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
if [ -n "$hdmi_tmp" ]
then
echo "c" > /etc/kvm/hdmi_version
else
hdmi_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
if [ -n "$hdmi_tmp1" ]
then
echo "ux" > /etc/kvm/hdmi_version
kvm_hdmi_version_detect()
{
if i2cdetect -ry 4 0x2c 0x2c | grep -qw "2c"; then
echo "c" > /etc/kvm/hdmi_version
elif i2cdetect -ry 4 0x2b 0x2b | grep -qw "2b"; then
if i2cdetect -ry 4 0x44 0x44 | grep -qw "44"; then
echo "ux" > /etc/kvm/hdmi_version
else
echo "d" > /etc/kvm/hdmi_version
fi
else
echo "ue" > /etc/kvm/hdmi_version
echo "ue" > /etc/kvm/hdmi_version
fi
fi
}
case "$1" in

View File

@@ -21,12 +21,7 @@ resolve_user_home() {
printf '%s\n' "/root"
}
setup_runtime_env() {
[ -x "$BIN_PATH" ] || {
echo "picoclaw binary not found: $BIN_PATH"
return 1
}
setup_picoclaw_home() {
USER_HOME="$(resolve_user_home)"
PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}"
HOME="$USER_HOME"
@@ -37,6 +32,8 @@ setup_runtime_env() {
echo "failed to create picoclaw home: $PICOCLAW_HOME"
return 1
}
PICOCLAW_PID_FILE="${PICOCLAW_PID_FILE:-$PICOCLAW_HOME/.picoclaw.pid}"
export PICOCLAW_PID_FILE
cd "$PICOCLAW_HOME" || {
echo "failed to enter picoclaw home: $PICOCLAW_HOME"
@@ -44,6 +41,75 @@ setup_runtime_env() {
}
}
setup_runtime_env() {
[ -x "$BIN_PATH" ] || {
echo "picoclaw binary not found: $BIN_PATH"
return 1
}
setup_picoclaw_home || return 1
}
read_pid_file() {
[ -f "$PICOCLAW_PID_FILE" ] || return 1
pid="$(sed -n '1p' "$PICOCLAW_PID_FILE" 2>/dev/null | tr -d '[:space:]')"
case "$pid" in
''|*[!0-9]*)
return 1
;;
esac
printf '%s\n' "$pid"
}
is_gateway_pid() {
pid="$1"
[ -n "$pid" ] || return 1
[ -r "/proc/$pid/cmdline" ] || return 1
cmd_path="$(tr '\000' '\n' <"/proc/$pid/cmdline" 2>/dev/null | sed -n '1p')"
subcommand="$(tr '\000' '\n' <"/proc/$pid/cmdline" 2>/dev/null | sed -n '2p')"
[ "$(basename "$cmd_path")" = "$BIN_NAME" ] && [ "$subcommand" = "gateway" ] && return 0
return 1
}
find_gateway_pid() {
for proc_dir in /proc/[0-9]*; do
[ -d "$proc_dir" ] || continue
pid="${proc_dir#/proc/}"
if is_gateway_pid "$pid"; then
printf '%s\n' "$pid"
return 0
fi
done
return 1
}
cleanup_stale_pid_file() {
[ -f "$PICOCLAW_PID_FILE" ] || return 0
pid="$(read_pid_file 2>/dev/null)" || {
rm -f "$PICOCLAW_PID_FILE"
return 0
}
if ! is_gateway_pid "$pid"; then
echo "picoclaw stale pid file removed: $PICOCLAW_PID_FILE"
rm -f "$PICOCLAW_PID_FILE"
fi
}
gateway_running_pid() {
pid="$(read_pid_file 2>/dev/null)" && is_gateway_pid "$pid" && {
printf '%s\n' "$pid"
return 0
}
cleanup_stale_pid_file
pid="$(find_gateway_pid 2>/dev/null)" || return 1
printf '%s\n' "$pid" >"$PICOCLAW_PID_FILE" 2>/dev/null || true
printf '%s\n' "$pid"
}
resolve_kvm_control_source() {
for candidate in \
"/kvmapp/picoclaw/skills/kvm-control" \
@@ -107,8 +173,8 @@ sync_agent_profile() {
start_service() {
setup_runtime_env || return 1
if pidof "$BIN_NAME" >/dev/null 2>&1; then
echo "picoclaw already running"
if pid="$(gateway_running_pid)"; then
echo "picoclaw gateway already running (PID: $pid)"
return 0
fi
@@ -118,6 +184,12 @@ start_service() {
fi
"$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 &
gateway_pid="$!"
printf '%s\n' "$gateway_pid" >"$PICOCLAW_PID_FILE" || {
echo "failed to write picoclaw pid file: $PICOCLAW_PID_FILE"
return 1
}
echo "picoclaw gateway started (PID: $gateway_pid)"
}
onboard_service() {
@@ -128,7 +200,26 @@ onboard_service() {
}
stop_service() {
killall "$BIN_NAME" 2>/dev/null || true
setup_picoclaw_home || return 1
pid="$(gateway_running_pid 2>/dev/null)" || {
cleanup_stale_pid_file
return 0
}
kill "$pid" 2>/dev/null || true
count=0
while is_gateway_pid "$pid"; do
count=$((count + 1))
if [ "$count" -ge 15 ]; then
echo "picoclaw gateway did not stop after TERM, killing PID: $pid"
kill -9 "$pid" 2>/dev/null || true
break
fi
sleep 1
done
rm -f "$PICOCLAW_PID_FILE"
}
case "$1" in

98
scripts/README.md Normal file
View File

@@ -0,0 +1,98 @@
# Release packaging
These scripts assemble the application update package that NanoKVM devices pull
over the air: `nanokvm_<version>.tar.gz` plus its `latest.json` manifest.
| Script | Purpose |
|---|---|
| `build-in-container.sh` | Builds every riscv64 artifact (`kvm_system`, `libkvm.so`, `NanoKVM-Server`). Runs inside the `nanokvm-builder` image only. |
| `package.sh` | Stages the package tree, creates the tarball, and writes `latest.json`. |
| `compare-release.sh` | Diffs a freshly built package against the currently published one. Informational. |
| `verify-release-assets.sh` | Checks the three GitHub Release assets before publishing or promotion. |
| `verify-release-tag.sh` | Requires an annotated numeric tag whose commit is on `main`. |
## What the updater expects
The format is not arbitrary — it is fixed by the on-device updater in
`server/service/application/`:
- **One root directory.** `install.go` untars the package and moves the single
top-level directory over `/kvmapp`, so the tarball must contain exactly
`nanokvm_<version>/`.
- **`name` is the file name.** `version.go` builds the download URL as
`<base>/<name>`, where `<base>` is `https://cdn.sipeed.com/nanokvm` (or
`.../preview` when `/etc/kvm/preview_updates` exists).
- **`sha512` is base64, not hex.** `update.go` compares against
`base64(raw sha512 digest)`. A hex digest will fail verification on device.
- **`/kvmapp/version`** is what the device reports as its installed version, so
it must match the `version` field.
`size` is parsed into `Latest.Size` but never read anywhere, so nothing on the
device depends on its units. Published manifests have carried a kilobyte-ish
value; `package.sh` writes the exact byte count instead.
## Building a release
The frontend needs Node and pnpm on the host; everything riscv64 needs the
builder image (see `docker/Dockerfile`).
```bash
make release VERSION=2.4.4
```
That is equivalent to:
```bash
make release-build # kvm_system + libkvm.so + NanoKVM-Server, in Docker
make web # web/dist
make package VERSION=2.4.4 # build/release/{nanokvm_2.4.4.tar.gz,latest.json}
```
In CI this runs as the **NanoKVM Package** workflow. Pull requests and manual
runs only create Actions artifacts. For a public release, run **NanoKVM Create
Tag**, then run **NanoKVM Release** and choose prerelease, stable, or promotion.
Publishing attaches three assets:
Publishing is one-shot: an existing Release or draft is an error, and only a
published prerelease can be promoted.
- `nanokvm_<version>.tar.gz`
- `latest.json`
- `sha256.txt`
The Actions artifact also includes `BUILD_INFO.txt` with the source commit,
workflow run, immutable builder image digest, and both checksum encodings; that
provenance file is not attached to the GitHub release. Uploading the tarball and
`latest.json` to the CDN is what actually offers the update to devices in the
field, so that step stays manual. `sha256.txt` is only for users verifying a
manual download and does not change the on-device `latest.json` contract.
## Where each file in the package comes from
| Package path | Source |
|---|---|
| `version` | the requested version |
| `server/NanoKVM-Server` | `server/build.sh` (BoringCrypto, RPATH `$ORIGIN/dl_lib`) |
| `server/dl_lib/` | whatever the `kvm_vision` build emits, with any library it does not emit backfilled from the tracked `server/dl_lib/` |
| `server/web/` | `web/dist/` |
| `kvm_system/kvm_system` | `support/sg2002` `build kvm_system` |
| `system/tool/` | prebuilt binaries in `tools/nanokvm_update_edid/` |
| `kvm/` | default runtime state (resolution, fps, quality) |
| everything else | tracked `kvmapp/` |
Note that `kvm/` ships default runtime state, and `install.go` replaces
`/kvmapp` wholesale, so an update resets those values on the device. That is
long-standing behaviour, not something these scripts introduce.
## The two copies of libkvm.so
`NanoKVM-Server` is cgo-linked against the **tracked** `server/dl_lib/libkvm.so`
(`server/common/kvm_vision.go`: `-L../dl_lib -lkvm`), but the package ships the
library the `kvm_vision` build just produced. Those are two different files, and
the tracked one can lag well behind — published releases have shipped this
mismatch for a long time.
Because a symbol the binary imports could in principle be absent from the
shipped library, and that would only surface as a crash on a real device,
`build-in-container.sh` checks the shipped `libkvm.so` still exports every
symbol `NanoKVM-Server` actually imports from it, and fails the build otherwise.

144
scripts/build-in-container.sh Executable file
View File

@@ -0,0 +1,144 @@
#!/bin/bash
#
# Build every riscv64 artifact of a release, from inside the nanokvm-builder
# image (docker/Dockerfile). Not meant to be run on the host: it needs the
# riscv64 musl toolchain and the patched MaixCDK checkout that the image ships.
#
# Produces:
# kvmapp/kvm_system/kvm_system kvm_system daemon
# kvmapp/server/dl_lib/ video libraries, including a fresh libkvm.so
# server/NanoKVM-Server Go server (BoringCrypto, RPATH $ORIGIN/dl_lib)
set -euo pipefail
# docker/entrypoint sets HOME for the build user. support/sg2002/build resolves
# both MaixCDK and the source tree through ~, so a wrong HOME would send the
# whole build at /root; say so plainly rather than emitting "directory missing".
BUILD_HOME="${BUILD_HOME:-/home/build}"
if [ "${HOME:-}" != "$BUILD_HOME" ]; then
echo "[ERROR] HOME is '${HOME:-unset}', expected $BUILD_HOME" >&2
echo " run this inside the builder image via the Makefile" >&2
exit 1
fi
MAIXCDK_PATH="$HOME/MaixCDK"
NANOKVM_PATH="$HOME/NanoKVM"
for dir in "$MAIXCDK_PATH" "$NANOKVM_PATH"; do
if [ ! -d "$dir" ]; then
echo "[ERROR] $dir not found - is this running inside nanokvm-builder?" >&2
exit 1
fi
done
# Check the toolchain up front. builder-image reuses any existing image, so an
# image built before a tooling change silently lacks it - and without this the
# SDK build would run for minutes before server/build.sh failed at the end.
for tool in go riscv64-unknown-linux-musl-gcc patchelf; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "[ERROR] '$tool' is missing from the builder image" >&2
echo " the image is out of date; rebuild it with: make rebuild-image" >&2
exit 1
fi
done
# The SDK tree is owned by the ids baked into the image (DOCKER_UID/DOCKER_GID),
# while the entrypoint drops to the caller's ids. If they disagree, syncing
# components fails partway through a long build; catch it here instead.
if [ ! -w "$MAIXCDK_PATH/components" ]; then
echo "[ERROR] $MAIXCDK_PATH/components is not writable by $(id -u):$(id -g)" >&2
echo " the image was built for different ids; rebuild it with:" >&2
echo " make rebuild-image" >&2
exit 1
fi
# shellcheck disable=SC1091
. "$MAIXCDK_PATH/bin/activate"
echo "::group::support: sync MaixCDK components"
cd "$NANOKVM_PATH/support/sg2002"
# The image bakes support/sg2002/additional/ into MaixCDK's components at image
# build time, and ./build only re-copies them when components/kvm is missing.
# Refresh unconditionally so a cached image never yields stale libraries.
./build update_lib
echo "::endgroup::"
echo "::group::support: build kvm_system"
./build kvm_system
./build kvm_system add_to_kvmapp
echo "::endgroup::"
echo "::group::support: build kvm_vision (libkvm.so)"
./build kvm_vision
./build kvm_vision add_to_kvmapp
echo "::endgroup::"
echo "::group::server: cross-compile NanoKVM-Server"
cd "$NANOKVM_PATH/server"
./build.sh
echo "::endgroup::"
# support/sg2002/build reports "Build Error!" without a non-zero exit status for
# some failure paths, so assert on the artifacts rather than trusting $?.
echo "[INFO] verifying artifacts"
missing=0
for artifact in \
"$NANOKVM_PATH/kvmapp/kvm_system/kvm_system" \
"$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so" \
"$NANOKVM_PATH/server/NanoKVM-Server"
do
if [ -f "$artifact" ]; then
echo " ok ${artifact#$NANOKVM_PATH/}"
else
echo " MISSING ${artifact#$NANOKVM_PATH/}" >&2
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo "[ERROR] build did not produce all expected artifacts" >&2
exit 1
fi
# NanoKVM-Server is cgo-linked against the tracked server/dl_lib/libkvm.so
# (server/common/kvm_vision.go: -L../dl_lib -lkvm), but the package ships the
# freshly built one from kvm_vision. Those are different files, so confirm the
# shipped library still exports every symbol the binary actually imports from
# it. Without this a dropped symbol only shows up as a crash on a real device.
echo "[INFO] verifying shipped libkvm.so satisfies NanoKVM-Server"
if ! command -v readelf >/dev/null 2>&1; then
echo "[WARN] readelf unavailable, skipping ABI check"
else
exported() {
readelf --dyn-syms --wide "$1" 2>/dev/null \
| awk '$7 != "UND" && $8 != "" { print $8 }' \
| sed 's/@.*//' | LC_ALL=C sort -u
}
imported() {
readelf --dyn-syms --wide "$1" 2>/dev/null \
| awk '$7 == "UND" && $8 != "" { print $8 }' \
| sed 's/@.*//' | LC_ALL=C sort -u
}
linked_lib="$NANOKVM_PATH/server/dl_lib/libkvm.so"
shipped_lib="$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so"
server_bin="$NANOKVM_PATH/server/NanoKVM-Server"
# Symbols the binary imports that the link-time library provides, i.e. the
# ones libkvm.so is actually responsible for at runtime.
needed=$(comm -12 <(imported "$server_bin") <(exported "$linked_lib"))
if [ -z "$needed" ]; then
echo "[WARN] no libkvm symbols resolved; skipping ABI check"
else
absent=$(comm -23 <(printf '%s\n' "$needed") <(exported "$shipped_lib"))
if [ -n "$absent" ]; then
echo "[ERROR] shipped libkvm.so is missing symbols NanoKVM-Server needs:" >&2
printf ' %s\n' $absent >&2
exit 1
fi
echo " ok $(printf '%s\n' "$needed" | wc -l | tr -d ' ') libkvm symbols resolve"
fi
fi
echo "[DONE] riscv64 artifacts built"

103
scripts/compare-release.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/bin/bash
#
# Compare a freshly built package against the currently published release.
#
# Purely informational: it exits 0 whatever it finds. The point is to make the
# delta reviewable before the tarball is published, since the package is what
# every device pulls over OTA. Unexpected entries here (a library that vanished,
# an init script that changed without a matching commit) are worth a look.
#
# Usage: scripts/compare-release.sh <new-tarball> [base-url]
set -uo pipefail
NEW_TARBALL="${1:-}"
BASE_URL="${2:-https://cdn.sipeed.com/nanokvm}"
if [ -z "$NEW_TARBALL" ] || [ ! -f "$NEW_TARBALL" ]; then
echo "Usage: $0 <new-tarball> [base-url]" >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
echo "[INFO] fetching published manifest from $BASE_URL/latest.json"
if ! curl -fsSL "$BASE_URL/latest.json" -o "$WORK/latest.json"; then
echo "[WARN] could not fetch latest.json - skipping comparison"
exit 0
fi
cat "$WORK/latest.json"
OLD_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$WORK/latest.json")"
if [ -z "$OLD_NAME" ]; then
echo "[WARN] no \"name\" in latest.json - skipping comparison"
exit 0
fi
echo "[INFO] downloading published package $OLD_NAME"
if ! curl -fsSL "$BASE_URL/$OLD_NAME" -o "$WORK/$OLD_NAME"; then
echo "[WARN] could not download $OLD_NAME - skipping comparison"
exit 0
fi
mkdir -p "$WORK/old" "$WORK/new"
if ! tar xzf "$WORK/$OLD_NAME" -C "$WORK/old"; then
echo "[WARN] could not extract $OLD_NAME - skipping comparison"
exit 0
fi
if ! tar xzf "$NEW_TARBALL" -C "$WORK/new"; then
echo "[WARN] could not extract $(basename "$NEW_TARBALL") - skipping comparison"
exit 0
fi
# Strip the nanokvm_<version>/ prefix so the two trees are comparable.
OLD_ROOT="$(find "$WORK/old" -mindepth 1 -maxdepth 1 -type d | head -1)"
NEW_ROOT="$(find "$WORK/new" -mindepth 1 -maxdepth 1 -type d | head -1)"
if [ -z "$OLD_ROOT" ] || [ -z "$NEW_ROOT" ]; then
echo "[WARN] could not locate a package root directory - skipping comparison"
exit 0
fi
( cd "$OLD_ROOT" && find . -type f | sed 's|^\./||' | LC_ALL=C sort ) > "$WORK/old.list"
( cd "$NEW_ROOT" && find . -type f | sed 's|^\./||' | LC_ALL=C sort ) > "$WORK/new.list"
# An empty inventory would make every section below print "(none)", which reads
# identically to "verified, nothing changed". Refuse to imply that.
if [ ! -s "$WORK/old.list" ] || [ ! -s "$WORK/new.list" ]; then
echo "[WARN] one of the packages produced an empty file list - comparison unreliable, skipping"
exit 0
fi
echo
echo "=============================================================="
echo " $(basename "$OLD_ROOT") -> $(basename "$NEW_ROOT")"
echo "=============================================================="
printf ' files: %s -> %s\n' "$(wc -l < "$WORK/old.list" | tr -d ' ')" \
"$(wc -l < "$WORK/new.list" | tr -d ' ')"
echo
echo "--- added ----------------------------------------------------"
comm -13 "$WORK/old.list" "$WORK/new.list" | sed 's/^/ + /' || true
echo
echo "--- removed --------------------------------------------------"
comm -23 "$WORK/old.list" "$WORK/new.list" | sed 's/^/ - /' || true
echo
echo "--- changed --------------------------------------------------"
changed=0
while IFS= read -r file; do
if ! cmp -s "$OLD_ROOT/$file" "$NEW_ROOT/$file"; then
old_size="$(wc -c < "$OLD_ROOT/$file" | tr -d ' ')"
new_size="$(wc -c < "$NEW_ROOT/$file" | tr -d ' ')"
printf ' ~ %-52s %s -> %s bytes\n' "$file" "$old_size" "$new_size"
changed=$((changed + 1))
fi
done < <(comm -12 "$WORK/old.list" "$WORK/new.list")
[ "$changed" -eq 0 ] && echo " (none)"
echo
echo "[INFO] comparison complete"

216
scripts/package.sh Executable file
View File

@@ -0,0 +1,216 @@
#!/bin/bash
#
# Assemble a NanoKVM release package: nanokvm_<version>.tar.gz + latest.json.
#
# The layout and the manifest fields are dictated by the on-device updater:
# - server/service/application/version.go parses latest.json and derives the
# download URL as "<base>/<name>", so "name" must be the tarball file name.
# - server/service/application/update.go verifies the download against
# "sha512", which is the *base64* encoding of the raw SHA-512 digest.
# - server/service/application/install.go untars the package and moves the
# single top-level directory over /kvmapp, so the tarball must contain
# exactly one root directory: nanokvm_<version>/.
#
# Build artifacts are expected to be in place already (see
# scripts/build-in-container.sh and the "web" target in the Makefile):
# server/NanoKVM-Server riscv64 server binary
# kvmapp/kvm_system/kvm_system kvm_system daemon
# kvmapp/server/dl_lib/libkvm.so freshly built video library (optional)
# web/dist/ built frontend
#
# Usage: scripts/package.sh <version>
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
VERSION="${1:-}"
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version> e.g. $0 2.4.4" >&2
exit 1
fi
# The updater rejects file names outside [a-zA-Z0-9._-] (update_offline.go),
# and the version string ends up in the file name, so validate it up front.
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "[ERROR] invalid version '$VERSION', expected MAJOR.MINOR.PATCH" >&2
exit 1
fi
OUT="$ROOT/build/release"
STAGE="$OUT/nanokvm_$VERSION"
TARBALL="$OUT/nanokvm_$VERSION.tar.gz"
MANIFEST="$OUT/latest.json"
require_file() {
if [ ! -f "$1" ]; then
echo "[ERROR] missing build artifact: ${1#"$ROOT"/}" >&2
echo " $2" >&2
exit 1
fi
}
# A host-architecture build is indistinguishable from a cross-compiled one by
# name alone, and NanoKVM-Server is gitignored, so nothing else would catch it.
# Refuse to package anything that is not riscv64 (ELF e_machine 243).
require_riscv64() {
local path="$1" magic machine
magic=$(od -An -tx1 -N4 "$path" | tr -d ' \n')
if [ "$magic" != "7f454c46" ]; then
echo "[ERROR] not an ELF object: ${path#"$ROOT"/}" >&2
exit 1
fi
machine=$(od -An -tu1 -j18 -N1 "$path" | tr -d ' \n')
if [ "$machine" != "243" ]; then
echo "[ERROR] ${path#"$ROOT"/} is not riscv64 (ELF e_machine=$machine)" >&2
echo " a host-architecture build must never be packaged" >&2
exit 1
fi
}
require_file "$ROOT/server/NanoKVM-Server" "run: make app"
require_file "$ROOT/kvmapp/kvm_system/kvm_system" "run: make support"
require_file "$ROOT/web/dist/index.html" "run: make web"
# libkvm.so is the one runtime library that must come from the build: the copy
# tracked in server/dl_lib/ exists for cgo link time and lags far behind. If it
# were optional here, "make package" on its own would quietly ship the stale one.
require_file "$ROOT/kvmapp/server/dl_lib/libkvm.so" "run: make vision"
require_riscv64 "$ROOT/server/NanoKVM-Server"
require_riscv64 "$ROOT/kvmapp/kvm_system/kvm_system"
require_riscv64 "$ROOT/kvmapp/server/dl_lib/libkvm.so"
echo "[INFO] staging nanokvm_$VERSION"
# Clear the whole output directory: release.yml uploads build/release/nanokvm_*
# by glob, and a leftover tarball from an earlier version would ride along.
rm -rf "$OUT"
mkdir -p "$STAGE"
# 1. Everything tracked under kvmapp/ (init scripts, picoclaw bundle, kernel
# module, jpg_stream, kvm_stream) plus whatever the support build dropped in
# (kvm_system, server/dl_lib). -a carries the exec bits over from git, which
# only matters for reading the archive by hand: install.go chmods the whole
# tree to 0755 on the device regardless.
cp -a "$ROOT/kvmapp/." "$STAGE/"
# 2. Version marker. The updater reads /kvmapp/version to report the currently
# installed version (version.go).
printf '%s\n' "$VERSION" > "$STAGE/version"
# 3. Go server binary.
mkdir -p "$STAGE/server"
cp -a "$ROOT/server/NanoKVM-Server" "$STAGE/server/NanoKVM-Server"
# 4. Runtime shared libraries. "build kvm_vision add_to_kvmapp" copies its whole
# dist/dl_lib into kvmapp/server/dl_lib, so freshly built libraries are
# already staged; the tracked server/dl_lib/ backfills the rest. Never
# clobber a fresh library with the tracked (link-time) copy.
mkdir -p "$STAGE/server/dl_lib"
for lib in "$ROOT"/server/dl_lib/*; do
[ -e "$lib" ] || continue
name="$(basename "$lib")"
if [ ! -e "$STAGE/server/dl_lib/$name" ]; then
cp -a "$lib" "$STAGE/server/dl_lib/$name"
fi
done
# The SDK is built from an unpinned MaixCDK checkout, so its dist could one
# day add or rename a library (e.g. an soname bump leaving both .409 and
# .410 behind) and we would ship a library set nobody reviewed. Require the
# shipped names to be exactly the tracked set, and fail loudly otherwise.
staged_libs=$(cd "$STAGE/server/dl_lib" && ls -1 | LC_ALL=C sort)
tracked_libs=$(cd "$ROOT/server/dl_lib" && ls -1 | LC_ALL=C sort)
if [ "$staged_libs" != "$tracked_libs" ]; then
echo "[ERROR] shipped server/dl_lib does not match the tracked library set" >&2
echo " unexpected (in package, not tracked):" >&2
comm -23 <(printf '%s\n' "$staged_libs") <(printf '%s\n' "$tracked_libs") \
| sed 's/^/ + /' >&2
echo " absent (tracked, not in package):" >&2
comm -13 <(printf '%s\n' "$staged_libs") <(printf '%s\n' "$tracked_libs") \
| sed 's/^/ - /' >&2
echo " if this change is intended, update server/dl_lib/ to match." >&2
exit 1
fi
# A library arrives either from the SDK dist (mode 755) or from the tracked
# backfill (git stores them 644), so without this the archive would depend on
# which path populated each file. 644 is what published releases have always
# carried, and install.go chmods the installed tree to 0755 anyway.
chmod 644 "$STAGE"/server/dl_lib/*
# 5. Frontend. router.go serves <dir of executable>/web.
rm -rf "$STAGE/server/web"
mkdir -p "$STAGE/server/web"
cp -a "$ROOT/web/dist/." "$STAGE/server/web/"
# 6. EDID helper shipped under system/tool/ (prebuilt riscv64 binary in tools/).
mkdir -p "$STAGE/system/tool"
cp -a "$ROOT/tools/nanokvm_update_edid/nanokvm_update_edid" "$STAGE/system/tool/"
cp -a "$ROOT/tools/nanokvm_update_edid/E21_NanoKVM.bin" "$STAGE/system/tool/"
# 7. Default runtime state read by common.GetScreen() on first boot.
mkdir -p "$STAGE/kvm"
printf '30\n' > "$STAGE/kvm/fps"
printf '0\n' > "$STAGE/kvm/now_fps"
printf '60\n' > "$STAGE/kvm/qlty"
printf '1920\n' > "$STAGE/kvm/width"
printf '1080\n' > "$STAGE/kvm/height"
printf '1\n' > "$STAGE/kvm/state"
printf 'mjpeg\n' > "$STAGE/kvm/type"
printf '0' > "$STAGE/kvm/res"
# 8. Directories the released package has always carried, kept so the layout
# matches previous releases exactly (system_init.cpp probes inside them).
mkdir -p "$STAGE/jpg_stream/dl_lib" "$STAGE/kvm_system/dl_lib"
# --- archive -----------------------------------------------------------------
# Normalise owner and timestamps so the same source tree yields the same
# tarball, which makes the published sha512 verifiable after the fact.
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" log -1 --format=%ct 2>/dev/null || echo 0)}"
find "$STAGE" -exec touch -d "@$SOURCE_DATE_EPOCH" {} + 2>/dev/null \
|| find "$STAGE" -exec touch -t "$(date -r "$SOURCE_DATE_EPOCH" +%Y%m%d%H%M.%S)" {} + 2>/dev/null \
|| echo "[WARN] could not normalise timestamps"
echo "[INFO] creating $(basename "$TARBALL")"
rm -f "$TARBALL"
# Read the version into a variable first: piping through head can hand tar a
# SIGPIPE, and under "set -o pipefail" that would silently select the bsdtar
# branch on a GNU system, quietly losing reproducibility.
tar_version="$(tar --version 2>/dev/null || true)"
if [ "${tar_version#*GNU}" != "$tar_version" ]; then
tar --format=gnu --sort=name \
--owner=0 --group=0 --numeric-owner \
--mtime="@$SOURCE_DATE_EPOCH" \
-C "$OUT" -cf - "nanokvm_$VERSION" \
| gzip -n -9 > "$TARBALL"
else
# bsdtar (macOS): no --sort/--mtime, so the archive is not byte-reproducible.
echo "[WARN] GNU tar not found; archive will not be byte-reproducible"
tar --uid 0 --gid 0 --uname '' --gname '' \
-C "$OUT" -cf - "nanokvm_$VERSION" \
| gzip -n -9 > "$TARBALL"
fi
# --- manifest ----------------------------------------------------------------
# update.go compares base64(raw sha512), not the hex digest.
SHA512="$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)"
SIZE="$(wc -c < "$TARBALL" | tr -d ' ')"
cat > "$MANIFEST" <<EOF
{
"version": "$VERSION",
"name": "nanokvm_$VERSION.tar.gz",
"sha512": "$SHA512",
"size": $SIZE
}
EOF
echo
echo "[DONE] $TARBALL"
echo "[DONE] $MANIFEST"
cat "$MANIFEST"

118
scripts/verify-release-assets.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/bin/bash
# Verify the three assets that make up a NanoKVM GitHub release.
set -euo pipefail
ASSET_DIR="${1:-}"
VERSION="${2:-}"
if [ -z "$ASSET_DIR" ] || [ -z "$VERSION" ]; then
echo "Usage: $0 <asset-directory> <version>" >&2
exit 1
fi
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "[ERROR] invalid version '$VERSION', expected MAJOR.MINOR.PATCH" >&2
exit 1
fi
TARBALL_NAME="nanokvm_${VERSION}.tar.gz"
TARBALL="$ASSET_DIR/$TARBALL_NAME"
MANIFEST="$ASSET_DIR/latest.json"
CHECKSUM="$ASSET_DIR/sha256.txt"
for path in "$TARBALL" "$MANIFEST" "$CHECKSUM"; do
if [ ! -f "$path" ]; then
echo "[ERROR] missing release asset: $path" >&2
exit 1
fi
done
PACKAGE_ROOT="nanokvm_${VERSION}"
ENTRY_LIST=$(mktemp)
VERBOSE_LIST=$(mktemp)
trap 'rm -f "$ENTRY_LIST" "$VERBOSE_LIST"' EXIT
if ! tar -tzf "$TARBALL" > "$ENTRY_LIST"; then
echo "[ERROR] could not list release tarball" >&2
exit 1
fi
if ! tar -tvzf "$TARBALL" > "$VERBOSE_LIST"; then
echo "[ERROR] could not inspect release tarball entry types" >&2
exit 1
fi
ENTRY_COUNT=0
while IFS= read -r entry; do
ENTRY_COUNT=$((ENTRY_COUNT + 1))
case "$entry" in
"$PACKAGE_ROOT"|"$PACKAGE_ROOT"/*) ;;
*)
echo "[ERROR] archive entry is outside $PACKAGE_ROOT/: $entry" >&2
exit 1
;;
esac
case "/$entry/" in
*/../*|*/./*)
echo "[ERROR] archive entry contains an unsafe path component: $entry" >&2
exit 1
;;
esac
done < "$ENTRY_LIST"
if [ "$ENTRY_COUNT" -eq 0 ]; then
echo "[ERROR] release tarball is empty" >&2
exit 1
fi
while IFS= read -r verbose_entry; do
entry_type=${verbose_entry:0:1}
case "$entry_type" in
-|d) ;;
*)
echo "[ERROR] archive contains a link or special entry: $verbose_entry" >&2
exit 1
;;
esac
done < "$VERBOSE_LIST"
ARCHIVE_VERSION=$(tar -xOzf "$TARBALL" "$PACKAGE_ROOT/version" 2>/dev/null) || {
echo "[ERROR] release tarball does not contain $PACKAGE_ROOT/version" >&2
exit 1
}
if [ "$ARCHIVE_VERSION" != "$VERSION" ]; then
echo "[ERROR] archive version '$ARCHIVE_VERSION' does not match '$VERSION'" >&2
exit 1
fi
SHA256=$(sha256sum "$TARBALL" | cut -d' ' -f1)
if ! printf '%s %s\n' "$SHA256" "$TARBALL_NAME" | cmp -s - "$CHECKSUM"; then
echo "[ERROR] sha256.txt does not match $TARBALL_NAME" >&2
exit 1
fi
MANIFEST_VERSION=$(jq -er '.version | select(type == "string" and length > 0)' "$MANIFEST")
MANIFEST_NAME=$(jq -er '.name | select(type == "string" and length > 0)' "$MANIFEST")
MANIFEST_SIZE=$(jq -er '.size | select(type == "number" and . >= 0 and floor == .)' "$MANIFEST")
MANIFEST_SHA512=$(jq -er '.sha512 | select(type == "string" and length > 0)' "$MANIFEST")
if [ "$MANIFEST_VERSION" != "$VERSION" ]; then
echo "[ERROR] latest.json version '$MANIFEST_VERSION' does not match '$VERSION'" >&2
exit 1
fi
if [ "$MANIFEST_NAME" != "$TARBALL_NAME" ]; then
echo "[ERROR] latest.json name '$MANIFEST_NAME' does not match '$TARBALL_NAME'" >&2
exit 1
fi
ACTUAL_SIZE=$(wc -c < "$TARBALL" | tr -d ' ')
if [ "$MANIFEST_SIZE" != "$ACTUAL_SIZE" ]; then
echo "[ERROR] latest.json size '$MANIFEST_SIZE' does not match '$ACTUAL_SIZE'" >&2
exit 1
fi
ACTUAL_SHA512=$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)
if [ "$MANIFEST_SHA512" != "$ACTUAL_SHA512" ]; then
echo "[ERROR] latest.json sha512 does not match $TARBALL_NAME" >&2
exit 1
fi
echo "[OK] verified NanoKVM release assets for $VERSION"

43
scripts/verify-release-tag.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Resolve an annotated numeric tag from origin and verify its commit is on main.
set -euo pipefail
TAG="${1:-}"
EXPECTED_SHA="${2:-}"
if ! echo "$TAG" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "[ERROR] invalid tag '$TAG', expected MAJOR.MINOR.PATCH" >&2
exit 1
fi
if [ -n "$EXPECTED_SHA" ] && ! echo "$EXPECTED_SHA" | grep -qE '^[0-9a-f]{40}$'; then
echo "[ERROR] expected commit must be a full lowercase commit SHA" >&2
exit 1
fi
TAG_REF="refs/tags/$TAG"
PEELED_REF="${TAG_REF}^{}"
REMOTE_REFS=$(git ls-remote --tags origin "$TAG_REF" "$PEELED_REF")
TAG_OBJECT=$(printf '%s\n' "$REMOTE_REFS" | awk -v ref="$TAG_REF" '$2 == ref { print $1 }')
SOURCE_SHA=$(printf '%s\n' "$REMOTE_REFS" | awk -v ref="$PEELED_REF" '$2 == ref { print $1 }')
if ! echo "$TAG_OBJECT" | grep -qE '^[0-9a-f]{40}$'; then
echo "[ERROR] tag '$TAG' does not exist on origin" >&2
exit 1
fi
if ! echo "$SOURCE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
echo "[ERROR] tag '$TAG' is not an annotated tag" >&2
exit 1
fi
if [ -n "$EXPECTED_SHA" ] && [ "$SOURCE_SHA" != "$EXPECTED_SHA" ]; then
echo "[ERROR] tag '$TAG' moved from '$EXPECTED_SHA' to '$SOURCE_SHA'" >&2
exit 1
fi
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$SOURCE_SHA" FETCH_HEAD; then
echo "[ERROR] tag '$TAG' points to a commit outside origin/main" >&2
exit 1
fi
printf '%s\n' "$SOURCE_SHA"

View File

@@ -64,7 +64,7 @@ turn:
## Compile & Deploy
Note: Use Linux operating system (x86-64). This build process is not compatible with ARM, Windows or macOS.
Note: Use Linux operating system (x86-64) with Go 1.25 or newer. This build process is not compatible with ARM, Windows or macOS.
1. Install the Toolchain
1. Download the toolchain from the following link: [Download Link](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz).

View File

@@ -65,7 +65,7 @@ turn:
## コンパイルとデプロイ
注意: Linux オペレーティングシステム (x86-64) を使用してください。このビルドプロセスは ARM、Windows、macOS では互換性がありません。
注意: Linux オペレーティングシステム (x86-64) と Go 1.25 以降を使用してください。このビルドプロセスは ARM、Windows、macOS では互換性がありません。
1. ツールチェーンのインストール
1. 以下のリンクからツールチェーンをダウンロードします: [ダウンロードリンク](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz)。

View File

@@ -62,7 +62,7 @@ turn:
## 编译部署
**注意:请使用 Linux 操作系统(x86-64)。该工具链无法在 ARM、Windows 或 macOS 下使用。**
**注意:请使用 Linux 操作系统(x86-64)和 Go 1.25 或更高版本。该工具链无法在 ARM、Windows 或 macOS 下使用。**
1. 安装工具链
1. 下载工具链:[下载地址](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz);

Binary file not shown.

Binary file not shown.

View File

@@ -1,16 +1,18 @@
module NanoKVM-Server
go 1.24.0
go 1.25.0
require (
github.com/creack/pty v1.1.24
github.com/gin-gonic/contrib v0.0.0-20240508051311-c1c6bf0061b0
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/jsonschema-go v0.4.3
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/pion/dtls/v3 v3.1.2
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1
@@ -60,6 +62,8 @@ require (
github.com/rs/cors v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
@@ -68,12 +72,14 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect

View File

@@ -34,11 +34,13 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -65,6 +67,8 @@ github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0 h
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0/go.mod h1:Eb5RMoo9kOQra/2uRiUTGP+LfNuM13Vqm7y7P34+KKo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -119,6 +123,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
@@ -154,6 +162,8 @@ github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9O
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
@@ -167,13 +177,17 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -13,6 +13,7 @@ import (
"NanoKVM-Server/logger"
"NanoKVM-Server/middleware"
"NanoKVM-Server/router"
"NanoKVM-Server/service/vm"
"NanoKVM-Server/service/vm/jiggler"
"NanoKVM-Server/utils"
@@ -38,12 +39,12 @@ func initialize() {
_ = common.GetScreen()
// init HDMI
vision := common.GetKvmVision()
vision.SetHDMI(false)
vm.DisableHdmiCapture()
time.Sleep(10 * time.Millisecond)
if !utils.IsHdmiDisabled() {
vision.SetHDMI(true)
vm.EnableHdmiCapture()
}
vm.SetHdmiViewerCount(0)
// run mouse jiggler
jiggler.GetJiggler().Run()

View File

@@ -1,5 +1,10 @@
package proto
type DownloadImageReq struct {
File string `json:"file" validate:"required"`
SHA256Sum string `json:"sha256sum"`
}
type ImageEnabledRsp struct {
Enabled bool `json:"enabled"`
}

View File

@@ -4,6 +4,14 @@ type GetHidModeRsp struct {
Mode string `json:"mode"` // normal or hid-only
}
type GetKeyboardLedStatusRsp struct {
NumLock bool `json:"numLock"`
CapsLock bool `json:"capsLock"`
ScrollLock bool `json:"scrollLock"`
Known bool `json:"known"`
UpdatedAt string `json:"updatedAt"`
}
type SetHidModeReq struct {
Mode string `validate:"required"` // normal or hid-only
}

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

@@ -0,0 +1,12 @@
package proto
type GetMCPConfigRsp struct {
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"`
ControlMode string `json:"controlMode"`
Transitioning bool `json:"transitioning"`
}
type SetMCPConfigReq struct {
Enabled *bool `json:"enabled" form:"enabled" validate:"required"`
}

View File

@@ -98,7 +98,12 @@ type GetOLEDRsp struct {
}
type GetGetHdmiStateRsp struct {
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
IdleTimeout int `json:"idleTimeout"`
}
type SetHdmiIdleTimeoutReq struct {
Minutes int `validate:"gte=0,lte=10080"`
}
type GetSSHStateRsp struct {

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

@@ -0,0 +1,159 @@
package router
import (
"net/http"
"time"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/inputcontrol"
"NanoKVM-Server/service/picoclaw"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type setAIControlModeRequest struct {
Mode controlmode.Mode `json:"mode"`
}
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
group := r.Group("/api/ai/control").Use(middleware.CheckToken())
group.GET("/status", func(c *gin.Context) {
status, err := control.Status()
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": -1, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": status})
})
group.PUT("/mode", func(c *gin.Context) {
startedAt := time.Now()
var req setAIControlModeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"code": -1, "message": "invalid arguments"})
return
}
if !validAIControlMode(req.Mode) {
c.JSON(http.StatusOK, gin.H{"code": -1, "message": "invalid control mode"})
return
}
previousStatus, previousErr := control.Status()
if previousErr != nil {
log.WithFields(log.Fields{
"request_mode": string(req.Mode),
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).WithError(previousErr).Warn("AI control mode request failed before switch")
c.JSON(http.StatusOK, gin.H{"code": -2, "message": previousErr.Error()})
return
}
preempt := func() error {
inputcontrol.GetCoordinator().CancelMCP()
if req.Mode == controlmode.ModeMCP {
return picoclawService.PreemptControlLeasesForMCP()
}
picoclawService.CancelActiveControlOperations()
return nil
}
cleanup := func() error {
return hid.ReleaseAllHIDStateBestEffort()
}
if req.Mode == controlmode.ModeMCP {
cleanup = func() error {
if err := picoclawService.StopRuntimeForMCP(); err != nil {
return err
}
return hid.ReleaseAllHIDStateBestEffort()
}
}
if err := control.SwitchWithCleanup(req.Mode, preempt, cleanup); err != nil {
if req.Mode == controlmode.ModeOff {
if status, statusErr := control.Status(); statusErr == nil && status.Mode == controlmode.ModeOff {
picoclawService.CancelActiveControlOperations()
picoclawService.PreserveRuntimeForChatOnly("control_release_chat_only")
closedSessions := 0
picoclawService.PublishControlModeChangedFrom(status, "ai_control_mode")
log.WithFields(log.Fields{
"request_mode": string(req.Mode),
"previous_mode": string(previousStatus.Mode),
"final_mode": string(status.Mode),
"transitioning": status.Transitioning,
"closed_sessions": closedSessions,
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).WithError(err).Warn("AI control mode request completed with cleanup warning")
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": gin.H{
"control": status,
"runtime": picoclawService.RuntimeStatus(),
"released": true,
"closed_sessions": closedSessions,
"cleanup_warning": err.Error(),
},
})
return
}
}
log.WithFields(log.Fields{
"request_mode": string(req.Mode),
"previous_mode": string(previousStatus.Mode),
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).WithError(err).Warn("AI control mode request failed")
c.JSON(http.StatusOK, gin.H{"code": -2, "message": err.Error()})
return
}
status, err := control.Status()
if err != nil {
log.WithFields(log.Fields{
"request_mode": string(req.Mode),
"previous_mode": string(previousStatus.Mode),
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).WithError(err).Warn("AI control mode request failed after switch")
c.JSON(http.StatusOK, gin.H{"code": -2, "message": err.Error()})
return
}
closedSessions := 0
if req.Mode == controlmode.ModeOff {
picoclawService.CancelActiveControlOperations()
picoclawService.PreserveRuntimeForChatOnly("control_release_chat_only")
}
picoclawService.PublishControlModeChangedFrom(status, "ai_control_mode")
log.WithFields(log.Fields{
"request_mode": string(req.Mode),
"previous_mode": string(previousStatus.Mode),
"final_mode": string(status.Mode),
"transitioning": status.Transitioning,
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).Info("AI control mode request completed")
if req.Mode == controlmode.ModeOff {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": gin.H{
"control": status,
"runtime": picoclawService.RuntimeStatus(),
"released": true,
"closed_sessions": closedSessions,
"cleanup_warning": "",
},
})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": status})
})
}
func validAIControlMode(mode controlmode.Mode) bool {
switch mode {
case controlmode.ModeOff, controlmode.ModeMCP, controlmode.ModePicoclaw:
return true
default:
return false
}
}

View File

@@ -11,8 +11,9 @@ func downloadRouter(r *gin.Engine) {
service := download.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api.POST("/download/image", service.DownloadImage) // download image
api.GET("/download/image/status", service.StatusImage) // download image
api.GET("/download/image/enabled", service.ImageEnabled) // download image
api.POST("/download/file", service.DownloadImageFile) // download image
api.POST("/download/image", service.DownloadImage) // download image
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
api.GET("/download/image/status", service.StatusImage) // download image
api.GET("/download/image/enabled", service.ImageEnabled) // download image
api.POST("/download/file", service.DownloadImageFile) // download image
}

View File

@@ -30,6 +30,7 @@ func hidRouter(r *gin.Engine) {
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid
api.GET("/hid/leds", service.GetKeyboardLedStatus)
localAPI.POST("/usb/recover", service.RecoverUSB)
}

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

@@ -0,0 +1,34 @@
package router
import (
"net/http"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
mcpservice "NanoKVM-Server/service/mcp"
"NanoKVM-Server/service/mcp/capture/kvm"
"NanoKVM-Server/service/picoclaw"
"github.com/gin-gonic/gin"
)
func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
service := mcpservice.NewServiceWithPreempt(
control,
picoclawService.PreemptControlLeasesForMCP,
picoclawService.StopRuntimeForMCP,
hid.ReleaseAllHIDStateBestEffort,
func(status controlmode.Status) {
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
},
)
management := r.Group("/api/mcp").Use(middleware.CheckToken())
management.GET("/config", service.GetConfig)
management.POST("/config", service.SetConfig)
management.POST("/key/regenerate", service.RegenerateAPIKey)
handler := mcpservice.NewMCPHandler(control, kvmcapture.New())
handler = http.NewCrossOriginProtection().Handler(handler)
r.Any("/api/mcp", mcpservice.APIKeyMiddleware(control), gin.WrapH(handler))
}

View File

@@ -38,8 +38,7 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
return append([]string(nil), picoclawLoopbackHTTPAllowedPaths...)
}
func picoclawRouter(r *gin.Engine) {
service := picoclaw.NewService()
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())

View File

@@ -5,6 +5,9 @@ import (
"os"
"path/filepath"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/picoclaw"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -29,6 +32,9 @@ func web(r *gin.Engine) {
}
func server(r *gin.Engine) {
control := controlmode.GetManager()
picoclawService := picoclaw.NewService(control)
authRouter(r)
applicationRouter(r)
vmRouter(r)
@@ -36,7 +42,9 @@ func server(r *gin.Engine) {
storageRouter(r)
networkRouter(r)
hidRouter(r)
picoclawRouter(r)
controlRouter(r, control, picoclawService)
mcpRouter(r, control, picoclawService)
picoclawRouter(r, picoclawService)
wsRouter(r)
downloadRouter(r)
extensionsRouter(r)

View File

@@ -40,6 +40,7 @@ func vmRouter(r *gin.Engine) {
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
api.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH

View File

@@ -46,10 +46,6 @@ func offlineUpdate(c *gin.Context) error {
_ = os.RemoveAll(CacheDir)
}()
if err := checkDownloadInProgress(); err != nil {
return err
}
if err := createSentinelFile(); err != nil {
return err
}
@@ -75,19 +71,30 @@ func offlineUpdate(c *gin.Context) error {
return nil
}
func checkDownloadInProgress() error {
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
return fmt.Errorf("download already in progress")
}
return nil
}
func createSentinelFile() error {
if err := os.WriteFile(sentinelPath, []byte("downloading"), sentinelPermission); err != nil {
file, err := os.OpenFile(
sentinelPath,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
sentinelPermission,
)
if err != nil {
if os.IsExist(err) {
return fmt.Errorf("download already in progress")
}
log.Errorf("Failed to create sentinel file: %v", err)
return fmt.Errorf("failed to create sentinel file: %w", err)
}
if _, err := file.WriteString("downloading"); err != nil {
_ = file.Close()
_ = os.Remove(sentinelPath)
return fmt.Errorf("failed to initialize sentinel file: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(sentinelPath)
return fmt.Errorf("failed to close sentinel file: %w", err)
}
return nil
}

View File

@@ -0,0 +1,97 @@
package controlmode
import (
"errors"
"sync"
"time"
)
var ErrActivityWaitTimeout = errors.New("timed out waiting for active control operations")
// activityGate allows normal control operations to share the current mode while
// giving mode transitions exclusive access. Unlike sync.RWMutex, exclusive
// acquisition is bounded so an abandoned client cannot leave the manager stuck
// in the transitioning state forever.
type activityGate struct {
mu sync.Mutex
active int
exclusive bool
changed chan struct{}
}
func (g *activityGate) acquireShared() func() {
for {
g.mu.Lock()
if !g.exclusive {
g.active++
g.mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
g.mu.Lock()
if g.active > 0 {
g.active--
}
g.signalLocked()
g.mu.Unlock()
})
}
}
changed := g.changedLocked()
g.mu.Unlock()
<-changed
}
}
func (g *activityGate) acquireExclusive(timeout time.Duration) (func(), error) {
if timeout <= 0 {
return nil, ErrActivityWaitTimeout
}
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
g.mu.Lock()
if !g.exclusive && g.active == 0 {
g.exclusive = true
g.mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
g.mu.Lock()
g.exclusive = false
g.signalLocked()
g.mu.Unlock()
})
}, nil
}
changed := g.changedLocked()
g.mu.Unlock()
select {
case <-changed:
case <-timer.C:
return nil, ErrActivityWaitTimeout
}
}
}
func (g *activityGate) changedLocked() <-chan struct{} {
if g.changed == nil {
g.changed = make(chan struct{})
}
return g.changed
}
func (g *activityGate) signalLocked() {
if g.changed == nil {
return
}
close(g.changed)
g.changed = make(chan struct{})
}

View File

@@ -0,0 +1,555 @@
package controlmode
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
const ModeFile = "/etc/kvm/ai-control.mode"
const defaultActivityWaitTimeout = 30 * time.Second
type Mode string
const (
ModeOff Mode = "off"
ModeMCP Mode = "mcp"
ModePicoclaw Mode = "picoclaw"
)
var ErrModeConflict = errors.New("AI control mode conflict")
type Status struct {
Mode Mode `json:"mode"`
Transitioning bool `json:"transitioning"`
LastError string `json:"last_error,omitempty"`
ChangedAt time.Time `json:"changed_at,omitempty"`
}
type Manager struct {
transitionMu sync.Mutex
activity activityGate
mu sync.Mutex
path string
defaultMode Mode
activityWaitTimeout time.Duration
loaded bool
mode Mode
modeFileExists bool
modeFileSize int64
modeFileModTime time.Time
transitioning bool
lastError string
changedAt time.Time
}
var (
defaultManagerOnce sync.Once
defaultManager *Manager
)
func GetManager() *Manager {
defaultManagerOnce.Do(func() {
defaultManager = NewManager(ModeFile, ModePicoclaw)
})
return defaultManager
}
func NewManager(path string, defaultMode Mode) *Manager {
if !validMode(defaultMode) {
defaultMode = ModeOff
}
return &Manager{
path: path,
defaultMode: defaultMode,
activityWaitTimeout: defaultActivityWaitTimeout,
}
}
func (m *Manager) Status() (Status, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.loadLocked(); err != nil {
return Status{Mode: ModeOff, Transitioning: m.transitioning, LastError: err.Error(), ChangedAt: m.changedAt}, err
}
return m.statusLocked(), nil
}
func (m *Manager) Current() Mode {
status, err := m.Status()
if err != nil {
return ModeOff
}
return status.Mode
}
func (m *Manager) RequireWrite(expected Mode) error {
status, err := m.Status()
if err != nil {
return err
}
if status.Transitioning || status.Mode != expected {
return fmt.Errorf("%w: current=%s expected=%s", ErrModeConflict, status.Mode, expected)
}
return nil
}
func (m *Manager) Require(expected Mode) error {
return m.RequireWrite(expected)
}
func (m *Manager) RequireMode(expected Mode) error {
status, err := m.Status()
if err != nil {
return err
}
if status.Mode != expected {
return fmt.Errorf("%w: current=%s expected=%s", ErrModeConflict, status.Mode, expected)
}
return nil
}
func (m *Manager) AcquireWrite(expected Mode) (func(), error) {
if err := m.RequireWrite(expected); err != nil {
return nil, err
}
release := m.activity.acquireShared()
if err := m.RequireWrite(expected); err != nil {
release()
return nil, err
}
return release, nil
}
func (m *Manager) Acquire(expected Mode) (func(), error) {
return m.AcquireWrite(expected)
}
// AcquireStable holds the current control mode stable while a non-AI input
// operation is in flight. Mode transitions take the exclusive side of the
// activity gate, so they wait until the returned release function is called.
func (m *Manager) AcquireStable() (Status, func(), error) {
status, err := m.Status()
if err != nil {
return Status{Mode: ModeOff}, nil, err
}
if status.Transitioning {
return status, nil, fmt.Errorf("%w: control mode is transitioning", ErrModeConflict)
}
release := m.activity.acquireShared()
status, err = m.Status()
if err != nil {
release()
return Status{Mode: ModeOff}, nil, err
}
if status.Transitioning {
release()
return status, nil, fmt.Errorf("%w: control mode is transitioning", ErrModeConflict)
}
return status, release, nil
}
func (m *Manager) Switch(next Mode, preempt func() error) error {
return m.SwitchWithCleanup(next, preempt, nil)
}
func (m *Manager) SwitchWithCleanup(next Mode, preempt func() error, cleanup func() error) error {
startedAt := time.Now()
timings := log.Fields{
"to": string(next),
}
m.transitionMu.Lock()
defer m.transitionMu.Unlock()
stageStartedAt := time.Now()
status, err := m.Status()
timings["load_status_ms"] = elapsedMilliseconds(stageStartedAt)
if err != nil {
logModeSwitchFailure("AI control mode switch failed while loading status", timings, err, startedAt)
return err
}
timings["from"] = string(status.Mode)
if !validMode(next) {
err := fmt.Errorf("invalid AI control mode %q", next)
logModeSwitchFailure("AI control mode switch rejected invalid target", timings, err, startedAt)
return err
}
if status.Mode == next {
timings["total_ms"] = elapsedMilliseconds(startedAt)
log.WithFields(timings).Debug("AI control mode switch skipped")
return nil
}
m.setTransitioning(true)
defer m.setTransitioning(false)
if preempt != nil {
stageStartedAt = time.Now()
if err := callModeTransitionHook(preempt); err != nil {
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
m.setLastError(err)
logModeSwitchFailure("AI control mode switch preempt failed", timings, err, startedAt)
return err
}
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
}
stageStartedAt = time.Now()
releaseActivity, err := m.acquireExclusiveActivity()
timings["activity_wait_ms"] = elapsedMilliseconds(stageStartedAt)
if err != nil {
m.setLastError(err)
logModeSwitchFailure("AI control mode switch activity wait failed", timings, err, startedAt)
return err
}
defer releaseActivity()
if cleanup != nil {
stageStartedAt = time.Now()
if err := callModeTransitionHook(cleanup); err != nil {
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
if rollbackErr := m.save(ModeOff); rollbackErr != nil {
err = errors.Join(err, rollbackErr)
}
m.setLastError(err)
logModeSwitchFailure("AI control mode switch cleanup failed", timings, err, startedAt)
return err
}
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
}
stageStartedAt = time.Now()
if err := m.save(next); err != nil {
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
m.setLastError(err)
logModeSwitchFailure("AI control mode switch save failed", timings, err, startedAt)
return err
}
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
m.setLastError(nil)
timings["total_ms"] = elapsedMilliseconds(startedAt)
log.WithFields(timings).Info("AI control mode switch completed")
return nil
}
func (m *Manager) SwitchIf(expected Mode, next Mode, preempt func() error) (bool, error) {
return m.SwitchIfWithCleanup(expected, next, preempt, nil)
}
func (m *Manager) SwitchIfWithCleanup(expected Mode, next Mode, preempt func() error, cleanup func() error) (bool, error) {
startedAt := time.Now()
timings := log.Fields{
"expected": string(expected),
"to": string(next),
}
if !validMode(expected) || !validMode(next) {
err := fmt.Errorf("invalid AI control mode transition %q -> %q", expected, next)
logModeSwitchFailure("conditional AI control mode switch rejected invalid transition", timings, err, startedAt)
return false, err
}
m.transitionMu.Lock()
defer m.transitionMu.Unlock()
stageStartedAt := time.Now()
status, err := m.Status()
timings["load_status_ms"] = elapsedMilliseconds(stageStartedAt)
if err != nil {
logModeSwitchFailure("conditional AI control mode switch failed while loading status", timings, err, startedAt)
return false, err
}
timings["from"] = string(status.Mode)
if status.Mode != expected {
timings["matched"] = false
timings["total_ms"] = elapsedMilliseconds(startedAt)
log.WithFields(timings).Debug("conditional AI control mode switch skipped")
return false, nil
}
if status.Mode == next {
timings["matched"] = true
timings["total_ms"] = elapsedMilliseconds(startedAt)
log.WithFields(timings).Debug("conditional AI control mode switch already satisfied")
return true, nil
}
m.setTransitioning(true)
defer m.setTransitioning(false)
if preempt != nil {
stageStartedAt = time.Now()
if err := callModeTransitionHook(preempt); err != nil {
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
m.setLastError(err)
logModeSwitchFailure("conditional AI control mode switch preempt failed", timings, err, startedAt)
return false, err
}
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
}
stageStartedAt = time.Now()
releaseActivity, err := m.acquireExclusiveActivity()
timings["activity_wait_ms"] = elapsedMilliseconds(stageStartedAt)
if err != nil {
m.setLastError(err)
logModeSwitchFailure("conditional AI control mode switch activity wait failed", timings, err, startedAt)
return false, err
}
defer releaseActivity()
if cleanup != nil {
stageStartedAt = time.Now()
if err := callModeTransitionHook(cleanup); err != nil {
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
if rollbackErr := m.save(ModeOff); rollbackErr != nil {
err = errors.Join(err, rollbackErr)
}
m.setLastError(err)
logModeSwitchFailure("conditional AI control mode switch cleanup failed", timings, err, startedAt)
return false, err
}
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
}
stageStartedAt = time.Now()
if err := m.save(next); err != nil {
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
m.setLastError(err)
logModeSwitchFailure("conditional AI control mode switch save failed", timings, err, startedAt)
return false, err
}
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
m.setLastError(nil)
timings["matched"] = true
timings["total_ms"] = elapsedMilliseconds(startedAt)
log.WithFields(timings).Info("conditional AI control mode switch completed")
return true, nil
}
func (m *Manager) SwitchToMCP(preempt func() error) error {
return m.Switch(ModeMCP, preempt)
}
func (m *Manager) SwitchToMCPWithPreempt(preempt func() error) error {
return m.Switch(ModeMCP, preempt)
}
func (m *Manager) SwitchToPicoclaw(preempt func() error) error {
return m.Switch(ModePicoclaw, preempt)
}
func (m *Manager) SwitchToPicoclawWithPreempt(preempt func() error) error {
return m.Switch(ModePicoclaw, preempt)
}
func (m *Manager) SwitchOff(preempt func() error) error {
return m.Switch(ModeOff, preempt)
}
func (m *Manager) SwitchOffIf(expected Mode, preempt func() error) (bool, error) {
return m.SwitchIf(expected, ModeOff, preempt)
}
func (m *Manager) SwitchOffIfWithPreempt(expected Mode, preempt func() error) (bool, error) {
return m.SwitchIf(expected, ModeOff, preempt)
}
func callModeTransitionHook(hook func() error) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("panic: %v", recovered)
}
}()
return hook()
}
func (m *Manager) setTransitioning(transitioning bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.transitioning = transitioning
}
func (m *Manager) setLastError(err error) {
m.mu.Lock()
defer m.mu.Unlock()
if err == nil {
m.lastError = ""
return
}
m.lastError = err.Error()
}
func (m *Manager) statusLocked() Status {
return Status{
Mode: m.mode,
Transitioning: m.transitioning,
LastError: m.lastError,
ChangedAt: m.changedAt,
}
}
func (m *Manager) acquireExclusiveActivity() (func(), error) {
timeout := m.activityWaitTimeout
if timeout <= 0 {
timeout = defaultActivityWaitTimeout
}
release, err := m.activity.acquireExclusive(timeout)
if err != nil {
return nil, fmt.Errorf("wait for active control operations: %w", err)
}
return release, nil
}
func (m *Manager) save(mode Mode) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.saveLocked(mode)
}
func (m *Manager) loadLocked() error {
if m.loaded && m.transitioning {
return nil
}
info, statErr := os.Stat(m.path)
if m.loaded && statErr == nil && m.modeFileExists && info.Size() == m.modeFileSize && info.ModTime().Equal(m.modeFileModTime) {
return nil
}
if m.loaded && errors.Is(statErr, os.ErrNotExist) && !m.modeFileExists {
return nil
}
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return fmt.Errorf("stat AI control mode: %w", statErr)
}
data, err := os.ReadFile(m.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
m.mode = m.defaultMode
m.loaded = true
m.modeFileExists = false
m.modeFileSize = 0
m.modeFileModTime = time.Time{}
if m.changedAt.IsZero() {
m.changedAt = time.Now()
}
return nil
}
return fmt.Errorf("read AI control mode: %w", err)
}
mode := Mode(strings.TrimSpace(string(data)))
if !validMode(mode) {
m.mode = ModeOff
m.loaded = true
m.lastError = fmt.Sprintf("invalid AI control mode %q", strings.TrimSpace(string(data)))
m.cacheModeFileInfoLocked(info, data)
return nil
}
m.mode = mode
m.loaded = true
m.lastError = ""
m.cacheModeFileInfoLocked(info, data)
return nil
}
func (m *Manager) saveLocked(mode Mode) error {
if !validMode(mode) {
return fmt.Errorf("invalid AI control mode %q", mode)
}
dir := filepath.Dir(m.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create AI control mode directory: %w", err)
}
tmp, err := os.CreateTemp(dir, ".ai-control.mode.*")
if err != nil {
return fmt.Errorf("create temporary AI control mode: %w", err)
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("set AI control mode permissions: %w", err)
}
if _, err := tmp.WriteString(string(mode) + "\n"); err != nil {
_ = tmp.Close()
return fmt.Errorf("write AI control mode: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync AI control mode: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close AI control mode: %w", err)
}
if err := os.Rename(tmpPath, m.path); err != nil {
return fmt.Errorf("replace AI control mode: %w", err)
}
m.mode = mode
m.loaded = true
if info, err := os.Stat(m.path); err == nil {
m.cacheModeFileInfoLocked(info, []byte(string(mode)+"\n"))
} else {
m.modeFileExists = true
m.modeFileSize = int64(len(string(mode) + "\n"))
m.modeFileModTime = time.Now()
}
m.changedAt = time.Now()
directory, err := os.Open(dir)
if err == nil {
if syncErr := directory.Sync(); syncErr != nil {
_ = directory.Close()
return fmt.Errorf("sync AI control mode directory: %w", syncErr)
}
_ = directory.Close()
}
return nil
}
func (m *Manager) cacheModeFileInfoLocked(info os.FileInfo, data []byte) {
m.modeFileExists = true
if info != nil {
m.modeFileSize = info.Size()
m.modeFileModTime = info.ModTime()
if m.changedAt.IsZero() || info.ModTime().After(m.changedAt) {
m.changedAt = info.ModTime()
}
return
}
m.modeFileSize = int64(len(data))
m.modeFileModTime = time.Now()
m.changedAt = m.modeFileModTime
}
func validMode(mode Mode) bool {
switch mode {
case ModeOff, ModeMCP, ModePicoclaw:
return true
default:
return false
}
}
func elapsedMilliseconds(startedAt time.Time) int64 {
return time.Since(startedAt).Milliseconds()
}
func logModeSwitchFailure(message string, fields log.Fields, err error, startedAt time.Time) {
fields["total_ms"] = elapsedMilliseconds(startedAt)
log.WithFields(fields).WithError(err).Warn(message)
}

View File

@@ -0,0 +1,202 @@
package controlmode
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestManagerDefaultsToPicoclaw(t *testing.T) {
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
if got := manager.Current(); got != ModePicoclaw {
t.Fatalf("mode = %q, want %q", got, ModePicoclaw)
}
}
func TestSwitchPreemptsBeforeWaitingForActiveWrite(t *testing.T) {
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModeMCP)
release, err := manager.AcquireWrite(ModeMCP)
if err != nil {
t.Fatal(err)
}
preempted := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- manager.Switch(ModePicoclaw, func() error {
close(preempted)
return nil
})
}()
select {
case <-preempted:
case <-time.After(time.Second):
t.Fatal("preempt callback was not called before waiting")
}
if _, err := manager.AcquireWrite(ModeMCP); err == nil {
t.Fatal("new write acquired control during transition")
}
release()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("switch did not resume after active write was released")
}
if got := manager.Current(); got != ModePicoclaw {
t.Fatalf("mode = %q, want picoclaw", got)
}
}
func TestSwitchRunsCleanupAfterActiveWritesDrain(t *testing.T) {
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
release, err := manager.AcquireWrite(ModePicoclaw)
if err != nil {
t.Fatal(err)
}
cleanupCalled := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- manager.SwitchWithCleanup(ModeMCP, nil, func() error {
close(cleanupCalled)
return nil
})
}()
select {
case <-cleanupCalled:
t.Fatal("cleanup ran before active write drained")
case <-time.After(20 * time.Millisecond):
}
release()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("switch did not finish")
}
select {
case <-cleanupCalled:
default:
t.Fatal("cleanup was not called")
}
}
func TestSwitchCleanupFailureFailsClosed(t *testing.T) {
path := filepath.Join(t.TempDir(), "mode")
manager := NewManager(path, ModePicoclaw)
wantErr := errors.New("hid release failed")
if err := manager.SwitchWithCleanup(ModeMCP, nil, func() error { return wantErr }); !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if got := manager.Current(); got != ModeOff {
t.Fatalf("mode = %q, want off after cleanup failure", got)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(data) != "off\n" {
t.Fatalf("mode file = %q, want off", data)
}
}
func TestSwitchIfKeepsDifferentMode(t *testing.T) {
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
switched, err := manager.SwitchIf(ModeMCP, ModeOff, nil)
if err != nil {
t.Fatal(err)
}
if switched || manager.Current() != ModePicoclaw {
t.Fatalf("switched=%v mode=%q, want unchanged picoclaw", switched, manager.Current())
}
}
func TestSwitchTimeoutDoesNotLeaveManagerTransitioning(t *testing.T) {
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModeMCP)
manager.activityWaitTimeout = 20 * time.Millisecond
cleanupCalled := false
status, release, err := manager.AcquireStable()
if err != nil {
t.Fatal(err)
}
if status.Mode != ModeMCP {
t.Fatalf("mode = %q, want MCP", status.Mode)
}
err = manager.SwitchWithCleanup(ModePicoclaw, nil, func() error {
cleanupCalled = true
return nil
})
if !errors.Is(err, ErrActivityWaitTimeout) {
t.Fatalf("switch error = %v, want %v", err, ErrActivityWaitTimeout)
}
if cleanupCalled {
t.Fatal("cleanup ran even though exclusive activity lease was not acquired")
}
status, err = manager.Status()
if err != nil {
t.Fatal(err)
}
if status.Mode != ModeMCP || status.Transitioning {
t.Fatalf("status after timeout = %+v, want stable MCP", status)
}
release()
if err := manager.Switch(ModePicoclaw, nil); err != nil {
t.Fatalf("switch after lease release failed: %v", err)
}
}
func TestInvalidModeFailsClosed(t *testing.T) {
path := filepath.Join(t.TempDir(), "mode")
if err := os.WriteFile(path, []byte("invalid\n"), 0o600); err != nil {
t.Fatal(err)
}
manager := NewManager(path, ModePicoclaw)
status, err := manager.Status()
if err != nil {
t.Fatal(err)
}
if status.Mode != ModeOff {
t.Fatalf("mode = %q, want off", status.Mode)
}
if !strings.Contains(status.LastError, "invalid") {
t.Fatalf("last_error = %q, want invalid mode detail", status.LastError)
}
}
func TestStatusReloadsExternallyModifiedModeFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "mode")
if err := os.WriteFile(path, []byte("off\n"), 0o600); err != nil {
t.Fatal(err)
}
manager := NewManager(path, ModePicoclaw)
if got := manager.Current(); got != ModeOff {
t.Fatalf("initial mode = %q, want off", got)
}
if err := os.WriteFile(path, []byte("mcp\n"), 0o600); err != nil {
t.Fatal(err)
}
modTime := time.Now().Add(time.Second)
if err := os.Chtimes(path, modTime, modTime); err != nil {
t.Fatal(err)
}
if got := manager.Current(); got != ModeMCP {
t.Fatalf("mode after external write = %q, want MCP", got)
}
}

View File

@@ -2,54 +2,165 @@ package download
import (
"NanoKVM-Server/proto"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type Service struct{}
type downloadStatus string
var sentinelPath = "/tmp/.download_in_progress"
const (
transferSentinelPath = "/tmp/.download_in_progress"
downloadStatusIdle downloadStatus = "idle"
downloadStatusInProgress downloadStatus = "in_progress"
downloadStatusSuccess downloadStatus = "success"
downloadStatusFailed downloadStatus = "failed"
downloadStatusChecksumFailed downloadStatus = "checksum_failed"
)
var (
errDownloadInProgress = errors.New("download in progress")
errSHA256Mismatch = errors.New("sha256 mismatch")
validISOFilename = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
)
type Service struct {
downloadMutex sync.Mutex
downloadCancel context.CancelFunc
downloadDone chan struct{}
downloadStatus downloadStatus
downloadFile string
downloadPercentage string
}
func NewService() *Service {
// Clear sentinel
// If we are starting from scratch, we need to remove the sentinel file as any downloads at this point are done or broken
_ = os.Remove(sentinelPath)
return &Service{}
// A running transfer cannot survive a server restart, so any remaining lock
// file is stale at this point.
_ = os.Remove(transferSentinelPath)
return &Service{downloadStatus: downloadStatusIdle}
}
func (s *Service) CancelDownloadImage(c *gin.Context) {
var rsp proto.Response
s.downloadMutex.Lock()
cancel := s.downloadCancel
done := s.downloadDone
s.downloadMutex.Unlock()
if cancel == nil || done == nil {
rsp.ErrRsp(c, -1, "no cancellable download in progress")
return
}
cancel()
select {
case <-done:
rsp.OkRsp(c)
case <-time.After(10 * time.Second):
rsp.ErrRsp(c, -1, "cancel download timed out")
}
}
func (s *Service) beginDownload(file string, cancel context.CancelFunc) (chan struct{}, error) {
s.downloadMutex.Lock()
defer s.downloadMutex.Unlock()
if s.downloadDone != nil {
return nil, errDownloadInProgress
}
lockFile, err := os.OpenFile(
transferSentinelPath,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
0o644,
)
if err != nil {
if os.IsExist(err) {
return nil, errDownloadInProgress
}
return nil, fmt.Errorf("acquire transfer lock failed: %w", err)
}
if err := lockFile.Close(); err != nil {
_ = os.Remove(transferSentinelPath)
return nil, fmt.Errorf("close transfer lock failed: %w", err)
}
done := make(chan struct{})
s.downloadCancel = cancel
s.downloadDone = done
s.downloadStatus = downloadStatusInProgress
s.downloadFile = file
s.downloadPercentage = ""
return done, nil
}
func (s *Service) setDownloadFile(done chan struct{}, file string) {
s.downloadMutex.Lock()
defer s.downloadMutex.Unlock()
if s.downloadDone == done {
s.downloadFile = file
}
}
func (s *Service) setDownloadProgress(done chan struct{}, percentage string) {
s.downloadMutex.Lock()
defer s.downloadMutex.Unlock()
if s.downloadDone == done {
s.downloadPercentage = percentage
}
}
func (s *Service) finishDownload(done chan struct{}, status downloadStatus) {
s.downloadMutex.Lock()
if s.downloadDone != done {
s.downloadMutex.Unlock()
return
}
s.downloadCancel = nil
s.downloadDone = nil
s.downloadStatus = status
s.downloadFile = ""
s.downloadPercentage = ""
_ = os.Remove(transferSentinelPath)
s.downloadMutex.Unlock()
close(done)
}
func (s *Service) ImageEnabled(c *gin.Context) {
var rsp proto.Response
// Check if /data mount is RO/RW
testFile := "/data/.testfile"
file, err := os.Create(testFile)
defer file.Close()
defer os.Remove(testFile)
if err != nil {
if os.IsPermission(err) {
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
Enabled: false,
})
return
}
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
Enabled: false,
})
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: false})
return
}
defer file.Close()
defer os.Remove(testFile)
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
Enabled: true,
})
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: true})
}
func isISO9660(path string) (bool, error) {
@@ -59,15 +170,13 @@ func isISO9660(path string) (bool, error) {
}
defer f.Close()
// ISO-9660 Magic "CD001" bei Offset 32769
_, err = f.Seek(0x8001, io.SeekStart)
if err != nil {
// ISO-9660 magic "CD001" at offset 32769.
if _, err = f.Seek(0x8001, io.SeekStart); err != nil {
return false, err
}
buf := make([]byte, 5)
_, err = io.ReadFull(f, buf)
if err != nil {
if _, err = io.ReadFull(f, buf); err != nil {
return false, err
}
@@ -77,211 +186,141 @@ func isISO9660(path string) (bool, error) {
func (s *Service) StatusImage(c *gin.Context) {
var rsp proto.Response
// Check if the sentinel file exists
log.Debug("StatusImage")
if _, err := os.Stat(sentinelPath); err == nil {
content, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Failed to read sentinel file")
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "in_progress",
File: "",
Percentage: "",
})
return
}
splitted := strings.Split(string(content), ";")
if len(splitted) == 1 {
// No percentage, just the URL
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "in_progress",
File: splitted[0],
Percentage: "",
})
} else {
// Percentage is available
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "in_progress",
File: splitted[0],
Percentage: splitted[1],
})
}
s.downloadMutex.Lock()
status := s.downloadStatus
file := s.downloadFile
percentage := s.downloadPercentage
s.downloadMutex.Unlock()
return
}
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "idle",
File: "",
Percentage: "",
Status: string(status),
File: file,
Percentage: percentage,
})
}
func (s *Service) DownloadImageFile(c *gin.Context) {
var rsp proto.Response
log.Debug("DownloadImage")
// Set a sentinel file to mark that there is a download in progress
// This is to prevent multiple downloads at the same time
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
rsp.ErrRsp(c, -1, "download in progress")
return
}
// Create the sentinel file
err := os.WriteFile(sentinelPath, []byte("start"), 0644)
log.Debug("DownloadImageFile")
expectedSHA256, err := parseSHA256(c.GetHeader("X-SHA256-Sum"))
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
rsp.ErrRsp(c, -1, err.Error())
return
}
// Multipart Reader direkt nutzen (keine FormFile!)
reader, err := c.Request.MultipartReader()
if err != nil {
done, err := s.beginDownload("", nil)
if err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
finalStatus := downloadStatusFailed
defer func() {
s.finishDownload(done, finalStatus)
}()
reader, err := c.Request.MultipartReader()
if err != nil {
log.Error("invalid multipart data")
rsp.ErrRsp(c, -1, "invalid multipart data")
defer os.Remove(sentinelPath)
return
}
return
}
var lw *loggingWriter
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
log.Error("failed to read part")
rsp.ErrRsp(c, -1, "failed to read part")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
if part.FormName() != "file" {
continue
}
filename := part.FileName()
if filename == "" {
log.Error("no filename")
rsp.ErrRsp(c, -1, "no filename")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
filename = filepath.Base(filename)
if filename != part.FileName() {
log.Warn("path detected in filename")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
if strings.Contains(filename, "..") {
log.Warn("path traversal attempt")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
if part.FormName() != "file" {
_ = part.Close()
continue
}
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
rsp.ErrRsp(c, -1, "only .iso files allowed")
defer os.Remove(sentinelPath)
filename := part.FileName()
if err := validateISOFilename(filename); err != nil {
_ = part.Close()
rsp.ErrRsp(c, -1, err.Error())
return
}
s.setDownloadFile(done, filename)
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if !valid.MatchString(filename) {
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
data, err := os.ReadFile(sentinelPath)
out, err := os.CreateTemp("/data", ".nanokvm-upload-*")
if err != nil {
log.Error("Read failed")
rsp.ErrRsp(c, -1, "Read failed")
lw.stopTicker()
defer os.Remove(sentinelPath)
_ = part.Close()
log.Error("cannot create temporary file")
rsp.ErrRsp(c, -1, "cannot create temporary file")
return
}
tempPath := out.Name()
defer os.Remove(tempPath)
outPath := "/data/" + filename
out, err := os.Create(outPath)
if err != nil {
log.Error("cannot create file")
rsp.ErrRsp(c, -1, "cannot create file")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
defer out.Close()
if strings.Contains(string(data), "start") {
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
lw.startTicker()
} else {
if !strings.Contains(string(data), filename) {
log.Error("failed")
rsp.ErrRsp(c, -1, "failed")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
}
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
_, err = io.Copy(lw, part)
if err != nil {
hasher := sha256.New()
lw := newLoggingWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(percentage string) {
s.setDownloadProgress(done, percentage)
})
_, copyErr := io.Copy(lw, part)
lw.stopTicker()
partCloseErr := part.Close()
outCloseErr := out.Close()
if copyErr != nil || partCloseErr != nil || outCloseErr != nil {
log.Error("write failed")
rsp.ErrRsp(c, -1, "write failed")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
ok, err := isISO9660(outPath)
if err != nil || !ok {
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
}
lw.stopTicker()
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "idle",
File: "",
Percentage: "",
})
defer os.Remove(sentinelPath)
return
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
finalStatus = downloadStatusChecksumFailed
rsp.ErrRsp(c, -1, errSHA256Mismatch.Error())
return
}
valid, err := isISO9660(tempPath)
if err != nil || !valid {
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
return
}
outPath := filepath.Join("/data", filename)
if err := os.Rename(tempPath, outPath); err != nil {
rsp.ErrRsp(c, -1, "cannot install uploaded image")
return
}
finalStatus = downloadStatusIdle
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: string(downloadStatusIdle),
File: "",
Percentage: "",
})
return
}
rsp.ErrRsp(c, -1, "file is required")
}
func validateISOFilename(filename string) error {
if filename == "" {
return errors.New("no filename")
}
if filepath.Base(filename) != filename || strings.Contains(filename, "..") {
return errors.New("invalid filename")
}
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
return errors.New("only .iso files allowed")
}
if !validISOFilename.MatchString(filename) {
return errors.New("invalid filename")
}
return nil
}
func (s *Service) DownloadImage(c *gin.Context) {
var req proto.MountImageReq
var req proto.DownloadImageReq
var rsp proto.Response
log.Debug("DownloadImage")
@@ -291,127 +330,190 @@ func (s *Service) DownloadImage(c *gin.Context) {
return
}
if req.File == "" {
rsp.ErrRsp(c, -1, "invalid arguments")
expectedSHA256, err := parseSHA256(req.SHA256Sum)
if err != nil {
rsp.ErrRsp(c, -1, err.Error())
return
}
// Parse the URI to see if its valid http/s
u, err := url.Parse(req.File)
if err != nil || u.Scheme == "" || u.Host == "" {
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path == "" {
rsp.ErrRsp(c, -1, "invalid url")
return
}
filename := filepath.Base(u.Path)
if filename == "." || filename == "/" || filename == "" {
rsp.ErrRsp(c, -1, "invalid url")
return
}
// Set a sentinel file to mark that there is a download in progress
// This is to prevent multiple downloads at the same time
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
rsp.ErrRsp(c, -1, "download in progress")
return
}
// Create the sentinel file
err = os.WriteFile(sentinelPath, []byte(req.File), 0644)
ctx, cancel := context.WithCancel(context.Background())
done, err := s.beginDownload(req.File, cancel)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
cancel()
rsp.ErrRsp(c, -1, err.Error())
return
}
// Check if it actually exists and fail if it doesn't
resp, err := http.Head(req.File)
if resp.StatusCode != http.StatusOK || err != nil {
rsp.ErrRsp(c, resp.StatusCode, "failed when checking the url")
log.Error("Failed to check the URL")
defer os.Remove(sentinelPath)
return
}
defer resp.Body.Close()
// Download the image in a goroutine to not block the request
go func() {
defer os.Remove(sentinelPath)
resp, err = http.Get(req.File)
if err != nil {
log.Error("Failed to download the file")
rsp.ErrRsp(c, -1, "failed to download the file")
return
}
defer resp.Body.Close()
// Create the destination file
destPath := filepath.Join("/data", filepath.Base(u.Path))
out, err := os.Create(destPath)
if err != nil {
log.Error("Failed to create destination file")
rsp.ErrRsp(c, -1, "failed to create destination file")
return
}
defer out.Close()
defer cancel()
lw := &loggingWriter{writer: out, totalSize: resp.ContentLength}
lw.startTicker()
_, err = io.Copy(lw, resp.Body)
if err != nil {
log.Error("Failed to save the file")
rsp.ErrRsp(c, -1, "failed to save the file")
lw.stopTicker()
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(percentage string) {
s.setDownloadProgress(done, percentage)
}); err != nil {
if errors.Is(err, context.Canceled) {
log.Debug("Image download canceled")
s.finishDownload(done, downloadStatusIdle)
return
}
log.Errorf("Failed to download image: %v", err)
status := downloadStatusFailed
if errors.Is(err, errSHA256Mismatch) {
status = downloadStatusChecksumFailed
}
s.finishDownload(done, status)
return
}
lw.stopTicker()
s.finishDownload(done, downloadStatusSuccess)
}()
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "in_progress",
Status: string(downloadStatusInProgress),
File: req.File,
Percentage: "",
})
}
func parseSHA256(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
sum, err := hex.DecodeString(value)
if err != nil || len(sum) != sha256.Size {
return nil, errors.New("invalid sha256sum")
}
return sum, nil
}
func (s *Service) downloadRemoteImage(
ctx context.Context,
rawURL string,
expectedSHA256 []byte,
filename string,
onProgress func(string),
) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return fmt.Errorf("create download request failed: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download request returned status %d", resp.StatusCode)
}
tempFile, err := os.CreateTemp("/data", ".nanokvm-download-*")
if err != nil {
return fmt.Errorf("create temporary image failed: %w", err)
}
tempPath := tempFile.Name()
defer os.Remove(tempPath)
hasher := sha256.New()
lw := newLoggingWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
_, copyErr := io.Copy(lw, resp.Body)
lw.stopTicker()
closeErr := tempFile.Close()
if ctx.Err() != nil {
return ctx.Err()
}
if copyErr != nil {
return fmt.Errorf("save downloaded image failed: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("close downloaded image failed: %w", closeErr)
}
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
return errSHA256Mismatch
}
if ctx.Err() != nil {
return ctx.Err()
}
destPath := filepath.Join("/data", filename)
if err := os.Rename(tempPath, destPath); err != nil {
return fmt.Errorf("install downloaded image failed: %w", err)
}
return nil
}
type loggingWriter struct {
writer io.Writer
total int64
totalSize int64
ticker *time.Ticker
done chan bool
writer io.Writer
total atomic.Int64
totalSize int64
ticker *time.Ticker
done chan struct{}
stopOnce sync.Once
onProgress func(string)
}
func newLoggingWriter(writer io.Writer, totalSize int64, onProgress func(string)) *loggingWriter {
lw := &loggingWriter{
writer: writer,
totalSize: totalSize,
onProgress: onProgress,
}
lw.startTicker()
return lw
}
func (lw *loggingWriter) startTicker() {
lw.ticker = time.NewTicker(2500 * time.Millisecond)
lw.done = make(chan bool)
lw.done = make(chan struct{})
go func() {
for {
select {
case <-lw.done:
return
case <-lw.ticker.C:
lw.updateSentinel()
lw.updateProgress()
}
}
}()
}
func (lw *loggingWriter) stopTicker() {
lw.ticker.Stop()
lw.done <- true
if lw == nil {
return
}
lw.stopOnce.Do(func() {
lw.ticker.Stop()
close(lw.done)
})
}
func (lw *loggingWriter) updateSentinel() {
percentage := float64(lw.total) / float64(lw.totalSize) * 100
content, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Failed to read sentinel file")
func (lw *loggingWriter) updateProgress() {
if lw.totalSize <= 0 || lw.onProgress == nil {
return
}
splitted := strings.Split(string(content), ";")
if len(splitted) == 0 {
return
}
err = os.WriteFile(sentinelPath, []byte(fmt.Sprintf("%s;%.2f%%", splitted[0], percentage)), 0644)
if err != nil {
log.Error("Failed to update sentinel file")
}
percentage := float64(lw.total.Load()) / float64(lw.totalSize) * 100
lw.onProgress(fmt.Sprintf("%.2f%%", percentage))
}
func (lw *loggingWriter) Write(p []byte) (int, error) {
n, err := lw.writer.Write(p)
lw.total += int64(n)
lw.total.Add(int64(n))
return n, err
}

View File

@@ -13,11 +13,17 @@ import (
)
type Hid struct {
g0 *os.File
g1 *os.File
g2 *os.File
kbMutex sync.Mutex
mouseMutex sync.Mutex
g0 *os.File
g0Reader *os.File
g1 *os.File
g2 *os.File
ledReaderNotifyReader *os.File
ledReaderNotifyWriter *os.File
ledReaderNotifyReadFD int
ledReaderNotifyWriteFD int
kbMutex sync.Mutex
mouseMutex sync.Mutex
ledReaderStartOnce sync.Once
}
const (
@@ -124,6 +130,15 @@ func (h *Hid) OpenNoLock() error {
}
}
if h.g0 != nil {
if err := h.openKeyboardLedReaderNoLock(); err != nil {
log.Errorf("open keyboard LED reader failed: %s", err)
errs = append(errs, err)
} else {
h.startKeyboardLedReader()
}
}
return errors.Join(errs...)
}
@@ -162,6 +177,8 @@ func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error
}
func (h *Hid) CloseNoLock() {
h.closeKeyboardLedReaderNoLock()
for _, device := range h.devices() {
h.closeDeviceNoLock(device)
}
@@ -193,6 +210,41 @@ func (h *Hid) closeDeviceNoLock(device hidDevice) {
}
}
func (h *Hid) openKeyboardLedReaderNoLock() error {
if h.g0Reader != nil {
return nil
}
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
return err
}
// Keep this descriptor blocking. The LED reader waits for either an output
// report or a lifecycle notification, so an idle host does not cause a
// periodic EAGAIN retry loop.
file, err := os.OpenFile(HID0, os.O_RDONLY, 0o666)
if err != nil {
return fmt.Errorf("%s: %w", HID0, err)
}
h.g0Reader = file
h.notifyKeyboardLedReaderNoLock()
return nil
}
func (h *Hid) closeKeyboardLedReaderNoLock() {
if h.g0Reader == nil {
return
}
file := h.g0Reader
h.g0Reader = nil
h.notifyKeyboardLedReaderNoLock()
if err := file.Close(); err != nil {
log.Debugf("close keyboard LED reader failed: %s", err)
}
}
// writeWithTimeout bounds how long callers hold HID locks when writing to a
// nonblocking descriptor. EAGAIN means the host is not accepting HID reports
// yet, so retry until the caller's deadline expires.
@@ -249,15 +301,42 @@ func (h *Hid) Close() {
}
func (h *Hid) WriteHid0(data []byte) {
h.writeHIDReport(h.keyboardDevice(HID0), data)
if err := h.WriteKeyboardReport(data); err != nil {
log.Errorf("write to %s failed: %s", HID0, err)
}
}
func (h *Hid) WriteHid1(data []byte) {
h.writeHIDReport(h.relativeMouseDevice(HID1), data)
if err := h.WriteRelativeMouseReport(data); err != nil {
log.Errorf("write to %s failed: %s", HID1, err)
}
}
func (h *Hid) WriteHid2(data []byte) {
h.writeHIDReport(h.absoluteMouseDevice(HID2), data)
if err := h.WriteAbsoluteMouseReport(data); err != nil {
log.Errorf("write to %s failed: %s", HID2, err)
}
}
func (h *Hid) WriteKeyboardReport(data []byte) error {
if len(data) != 8 {
return fmt.Errorf("invalid keyboard report length: %d", len(data))
}
return h.writeHID(h.keyboardDevice(HID0), data)
}
func (h *Hid) WriteRelativeMouseReport(data []byte) error {
if len(data) != 4 {
return fmt.Errorf("invalid relative mouse report length: %d", len(data))
}
return h.writeHID(h.relativeMouseDevice(HID1), data)
}
func (h *Hid) WriteAbsoluteMouseReport(data []byte) error {
if len(data) != 6 {
return fmt.Errorf("invalid absolute mouse report length: %d", len(data))
}
return h.writeHID(h.absoluteMouseDevice(HID2), data)
}
func (h *Hid) writeHIDReport(device hidDevice, data []byte) bool {
@@ -275,6 +354,13 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error {
if err := h.openDeviceNoLock(device); err != nil {
return err
}
if device.path == HID0 {
if err := h.openKeyboardLedReaderNoLock(); err != nil {
log.Debugf("open keyboard LED reader failed: %s", err)
} else {
h.startKeyboardLedReader()
}
}
file := device.get()
if file == nil {
@@ -287,6 +373,9 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error {
}
if err := writeWithTimeout(file, data, hidWriteTimeout); err != nil {
if device.path == HID0 {
h.closeKeyboardLedReaderNoLock()
}
h.closeDeviceNoLock(device)
switch {
case errors.Is(err, os.ErrClosed):

View File

@@ -0,0 +1,28 @@
package hid
import (
"testing"
"time"
)
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")
}
}
func TestPasteDurationLeavesModeSwitchMargin(t *testing.T) {
if maxPasteDuration >= 30*time.Second {
t.Fatalf("maxPasteDuration = %s, want below 30s mode switch wait budget", maxPasteDuration)
}
if got := time.Duration(maxPasteContentRunes) * defaultPasteDelay; got > maxPasteDuration {
t.Fatalf("max paste content duration = %s, want <= %s", got, maxPasteDuration)
}
}

View File

@@ -5,43 +5,105 @@ import (
)
func (h *Hid) Keyboard(queue <-chan []byte) {
h.keyboard(queue, HID0)
legacy := make(chan QueuedReport)
go func() {
defer close(legacy)
for report := range queue {
legacy <- QueuedReport{Data: report}
}
}()
h.KeyboardReports(legacy)
}
func (h *Hid) keyboard(queue <-chan []byte, path string) {
defer h.releaseKeyboard(path)
func (h *Hid) KeyboardReports(queue <-chan QueuedReport) {
h.keyboardReports(queue, HID0)
}
func (h *Hid) keyboardReports(queue <-chan QueuedReport, path string) {
var execute func(func() error) error
var resetKeyboard func()
keyboardActive := false
defer func() {
if !keyboardActive {
return
}
if err := runCleanup(execute, func() error {
return h.writeHID(h.keyboardDevice(path), keyboardReleaseReport())
}); err != nil {
log.Errorf("release keyboard on queue close failed: %s", err)
return
}
if resetKeyboard != nil {
resetKeyboard()
}
}()
for event := range queue {
if len(event) != 8 {
log.Debugf("invalid keyboard event: %v", event)
execute = event.Execute
resetKeyboard = event.ResetKeyboard
if len(event.Data) != 8 {
event.complete(false)
log.Debugf("invalid keyboard event: %v", event.Data)
continue
}
if !h.writeHIDReport(h.keyboardDevice(path), event) {
if err := event.run(func() error {
return h.writeHID(h.keyboardDevice(path), event.Data)
}); err != nil {
log.Errorf("write to %s failed: %s", path, err)
if dropped := drainHIDQueue(queue); dropped > 0 {
log.Debugf("dropped %d stale keyboard HID reports after write failure", dropped)
}
h.releaseKeyboard(path)
cleanupErr := runCleanup(execute, func() error {
return h.writeHID(h.keyboardDevice(path), keyboardReleaseReport())
})
if cleanupErr != nil {
log.Errorf("release keyboard after write failure failed: %s", cleanupErr)
keyboardActive = keyboardActive || keyboardReportActive(event.Data)
event.complete(false)
continue
}
keyboardActive = false
if resetKeyboard != nil {
resetKeyboard()
}
event.complete(false)
continue
}
keyboardActive = keyboardReportActive(event.Data)
event.complete(true)
}
}
func (h *Hid) releaseKeyboard(path string) {
h.writeHIDReport(h.keyboardDevice(path), keyboardReleaseReport())
func keyboardReportActive(report []byte) bool {
if len(report) != 8 {
return false
}
if report[0] != 0 {
return true
}
for _, key := range report[2:] {
if key != 0 {
return true
}
}
return false
}
func keyboardReleaseReport() []byte {
return []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
}
func drainHIDQueue(queue <-chan []byte) int {
func drainHIDQueue(queue <-chan QueuedReport) int {
dropped := 0
for {
select {
case _, ok := <-queue:
case report, ok := <-queue:
if !ok {
return dropped
}
report.complete(false)
dropped++
default:
return dropped

279
server/service/hid/leds.go Normal file
View File

@@ -0,0 +1,279 @@
package hid
import (
"errors"
"fmt"
"os"
"sync"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
)
const (
KeyboardLedStatusEvent = "hid-led-status"
)
// KeyboardLedStatus is the lock-key LED state last reported by the remote host
// through the keyboard HID output report.
type KeyboardLedStatus struct {
NumLock bool `json:"numLock"`
CapsLock bool `json:"capsLock"`
ScrollLock bool `json:"scrollLock"`
Known bool `json:"known"`
UpdatedAt time.Time `json:"updatedAt"`
}
type keyboardLedStatusStore struct {
mutex sync.RWMutex
status KeyboardLedStatus
subscribers map[int]func(KeyboardLedStatus)
nextID int
now func() time.Time
}
var keyboardLeds = newKeyboardLedStatusStore(time.Now)
func newKeyboardLedStatusStore(now func() time.Time) *keyboardLedStatusStore {
return &keyboardLedStatusStore{
subscribers: make(map[int]func(KeyboardLedStatus)),
now: now,
}
}
// GetKeyboardLedStatus returns a snapshot. Known is false until the host has
// sent at least one keyboard HID output report.
func GetKeyboardLedStatus() KeyboardLedStatus {
return keyboardLeds.Get()
}
// SubscribeKeyboardLedStatus subscribes to state changes and returns a
// function that removes the subscription.
func SubscribeKeyboardLedStatus(subscriber func(KeyboardLedStatus)) func() {
return keyboardLeds.Subscribe(subscriber)
}
func (s *keyboardLedStatusStore) Get() KeyboardLedStatus {
s.mutex.RLock()
defer s.mutex.RUnlock()
return s.status
}
func (s *keyboardLedStatusStore) Subscribe(subscriber func(KeyboardLedStatus)) func() {
s.mutex.Lock()
id := s.nextID
s.nextID++
s.subscribers[id] = subscriber
s.mutex.Unlock()
return func() {
s.mutex.Lock()
delete(s.subscribers, id)
s.mutex.Unlock()
}
}
func (s *keyboardLedStatusStore) Update(report byte) {
next := keyboardLedStatusFromReport(report, s.now())
s.mutex.Lock()
previous := s.status
s.status = next
if sameKeyboardLedState(previous, next) {
s.mutex.Unlock()
return
}
subscribers := make([]func(KeyboardLedStatus), 0, len(s.subscribers))
for _, subscriber := range s.subscribers {
subscribers = append(subscribers, subscriber)
}
s.mutex.Unlock()
for _, subscriber := range subscribers {
subscriber(next)
}
}
func keyboardLedStatusFromReport(report byte, updatedAt time.Time) KeyboardLedStatus {
return KeyboardLedStatus{
NumLock: report&0x01 != 0,
CapsLock: report&0x02 != 0,
ScrollLock: report&0x04 != 0,
Known: true,
UpdatedAt: updatedAt,
}
}
func sameKeyboardLedState(a, b KeyboardLedStatus) bool {
return a.NumLock == b.NumLock &&
a.CapsLock == b.CapsLock &&
a.ScrollLock == b.ScrollLock &&
a.Known == b.Known
}
func (h *Hid) startKeyboardLedReader() {
h.ledReaderStartOnce.Do(func() {
go h.readKeyboardLeds()
})
}
func (h *Hid) readKeyboardLeds() {
buf := make([]byte, 64)
for {
file, notifierFD := h.keyboardLedReaderHandles()
if file == nil {
if err := waitForKeyboardLedReaderChange(notifierFD); err != nil {
log.Debugf("wait for keyboard LED reader failed: %s", err)
}
continue
}
changed, err := waitForKeyboardLedReportOrChange(file, notifierFD)
if err != nil {
if !errors.Is(err, os.ErrClosed) {
log.Debugf("wait for keyboard LED report failed: %s", err)
}
h.reopenKeyboardLedReader(file)
continue
}
if changed {
continue
}
n, err := file.Read(buf)
if n > 0 {
// Both keyboard gadget descriptors in kvmapp/system/init.d declare
// boot-keyboard reports without a Report ID (no 0x85 item). Their LED
// output report is one byte: bits 0-4 are the LED bitmap and bits 5-7
// are padding. report_length is 8 because it also covers input reports.
keyboardLeds.Update(buf[0])
}
if err == nil && n > 0 {
continue
}
if err != nil && !errors.Is(err, os.ErrClosed) {
log.Debugf("read keyboard LED report failed: %s", err)
}
h.reopenKeyboardLedReader(file)
}
}
func (h *Hid) keyboardLedReaderHandles() (*os.File, int) {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
if h.ledReaderNotifyReader == nil {
return h.g0Reader, -1
}
return h.g0Reader, h.ledReaderNotifyReadFD
}
func (h *Hid) reopenKeyboardLedReader(file *os.File) {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
// A reset may have installed a new reader while the old descriptor was
// reporting an error. Never let that stale reader close the replacement.
if h.g0Reader != file {
return
}
h.closeKeyboardLedReaderNoLock()
if h.g0 == nil {
return
}
if err := h.openKeyboardLedReaderNoLock(); err != nil {
log.Debugf("reopen keyboard LED reader failed: %s", err)
}
}
func (h *Hid) ensureKeyboardLedReaderNotifierNoLock() error {
if h.ledReaderNotifyReader != nil {
return nil
}
reader, writer, err := os.Pipe()
if err != nil {
return fmt.Errorf("create keyboard LED reader notifier: %w", err)
}
readerFD := int(reader.Fd())
writerFD := int(writer.Fd())
if err := unix.SetNonblock(readerFD, true); err != nil {
_ = reader.Close()
_ = writer.Close()
return fmt.Errorf("set keyboard LED reader notifier read end nonblocking: %w", err)
}
if err := unix.SetNonblock(writerFD, true); err != nil {
_ = reader.Close()
_ = writer.Close()
return fmt.Errorf("set keyboard LED reader notifier write end nonblocking: %w", err)
}
h.ledReaderNotifyReader = reader
h.ledReaderNotifyWriter = writer
h.ledReaderNotifyReadFD = readerFD
h.ledReaderNotifyWriteFD = writerFD
return nil
}
func (h *Hid) notifyKeyboardLedReaderNoLock() {
if h.ledReaderNotifyWriter == nil {
return
}
_, err := unix.Write(h.ledReaderNotifyWriteFD, []byte{1})
if err != nil && !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EWOULDBLOCK) {
log.Debugf("notify keyboard LED reader failed: %s", err)
}
}
func waitForKeyboardLedReaderChange(notifierFD int) error {
if notifierFD < 0 {
return fmt.Errorf("keyboard LED reader notifier is nil")
}
_, err := unix.Poll([]unix.PollFd{{Fd: int32(notifierFD), Events: unix.POLLIN}}, -1)
if err != nil {
return err
}
drainKeyboardLedReaderNotifier(notifierFD)
return nil
}
// waitForKeyboardLedReportOrChange blocks until a HID output report arrives or
// the reader is replaced/closed. Its bool result is true for the latter.
func waitForKeyboardLedReportOrChange(file *os.File, notifierFD int) (bool, error) {
if file == nil || notifierFD < 0 {
return false, fmt.Errorf("keyboard LED reader or notifier is nil")
}
fds := []unix.PollFd{
{Fd: int32(file.Fd()), Events: unix.POLLIN},
{Fd: int32(notifierFD), Events: unix.POLLIN},
}
_, err := unix.Poll(fds, -1)
if err != nil {
return false, err
}
if fds[1].Revents&unix.POLLIN != 0 {
drainKeyboardLedReaderNotifier(notifierFD)
return true, nil
}
if fds[0].Revents&(unix.POLLIN|unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
return false, nil
}
return false, fmt.Errorf("keyboard LED poll returned without an event")
}
func drainKeyboardLedReaderNotifier(notifierFD int) {
buf := make([]byte, 64)
for {
_, err := unix.Read(notifierFD, buf)
if err != nil {
if !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EWOULDBLOCK) {
log.Debugf("drain keyboard LED reader notifier failed: %s", err)
}
return
}
}
}

View File

@@ -0,0 +1,168 @@
package hid
import (
"os"
"testing"
"time"
)
func TestKeyboardLedStatusFromReport(t *testing.T) {
updatedAt := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
status := keyboardLedStatusFromReport(0x17, updatedAt)
if !status.NumLock || !status.CapsLock || !status.ScrollLock || !status.Known {
t.Fatalf("unexpected parsed status: %+v", status)
}
if !status.UpdatedAt.Equal(updatedAt) {
t.Fatalf("updatedAt = %s, want %s", status.UpdatedAt, updatedAt)
}
}
func TestKeyboardLedStatusStoreOnlyNotifiesStateChanges(t *testing.T) {
now := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
store := newKeyboardLedStatusStore(func() time.Time { return now })
notifications := make(chan KeyboardLedStatus, 2)
store.Subscribe(func(status KeyboardLedStatus) {
notifications <- status
})
store.Update(0x01)
store.Update(0x01)
now = now.Add(time.Second)
store.Update(0x03)
first := <-notifications
second := <-notifications
if !first.NumLock || first.CapsLock || !second.NumLock || !second.CapsLock {
t.Fatalf("unexpected notifications: first=%+v second=%+v", first, second)
}
select {
case status := <-notifications:
t.Fatalf("unexpected duplicate notification: %+v", status)
default:
}
if status := store.Get(); !status.UpdatedAt.Equal(now) {
t.Fatalf("latest updatedAt = %s, want %s", status.UpdatedAt, now)
}
}
func TestWaitForKeyboardLedReaderChangeWaitsForNotification(t *testing.T) {
h := &Hid{}
h.kbMutex.Lock()
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
h.kbMutex.Unlock()
t.Fatal(err)
}
notifierFD := h.ledReaderNotifyReadFD
h.kbMutex.Unlock()
defer h.ledReaderNotifyReader.Close()
defer h.ledReaderNotifyWriter.Close()
done := make(chan error, 1)
go func() { done <- waitForKeyboardLedReaderChange(notifierFD) }()
select {
case err := <-done:
t.Fatalf("wait returned before a notification: %v", err)
case <-time.After(25 * time.Millisecond):
}
h.kbMutex.Lock()
h.notifyKeyboardLedReaderNoLock()
h.kbMutex.Unlock()
select {
case err := <-done:
if err != nil {
t.Fatalf("wait returned error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("wait did not wake after a notification")
}
}
func TestWaitForKeyboardLedReportOrChangeReturnsForReport(t *testing.T) {
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
defer writer.Close()
h := &Hid{}
h.kbMutex.Lock()
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
h.kbMutex.Unlock()
t.Fatal(err)
}
notifierFD := h.ledReaderNotifyReadFD
h.kbMutex.Unlock()
defer h.ledReaderNotifyReader.Close()
defer h.ledReaderNotifyWriter.Close()
done := make(chan struct {
changed bool
err error
}, 1)
go func() {
changed, err := waitForKeyboardLedReportOrChange(reader, notifierFD)
done <- struct {
changed bool
err error
}{changed, err}
}()
if _, err := writer.Write([]byte{0x03}); err != nil {
t.Fatal(err)
}
select {
case result := <-done:
if result.err != nil || result.changed {
t.Fatalf("result = changed=%t, err=%v; want report", result.changed, result.err)
}
case <-time.After(time.Second):
t.Fatal("wait did not wake for report")
}
}
func TestReopenKeyboardLedReaderDoesNotCloseReplacement(t *testing.T) {
oldReader, oldWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer oldReader.Close()
defer oldWriter.Close()
replacement, replacementWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer replacement.Close()
defer replacementWriter.Close()
h := &Hid{g0Reader: oldReader}
h.kbMutex.Lock()
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
h.kbMutex.Unlock()
t.Fatal(err)
}
h.g0Reader = replacement // Simulate reset/reopen replacing the reader.
h.kbMutex.Unlock()
defer h.ledReaderNotifyReader.Close()
defer h.ledReaderNotifyWriter.Close()
h.reopenKeyboardLedReader(oldReader)
current, _ := h.keyboardLedReaderHandles()
if current != replacement {
t.Fatal("stale reader close replaced the active reader")
}
if _, err := replacementWriter.Write([]byte{0x01}); err != nil {
t.Fatalf("replacement reader was closed: %v", err)
}
buf := make([]byte, 1)
if _, err := replacement.Read(buf); err != nil {
t.Fatalf("replacement reader was closed: %v", err)
}
}

View File

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

View File

@@ -1,12 +1,14 @@
package hid
import (
"context"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/inputcontrol"
)
type Char struct {
@@ -19,6 +21,12 @@ type PasteReq struct {
Langue string `form:"langue"`
}
const (
defaultPasteDelay = 30 * time.Millisecond
maxPasteDuration = 25 * time.Second
maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay)
)
func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
// if no language is specified → return base map
if lang == "" {
@@ -86,15 +94,15 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
case "fr":
// French AZERTY layout
// Letters: a↔q swap, z↔w swap, m moved to physical ; position
m['a'] = Char{0, 20} // a is at physical Q key (HID 20)
m['a'] = Char{0, 20} // a is at physical Q key (HID 20)
m['A'] = Char{2, 20}
m['q'] = Char{0, 4} // q is at physical A key (HID 4)
m['q'] = Char{0, 4} // q is at physical A key (HID 4)
m['Q'] = Char{2, 4}
m['z'] = Char{0, 26} // z is at physical W key (HID 26)
m['z'] = Char{0, 26} // z is at physical W key (HID 26)
m['Z'] = Char{2, 26}
m['w'] = Char{0, 29} // w is at physical Z key (HID 29)
m['w'] = Char{0, 29} // w is at physical Z key (HID 29)
m['W'] = Char{2, 29}
m['m'] = Char{0, 51} // m is at physical ; key (HID 51)
m['m'] = Char{0, 51} // m is at physical ; key (HID 51)
m['M'] = Char{2, 51}
// Numbers require Shift on AZERTY
@@ -110,53 +118,53 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
m['0'] = Char{2, 39}
// Unshifted number row → French/special characters
m['&'] = Char{0, 30} // & at physical key 1
m['\u00E9'] = Char{0, 31} // é at physical key 2
m['"'] = Char{0, 32} // " at physical key 3
m['\''] = Char{0, 33} // ' at physical key 4
m['('] = Char{0, 34} // ( at physical key 5
m['-'] = Char{0, 35} // - at physical key 6
m['\u00E8'] = Char{0, 36} // è at physical key 7
m['_'] = Char{0, 37} // _ at physical key 8
m['\u00E7'] = Char{0, 38} // ç at physical key 9
m['\u00E0'] = Char{0, 39} // à at physical key 0
m['&'] = Char{0, 30} // & at physical key 1
m['\u00E9'] = Char{0, 31} // é at physical key 2
m['"'] = Char{0, 32} // " at physical key 3
m['\''] = Char{0, 33} // ' at physical key 4
m['('] = Char{0, 34} // ( at physical key 5
m['-'] = Char{0, 35} // - at physical key 6
m['\u00E8'] = Char{0, 36} // è at physical key 7
m['_'] = Char{0, 37} // _ at physical key 8
m['\u00E7'] = Char{0, 38} // ç at physical key 9
m['\u00E0'] = Char{0, 39} // à at physical key 0
// Physical - key (HID 45) → ) on AZERTY
m[')'] = Char{0, 45} // ) at physical - key
m['\u00B0'] = Char{2, 45} // ° at shift+physical - key
m[')'] = Char{0, 45} // ) at physical - key
m['\u00B0'] = Char{2, 45} // ° at shift+physical - key
// Letter-row bracket/special keys
m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47)
m['\u00A8'] = Char{2, 47} // ¨ at shift+[
m['$'] = Char{0, 48} // $ at physical ] key (HID 48)
m['\u00A3'] = Char{2, 48} // £ at shift+]
m['*'] = Char{0, 49} // * at physical \ key (HID 49)
m['\u00B5'] = Char{2, 49} // µ at shift+\
m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52)
m['%'] = Char{2, 52} // % at shift+'
m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47)
m['\u00A8'] = Char{2, 47} // ¨ at shift+[
m['$'] = Char{0, 48} // $ at physical ] key (HID 48)
m['\u00A3'] = Char{2, 48} // £ at shift+]
m['*'] = Char{0, 49} // * at physical \ key (HID 49)
m['\u00B5'] = Char{2, 49} // µ at shift+\
m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52)
m['%'] = Char{2, 52} // % at shift+'
// Bottom row remappings
m[','] = Char{0, 16} // , at physical M key (HID 16)
m['?'] = Char{2, 16} // ? at shift+physical M
m[';'] = Char{0, 54} // ; at physical , key (HID 54)
m['.'] = Char{2, 54} // . at shift+physical ,
m[':'] = Char{0, 55} // : at physical . key (HID 55)
m['/'] = Char{2, 55} // / at shift+physical .
m['!'] = Char{0, 56} // ! at physical / key (HID 56)
m['\u00A7'] = Char{2, 56} // § at shift+physical /
m[','] = Char{0, 16} // , at physical M key (HID 16)
m['?'] = Char{2, 16} // ? at shift+physical M
m[';'] = Char{0, 54} // ; at physical , key (HID 54)
m['.'] = Char{2, 54} // . at shift+physical ,
m[':'] = Char{0, 55} // : at physical . key (HID 55)
m['/'] = Char{2, 55} // / at shift+physical .
m['!'] = Char{0, 56} // ! at physical / key (HID 56)
m['\u00A7'] = Char{2, 56} // § at shift+physical /
// AltGr combinations
m['~'] = Char{0x40, 31} // AltGr+2
m['#'] = Char{0x40, 32} // AltGr+3
m['{'] = Char{0x40, 33} // AltGr+4
m['['] = Char{0x40, 34} // AltGr+5
m['|'] = Char{0x40, 35} // AltGr+6
m['`'] = Char{0x40, 36} // AltGr+7
m['\\'] = Char{0x40, 37} // AltGr+8
m[']'] = Char{0x40, 45} // AltGr+physical -
m['}'] = Char{0x40, 46} // AltGr+=
m['@'] = Char{0x40, 39} // AltGr+0
m['\u20AC'] = Char{0x40, 8} // € AltGr+E
m['~'] = Char{0x40, 31} // AltGr+2
m['#'] = Char{0x40, 32} // AltGr+3
m['{'] = Char{0x40, 33} // AltGr+4
m['['] = Char{0x40, 34} // AltGr+5
m['|'] = Char{0x40, 35} // AltGr+6
m['`'] = Char{0x40, 36} // AltGr+7
m['\\'] = Char{0x40, 37} // AltGr+8
m[']'] = Char{0x40, 45} // AltGr+physical -
m['}'] = Char{0x40, 46} // AltGr+=
m['@'] = Char{0x40, 39} // AltGr+0
m['\u20AC'] = Char{0x40, 8} // € AltGr+E
}
return m
@@ -171,16 +179,44 @@ func (s *Service) Paste(c *gin.Context) {
return
}
if len(req.Content) > 1024 {
contentRunes := []rune(req.Content)
if len(contentRunes) > maxPasteContentRunes {
rsp.ErrRsp(c, -2, "content too long")
return
}
charMapLocal := LangueSwitch(charMap, req.Langue)
typeableRunes := 0
for _, char := range contentRunes {
if _, ok := charMapLocal[char]; ok {
typeableRunes++
}
}
if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration {
rsp.ErrRsp(c, -2, "paste duration exceeds 25s")
return
}
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
manual := s.newManualSession()
defer manual.Close()
reservation, err := manual.Reserve(c.Request.Context(), inputcontrol.ManualKeyboard, false, nil)
if err != nil {
log.Errorf("manual paste failed to acquire HID control: %v", err)
rsp.ErrRsp(c, -3, "HID control is busy")
return
}
for _, char := range req.Content {
writeKeyboardReport := func(report []byte) error {
return manual.Execute(func() error {
return s.hid.WriteKeyboardReport(report)
})
}
for _, char := range contentRunes {
if err := context.Cause(c.Request.Context()); err != nil {
break
}
key, ok := charMapLocal[char]
if !ok {
log.Debugf("unknown key '%c' (rune: %d)", char, char)
@@ -188,14 +224,40 @@ func (s *Service) Paste(c *gin.Context) {
}
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
hid.WriteHid0(keyDown)
hid.WriteHid0(keyUp)
time.Sleep(30 * time.Millisecond)
if err = writeKeyboardReport(keyDown); err != nil {
break
}
if err = writeKeyboardReport(keyUp); err != nil {
break
}
if err = sleepPasteContext(c.Request.Context(), defaultPasteDelay); err != nil {
break
}
}
if err == nil {
err = context.Cause(c.Request.Context())
}
_ = writeKeyboardReport(keyUp)
reservation.Complete(err == nil)
if err != nil {
log.Errorf("hid paste failed: %v", err)
rsp.ErrRsp(c, -3, "HID paste failed")
return
}
rsp.OkRsp(c)
log.Debugf("hid paste success, total %d characters processed", len(req.Content))
log.Debugf("hid paste success, total %d characters processed", len(contentRunes))
}
func sleepPasteContext(ctx context.Context, delay time.Duration) error {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return context.Cause(ctx)
case <-timer.C:
return nil
}
}
func copyMap(src map[rune]Char) map[rune]Char {

View File

@@ -0,0 +1,30 @@
package hid
type QueuedReport struct {
Data []byte
Execute func(func() error) error
Complete func(bool)
ResetKeyboard func()
ResetRelativeMouse func()
ResetAbsoluteMouse func()
}
func (r QueuedReport) run(write func() error) error {
if r.Execute != nil {
return r.Execute(write)
}
return write()
}
func (r QueuedReport) complete(success bool) {
if r.Complete != nil {
r.Complete(success)
}
}
func runCleanup(execute func(func() error) error, write func() error) error {
if execute != nil {
return execute(write)
}
return write()
}

View File

@@ -0,0 +1,245 @@
package hid
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"time"
)
func TestMouseModeSwitchReleasesPreviousDevice(t *testing.T) {
relativeReader, relativeWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer relativeReader.Close()
absoluteReader, absoluteWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer absoluteReader.Close()
h := &Hid{g1: relativeWriter, g2: absoluteWriter}
queue := make(chan QueuedReport, 2)
completed := make(chan bool, 2)
absoluteReset := make(chan struct{}, 1)
absoluteDown := []byte{1, 0x34, 0x12, 0x78, 0x56, 0}
queue <- QueuedReport{
Data: absoluteDown,
Complete: func(success bool) { completed <- success },
ResetAbsoluteMouse: func() {
absoluteReset <- struct{}{}
},
}
queue <- QueuedReport{
Data: relativeMouseReleaseReport(),
Complete: func(success bool) { completed <- success },
ResetAbsoluteMouse: func() { absoluteReset <- struct{}{} },
}
close(queue)
done := make(chan struct{})
go func() {
h.mouseReports(queue, "unused-relative", "unused-absolute")
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("mouse worker did not stop")
}
for range 2 {
select {
case success := <-completed:
if !success {
t.Fatal("mouse report failed")
}
default:
t.Fatal("missing mouse completion")
}
}
select {
case <-absoluteReset:
default:
t.Fatal("absolute state was not reset during mode switch")
}
absoluteData := make([]byte, 12)
if _, err := io.ReadFull(absoluteReader, absoluteData); err != nil {
t.Fatal(err)
}
if !bytes.Equal(absoluteData[:6], absoluteDown) {
t.Fatalf("absolute down report = %v", absoluteData[:6])
}
wantRelease := absoluteMouseReleaseReport(absoluteDown)
if !bytes.Equal(absoluteData[6:], wantRelease) {
t.Fatalf("absolute release report = %v, want %v", absoluteData[6:], wantRelease)
}
relativeData := make([]byte, 4)
if _, err := io.ReadFull(relativeReader, relativeData); err != nil {
t.Fatal(err)
}
if !bytes.Equal(relativeData, relativeMouseReleaseReport()) {
t.Fatalf("relative report = %v", relativeData)
}
h.Close()
}
func TestKeyboardFailureCompletesAfterCleanup(t *testing.T) {
path := filepath.Join(t.TempDir(), "hidg0")
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
if err := closedFile.Close(); err != nil {
t.Fatal(err)
}
h := &Hid{g0: closedFile}
queue := make(chan QueuedReport, 1)
cleanupStarted := make(chan struct{})
allowCleanup := make(chan struct{})
cleanupFinished := make(chan struct{})
completed := make(chan bool, 1)
executions := 0
queue <- QueuedReport{
Data: []byte{0, 0, 4, 0, 0, 0, 0, 0},
Execute: func(write func() error) error {
executions++
if executions == 2 {
close(cleanupStarted)
<-allowCleanup
}
err := write()
if executions == 2 {
close(cleanupFinished)
}
return err
},
Complete: func(success bool) { completed <- success },
}
close(queue)
done := make(chan struct{})
go func() {
h.keyboardReports(queue, path)
close(done)
}()
select {
case <-cleanupStarted:
case <-time.After(time.Second):
t.Fatal("cleanup did not start")
}
select {
case success := <-completed:
t.Fatalf("reservation completed before cleanup: success=%v", success)
default:
}
close(allowCleanup)
select {
case <-cleanupFinished:
case <-time.After(time.Second):
t.Fatal("cleanup did not finish")
}
select {
case success := <-completed:
if success {
t.Fatal("failed write completed successfully")
}
case <-time.After(time.Second):
t.Fatal("reservation was not completed")
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("keyboard worker did not stop")
}
h.Close()
}
func TestKeyboardFailureCompletesFalseWhenCleanupFails(t *testing.T) {
path := filepath.Join(t.TempDir(), "hidg0")
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
if err := closedFile.Close(); err != nil {
t.Fatal(err)
}
h := &Hid{g0: closedFile}
queue := make(chan QueuedReport, 1)
completed := make(chan bool, 1)
queue <- QueuedReport{
Data: []byte{0, 0, 4, 0, 0, 0, 0, 0},
Complete: func(success bool) { completed <- success },
}
close(queue)
done := make(chan struct{})
go func() {
h.keyboardReports(queue, filepath.Join(t.TempDir(), "missing", "hidg0"))
close(done)
}()
select {
case success := <-completed:
if success {
t.Fatal("failed keyboard write completed successfully after cleanup failed")
}
case <-time.After(time.Second):
t.Fatal("reservation was not completed")
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("keyboard worker did not stop")
}
h.Close()
}
func TestMouseFailureCompletesFalseWhenCleanupFails(t *testing.T) {
path := filepath.Join(t.TempDir(), "hidg1")
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
t.Fatal(err)
}
if err := closedFile.Close(); err != nil {
t.Fatal(err)
}
h := &Hid{g1: closedFile}
queue := make(chan QueuedReport, 1)
completed := make(chan bool, 1)
queue <- QueuedReport{
Data: []byte{1, 0, 0, 0},
Complete: func(success bool) { completed <- success },
}
close(queue)
done := make(chan struct{})
go func() {
h.mouseReports(queue, filepath.Join(t.TempDir(), "missing", "hidg1"), filepath.Join(t.TempDir(), "hidg2"))
close(done)
}()
select {
case success := <-completed:
if success {
t.Fatal("failed mouse write completed successfully after cleanup failed")
}
case <-time.After(time.Second):
t.Fatal("reservation was not completed")
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("mouse worker did not stop")
}
h.Close()
}

View File

@@ -0,0 +1,48 @@
package hid
import (
"errors"
"fmt"
log "github.com/sirupsen/logrus"
)
type reportWriter interface {
WriteKeyboardReport([]byte) error
WriteRelativeMouseReport([]byte) error
WriteAbsoluteMouseReport([]byte) error
}
func ReleaseAllHIDState() error {
return releaseAllHIDState(GetHid())
}
func ReleaseAllHIDStateBestEffort() error {
return releaseAllHIDStateBestEffort(GetHid())
}
func releaseAllHIDStateBestEffort(writer reportWriter) error {
if err := releaseAllHIDState(writer); err != nil {
log.Warnf("failed to release HID state during control switch: %v", err)
}
return nil
}
func releaseAllHIDState(writer reportWriter) error {
if writer == nil {
return fmt.Errorf("HID writer is unavailable")
}
var errs []error
if err := writer.WriteKeyboardReport(keyboardReleaseReport()); err != nil {
errs = append(errs, fmt.Errorf("release keyboard: %w", err))
}
if err := writer.WriteRelativeMouseReport(relativeMouseReleaseReport()); err != nil {
errs = append(errs, fmt.Errorf("release relative mouse: %w", err))
}
if err := writer.WriteAbsoluteMouseReport(absoluteMouseReleaseReport(nil)); err != nil {
errs = append(errs, fmt.Errorf("release absolute mouse: %w", err))
}
return errors.Join(errs...)
}

View File

@@ -0,0 +1,77 @@
package hid
import (
"errors"
"strings"
"testing"
)
type releaseRecordingWriter struct {
keyboardErr error
relativeErr error
absoluteErr error
keyboard int
relative int
absolute int
}
func (w *releaseRecordingWriter) WriteKeyboardReport([]byte) error {
w.keyboard++
return w.keyboardErr
}
func (w *releaseRecordingWriter) WriteRelativeMouseReport([]byte) error {
w.relative++
return w.relativeErr
}
func (w *releaseRecordingWriter) WriteAbsoluteMouseReport([]byte) error {
w.absolute++
return w.absoluteErr
}
func TestReleaseAllHIDStateAttemptsEveryDevice(t *testing.T) {
writer := &releaseRecordingWriter{
keyboardErr: errors.New("keyboard failed"),
relativeErr: errors.New("relative failed"),
absoluteErr: errors.New("absolute failed"),
}
err := releaseAllHIDState(writer)
if err == nil {
t.Fatal("expected joined release error")
}
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
}
for _, message := range []string{"release keyboard", "release relative mouse", "release absolute mouse"} {
if !strings.Contains(err.Error(), message) {
t.Fatalf("error %q missing %q", err, message)
}
}
}
func TestReleaseAllHIDStateSuccess(t *testing.T) {
writer := &releaseRecordingWriter{}
if err := releaseAllHIDState(writer); err != nil {
t.Fatal(err)
}
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
}
}
func TestReleaseAllHIDStateBestEffortSuppressesErrors(t *testing.T) {
writer := &releaseRecordingWriter{
keyboardErr: errors.New("keyboard failed"),
relativeErr: errors.New("relative failed"),
absoluteErr: errors.New("absolute failed"),
}
if err := releaseAllHIDStateBestEffort(writer); err != nil {
t.Fatalf("best-effort release error = %v, want nil", err)
}
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
}
}

View File

@@ -1,11 +1,24 @@
package hid
import (
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/inputcontrol"
)
type Service struct {
hid *Hid
hid *Hid
control *controlmode.Manager
coordinator *inputcontrol.Coordinator
}
func NewService() *Service {
return &Service{
hid: GetHid(),
hid: GetHid(),
control: controlmode.GetManager(),
coordinator: inputcontrol.GetCoordinator(),
}
}
func (s *Service) newManualSession() *inputcontrol.ManualSession {
return inputcontrol.NewManualSession(s.control, s.coordinator)
}

View File

@@ -1,7 +1,6 @@
package hid
import (
"NanoKVM-Server/proto"
"errors"
"fmt"
"io"
@@ -10,6 +9,9 @@ import (
"strings"
"time"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/inputcontrol"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
@@ -33,7 +35,7 @@ var modeMap = map[string]string{
func (s *Service) GetHidMode(c *gin.Context) {
var rsp proto.Response
mode, err := getHidMode()
mode, err := GetMode()
if err != nil {
rsp.ErrRsp(c, -1, "get HID mode failed")
return
@@ -45,6 +47,23 @@ func (s *Service) GetHidMode(c *gin.Context) {
log.Debugf("get hid mode: %s", mode)
}
func (s *Service) GetKeyboardLedStatus(c *gin.Context) {
var rsp proto.Response
status := GetKeyboardLedStatus()
updatedAt := ""
if !status.UpdatedAt.IsZero() {
updatedAt = status.UpdatedAt.UTC().Format(time.RFC3339Nano)
}
rsp.OkRspWithData(c, &proto.GetKeyboardLedStatusRsp{
NumLock: status.NumLock,
CapsLock: status.CapsLock,
ScrollLock: status.ScrollLock,
Known: status.Known,
UpdatedAt: updatedAt,
})
}
func (s *Service) SetHidMode(c *gin.Context) {
var req proto.SetHidModeReq
var rsp proto.Response
@@ -58,7 +77,7 @@ func (s *Service) SetHidMode(c *gin.Context) {
return
}
if mode, _ := getHidMode(); req.Mode == mode {
if mode, _ := GetMode(); req.Mode == mode {
rsp.OkRsp(c)
return
}
@@ -91,7 +110,17 @@ func (s *Service) SetHidMode(c *gin.Context) {
func (s *Service) ResetHid(c *gin.Context) {
var rsp proto.Response
if err := ResetUSBPHY(); err != nil {
manual := s.newManualSession()
defer manual.Close()
reservation, err := manual.Reserve(c.Request.Context(), inputcontrol.ManualRelativeMouse, false, nil)
if err != nil {
log.Errorf("failed to acquire manual control for HID reset: %v", err)
rsp.ErrRsp(c, -1, "HID control is busy")
return
}
err = manual.Execute(ResetUSBPHY)
reservation.Complete(err == nil)
if err != nil {
log.Errorf("failed to reset hid: %v", err)
rsp.ErrRsp(c, -1, "failed to reset hid")
return
@@ -194,7 +223,7 @@ func copyModeFile(srcScript string) error {
return nil
}
func getHidMode() (string, error) {
func GetMode() (string, error) {
data, err := os.ReadFile(ModeFlag)
if err != nil {
log.Errorf("failed to read %s: %s", ModeFlag, err)

View File

@@ -0,0 +1,462 @@
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
}
} else {
s.keyboardHeld = false
s.relativeMouseHeld = false
s.absoluteMouseHeld = false
}
releaseControl, end, startCooldown := s.finishIfIdleLocked()
s.mu.Unlock()
s.finish(releaseControl, end, startCooldown)
}
func (s *ManualSession) finishIfIdleLocked() (func(), bool, bool) {
if !s.active || s.pending > 0 || s.keyboardHeld || s.relativeMouseHeld || s.absoluteMouseHeld {
return nil, false, false
}
releaseControl := s.releaseControl
startCooldown := s.cooldownOnIdle
s.active = false
s.releaseControl = nil
s.cooldownOnIdle = false
return releaseControl, true, startCooldown
}
func (s *ManualSession) finish(releaseControl func(), end bool, startCooldown bool) {
if !end {
return
}
if releaseControl != nil {
releaseControl()
}
s.coordinator.endManual(startCooldown)
}

View File

@@ -0,0 +1,474 @@
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 TestFailedReleaseReportClearsHeldManualInput(t *testing.T) {
tests := []struct {
name string
kind ManualReportKind
}{
{name: "keyboard", kind: ManualKeyboard},
{name: "relative mouse", kind: ManualRelativeMouse},
{name: "absolute mouse", kind: ManualAbsoluteMouse},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
now := time.Unix(260, 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(), tc.kind, true, nil)
if err != nil {
t.Fatal(err)
}
down.Complete(true)
up, err := manual.Reserve(context.Background(), tc.kind, false, nil)
if err != nil {
t.Fatal(err)
}
up.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 release report left held input active: %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 TestBlockedActiveSessionFailedReleaseClearsHeldReport(t *testing.T) {
now := time.Unix(325, 0)
coordinator := newCoordinator(time.Second, func() time.Time { return 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(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 blocked release report left held input active: %v", err)
}
release()
}
func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) {
coordinator := newCoordinator(defaultManualCooldown, time.Now)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
manual := NewManualSession(control, coordinator)
defer manual.Close()
reservation, err := manual.Reserve(context.Background(), ManualRelativeMouse, true, nil)
if err != nil {
t.Fatal(err)
}
reservation.Complete(true)
_, release, err := coordinator.BeginMCP(context.Background(), OperationReadOnly)
if err != nil {
t.Fatalf("read-only MCP operation was blocked by manual control: %v", err)
}
release()
}
func TestManualWritesAreSerialized(t *testing.T) {
coordinator := newCoordinator(defaultManualCooldown, time.Now)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
first := NewManualSession(control, coordinator)
second := NewManualSession(control, coordinator)
defer first.Close()
defer second.Close()
firstReservation, err := first.Reserve(context.Background(), ManualKeyboard, false, nil)
if err != nil {
t.Fatal(err)
}
secondReservation, err := second.Reserve(context.Background(), ManualRelativeMouse, false, nil)
if err != nil {
t.Fatal(err)
}
entered := make(chan struct{})
releaseFirst := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_ = first.Execute(func() error {
close(entered)
<-releaseFirst
return nil
})
firstReservation.Complete(true)
}()
<-entered
secondEntered := make(chan struct{})
go func() {
defer wg.Done()
_ = second.Execute(func() error {
close(secondEntered)
return nil
})
secondReservation.Complete(true)
}()
select {
case <-secondEntered:
t.Fatal("second manual write entered before the first completed")
case <-time.After(20 * time.Millisecond):
}
close(releaseFirst)
wg.Wait()
}

View File

@@ -0,0 +1,51 @@
package mcpservice
import (
"crypto/subtle"
"net/http"
"strings"
"NanoKVM-Server/service/controlmode"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
func APIKeyMiddleware(control *controlmode.Manager) gin.HandlerFunc {
if control == nil {
control = controlmode.GetManager()
}
return func(c *gin.Context) {
if err := control.Require(controlmode.ModeMCP); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "MCP service is disabled"})
c.Abort()
return
}
cfg, err := loadConfig()
if err != nil {
log.Errorf("failed to load MCP config for authentication: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "MCP auth unavailable"})
c.Abort()
return
}
token, ok := extractBearer(c.GetHeader("Authorization"))
if !ok || cfg.APIKey == "" || subtle.ConstantTimeCompare([]byte(token), []byte(cfg.APIKey)) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid MCP API key"})
c.Abort()
return
}
c.Next()
}
}
func extractBearer(header string) (string, bool) {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return "", false
}
token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
return token, token != ""
}

View File

@@ -0,0 +1,163 @@
package mcpcapture
import (
"bytes"
"context"
"fmt"
"image/jpeg"
"time"
mcpservice "NanoKVM-Server/service/mcp"
)
const (
defaultQuality = 60
minimumQuality = 51
maximumQuality = 60
maximumJPEGBytes = 1 << 20
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 CaptureLeaseAcquirer func(context.Context) (release func(), claimFresh func() bool, err error)
type Snapshotter struct {
vision VisionReader
readScreen ScreenReader
captureSlot chan struct{}
retryDelay time.Duration
acquireLease CaptureLeaseAcquirer
}
func New(vision VisionReader, readScreen ScreenReader) *Snapshotter {
return NewWithCaptureLease(vision, readScreen, nil)
}
func NewWithCaptureLease(vision VisionReader, readScreen ScreenReader, acquireLease CaptureLeaseAcquirer) *Snapshotter {
return &Snapshotter{
vision: vision,
readScreen: readScreen,
captureSlot: make(chan struct{}, 1),
retryDelay: screenshotRetryDelay,
acquireLease: acquireLease,
}
}
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, minimumQuality, maximumQuality)
timeoutMS := 3000
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()
}
if err := ctx.Err(); err != nil {
return mcpservice.Snapshot{}, err
}
if s.acquireLease != nil {
releaseLease, claimFresh, err := s.acquireLease(ctx)
if err != nil {
return mcpservice.Snapshot{}, err
}
if releaseLease != nil {
defer releaseLease()
}
deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)
width, height := s.readScreen()
return s.capture(ctx, width, height, quality, deadline, timeoutMS, claimFresh)
}
deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)
width, height := s.readScreen()
return s.capture(ctx, width, height, quality, deadline, timeoutMS, nil)
}
func (s *Snapshotter) capture(ctx context.Context, width uint16, height uint16, quality int, deadline time.Time, timeoutMS int, claimFresh func() bool) (mcpservice.Snapshot, error) {
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:
if claimFresh != nil && claimFresh() {
claimFresh = nil
continue
}
if len(data) > maximumJPEGBytes {
snapshot.JPEG = nil
snapshot.Message = "captured JPEG exceeds the MCP response size limit"
return snapshot, nil
}
config, err := jpeg.DecodeConfig(bytes.NewReader(data))
if err != nil {
snapshot.Message = "captured data is not a valid JPEG"
break
}
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"
}
if timeoutMS == 0 || time.Now().Add(s.retryDelay).After(deadline) {
return snapshot, nil
}
timer := time.NewTimer(s.retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return mcpservice.Snapshot{}, ctx.Err()
case <-timer.C:
}
}
}
func clamp(value int, minValue int, maxValue int) int {
if value < minValue {
return minValue
}
if value > maxValue {
return maxValue
}
return value
}

View File

@@ -0,0 +1,323 @@
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
width uint16
height uint16
active int
maxActive int
delay time.Duration
}
func (v *fakeVision) ReadMjpeg(width uint16, height uint16, quality uint16) ([]byte, int) {
v.mu.Lock()
v.calls++
v.quality = quality
v.width = width
v.height = height
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 || vision.width != 1920 || vision.height != 1080 {
t.Fatalf("snapshot=%+v capture=%dx%d quality=%d", snapshot, vision.width, vision.height, vision.quality)
}
_, err = snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{Quality: 200})
if err != nil || vision.quality != maximumQuality {
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 TestCaptureRetriesInitialReadFailure(t *testing.T) {
vision := &fakeVision{responses: []visionResponse{
{result: -1},
{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 != 2 {
t.Fatalf("snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err)
}
}
func TestCaptureRetriesInvalidJPEG(t *testing.T) {
vision := &fakeVision{responses: []visionResponse{
{data: []byte("partial jpeg"), result: 0},
{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 != 2 {
t.Fatalf("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 TestCapturePassesThroughAutomaticResolution(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 || vision.width != 0 || vision.height != 0 {
t.Fatalf("snapshot=%+v capture=%dx%d", snapshot, vision.width, vision.height)
}
}
func TestCaptureDropsClaimedFirstFreshFrame(t *testing.T) {
first := testJPEG(t, 320, 240)
second := testJPEG(t, 640, 480)
vision := &fakeVision{responses: []visionResponse{
{data: first, result: 0},
{data: second, result: 0},
}}
claimed := 0
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 640, 480 }, func(context.Context) (func(), func() bool, error) {
return nil, func() bool {
claimed++
return claimed == 1
}, nil
})
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
if err != nil || !snapshot.OK || !bytes.Equal(snapshot.JPEG, second) || vision.calls != 2 || claimed != 1 {
t.Fatalf("snapshot=%+v calls=%d claimed=%d err=%v", snapshot, vision.calls, claimed, err)
}
}
func TestCaptureRejectsOversizedJPEG(t *testing.T) {
data := make([]byte, maximumJPEGBytes+1)
vision := &fakeVision{responses: []visionResponse{{data: data, result: 0}}}
snapshotter := New(vision, func() (uint16, uint16) { return 960, 540 })
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
if err != nil || snapshot.OK || len(snapshot.JPEG) != 0 || snapshot.Message == "" {
t.Fatalf("snapshot=%+v err=%v", snapshot, err)
}
}
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")
}
}
func TestCaptureReleasesLeaseOnSuccessAndCancellation(t *testing.T) {
vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 320, 240), result: 0}}}
acquired := 0
released := 0
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 320, 240 }, func(context.Context) (func(), func() bool, error) {
acquired++
return func() { released++ }, nil, nil
})
if _, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{}); err != nil {
t.Fatal(err)
}
if acquired != 1 || released != 1 {
t.Fatalf("lease acquired=%d released=%d, want 1/1", acquired, released)
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := snapshotter.Capture(canceled, mcpservice.SnapshotRequest{}); !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want canceled", err)
}
if acquired != 1 || released != 1 {
t.Fatalf("lease acquired=%d released=%d after cancellation before acquisition, want 1/1", acquired, released)
}
}
type blockingVision struct {
started chan struct{}
finish chan struct{}
}
func (v *blockingVision) ReadMjpeg(_ uint16, _ uint16, _ uint16) ([]byte, int) {
v.started <- struct{}{}
<-v.finish
return nil, 5
}
func TestCaptureReleasesLeaseAfterAcquisitionCancellation(t *testing.T) {
vision := &blockingVision{started: make(chan struct{}, 1), finish: make(chan struct{})}
acquired := 0
released := 0
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 320, 240 }, func(context.Context) (func(), func() bool, error) {
acquired++
return func() { released++ }, nil, nil
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := snapshotter.Capture(ctx, mcpservice.SnapshotRequest{})
done <- err
}()
<-vision.started
cancel()
close(vision.finish)
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want canceled", err)
}
if acquired != 1 || released != 1 {
t.Fatalf("lease acquired=%d released=%d after cancellation during capture, want 1/1", acquired, released)
}
}

View File

@@ -0,0 +1,16 @@
package kvmcapture
import (
"NanoKVM-Server/common"
mcpservice "NanoKVM-Server/service/mcp"
"NanoKVM-Server/service/mcp/capture"
"NanoKVM-Server/service/vm"
)
func New() mcpservice.Snapshotter {
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), func() (uint16, uint16) {
screen := common.GetScreen()
common.CheckScreen()
return screen.Width, screen.Height
}, vm.AcquireHdmiCaptureLeaseForRead)
}

View File

@@ -0,0 +1,154 @@
package mcpservice
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
const (
ConfigFile = "/etc/kvm/mcp.json"
apiKeyPrefix = "nag_mcp_"
apiKeyBytes = 32
)
var (
configMu sync.Mutex
configFilePath = ConfigFile
)
type Config struct {
APIKey string `json:"apiKey"`
}
func loadConfig() (Config, error) {
configMu.Lock()
defer configMu.Unlock()
return loadConfigFromPath(configFilePath)
}
func updateConfig(update func(Config) (Config, error)) (Config, error) {
configMu.Lock()
defer configMu.Unlock()
cfg, err := loadConfigFromPath(configFilePath)
if err != nil {
return Config{}, err
}
updated, err := update(cfg)
if err != nil {
return Config{}, err
}
if err := saveConfigToPath(configFilePath, updated); err != nil {
return Config{}, err
}
return updated, nil
}
func loadConfigFromPath(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Config{}, nil
}
return Config{}, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("decode MCP config: %w", err)
}
return cfg, nil
}
func saveConfigToPath(path string, cfg Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create MCP config directory: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode MCP config: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(dir, ".mcp.json.*")
if err != nil {
return fmt.Errorf("create temporary MCP config: %w", err)
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("set temporary MCP config permissions: %w", err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temporary MCP config: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync temporary MCP config: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary MCP config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("replace MCP config: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("set MCP config permissions: %w", err)
}
directory, err := os.Open(dir)
if err == nil {
if syncErr := directory.Sync(); syncErr != nil {
_ = directory.Close()
return fmt.Errorf("sync MCP config directory: %w", syncErr)
}
_ = directory.Close()
}
return nil
}
func ensureAPIKey(cfg Config) (Config, error) {
if cfg.APIKey != "" {
return cfg, nil
}
key, err := generateAPIKey()
if err != nil {
return Config{}, err
}
cfg.APIKey = key
return cfg, nil
}
func regenerateAPIKey(cfg Config) (Config, error) {
key, err := generateAPIKey()
if err != nil {
return Config{}, err
}
cfg.APIKey = key
return cfg, nil
}
func generateAPIKey() (string, error) {
raw := make([]byte, apiKeyBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate MCP API key: %w", err)
}
return apiKeyPrefix + base64.RawURLEncoding.EncodeToString(raw), nil
}

View File

@@ -0,0 +1,262 @@
package mcpservice
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"NanoKVM-Server/service/controlmode"
"github.com/gin-gonic/gin"
)
func useTestConfig(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "mcp.json")
oldPath := configFilePath
configFilePath = path
t.Cleanup(func() { configFilePath = oldPath })
return path
}
func TestConfigLifecycle(t *testing.T) {
path := useTestConfig(t)
cfg, err := loadConfig()
if err != nil || cfg.APIKey != "" {
t.Fatalf("unexpected missing config result: cfg=%+v err=%v", cfg, err)
}
cfg, err = updateConfig(func(cfg Config) (Config, error) {
cfg, err = ensureAPIKey(cfg)
return cfg, err
})
if err != nil || !strings.HasPrefix(cfg.APIKey, apiKeyPrefix) {
t.Fatalf("enable MCP: cfg=%+v err=%v", cfg, err)
}
firstKey := cfg.APIKey
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat config: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("config permissions = %o, want 600", info.Mode().Perm())
}
cfg, err = updateConfig(ensureAPIKey)
if err != nil || cfg.APIKey != firstKey {
t.Fatalf("ensure existing key changed it: cfg=%+v err=%v", cfg, err)
}
cfg, err = updateConfig(regenerateAPIKey)
if err != nil || cfg.APIKey == firstKey {
t.Fatalf("regenerate did not replace key: cfg=%+v err=%v", cfg, err)
}
}
func TestLoadConfigRejectsCorruptJSON(t *testing.T) {
path := useTestConfig(t)
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(); err == nil {
t.Fatal("expected corrupt config error")
}
}
func TestAPIKeyMiddleware(t *testing.T) {
useTestConfig(t)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeOff)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/mcp", APIKeyMiddleware(control), func(c *gin.Context) { c.Status(http.StatusNoContent) })
writeConfig := func(cfg Config) {
if _, err := updateConfig(func(Config) (Config, error) { return cfg, nil }); err != nil {
t.Fatal(err)
}
}
request := func(header string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/mcp", nil)
if header != "" {
req.Header.Set("Authorization", header)
}
router.ServeHTTP(recorder, req)
return recorder
}
writeConfig(Config{APIKey: "secret"})
if got := request("Bearer secret").Code; got != http.StatusForbidden {
t.Fatalf("disabled status = %d", got)
}
if err := control.SwitchToMCP(nil); err != nil {
t.Fatal(err)
}
for _, header := range []string{"", "secret", "Basic secret", "Bearer wrong"} {
if got := request(header).Code; got != http.StatusUnauthorized {
t.Fatalf("header %q status = %d", header, got)
}
}
if got := request("Bearer secret").Code; got != http.StatusNoContent {
t.Fatalf("valid key status = %d", got)
}
writeConfig(Config{APIKey: "rotated"})
if got := request("Bearer secret").Code; got != http.StatusUnauthorized {
t.Fatalf("old key after rotation status = %d", got)
}
if got := request("Bearer rotated").Code; got != http.StatusNoContent {
t.Fatalf("rotated key status = %d", got)
}
}
func TestSetConfigAPI(t *testing.T) {
useTestConfig(t)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
preemptLeasesCalled := false
stopRuntimeCalled := false
releaseCalled := false
gin.SetMode(gin.TestMode)
service := NewServiceWithPreempt(control, func() error {
preemptLeasesCalled = true
return nil
}, func() error {
stopRuntimeCalled = true
return nil
}, func() error {
releaseCalled = true
return nil
}, nil)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
service.SetConfig(c)
if got := recorder.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
var response struct {
Code int `json:"code"`
Data struct {
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey"`
ControlMode string `json:"controlMode"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Code != 0 || !response.Data.Enabled || response.Data.APIKey == "" || response.Data.ControlMode != string(controlmode.ModeMCP) || !preemptLeasesCalled || !stopRuntimeCalled || !releaseCalled {
t.Fatalf("unexpected response: %+v", response)
}
recorder = httptest.NewRecorder()
c, _ = gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":false}`))
c.Request.Header.Set("Content-Type", "application/json")
service.SetConfig(c)
if control.Current() != controlmode.ModeOff {
t.Fatalf("mode after disabling MCP = %q, want off", control.Current())
}
}
func TestSetConfigEnablePreemptFailureDoesNotEnableMCP(t *testing.T) {
useTestConfig(t)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
wantErr := errors.New("picoclaw stop failed")
service := NewServiceWithPreempt(control, func() error {
return wantErr
}, func() error {
t.Fatal("runtime stop should not run when soft preempt fails")
return nil
}, func() error {
t.Fatal("release should not run when preempt fails")
return nil
}, nil)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
service.SetConfig(c)
if control.Current() != controlmode.ModePicoclaw {
t.Fatalf("mode after failed preempt = %q, want picoclaw", control.Current())
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Code != -2 || !strings.Contains(response.Msg, wantErr.Error()) {
t.Fatalf("response = %+v, want preempt failure", response)
}
}
func TestSetConfigEnableCleanupFailureFailsClosed(t *testing.T) {
useTestConfig(t)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
wantErr := errors.New("picoclaw stop failed")
preemptCalled := false
releaseCalled := false
service := NewServiceWithPreempt(control, func() error {
preemptCalled = true
return nil
}, func() error {
return wantErr
}, func() error {
releaseCalled = true
return nil
}, nil)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
service.SetConfig(c)
if control.Current() != controlmode.ModeOff {
t.Fatalf("mode after failed destructive cleanup = %q, want off", control.Current())
}
if !preemptCalled || !releaseCalled {
t.Fatalf("preemptCalled=%v releaseCalled=%v, want both true", preemptCalled, releaseCalled)
}
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Code != -2 || !strings.Contains(response.Msg, wantErr.Error()) {
t.Fatalf("response = %+v, want cleanup failure", response)
}
}
func TestSetConfigEnableReleaseFailureFailsClosed(t *testing.T) {
useTestConfig(t)
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
service := NewService(control, func() error { return errors.New("release failed") })
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
service.SetConfig(c)
if control.Current() != controlmode.ModeOff {
t.Fatalf("mode after failed enable = %q, want off", control.Current())
}
cfg, err := loadConfig()
if err != nil || cfg.APIKey == "" {
t.Fatalf("API key should be persisted before switching: cfg=%+v err=%v", cfg, err)
}
}

View File

@@ -0,0 +1,122 @@
package mcpservice
import (
"fmt"
"sort"
"NanoKVM-Server/service/hid"
)
const maxKeyboardKeys = 6
var keyUpReport = []byte{0, 0, 0, 0, 0, 0, 0, 0}
var modifierMap = map[string]byte{
"ControlLeft": 1 << 0,
"ShiftLeft": 1 << 1,
"AltLeft": 1 << 2,
"MetaLeft": 1 << 3,
"ControlRight": 1 << 4,
"ShiftRight": 1 << 5,
"AltRight": 1 << 6,
"MetaRight": 1 << 7,
}
var keyCodeMap = map[string]byte{
"KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, "KeyE": 0x08,
"KeyF": 0x09, "KeyG": 0x0a, "KeyH": 0x0b, "KeyI": 0x0c, "KeyJ": 0x0d,
"KeyK": 0x0e, "KeyL": 0x0f, "KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12,
"KeyP": 0x13, "KeyQ": 0x14, "KeyR": 0x15, "KeyS": 0x16, "KeyT": 0x17,
"KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1a, "KeyX": 0x1b, "KeyY": 0x1c,
"KeyZ": 0x1d,
"Digit1": 0x1e, "Digit2": 0x1f, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22,
"Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27,
"Enter": 0x28, "Escape": 0x29, "Backspace": 0x2a, "Tab": 0x2b, "Space": 0x2c,
"Minus": 0x2d, "Equal": 0x2e, "BracketLeft": 0x2f, "BracketRight": 0x30,
"Backslash": 0x31, "IntlHash": 0x32, "Semicolon": 0x33, "Quote": 0x34,
"Backquote": 0x35, "Comma": 0x36, "Period": 0x37, "Slash": 0x38,
"CapsLock": 0x39,
"F1": 0x3a, "F2": 0x3b, "F3": 0x3c, "F4": 0x3d, "F5": 0x3e, "F6": 0x3f,
"F7": 0x40, "F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
"PrintScreen": 0x46, "ScrollLock": 0x47, "Pause": 0x48, "Insert": 0x49,
"Home": 0x4a, "PageUp": 0x4b, "Delete": 0x4c, "End": 0x4d,
"PageDown": 0x4e, "ArrowRight": 0x4f, "ArrowLeft": 0x50,
"ArrowDown": 0x51, "ArrowUp": 0x52,
"NumLock": 0x53, "NumpadDivide": 0x54, "NumpadMultiply": 0x55,
"NumpadSubtract": 0x56, "NumpadAdd": 0x57, "NumpadEnter": 0x58,
"Numpad1": 0x59, "Numpad2": 0x5a, "Numpad3": 0x5b, "Numpad4": 0x5c,
"Numpad5": 0x5d, "Numpad6": 0x5e, "Numpad7": 0x5f, "Numpad8": 0x60,
"Numpad9": 0x61, "Numpad0": 0x62, "NumpadDecimal": 0x63,
"IntlBackslash": 0x64, "ContextMenu": 0x65,
}
func supportedKeyNames() []any {
names := make([]string, 0, len(modifierMap)+len(keyCodeMap))
for name := range modifierMap {
names = append(names, name)
}
for name := range keyCodeMap {
names = append(names, name)
}
sort.Strings(names)
values := make([]any, len(names))
for index, name := range names {
values[index] = name
}
return values
}
func buildKeyComboReport(keys []string) ([]byte, error) {
report := make([]byte, 8)
seenKeys := make(map[byte]struct{})
keyIndex := 2
for _, key := range keys {
if modifier, ok := modifierMap[key]; ok {
report[0] |= modifier
continue
}
code, ok := keyCodeMap[key]
if !ok {
return nil, fmt.Errorf("unknown key: %s", key)
}
if _, ok := seenKeys[code]; ok {
continue
}
if keyIndex >= 2+maxKeyboardKeys {
return nil, fmt.Errorf("too many non-modifier keys")
}
seenKeys[code] = struct{}{}
report[keyIndex] = code
keyIndex++
}
return report, nil
}
func buildTypeReports(text string) ([][]byte, []rune) {
charMap := hid.GetCharMap("")
reports := make([][]byte, 0, len(text)*2)
skipped := make([]rune, 0)
for _, char := range text {
key, ok := charMap[char]
if !ok {
skipped = append(skipped, char)
continue
}
reports = append(reports,
[]byte{byte(key.Modifiers), 0, byte(key.Code), 0, 0, 0, 0, 0},
append([]byte(nil), keyUpReport...),
)
}
return reports, skipped
}

View File

@@ -0,0 +1,37 @@
package mcpservice
import "testing"
func TestBuildKeyComboReport(t *testing.T) {
report, err := buildKeyComboReport([]string{"ControlLeft", "AltLeft", "Delete"})
if err != nil {
t.Fatal(err)
}
if report[0] != 0x05 || report[2] != 0x4c {
t.Fatalf("unexpected combo report: %v", report)
}
if _, err := buildKeyComboReport([]string{"Unknown"}); err == nil {
t.Fatal("expected unknown key error")
}
if _, err := buildKeyComboReport([]string{"KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG"}); err == nil {
t.Fatal("expected six-key limit error")
}
}
func TestBuildTypeReportsReturnsSkippedRunes(t *testing.T) {
reports, skipped := buildTypeReports("A界")
if len(reports) != 2 || string(skipped) != "界" {
t.Fatalf("reports=%d skipped=%q", len(reports), string(skipped))
}
if reports[0][0] != 2 || reports[0][2] != 4 || string(reports[1]) != string(keyUpReport) {
t.Fatalf("unexpected reports: %v", reports)
}
}
func TestSupportedKeyNamesMatchKeyboardDescriptor(t *testing.T) {
for name, code := range keyCodeMap {
if code > 0x65 {
t.Fatalf("key %s uses unsupported descriptor code %#x", name, code)
}
}
}

View File

@@ -0,0 +1,93 @@
package mcpservice
import (
"fmt"
"math"
)
const (
maxRelativeMouseReports = 1024
maxRelativeMouseDelta = 127 * maxRelativeMouseReports
)
func absoluteCoordinate(value float64) uint16 {
value = math.Max(0, math.Min(1, value))
return uint16(math.Floor(0x7fff*value)) + 1
}
func buildAbsolutePointerReport(x uint16, y uint16, buttons byte, wheel int) []byte {
return []byte{
buttons,
byte(x), byte(x >> 8),
byte(y), byte(y >> 8),
byte(int8(clampInt(wheel, -127, 127))),
}
}
func buildRelativeMouseReport(deltaX int, deltaY int, buttons byte, wheel int) []byte {
return []byte{
buttons,
byte(int8(clampInt(deltaX, -127, 127))),
byte(int8(clampInt(deltaY, -127, 127))),
byte(int8(clampInt(wheel, -127, 127))),
}
}
func buildRelativeMoveReports(deltaX int, deltaY int) ([][]byte, error) {
if relativeReportCount(deltaX, deltaY) > maxRelativeMouseReports {
return nil, fmt.Errorf("relative movement exceeds %d reports", maxRelativeMouseReports)
}
reports := make([][]byte, 0)
for deltaX != 0 || deltaY != 0 {
x := clampInt(deltaX, -127, 127)
y := clampInt(deltaY, -127, 127)
reports = append(reports, buildRelativeMouseReport(x, y, 0, 0))
deltaX -= x
deltaY -= y
}
return reports, nil
}
func mouseButtonBit(button string) (byte, error) {
switch button {
case "left":
return 1 << 0, nil
case "right":
return 1 << 1, nil
case "middle":
return 1 << 2, nil
default:
return 0, fmt.Errorf("unknown mouse button: %s", button)
}
}
func clampInt(value int, minValue int, maxValue int) int {
if value < minValue {
return minValue
}
if value > maxValue {
return maxValue
}
return value
}
func relativeReportCount(deltaX int, deltaY int) int {
xReports := reportsForDelta(deltaX)
yReports := reportsForDelta(deltaY)
if xReports > yReports {
return xReports
}
return yReports
}
func reportsForDelta(delta int) int {
value := int64(delta)
if value == 0 {
return 0
}
if value < 0 {
return int((-(value + 1))/127 + 1)
}
return int((value-1)/127 + 1)
}

View File

@@ -0,0 +1,53 @@
package mcpservice
import "testing"
func TestAbsoluteCoordinateUsesNanoKVMRange(t *testing.T) {
tests := []struct {
value float64
want uint16
}{
{-1, 1},
{0, 1},
{0.5, 0x4000},
{1, 0x8000},
{2, 0x8000},
}
for _, test := range tests {
if got := absoluteCoordinate(test.value); got != test.want {
t.Fatalf("absoluteCoordinate(%v) = %#x, want %#x", test.value, got, test.want)
}
}
}
func TestAbsoluteReportIsSixBytes(t *testing.T) {
report := buildAbsolutePointerReport(0x1234, 0x5678, 1, -2)
want := []byte{1, 0x34, 0x12, 0x78, 0x56, 0xfe}
if string(report) != string(want) {
t.Fatalf("report = %v, want %v", report, want)
}
}
func TestRelativeMovementSplitsReports(t *testing.T) {
reports, err := buildRelativeMoveReports(300, -300)
if err != nil {
t.Fatal(err)
}
if len(reports) != 3 {
t.Fatalf("report count = %d, want 3", len(reports))
}
if reports[0][1] != byte(int8(127)) || int8(reports[0][2]) != -127 {
t.Fatalf("first report = %v", reports[0])
}
if _, err := buildRelativeMoveReports(127*(maxRelativeMouseReports+1), 0); err == nil {
t.Fatal("expected oversized movement error")
}
}
func TestUnsupportedAuxiliaryMouseButtons(t *testing.T) {
for _, button := range []string{"back", "forward"} {
if _, err := mouseButtonBit(button); err == nil {
t.Fatalf("button %q should be rejected", button)
}
}
}

View File

@@ -0,0 +1,234 @@
package mcpservice
import (
"context"
"fmt"
"sync"
"time"
"NanoKVM-Server/service/hid"
)
type HIDWriter interface {
WriteKeyboardReport([]byte) error
WriteRelativeMouseReport([]byte) error
WriteAbsoluteMouseReport([]byte) error
}
type Remote struct {
hid HIDWriter
hidMu sync.Mutex
}
func NewRemote() *Remote {
return &Remote{hid: hid.GetHid()}
}
func newRemoteWithHID(writer HIDWriter) *Remote {
return &Remote{hid: writer}
}
func (r *Remote) TypeText(ctx context.Context, text string, delay time.Duration) ([]rune, error) {
reports, skipped := buildTypeReports(text)
r.hidMu.Lock()
defer r.hidMu.Unlock()
keyDown := false
for index, report := range reports {
if err := contextError(ctx); err != nil {
if keyDown {
_ = r.hid.WriteKeyboardReport(keyUpReport)
}
return skipped, err
}
if err := r.hid.WriteKeyboardReport(report); err != nil {
_ = r.hid.WriteKeyboardReport(keyUpReport)
return skipped, err
}
keyDown = index%2 == 0
if !keyDown && index+1 < len(reports) {
if err := sleepContext(ctx, delay); err != nil {
return skipped, err
}
}
}
return skipped, nil
}
func (r *Remote) PressKeys(ctx context.Context, keys []string, hold time.Duration) error {
report, err := buildKeyComboReport(keys)
if err != nil {
return err
}
r.hidMu.Lock()
defer r.hidMu.Unlock()
if err := contextError(ctx); err != nil {
return err
}
if err := r.hid.WriteKeyboardReport(report); err != nil {
_ = r.hid.WriteKeyboardReport(keyUpReport)
return err
}
if err := sleepContext(ctx, hold); err != nil {
_ = r.hid.WriteKeyboardReport(keyUpReport)
return err
}
return r.hid.WriteKeyboardReport(keyUpReport)
}
func (r *Remote) MoveAbsolute(ctx context.Context, x float64, y float64) error {
if x < 0 || x > 1 || y < 0 || y > 1 {
return fmt.Errorf("absolute coordinates must be between 0 and 1")
}
report := buildAbsolutePointerReport(absoluteCoordinate(x), absoluteCoordinate(y), 0, 0)
r.hidMu.Lock()
defer r.hidMu.Unlock()
if err := contextError(ctx); err != nil {
return err
}
return r.hid.WriteAbsoluteMouseReport(report)
}
func (r *Remote) TouchAbsoluteButton(ctx context.Context, x float64, y float64, button string, hold time.Duration) error {
if x < 0 || x > 1 || y < 0 || y > 1 {
return fmt.Errorf("absolute coordinates must be between 0 and 1")
}
bit, err := mouseButtonBit(button)
if err != nil {
return err
}
hidX := absoluteCoordinate(x)
hidY := absoluteCoordinate(y)
release := buildAbsolutePointerReport(hidX, hidY, 0, 0)
r.hidMu.Lock()
defer r.hidMu.Unlock()
if err := contextError(ctx); err != nil {
return err
}
if err := r.hid.WriteAbsoluteMouseReport(buildAbsolutePointerReport(hidX, hidY, bit, 0)); err != nil {
_ = r.hid.WriteAbsoluteMouseReport(release)
return err
}
if err := sleepContext(ctx, hold); err != nil {
_ = r.hid.WriteAbsoluteMouseReport(release)
return err
}
return r.hid.WriteAbsoluteMouseReport(release)
}
func (r *Remote) MoveRelative(ctx context.Context, deltaX int, deltaY int) error {
reports, err := buildRelativeMoveReports(deltaX, deltaY)
if err != nil {
return err
}
r.hidMu.Lock()
defer r.hidMu.Unlock()
for _, report := range reports {
if err := contextError(ctx); err != nil {
_ = r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, 0, 0))
return err
}
if err := r.hid.WriteRelativeMouseReport(report); err != nil {
_ = r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, 0, 0))
return err
}
}
return nil
}
func (r *Remote) Click(ctx context.Context, button string, clicks int, delay time.Duration) error {
bit, err := mouseButtonBit(button)
if err != nil {
return err
}
clicks = clampInt(clicks, 1, 5)
release := buildRelativeMouseReport(0, 0, 0, 0)
r.hidMu.Lock()
defer r.hidMu.Unlock()
for i := 0; i < clicks; i++ {
if err := contextError(ctx); err != nil {
_ = r.hid.WriteRelativeMouseReport(release)
return err
}
if err := r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, bit, 0)); err != nil {
_ = r.hid.WriteRelativeMouseReport(release)
return err
}
if err := sleepContext(ctx, delay); err != nil {
_ = r.hid.WriteRelativeMouseReport(release)
return err
}
if err := r.hid.WriteRelativeMouseReport(release); err != nil {
_ = r.hid.WriteRelativeMouseReport(release)
return err
}
if i+1 < clicks {
if err := sleepContext(ctx, delay); err != nil {
return err
}
}
}
return nil
}
func (r *Remote) Scroll(ctx context.Context, deltaY int) error {
if reportsForDelta(deltaY) > maxRelativeMouseReports {
return fmt.Errorf("scroll exceeds %d reports", maxRelativeMouseReports)
}
x := absoluteCoordinate(0.5)
y := absoluteCoordinate(0.5)
release := buildAbsolutePointerReport(x, y, 0, 0)
r.hidMu.Lock()
defer r.hidMu.Unlock()
for deltaY != 0 {
if err := contextError(ctx); err != nil {
_ = r.hid.WriteAbsoluteMouseReport(release)
return err
}
wheel := clampInt(deltaY, -127, 127)
if err := r.hid.WriteAbsoluteMouseReport(buildAbsolutePointerReport(x, y, 0, wheel)); err != nil {
_ = r.hid.WriteAbsoluteMouseReport(release)
return err
}
if err := r.hid.WriteAbsoluteMouseReport(release); err != nil {
_ = r.hid.WriteAbsoluteMouseReport(release)
return err
}
deltaY -= wheel
}
return nil
}
func sleepContext(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return contextError(ctx)
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return context.Cause(ctx)
case <-timer.C:
return nil
}
}
func contextError(ctx context.Context) error {
if ctx == nil {
return nil
}
select {
case <-ctx.Done():
return context.Cause(ctx)
default:
return nil
}
}

View File

@@ -0,0 +1,115 @@
package mcpservice
import (
"context"
"errors"
"testing"
)
type recordingHID struct {
keyboard [][]byte
relative [][]byte
absolute [][]byte
failAt int
writes int
onWrite func(int)
}
func (h *recordingHID) record(target *[][]byte, report []byte) error {
h.writes++
if h.onWrite != nil {
h.onWrite(h.writes)
}
*target = append(*target, append([]byte(nil), report...))
if h.failAt > 0 && h.writes == h.failAt {
return errors.New("write failed")
}
return nil
}
func (h *recordingHID) WriteKeyboardReport(report []byte) error {
return h.record(&h.keyboard, report)
}
func (h *recordingHID) WriteRelativeMouseReport(report []byte) error {
return h.record(&h.relative, report)
}
func (h *recordingHID) WriteAbsoluteMouseReport(report []byte) error {
return h.record(&h.absolute, report)
}
func TestRemotePressKeysAlwaysReleases(t *testing.T) {
hid := &recordingHID{}
remote := newRemoteWithHID(hid)
if err := remote.PressKeys(context.Background(), []string{"ShiftLeft", "KeyA"}, 0); err != nil {
t.Fatal(err)
}
if len(hid.keyboard) != 2 || string(hid.keyboard[1]) != string(keyUpReport) {
t.Fatalf("keyboard reports: %v", hid.keyboard)
}
failing := &recordingHID{failAt: 1}
if err := newRemoteWithHID(failing).PressKeys(context.Background(), []string{"KeyA"}, 0); err == nil {
t.Fatal("expected write error")
}
if len(failing.keyboard) != 2 || string(failing.keyboard[1]) != string(keyUpReport) {
t.Fatalf("release not attempted after error: %v", failing.keyboard)
}
}
func TestRemoteClickReleasesButton(t *testing.T) {
hid := &recordingHID{}
if err := newRemoteWithHID(hid).Click(context.Background(), "left", 1, 0); err != nil {
t.Fatal(err)
}
if len(hid.relative) != 2 || hid.relative[0][0] != 1 || hid.relative[1][0] != 0 {
t.Fatalf("relative reports: %v", hid.relative)
}
}
func TestRemoteScrollUsesAbsoluteReportsAndReleasesWheel(t *testing.T) {
hid := &recordingHID{}
if err := newRemoteWithHID(hid).Scroll(context.Background(), 130); err != nil {
t.Fatal(err)
}
if len(hid.relative) != 0 {
t.Fatalf("relative reports = %v, want none", hid.relative)
}
if len(hid.absolute) != 4 {
t.Fatalf("absolute report count = %d, want 4: %v", len(hid.absolute), hid.absolute)
}
x := absoluteCoordinate(0.5)
y := absoluteCoordinate(0.5)
want := [][]byte{
buildAbsolutePointerReport(x, y, 0, 127),
buildAbsolutePointerReport(x, y, 0, 0),
buildAbsolutePointerReport(x, y, 0, 3),
buildAbsolutePointerReport(x, y, 0, 0),
}
for i := range want {
if string(hid.absolute[i]) != string(want[i]) {
t.Fatalf("absolute[%d] = %v, want %v", i, hid.absolute[i], want[i])
}
}
}
func TestRemoteTypeTextCancellationReleasesKeyboard(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
hid := &recordingHID{}
hid.onWrite = func(write int) {
if write == 1 {
cancel()
}
}
_, err := newRemoteWithHID(hid).TypeText(ctx, "A", 0)
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want canceled", err)
}
if len(hid.keyboard) != 2 || string(hid.keyboard[1]) != string(keyUpReport) {
t.Fatalf("keyboard was not released after cancellation: %v", hid.keyboard)
}
}

View File

@@ -0,0 +1,27 @@
package mcpservice
import "context"
const snapshotTimeoutMaxMS = 30_000
type SnapshotRequest struct {
Quality int
TimeoutMS *int
X int
Y int
W int
H int
}
type Snapshot struct {
OK bool
RetCode int
Message string
Width int
Height int
JPEG []byte
}
type Snapshotter interface {
Capture(context.Context, SnapshotRequest) (Snapshot, error)
}

View File

@@ -0,0 +1,544 @@
package mcpservice
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/inputcontrol"
"github.com/google/jsonschema-go/jsonschema"
protocol "github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
mcpServerName = "nanokvm-cube-remote-control"
typeTextToolName = "cube_type_text"
pressKeysToolName = "cube_press_keys"
moveMouseToolName = "cube_move_mouse"
clickMouseToolName = "cube_click_mouse"
scrollMouseToolName = "cube_scroll_mouse"
screenshotToolName = "cube_screenshot"
defaultTypeDelayMS = 30
defaultHoldMS = 50
defaultClickDelayMS = 50
maxPressKeyItems = maxKeyboardKeys + 8
maxTypeTextDuration = 30 * time.Second
maxTypeTextRunes = int(maxTypeTextDuration / (defaultTypeDelayMS * time.Millisecond))
maxRequestBodyBytes = 1 << 20
)
type TypeTextParams struct {
Text string `json:"text" jsonschema:"text to type on the remote host"`
DelayMS *int `json:"delayMs,omitempty" jsonschema:"delay between typed characters in milliseconds"`
}
type TypeTextResult struct {
Typed int `json:"typed"`
Skipped string `json:"skipped,omitempty"`
SkippedCount int `json:"skippedCount"`
}
type PressKeysParams struct {
Keys []string `json:"keys" jsonschema:"KeyboardEvent.code key names"`
HoldMS *int `json:"holdMs,omitempty" jsonschema:"hold time in milliseconds"`
}
type MoveMouseParams struct {
Mode string `json:"mode" jsonschema:"absolute or relative"`
X *float64 `json:"x,omitempty" jsonschema:"absolute normalized x"`
Y *float64 `json:"y,omitempty" jsonschema:"absolute normalized y"`
DeltaX int `json:"deltaX,omitempty" jsonschema:"relative x movement"`
DeltaY int `json:"deltaY,omitempty" jsonschema:"relative y movement"`
}
type ClickMouseParams struct {
Button string `json:"button" jsonschema:"left, right, or middle"`
Clicks int `json:"clicks,omitempty" jsonschema:"number of clicks"`
Mode string `json:"mode,omitempty" jsonschema:"absolute or relative"`
X *float64 `json:"x,omitempty" jsonschema:"absolute normalized x"`
Y *float64 `json:"y,omitempty" jsonschema:"absolute normalized y"`
}
type ScrollMouseParams struct {
DeltaY int `json:"deltaY" jsonschema:"vertical wheel movement"`
}
type ScreenshotParams struct {
Quality int `json:"quality,omitempty"`
TimeoutMS *int `json:"timeoutMs,omitempty"`
}
type ScreenshotResult struct {
OK bool `json:"ok"`
RetCode int `json:"retCode"`
Message string `json:"message"`
Width int `json:"width"`
Height int `json:"height"`
Size int `json:"size"`
}
type ToolOKResult struct {
OK bool `json:"ok"`
}
type toolExecutor struct {
control *controlmode.Manager
coordinator *inputcontrol.Coordinator
}
func NewMCPHandler(control *controlmode.Manager, snapshotter Snapshotter) http.Handler {
return newMCPHandler(control, inputcontrol.GetCoordinator(), NewRemote(), snapshotter)
}
func newMCPHandler(control *controlmode.Manager, coordinator *inputcontrol.Coordinator, remote *Remote, snapshotter Snapshotter) http.Handler {
executor := &toolExecutor{control: control, coordinator: coordinator}
server := protocol.NewServer(&protocol.Implementation{
Name: mcpServerName,
Version: "v1.0.0",
}, nil)
registerTools(server, executor, remote, snapshotter)
handler := protocol.NewStreamableHTTPHandler(
func(*http.Request) *protocol.Server { return server },
&protocol.StreamableHTTPOptions{Stateless: true, JSONResponse: 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; values are limited to 51-60 on the device", 1, 100),
"timeoutMs": integerSchema("Capture timeout in milliseconds", 0, 30000),
})
}
func objectSchema(properties map[string]*jsonschema.Schema, required ...string) *jsonschema.Schema {
return &jsonschema.Schema{
Type: "object",
Properties: properties,
Required: required,
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
}
}
func integerSchema(description string, minimum int, maximum int) *jsonschema.Schema {
return &jsonschema.Schema{
Type: "integer",
Description: description,
Minimum: jsonschema.Ptr(float64(minimum)),
Maximum: jsonschema.Ptr(float64(maximum)),
}
}
func numberSchema(description string, minimum float64, maximum float64) *jsonschema.Schema {
return &jsonschema.Schema{
Type: "number",
Description: description,
Minimum: jsonschema.Ptr(minimum),
Maximum: jsonschema.Ptr(maximum),
}
}
func toolError(message string) *protocol.CallToolResult {
return &protocol.CallToolResult{
Content: []protocol.Content{&protocol.TextContent{Text: message}},
IsError: true,
}
}
func normalizedMode(mode string, fallback string) string {
normalized := strings.ToLower(strings.TrimSpace(mode))
if normalized == "" {
return fallback
}
return normalized
}

View File

@@ -0,0 +1,402 @@
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())
}
if contentType := initialize.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
t.Fatalf("initialize content type=%q, want application/json", contentType)
}
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())
}
if call.name == "cube_screenshot" && strings.HasPrefix(response.Body.String(), "data:") {
t.Fatalf("screenshot unexpectedly used SSE response: %s", response.Body.String())
}
}
invalidCalls := []struct {
id int
name string
arguments string
}{
{9, "cube_move_mouse", `{"mode":"absolute"}`},
{10, "cube_move_mouse", `{"mode":"absolute","x":1.1,"y":0.5}`},
{11, "cube_click_mouse", `{"mode":"absolute","button":"left"}`},
{12, "cube_screenshot", `{"x":10}`},
{13, "cube_move_mouse", `{"mode":"relative","x":0.5,"y":0.5}`},
{14, "cube_move_mouse", `{"mode":"relative"}`},
{15, "cube_click_mouse", `{"mode":"absolute","button":"middle","x":0.5,"y":0.5}`},
{16, "cube_click_mouse", `{"button":"back"}`},
{17, "cube_press_keys", `{"keys":["MediaPlayPause"]}`},
}
for _, call := range invalidCalls {
body := `{"jsonrpc":"2.0","id":` + fmt.Sprint(call.id) + `,"method":"tools/call","params":{"name":"` + call.name + `","arguments":` + call.arguments + `}}`
response := request(http.MethodPost, body, sessionID)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"isError":true`) {
t.Fatalf("invalid tools/call %s status=%d body=%s", call.name, response.Code, response.Body.String())
}
}
closed := request(http.MethodDelete, "", sessionID)
if closed.Code != http.StatusNoContent {
t.Fatalf("delete status=%d body=%s", closed.Code, closed.Body.String())
}
}
func TestMCPHandlerRejectsCrossOriginBrowserRequests(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
handler := http.NewCrossOriginProtection().Handler(newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(&recordingHID{}), nil))
request := func(origin string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "http://nanokvm.local/api/mcp", strings.NewReader(`{}`))
req.Header.Set("Origin", origin)
handler.ServeHTTP(recorder, req)
return recorder
}
if got := request("https://evil.example").Code; got != http.StatusForbidden {
t.Fatalf("cross-origin status = %d, want 403", got)
}
if got := request("http://nanokvm.local").Code; got == http.StatusForbidden {
t.Fatalf("same-origin request was rejected with status %d", got)
}
}
func TestMCPHandlerRejectsOversizedRequestBody(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
handler := newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(&recordingHID{}), nil)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/mcp",
strings.NewReader(strings.Repeat(" ", maxRequestBodyBytes+1)),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
handler.ServeHTTP(recorder, req)
if recorder.Code == http.StatusOK {
t.Fatalf("oversized request was accepted: status=%d", recorder.Code)
}
if !strings.Contains(recorder.Body.String(), "failed to read body") {
t.Fatalf("unexpected oversized response: status=%d body=%q", recorder.Code, recorder.Body.String())
}
}
func TestToolExecutorRejectsConcurrentControl(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
coordinator := &inputcontrol.Coordinator{}
executor := &toolExecutor{control: control, coordinator: coordinator}
_, release, err := executor.begin(context.Background(), inputcontrol.OperationHID)
if err != nil {
t.Fatal(err)
}
defer release()
if _, _, err := executor.begin(context.Background(), inputcontrol.OperationHID); !errors.Is(err, inputcontrol.ErrMCPBusy) {
t.Fatalf("error = %v, want busy", err)
}
}
func TestManualControlBlocksHIDButAllowsScreenshot(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
coordinator := &inputcontrol.Coordinator{}
manual := inputcontrol.NewManualSession(control, coordinator)
defer manual.Close()
reservation, err := manual.Reserve(context.Background(), inputcontrol.ManualRelativeMouse, true, nil)
if err != nil {
t.Fatal(err)
}
reservation.Complete(true)
hid := &recordingHID{}
executor := &toolExecutor{control: control, coordinator: coordinator}
x, y := 0.5, 0.5
_, _, err = moveMouseHandler(executor, newRemoteWithHID(hid))(
context.Background(), nil, MoveMouseParams{Mode: "absolute", X: &x, Y: &y},
)
if !errors.Is(err, inputcontrol.ErrManualControlActive) {
t.Fatalf("move error = %v, want manual-control busy", err)
}
if hid.writes != 0 {
t.Fatalf("manual-control rejection wrote %d HID reports", hid.writes)
}
result, output, err := screenshotHandler(executor, fakeSnapshotter{snapshot: Snapshot{
OK: true, Width: 800, Height: 600, JPEG: []byte{0xff, 0xd8, 0xff},
}})(context.Background(), nil, ScreenshotParams{})
if err != nil {
t.Fatalf("screenshot was blocked by manual control: %v", err)
}
if result == nil || !output.OK {
t.Fatalf("result=%+v output=%+v", result, output)
}
manual.Reset(inputcontrol.ManualRelativeMouse)
if got := control.Current(); got != controlmode.ModeMCP {
t.Fatalf("mode = %q, want MCP to remain enabled", got)
}
}
func TestModeSwitchCancelsActiveMCPTool(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
coordinator := &inputcontrol.Coordinator{}
hid := &recordingHID{}
started := make(chan struct{}, 1)
hid.onWrite = func(write int) {
if write == 1 {
started <- struct{}{}
}
}
handler := typeTextHandler(
&toolExecutor{control: control, coordinator: coordinator},
newRemoteWithHID(hid),
)
toolDone := make(chan error, 1)
go func() {
delayMS := 1000
_, _, err := handler(context.Background(), nil, TypeTextParams{Text: "AB", DelayMS: &delayMS})
toolDone <- err
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("MCP tool did not start")
}
switched, err := control.SwitchIf(controlmode.ModeMCP, controlmode.ModeOff, func() error {
coordinator.CancelMCP()
return nil
})
if err != nil || !switched {
t.Fatalf("switched=%v err=%v", switched, err)
}
select {
case err := <-toolDone:
if !errors.Is(err, inputcontrol.ErrMCPModeChanged) {
t.Fatalf("tool error=%v, want mode-change cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("canceled MCP tool did not return")
}
}
func TestManualInputCancelsActiveMCPToolAndKeepsModeEnabled(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
coordinator := &inputcontrol.Coordinator{}
hid := &recordingHID{}
started := make(chan struct{}, 1)
hid.onWrite = func(write int) {
if write == 1 {
started <- struct{}{}
}
}
handler := typeTextHandler(
&toolExecutor{control: control, coordinator: coordinator},
newRemoteWithHID(hid),
)
toolDone := make(chan error, 1)
go func() {
delayMS := 1000
_, _, err := handler(context.Background(), nil, TypeTextParams{Text: "AB", DelayMS: &delayMS})
toolDone <- err
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("MCP tool did not start")
}
manual := inputcontrol.NewManualSession(control, coordinator)
defer manual.Close()
reservationDone := make(chan *inputcontrol.ManualReservation, 1)
reservationErr := make(chan error, 1)
go func() {
reservation, err := manual.Reserve(context.Background(), inputcontrol.ManualKeyboard, false, nil)
if err != nil {
reservationErr <- err
return
}
reservationDone <- reservation
}()
select {
case err := <-toolDone:
if !errors.Is(err, inputcontrol.ErrManualPreempted) {
t.Fatalf("tool error=%v, want manual preemption", err)
}
case <-time.After(time.Second):
t.Fatal("manual input did not cancel MCP tool")
}
select {
case err := <-reservationErr:
t.Fatal(err)
case reservation := <-reservationDone:
reservation.Complete(true)
case <-time.After(time.Second):
t.Fatal("manual input did not acquire control after MCP cleanup")
}
if got := control.Current(); got != controlmode.ModeMCP {
t.Fatalf("mode = %q, want MCP to remain enabled", got)
}
if len(hid.keyboard) < 2 || string(hid.keyboard[len(hid.keyboard)-1]) != string(keyUpReport) {
t.Fatalf("keyboard was not released before manual takeover: %v", hid.keyboard)
}
}

View File

@@ -0,0 +1,169 @@
package mcpservice
import (
"errors"
"NanoKVM-Server/proto"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/inputcontrol"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type Service struct {
control *controlmode.Manager
preemptPicoclawLeases func() error
stopPicoclawForMCP func() error
releaseHID func() error
onControlChange func(controlmode.Status)
}
func NewService(control *controlmode.Manager, releaseHID func() error) *Service {
return NewServiceWithPreempt(control, nil, nil, releaseHID, nil)
}
func NewServiceWithPreempt(
control *controlmode.Manager,
preemptPicoclawLeases func() error,
stopPicoclawForMCP func() error,
releaseHID func() error,
onControlChange func(controlmode.Status),
) *Service {
if control == nil {
control = controlmode.GetManager()
}
if releaseHID == nil {
releaseHID = hid.ReleaseAllHIDState
}
return &Service{
control: control,
preemptPicoclawLeases: preemptPicoclawLeases,
stopPicoclawForMCP: stopPicoclawForMCP,
releaseHID: releaseHID,
onControlChange: onControlChange,
}
}
func (s *Service) GetConfig(c *gin.Context) {
var rsp proto.Response
c.Header("Cache-Control", "no-store")
cfg, err := loadConfig()
if err != nil {
log.Errorf("failed to load MCP config: %v", err)
rsp.ErrRsp(c, -1, "get MCP config failed")
return
}
status, err := s.control.Status()
if err != nil {
log.Errorf("failed to load AI control mode: %v", err)
rsp.ErrRsp(c, -1, "get MCP config failed")
return
}
rsp.OkRspWithData(c, mcpConfigResponse(cfg, status))
}
func (s *Service) SetConfig(c *gin.Context) {
var req proto.SetMCPConfigReq
var rsp proto.Response
c.Header("Cache-Control", "no-store")
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
var cfg Config
var err error
cancelMCP := func() error {
inputcontrol.GetCoordinator().CancelMCP()
return nil
}
preemptForEnable := func() error {
if err := cancelMCP(); err != nil {
return err
}
if s.preemptPicoclawLeases != nil {
return s.preemptPicoclawLeases()
}
return nil
}
cleanupForEnable := func() error {
var errs []error
if s.stopPicoclawForMCP != nil {
errs = append(errs, s.stopPicoclawForMCP())
}
if s.releaseHID != nil {
errs = append(errs, s.releaseHID())
}
return errors.Join(errs...)
}
if *req.Enabled {
cfg, err = updateConfig(ensureAPIKey)
if err == nil {
err = s.control.SwitchWithCleanup(
controlmode.ModeMCP,
preemptForEnable,
cleanupForEnable,
)
}
} else {
cfg, err = loadConfig()
if err == nil {
_, err = s.control.SwitchIfWithCleanup(
controlmode.ModeMCP,
controlmode.ModeOff,
cancelMCP,
s.releaseHID,
)
}
}
if err != nil {
log.Errorf("failed to switch MCP control mode: %v", err)
rsp.ErrRsp(c, -2, "operation failed: "+err.Error())
return
}
status, err := s.control.Status()
if err != nil {
log.Errorf("failed to load AI control mode: %v", err)
rsp.ErrRsp(c, -2, "operation failed: "+err.Error())
return
}
if s.onControlChange != nil {
s.onControlChange(status)
}
rsp.OkRspWithData(c, mcpConfigResponse(cfg, status))
}
func (s *Service) RegenerateAPIKey(c *gin.Context) {
var rsp proto.Response
c.Header("Cache-Control", "no-store")
cfg, err := updateConfig(regenerateAPIKey)
if err != nil {
log.Errorf("failed to regenerate MCP API key: %v", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
status, err := s.control.Status()
if err != nil {
log.Errorf("failed to load AI control mode: %v", err)
rsp.ErrRsp(c, -1, "operation failed")
return
}
rsp.OkRspWithData(c, mcpConfigResponse(cfg, status))
}
func mcpConfigResponse(cfg Config, status controlmode.Status) *proto.GetMCPConfigRsp {
return &proto.GetMCPConfigRsp{
Enabled: status.Mode == controlmode.ModeMCP && !status.Transitioning,
APIKey: cfg.APIKey,
ControlMode: string(status.Mode),
Transitioning: status.Transitioning,
}
}

View File

@@ -2,6 +2,7 @@ package picoclaw
import (
"bytes"
"context"
"encoding/json"
"math"
"strings"
@@ -20,6 +21,16 @@ const (
)
func (s *Service) Actions(c *gin.Context) {
releaseMode, modeErr := s.acquireControlMode()
if modeErr != nil {
writePicoclawError(c, modeErr)
return
}
defer releaseMode()
operationCtx, releaseOperation := s.beginControlOperation(c.Request.Context())
defer releaseOperation()
sessionID, sessionErr := s.requireSessionID(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
@@ -41,7 +52,7 @@ func (s *Service) Actions(c *gin.Context) {
return
}
result, execErr := s.executeActions(sessionID, actions)
result, execErr := s.executeActions(operationCtx, sessionID, actions)
if execErr != nil {
writePicoclawError(c, execErr)
return
@@ -74,7 +85,7 @@ func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) {
return []Action{action}, nil
}
func (s *Service) executeActions(sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
func (s *Service) executeActions(ctx context.Context, sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
startedAt := time.Now()
if len(actions) == 0 {
return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions")
@@ -88,12 +99,16 @@ func (s *Service) executeActions(sessionID string, actions []Action) (result Act
totalWrites := 0
for idx, action := range actions {
if contextErr := controlOperationError(ctx); contextErr != nil {
contextErr.Index = &idx
return ActionResult{}, contextErr
}
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
lockErr.Index = &idx
return ActionResult{}, lockErr
}
writes, execErr := s.executeAction(action)
writes, execErr := s.executeAction(ctx, action)
if execErr != nil {
execErr.Index = &idx
return ActionResult{}, execErr
@@ -114,7 +129,11 @@ func (s *Service) executeActions(sessionID string, actions []Action) (result Act
return result, nil
}
func (s *Service) executeAction(action Action) (int, *PicoclawError) {
func (s *Service) executeAction(ctx context.Context, action Action) (int, *PicoclawError) {
if err := controlOperationError(ctx); err != nil {
return 0, err
}
switch strings.ToLower(strings.TrimSpace(action.Action)) {
case "click":
x, y, err := normalizedPoint(action.X, action.Y)
@@ -129,7 +148,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
writes := 0
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
writes += s.sendMousePress(x, y, button)
time.Sleep(defaultClickHold)
if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil {
return writes, waitErr
}
writes += s.sendMouseRelease(x, y)
return writes, nil
@@ -144,7 +165,12 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
if action.DurationMs < 0 {
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 0")
}
time.Sleep(time.Duration(action.DurationMs) * time.Millisecond)
if action.DurationMs > maxWaitDurationMS {
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be <= 30000 milliseconds")
}
if waitErr := waitForControlOperation(ctx, time.Duration(action.DurationMs)*time.Millisecond); waitErr != nil {
return 0, waitErr
}
return 0, nil
case "drag":
@@ -165,6 +191,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0)
writes += s.sendMousePress(fromX, fromY, button)
for step := 1; step <= defaultDragSteps; step++ {
if contextErr := controlOperationError(ctx); contextErr != nil {
return writes, contextErr
}
ratio := float64(step) / float64(defaultDragSteps)
x := fromX + (toX-fromX)*ratio
y := fromY + (toY-fromY)*ratio
@@ -203,9 +232,14 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
writes := 0
for range amount {
if contextErr := controlOperationError(ctx); contextErr != nil {
return writes, contextErr
}
writes += s.sendMouseMoveWithButton(x, y, 0x00, wheel)
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
time.Sleep(defaultScrollStep)
if waitErr := waitForControlOperation(ctx, defaultScrollStep); waitErr != nil {
return writes, waitErr
}
}
return writes, nil
@@ -216,6 +250,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
charMap := hid.GetCharMap("")
writes := 0
for _, char := range action.Text {
if contextErr := controlOperationError(ctx); contextErr != nil {
return writes, contextErr
}
key, ok := charMap[char]
if !ok {
return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action")
@@ -223,7 +260,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
writes += s.sendKeyboardReport([]byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00})
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
time.Sleep(defaultKeyDelay)
if waitErr := waitForControlOperation(ctx, defaultKeyDelay); waitErr != nil {
return writes, waitErr
}
}
return writes, nil
@@ -234,7 +273,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
}
writes := 0
writes += s.sendKeyboardReport(report)
time.Sleep(defaultClickHold)
if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil {
return writes, waitErr
}
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
return writes, nil
}

View File

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

View File

@@ -0,0 +1,145 @@
package picoclaw
import (
"context"
"encoding/json"
"strings"
"time"
"NanoKVM-Server/service/vm"
)
const defaultTaskCaptureLeaseDuration = 2 * time.Minute
const maxTaskCaptureLeaseDuration = 30 * time.Minute
func (s *Service) acquireCaptureLease(ctx context.Context) (func(), func() bool, error) {
if s != nil && s.acquireHDMIForRead != nil {
return s.acquireHDMIForRead(ctx)
}
return vm.AcquireHdmiCaptureLeaseForRead(ctx)
}
func (s *Service) activateTaskCaptureLease(sessionID string, taskID string, duration time.Duration) {
if s == nil || sessionID == "" {
return
}
if duration <= 0 {
duration = defaultTaskCaptureLeaseDuration
}
if duration > maxTaskCaptureLeaseDuration {
duration = maxTaskCaptureLeaseDuration
}
key := taskCaptureLeaseKey(sessionID, taskID)
s.captureLeaseMu.Lock()
defer s.captureLeaseMu.Unlock()
if s.captureLeases == nil {
s.captureLeases = make(map[string]func())
}
if s.captureLeaseTimers == nil {
s.captureLeaseTimers = make(map[string]*time.Timer)
}
if s.captureLeases[key] == nil {
if s.acquireHDMILease != nil {
s.captureLeases[key] = s.acquireHDMILease()
} else {
s.captureLeases[key] = vm.AcquireHdmiCaptureLease()
}
}
if timer := s.captureLeaseTimers[key]; timer != nil {
timer.Stop()
}
s.captureLeaseTimers[key] = time.AfterFunc(duration, func() {
s.releaseCaptureLease(key)
})
}
func (s *Service) updateTaskCaptureLease(source string, sessionID string, data []byte) {
if s == nil || sessionID == "" || len(data) == 0 {
return
}
var message struct {
ID string `json:"id"`
Type string `json:"type"`
Payload struct {
MaxRuntimeMS int `json:"max_runtime_ms"`
} `json:"payload"`
}
if err := json.Unmarshal(data, &message); err != nil {
return
}
switch {
case source == "downstream" && message.Type == "message.send":
duration := defaultTaskCaptureLeaseDuration
if message.Payload.MaxRuntimeMS > 0 {
maxMilliseconds := int(maxTaskCaptureLeaseDuration / time.Millisecond)
duration = time.Duration(min(message.Payload.MaxRuntimeMS, maxMilliseconds)) * time.Millisecond
}
s.activateTaskCaptureLease(sessionID, message.ID, duration)
case source == "downstream" && message.Type == "message.cancel":
if message.ID == "" {
s.releaseCaptureLeasesForSession(sessionID)
return
}
s.releaseTaskCaptureLease(sessionID, message.ID)
case source == "upstream" && (message.Type == "typing.stop" || message.Type == "message.create" || message.Type == "message.update" || message.Type == "error"):
if message.ID == "" {
s.releaseCaptureLeasesForSession(sessionID)
return
}
s.releaseTaskCaptureLease(sessionID, message.ID)
}
}
func taskCaptureLeaseKey(sessionID string, taskID string) string {
if taskID == "" {
taskID = "default"
}
return "task:" + sessionID + ":" + taskID
}
func (s *Service) releaseTaskCaptureLease(sessionID string, taskID string) {
if sessionID == "" {
return
}
s.releaseCaptureLease(taskCaptureLeaseKey(sessionID, taskID))
}
func (s *Service) releaseCaptureLeasesForSession(sessionID string) {
if s == nil || sessionID == "" {
return
}
prefix := "task:" + sessionID + ":"
s.captureLeaseMu.Lock()
keys := make([]string, 0, len(s.captureLeases))
for key := range s.captureLeases {
if strings.HasPrefix(key, prefix) {
keys = append(keys, key)
}
}
s.captureLeaseMu.Unlock()
for _, key := range keys {
s.releaseCaptureLease(key)
}
}
func (s *Service) releaseCaptureLease(key string) {
if s == nil || key == "" {
return
}
s.captureLeaseMu.Lock()
release := s.captureLeases[key]
delete(s.captureLeases, key)
if timer := s.captureLeaseTimers[key]; timer != nil {
timer.Stop()
delete(s.captureLeaseTimers, key)
}
s.captureLeaseMu.Unlock()
if release != nil {
release()
}
}

View File

@@ -1,6 +1,7 @@
package picoclaw
import (
"errors"
"fmt"
"os"
"strings"
@@ -104,6 +105,53 @@ func (s *Service) syncConfigFromPicoclaw() *PicoclawError {
return nil
}
// syncRuntimeConfigMetadataFromPicoclaw refreshes the model metadata exposed
// by RuntimeStatus without changing PicoClaw's config or probing its gateway.
// This is used while PicoClaw does not own the control mode.
func (s *Service) syncRuntimeConfigMetadataFromPicoclaw() *PicoclawError {
doc, err := loadPicoclawConfigDocument()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
s.runtime.Update(func(status *RuntimeStatus) {
status.ModelConfigured = false
status.ModelName = ""
status.ConfigError = ""
if status.Status == "config_error" {
status.Status = "checking"
status.LastError = ""
}
status.CheckedAt = time.Now()
})
return nil
}
s.runtime.Update(func(status *RuntimeStatus) {
status.ModelConfigured = false
status.ModelName = ""
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
return newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
modelName := resolvePicoclawTargetModelName(doc.config)
modelConfigured := isPicoclawModelConfigured(doc.config, doc.security, modelName)
s.runtime.Update(func(status *RuntimeStatus) {
status.ModelConfigured = modelConfigured
status.ModelName = modelName
status.ConfigError = ""
if status.Status == "config_error" || (modelConfigured && status.Status == "model_not_configured") {
status.Status = "checking"
status.LastError = ""
}
status.CheckedAt = time.Now()
})
return nil
}
type picoclawGatewaySettings struct {
GatewayURL string
Token string

View File

@@ -3,6 +3,7 @@ package picoclaw
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/user"
@@ -93,19 +94,55 @@ func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) {
}, nil
}
var runPicoclawOnboardForConfig = runPicoclawOnboard
func loadOrInitializePicoclawConfigDocument() (*picoclawConfigDocument, error) {
doc, err := loadPicoclawConfigDocument()
if err == nil {
return doc, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if _, onboardErr := runPicoclawOnboardForConfig(); onboardErr != nil {
return nil, fmt.Errorf("failed to initialize PicoClaw config before saving model config: %w", onboardErr)
}
doc, err = loadPicoclawConfigDocument()
if err != nil {
return nil, fmt.Errorf("failed to load PicoClaw config after initialization: %w", err)
}
return doc, nil
}
func resolvePicoclawConfigPath() (string, error) {
home := os.Getenv("PICOCLAW_HOME")
if home == "" {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err)
}
home = filepath.Join(currentUser.HomeDir, ".picoclaw")
home, err := resolvePicoclawHome()
if err != nil {
return "", err
}
return filepath.Join(home, "config.json"), nil
}
func resolvePicoclawHome() (string, error) {
if home := strings.TrimSpace(os.Getenv("PICOCLAW_HOME")); home != "" {
return home, nil
}
currentUser, err := user.Current()
if err == nil && currentUser.HomeDir != "" {
return filepath.Join(currentUser.HomeDir, ".picoclaw"), nil
}
if home := strings.TrimSpace(os.Getenv("HOME")); home != "" {
return filepath.Join(home, ".picoclaw"), nil
}
if err != nil {
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err)
}
return filepath.Join("/root", ".picoclaw"), nil
}
func expandPicoclawPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {

View File

@@ -0,0 +1,50 @@
package picoclaw
import (
"time"
"NanoKVM-Server/service/controlmode"
)
func (s *Service) PublishControlModeChanged(status controlmode.Status) {
s.PublishControlModeChangedFrom(status, "")
}
func (s *Service) PublishControlModeChangedFrom(status controlmode.Status, source string) {
if s == nil {
return
}
s.ensureDependencies()
payload := controlModeChangedPayload(status, source)
message := picoGatewayMessage{
Type: "control.mode_changed",
Timestamp: time.Now().UnixMilli(),
Payload: payload,
}
cfg := s.config.Get()
for _, session := range GetSessionManager().Snapshot() {
if session == nil || session.State != SessionStateActive || session.Downstream == nil {
continue
}
_ = session.writeDownstreamJSON(cfg, message)
}
}
func controlModeChangedPayload(status controlmode.Status, source string) map[string]any {
payload := map[string]any{
"mode": string(status.Mode),
"transitioning": status.Transitioning,
"can_control": status.Mode == controlmode.ModePicoclaw && !status.Transitioning,
}
if status.LastError != "" {
payload["last_error"] = status.LastError
}
if !status.ChangedAt.IsZero() {
payload["changed_at"] = status.ChangedAt
}
if source != "" {
payload["source"] = source
}
return payload
}

View File

@@ -0,0 +1,50 @@
package picoclaw
import (
"testing"
"time"
"NanoKVM-Server/service/controlmode"
)
func TestControlModeChangedPayloadIncludesControlMetadata(t *testing.T) {
changedAt := time.Now().UTC()
payload := controlModeChangedPayload(controlmode.Status{
Mode: controlmode.ModeMCP,
Transitioning: true,
LastError: "switch failed",
ChangedAt: changedAt,
}, "mcp_config")
if payload["mode"] != string(controlmode.ModeMCP) {
t.Fatalf("mode = %v, want %q", payload["mode"], controlmode.ModeMCP)
}
if payload["transitioning"] != true {
t.Fatalf("transitioning = %v, want true", payload["transitioning"])
}
if payload["can_control"] != false {
t.Fatalf("can_control = %v, want false", payload["can_control"])
}
if payload["last_error"] != "switch failed" {
t.Fatalf("last_error = %v, want switch failed", payload["last_error"])
}
if payload["changed_at"] != changedAt {
t.Fatalf("changed_at = %v, want %v", payload["changed_at"], changedAt)
}
if payload["source"] != "mcp_config" {
t.Fatalf("source = %v, want mcp_config", payload["source"])
}
}
func TestControlModeChangedPayloadAllowsPicoclawControlWhenStable(t *testing.T) {
payload := controlModeChangedPayload(controlmode.Status{
Mode: controlmode.ModePicoclaw,
}, "")
if payload["can_control"] != true {
t.Fatalf("can_control = %v, want true", payload["can_control"])
}
if _, ok := payload["source"]; ok {
t.Fatalf("source = %v, want omitted", payload["source"])
}
}

View File

@@ -0,0 +1,267 @@
package picoclaw
import (
"errors"
"fmt"
"time"
log "github.com/sirupsen/logrus"
)
func (s *Service) PreemptForMCP() error {
if err := s.PreemptControlLeasesForMCP(); err != nil {
return err
}
return s.StopRuntimeForMCP()
}
func (s *Service) PreemptControlLeasesForMCP() error {
startedAt := time.Now()
if s == nil {
return fmt.Errorf("picoclaw service is unavailable")
}
s.ensureDependencies()
activeOperations := s.CancelActiveControlOperations()
closedSessions := s.ReleaseControlSessions(
CloseCodeControlModeSwitched,
"external MCP enabled",
)
log.WithFields(log.Fields{
"active_operations": activeOperations,
"closed_sessions": closedSessions,
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).Info("picoclaw control leases preempted for MCP")
return nil
}
func (s *Service) StopRuntimeForMCP() error {
startedAt := time.Now()
if s == nil {
return fmt.Errorf("picoclaw service is unavailable")
}
s.ensureDependencies()
currentStatus := s.runtime.Get()
statusRequiresStop :=
currentStatus.Ready ||
currentStatus.Status == "ready" ||
isRuntimeLifecycleStatusPending(currentStatus)
running, runningErr := isRuntimeRunning()
if runningErr != nil {
if !statusRequiresStop {
log.WithFields(log.Fields{
"status": currentStatus.Status,
"ready": currentStatus.Ready,
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).WithError(runningErr).Warn("skipping PicoClaw runtime stop for MCP because runtime is not active")
s.setRuntimeIntentDesired(false, "mcp_preempt")
return nil
}
return fmt.Errorf("check PicoClaw runtime for MCP: %w", runningErr)
}
if !running && !statusRequiresStop {
s.setRuntimeIntentDesired(false, "mcp_preempt")
log.WithFields(log.Fields{
"status": currentStatus.Status,
"ready": currentStatus.Ready,
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).Info("PicoClaw runtime stop skipped for MCP because runtime is not active")
return nil
}
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
status.Status = "stopping"
status.LastError = ""
status.CheckedAt = time.Now()
})
if err := s.stopRuntimeAndVerify(false); err != nil {
log.WithFields(log.Fields{
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).WithError(err).Warn("picoclaw stop runtime for MCP failed")
return fmt.Errorf("stop PicoClaw runtime for MCP: %w", err)
}
s.setRuntimeIntentDesired(false, "mcp_preempt")
log.WithFields(log.Fields{
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).Info("picoclaw runtime stopped for MCP")
return nil
}
func (s *Service) CompleteControlRelease(source string, closeCode int, closeReason string) int {
if s == nil {
return 0
}
s.ensureDependencies()
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
s.CancelActiveControlOperations()
s.setRuntimeIntentDesired(false, source)
return s.ReleaseControlSessions(closeCode, closeReason)
}
func (s *Service) PreserveRuntimeForChatOnly(source string) {
if s == nil {
return
}
s.ensureDependencies()
status := s.runtime.Get()
if !status.Ready && len(GetSessionManager().Snapshot()) == 0 {
return
}
s.setRuntimeIntentDesired(true, source)
}
func (s *Service) RuntimeStatus() RuntimeStatus {
if s == nil {
return RuntimeStatus{
Ready: false,
Installed: false,
InstallPath: picoclawBinaryPath,
Status: "unavailable",
}
}
return s.runtimeStatus()
}
func (s *Service) stopForRuntimeStop() error {
s.ensureDependencies()
return s.stopRuntimeAndCloseSessions(
CloseCodeRuntimeStopped,
"PicoClaw runtime stopped",
)
}
func (s *Service) stopRuntimeAndCloseSessions(closeCode int, closeReason string) error {
if s == nil {
return nil
}
s.ensureDependencies()
startedAt := time.Now()
sessions := GetSessionManager().Snapshot()
for _, session := range sessions {
s.closeGatewaySession(session, closeCode, closeReason)
}
closeElapsed := time.Since(startedAt)
if s.lock != nil {
s.lock.Release("")
}
stopStartedAt := time.Now()
if err := s.stopRuntimeAndVerify(false); err != nil {
log.WithFields(log.Fields{
"session_count": len(sessions),
"close_code": closeCode,
"close_sessions_ms": closeElapsed.Milliseconds(),
"stop_runtime_ms": time.Since(stopStartedAt).Milliseconds(),
"total_ms": time.Since(startedAt).Milliseconds(),
}).WithError(err).Warn("picoclaw stop runtime and close sessions failed")
return err
}
log.WithFields(log.Fields{
"session_count": len(sessions),
"close_code": closeCode,
"close_sessions_ms": closeElapsed.Milliseconds(),
"stop_runtime_ms": time.Since(stopStartedAt).Milliseconds(),
"total_ms": time.Since(startedAt).Milliseconds(),
}).Info("picoclaw runtime stopped and gateway sessions closed")
return nil
}
func (s *Service) ReleaseControlSessions(closeCode int, closeReason string) int {
if s == nil {
return 0
}
s.ensureDependencies()
startedAt := time.Now()
sessions := GetSessionManager().Snapshot()
for _, session := range sessions {
s.closeGatewaySession(session, closeCode, closeReason)
}
if s.lock != nil {
s.lock.Release("")
}
log.WithFields(log.Fields{
"session_count": len(sessions),
"close_code": closeCode,
"elapsed_ms": time.Since(startedAt).Milliseconds(),
}).Info("picoclaw gateway sessions closed for control release")
return len(sessions)
}
func (s *Service) stopRuntimeAndVerify(forceStop bool) error {
if s == nil {
return fmt.Errorf("picoclaw service is unavailable")
}
s.ensureDependencies()
err := stopRuntimeProcessAndVerify(
forceStop,
isRuntimeRunning,
func() error {
_, _, stopErr := s.stopRuntime()
return stopErr
},
picoclawStopTimeout,
100*time.Millisecond,
)
if err != nil {
return err
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "stopped"
status.CurrentSession = ""
status.CheckedAt = time.Now()
})
return nil
}
func stopRuntimeProcessAndVerify(
forceStop bool,
isRunning func() (bool, error),
stop func() error,
timeout time.Duration,
pollInterval time.Duration,
) error {
if !forceStop {
running, err := isRunning()
if err != nil {
return fmt.Errorf("check PicoClaw runtime: %w", err)
}
if !running {
return nil
}
}
var stopErr error
if err := stop(); err != nil {
stopErr = fmt.Errorf("stop PicoClaw runtime: %w", err)
}
deadline := time.Now().Add(timeout)
for {
running, err := isRunning()
if err != nil {
return errors.Join(stopErr, fmt.Errorf("verify PicoClaw stopped: %w", err))
}
if !running {
if stopErr != nil {
log.Warnf("PicoClaw stop command returned an error after the runtime stopped: %v", stopErr)
}
return nil
}
if !time.Now().Before(deadline) {
return errors.Join(stopErr, fmt.Errorf("PicoClaw runtime is still running"))
}
time.Sleep(pollInterval)
}
}

View File

@@ -0,0 +1,180 @@
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) beginRuntimeLifecycleOperation(parent context.Context) (context.Context, func()) {
if parent == nil {
parent = context.Background()
}
if s == nil || s.operations == nil {
ctx, cancel := context.WithCancelCause(parent)
return ctx, func() { cancel(context.Canceled) }
}
return s.operations.begin(parent)
}
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 runtimeLifecycleOperationError(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 runtime lifecycle canceled because the control mode is switching")
case errors.Is(cause, context.DeadlineExceeded):
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle timed out")
default:
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle was canceled")
}
}
func controlOperationError(ctx context.Context) *PicoclawError {
if ctx == nil || ctx.Err() == nil {
return nil
}
cause := context.Cause(ctx)
switch {
case errors.Is(cause, errControlModeSwitch):
return newPicoclawError(CodeControlModeConflict, "PicoClaw control operation canceled because the control mode is switching")
case errors.Is(cause, errControlTimeout), errors.Is(cause, context.DeadlineExceeded):
return newPicoclawError(CodeInvalidAction, "PicoClaw control operation exceeded the 35 second limit")
default:
return newPicoclawError(CodeInvalidAction, "PicoClaw control operation was canceled")
}
}
func waitForControlOperation(ctx context.Context, delay time.Duration) *PicoclawError {
if ctx == nil {
ctx = context.Background()
}
if err := controlOperationError(ctx); err != nil {
return err
}
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return controlOperationError(ctx)
case <-timer.C:
return nil
}
}

View File

@@ -0,0 +1,143 @@
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 TestControlModeSwitchCancelsRuntimeLifecycle(t *testing.T) {
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
service := &Service{
control: control,
operations: newControlOperationTracker(),
}
operationCtx, releaseOperation := service.beginRuntimeLifecycleOperation(context.Background())
releaseMode, modeErr := service.acquireControlMode()
if modeErr != nil {
t.Fatal(modeErr)
}
lifecycleDone := make(chan *PicoclawError, 1)
go func() {
<-operationCtx.Done()
lifecycleErr := runtimeLifecycleOperationError(operationCtx)
releaseOperation()
releaseMode()
lifecycleDone <- lifecycleErr
}()
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 runtime lifecycle operation")
}
select {
case lifecycleErr := <-lifecycleDone:
if lifecycleErr == nil || lifecycleErr.Code != CodeControlModeConflict {
t.Fatalf("lifecycle error = %+v, want %s", lifecycleErr, CodeControlModeConflict)
}
case <-time.After(time.Second):
t.Fatal("canceled runtime lifecycle operation did not return")
}
if got := control.Current(); got != controlmode.ModeMCP {
t.Fatalf("mode = %q, want %q", got, controlmode.ModeMCP)
}
}
func TestRuntimeReadyWaitHonorsLifecycleCancellation(t *testing.T) {
service := &Service{}
ctx, cancel := context.WithCancelCause(context.Background())
cancel(errControlModeSwitch)
err := service.waitForRuntimeReadyContext(ctx, time.Hour)
if err == nil || err.Code != CodeControlModeConflict {
t.Fatalf("error = %+v, want %s", err, CodeControlModeConflict)
}
}
func TestWaitDurationIsBounded(t *testing.T) {
service := &Service{}
_, err := service.executeAction(context.Background(), Action{
Action: "wait",
DurationMs: maxWaitDurationMS + 1,
})
if err == nil || err.Code != CodeInvalidAction {
t.Fatalf("error = %+v, want %s", err, CodeInvalidAction)
}
}

View File

@@ -7,15 +7,19 @@ import (
)
const (
CodePicoclawLockHeld = "AI_LOCK_HELD"
CodeScreenshotFailed = "SCREENSHOT_FAILED"
CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL"
CodeHIDWriteFailed = "HID_WRITE_FAILED"
CodeInvalidAction = "INVALID_ACTION"
CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE"
CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
CodeSessionIDMissing = "SESSION_ID_MISSING"
CodeSessionIDInvalid = "SESSION_ID_INVALID"
CodePicoclawLockHeld = "AI_LOCK_HELD"
CodeScreenshotFailed = "SCREENSHOT_FAILED"
CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL"
CodeHIDWriteFailed = "HID_WRITE_FAILED"
CodeInvalidAction = "INVALID_ACTION"
CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE"
CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
CodeSessionIDMissing = "SESSION_ID_MISSING"
CodeSessionIDInvalid = "SESSION_ID_INVALID"
CodeControlModeConflict = "AI_MODE_CONFLICT"
CodeControlRequired = "CONTROL_REQUIRED"
CodeControlOwnedByMCP = "CONTROL_OWNED_BY_MCP"
CodeControlTransitioning = "CONTROL_TRANSITIONING"
)
type PicoclawError struct {
@@ -47,6 +51,10 @@ func writeSuccess(c *gin.Context, data interface{}) {
}
func writePicoclawError(c *gin.Context, err *PicoclawError) {
writePicoclawErrorWithData(c, err, nil)
}
func writePicoclawErrorWithData(c *gin.Context, err *PicoclawError, data interface{}) {
if err == nil {
return
}
@@ -55,6 +63,9 @@ func writePicoclawError(c *gin.Context, err *PicoclawError) {
"code": err.Code,
"message": err.Message,
}
if data != nil {
payload["data"] = data
}
if err.SessionID != "" {
payload["session_id"] = err.SessionID
}

View File

@@ -10,6 +10,13 @@ import (
"github.com/gorilla/websocket"
)
type gatewayProbeError struct {
status string
configError string
lastError string
message string
}
func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) {
cfg := s.config.Get()
@@ -66,6 +73,69 @@ func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawEr
return upstream, nil
}
func probePicoclawGateway(cfg Config) *gatewayProbeError {
gatewayURL, err := buildGatewayURL(cfg, "runtime-probe")
if err != nil {
return &gatewayProbeError{
status: "config_error",
configError: err.Error(),
lastError: err.Error(),
message: "gateway config is invalid",
}
}
header := http.Header{}
if cfg.Token != "" {
header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Token))
}
timeout := time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
dialer := websocket.Dialer{
HandshakeTimeout: timeout,
NetDialContext: (&net.Dialer{
Timeout: timeout,
}).DialContext,
}
conn, response, err := dialer.Dial(gatewayURL, header)
if err == nil {
_ = conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "runtime probe complete"),
time.Now().Add(time.Second),
)
_ = conn.Close()
return nil
}
probeErr := &gatewayProbeError{
status: "unavailable",
lastError: err.Error(),
message: "gateway websocket is unavailable",
}
if response == nil {
return probeErr
}
switch response.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
probeErr.status = "config_error"
probeErr.configError = "gateway authentication failed"
probeErr.message = "gateway authentication failed"
case http.StatusNotFound:
probeErr.lastError = "gateway pico channel is unavailable"
probeErr.message = "gateway pico channel is unavailable"
default:
probeErr.lastError = fmt.Sprintf("gateway websocket handshake failed: HTTP %d", response.StatusCode)
probeErr.message = "gateway websocket handshake failed"
}
return probeErr
}
func buildGatewayURL(cfg Config, sessionID string) (string, error) {
parsed, err := url.Parse(cfg.GatewayURL)
if err != nil {

View File

@@ -7,6 +7,7 @@ import (
"sync"
"time"
"NanoKVM-Server/service/controlmode"
"NanoKVM-Server/service/stream/mjpeg"
"github.com/gin-gonic/gin"
@@ -30,6 +31,19 @@ type relayResult struct {
}
func (s *Service) ConnectGateway(c *gin.Context) {
s.ensureDependencies()
modeStatus, modeErr := s.control.Status()
if modeErr != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, modeErr.Error()))
return
}
if modeStatus.Transitioning || modeStatus.Mode == controlmode.ModeMCP {
controlErr := s.controlWriteError(controlmode.ModePicoclaw, nil)
controlErr.StatusCode = http.StatusConflict
writePicoclawError(c, controlErr)
return
}
sessionID := strings.TrimSpace(c.Query("session_id"))
if sessionID == "" {
sessionID = uuid.NewString()
@@ -69,7 +83,7 @@ func (s *Service) ConnectGateway(c *gin.Context) {
if err != nil {
log.Errorf("failed to upgrade gateway websocket: %s", err)
_ = upstream.Close()
ReleaseSession(sessionID)
s.releaseGatewaySession(sessionID)
GetSessionManager().SetState(sessionID, SessionStateClosed)
GetSessionManager().Remove(sessionID)
return
@@ -92,7 +106,6 @@ func (s *Service) ConnectGateway(c *gin.Context) {
go s.runPingLoop("upstream", session.SessionID, upstream, cfg, &wg)
go s.proxyMessages("downstream", session, downstream, cfg, &wg, results)
go s.proxyMessages("upstream", session, upstream, cfg, &wg, results)
result := <-results
closeCode := result.CloseCode
if closeCode == 0 {
@@ -142,6 +155,7 @@ func (s *Service) proxyMessages(source string, session *GatewaySession, src *web
}
return
}
s.updateTaskCaptureLease(source, session.SessionID, data)
var writeErr error
switch source {
@@ -202,6 +216,7 @@ func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, re
hadDownstream := session.Downstream != nil
mjpeg.DisableLatestFrameCache()
s.releaseCaptureLeasesForSession(session.SessionID)
GetSessionManager().SetState(session.SessionID, SessionStateClosing)
if session.Upstream != nil {
@@ -217,7 +232,7 @@ func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, re
cleanupPicoclawMediaTempDir()
}
ReleaseSession(session.SessionID)
s.releaseGatewaySession(session.SessionID)
GetSessionManager().SetState(session.SessionID, SessionStateClosed)
GetSessionManager().Remove(session.SessionID)

View File

@@ -28,8 +28,9 @@ type jsonRPCResponse struct {
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// MCP tool definitions
@@ -59,17 +60,22 @@ var mcpToolDefinitions = []map[string]interface{}{
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string"},
"x": map[string]interface{}{"type": "number"},
"y": map[string]interface{}{"type": "number"},
"button": map[string]interface{}{"type": "string"},
"text": map[string]interface{}{"type": "string"},
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"direction": map[string]interface{}{"type": "string"},
"amount": map[string]interface{}{"type": "integer"},
"duration_ms": map[string]interface{}{"type": "integer"},
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
"action": map[string]interface{}{"type": "string"},
"x": map[string]interface{}{"type": "number"},
"y": map[string]interface{}{"type": "number"},
"button": map[string]interface{}{"type": "string"},
"text": map[string]interface{}{"type": "string"},
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"direction": map[string]interface{}{"type": "string"},
"amount": map[string]interface{}{"type": "integer"},
"duration_ms": map[string]interface{}{
"type": "integer",
"minimum": 0,
"maximum": maxWaitDurationMS,
"description": "Wait duration in milliseconds, up to 30000",
},
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
},
"required": []string{"action"},
},
@@ -122,6 +128,33 @@ func (s *Service) MCPHandler(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
func mcpModeConflictResponse(req jsonRPCRequest, err *PicoclawError, status controlStatusForMCP) jsonRPCResponse {
message := "PicoClaw control mode is not active"
reason := CodeControlRequired
if err != nil && err.Message != "" {
message = err.Message
reason = err.Code
}
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{
Code: -32003,
Message: message,
Data: map[string]interface{}{
"reason": reason,
"control_mode": status.Mode,
"transitioning": status.Transitioning,
},
},
}
}
type controlStatusForMCP struct {
Mode string
Transitioning bool
}
func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
@@ -166,6 +199,22 @@ func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCRespon
case "kvm_screenshot":
return s.mcpScreenshot(req, params.Arguments, c)
case "kvm_actions":
releaseMode, modeErr := s.acquireControlMode()
if modeErr != nil {
controlStatus := controlStatusForMCP{}
if status, err := s.control.Status(); err == nil {
controlStatus = controlStatusForMCP{
Mode: string(status.Mode),
Transitioning: status.Transitioning,
}
}
return mcpModeConflictResponse(req, modeErr, controlStatus)
}
defer releaseMode()
operationCtx, releaseOperation := s.beginControlOperation(c.Request.Context())
defer releaseOperation()
c.Request = c.Request.WithContext(operationCtx)
return s.mcpActions(req, params.Arguments, c)
default:
return jsonRPCResponse{
@@ -193,7 +242,7 @@ func (s *Service) mcpScreenshot(req jsonRPCRequest, args json.RawMessage, c *gin
Quality: params.Quality,
}
data, meta, err := s.captureScreenshot(query)
data, meta, err := s.captureScreenshot(c.Request.Context(), query)
if err != nil {
return mcpToolError(req, err.Message)
}
@@ -262,7 +311,7 @@ func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Co
sessionID = s.lock.Owner()
}
result, err := s.executeActions(sessionID, params.Actions)
result, err := s.executeActions(c.Request.Context(), sessionID, params.Actions)
if err != nil {
return mcpToolError(req, err.Message)
}

View File

@@ -0,0 +1,128 @@
package picoclaw
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"NanoKVM-Server/service/controlmode"
"github.com/gin-gonic/gin"
)
func TestMCPBootstrapMethodsAllowedInAllControlModes(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, mode := range []controlmode.Mode{controlmode.ModeOff, controlmode.ModeMCP, controlmode.ModePicoclaw} {
t.Run(string(mode), func(t *testing.T) {
path := filepath.Join(t.TempDir(), "mode")
if err := os.WriteFile(path, []byte(string(mode)+"\n"), 0o600); err != nil {
t.Fatal(err)
}
service := &Service{control: controlmode.NewManager(path, controlmode.ModePicoclaw)}
for _, method := range []string{"initialize", "tools/list", "ping"} {
response := performMCPRequest(service, jsonRPCBody(method))
if response.Code != http.StatusOK {
t.Fatalf("%s status = %d, body = %s", method, response.Code, response.Body.String())
}
if strings.Contains(response.Body.String(), `"error"`) {
t.Fatalf("%s returned error: %s", method, response.Body.String())
}
}
})
}
}
func TestMCPBootstrapAllowedDuringControlTransition(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeOff)
service := &Service{control: manager}
err := manager.Switch(controlmode.ModePicoclaw, func() error {
response := performMCPRequest(service, jsonRPCBody("initialize"))
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if strings.Contains(response.Body.String(), `"error"`) {
t.Fatalf("bootstrap method returned error: %s", response.Body.String())
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
func TestMCPActionsReturnStructuredControlErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, test := range []struct {
mode controlmode.Mode
reason string
}{
{mode: controlmode.ModeOff, reason: CodeControlRequired},
{mode: controlmode.ModeMCP, reason: CodeControlOwnedByMCP},
} {
t.Run(string(test.mode), func(t *testing.T) {
path := filepath.Join(t.TempDir(), "mode")
if err := os.WriteFile(path, []byte(string(test.mode)+"\n"), 0o600); err != nil {
t.Fatal(err)
}
service := &Service{control: controlmode.NewManager(path, controlmode.ModePicoclaw)}
response := performMCPRequest(
service,
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kvm_actions","arguments":{"actions":[]}}}`,
)
body := response.Body.String()
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, body)
}
if !strings.Contains(body, `"code":-32003`) || !strings.Contains(body, `"reason":"`+test.reason+`"`) {
t.Fatalf("body = %s, want structured %s error", body, test.reason)
}
})
}
}
func TestMCPActionsRejectedDuringControlTransition(t *testing.T) {
gin.SetMode(gin.TestMode)
manager := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
service := &Service{control: manager}
err := manager.Switch(controlmode.ModeMCP, func() error {
response := performMCPRequest(
service,
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kvm_actions","arguments":{"actions":[]}}}`,
)
body := response.Body.String()
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, body)
}
if !strings.Contains(body, `"reason":"`+CodeControlTransitioning+`"`) {
t.Fatalf("body = %s, want transitioning control error", body)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
func performMCPRequest(service *Service, body string) *httptest.ResponseRecorder {
router := gin.New()
router.POST("/", service.MCPHandler)
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func jsonRPCBody(method string) string {
return `{"jsonrpc":"2.0","id":1,"method":"` + method + `"}`
}

View File

@@ -2,6 +2,7 @@ package picoclaw
import (
"fmt"
"regexp"
"strings"
"github.com/gin-gonic/gin"
@@ -20,6 +21,66 @@ func extractPicoclawModelName(model string) string {
return model
}
var (
picoclawProviderPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
picoclawKnownProviders = map[string]struct{}{
"anthropic": {},
"azure": {},
"azure_openai": {},
"baichuan": {},
"bedrock": {},
"cerebras": {},
"cohere": {},
"dashscope": {},
"deepseek": {},
"fireworks_ai": {},
"gemini": {},
"google": {},
"groq": {},
"lmstudio": {},
"mistral": {},
"moonshot": {},
"ollama": {},
"openai": {},
"openai_compatible": {},
"openrouter": {},
"qwen": {},
"siliconflow": {},
"together_ai": {},
"vertex_ai": {},
"vllm": {},
"volcengine": {},
"xai": {},
"zhipu": {},
}
)
func validatePicoclawModelIdentifier(model string) (string, error) {
model = strings.TrimSpace(model)
provider, modelRef, ok := strings.Cut(model, "/")
provider = strings.ToLower(strings.TrimSpace(provider))
modelRef = strings.TrimSpace(modelRef)
if !ok || provider == "" || modelRef == "" {
return "", fmt.Errorf("model identifier must use provider/model format")
}
if !picoclawProviderPattern.MatchString(provider) {
return "", fmt.Errorf("model provider %q is invalid", provider)
}
if provider == "openao" {
return "", fmt.Errorf("model provider %q is invalid; did you mean openai?", provider)
}
if _, ok := picoclawKnownProviders[provider]; !ok {
return "", fmt.Errorf("model provider %q is not supported by this PicoClaw integration", provider)
}
modelName := extractPicoclawModelName(model)
if modelName == "" {
return "", fmt.Errorf("model identifier must include a model name")
}
return modelName, nil
}
func isPicoclawModelConfigured(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool {
if modelName == "" {
return false
@@ -71,6 +132,7 @@ type ModelConfigUpdateRequest struct {
}
func (s *Service) UpdateModelConfig(c *gin.Context) {
s.ensureDependencies()
var req ModelConfigUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload"))
@@ -79,6 +141,33 @@ func (s *Service) UpdateModelConfig(c *gin.Context) {
currentStatus := s.runtime.Get()
shouldRestart := currentStatus.Ready || currentStatus.Status == "ready"
var releaseControl func()
if shouldRestart {
// Restart follows StartRuntime's lock order: stable PicoClaw control
// lease first, then the runtime lifecycle lock around config write and
// stop/start.
var controlErr *PicoclawError
releaseControl, controlErr = s.acquireControlMode()
if controlErr != nil {
writePicoclawErrorWithData(c, controlErr, gin.H{"status": s.runtimeStatus()})
return
}
defer releaseControl()
}
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
currentStatus = s.runtime.Get()
if !shouldRestart && (currentStatus.Ready || currentStatus.Status == "ready") {
var controlErr *PicoclawError
releaseControl, controlErr = s.acquireControlMode()
if controlErr != nil {
writePicoclawErrorWithData(c, controlErr, gin.H{"status": s.runtimeStatus()})
return
}
defer releaseControl()
shouldRestart = true
}
modelName, err := updatePicoclawModelConfig(
strings.TrimSpace(req.APIBase),
@@ -103,14 +192,15 @@ func (s *Service) UpdateModelConfig(c *gin.Context) {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+startErr.Message))
return
}
s.setRuntimeIntentDesired(true, "model_config")
} else {
_ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady()
_ = s.ensureRuntimeReadyForLifecycle()
}
writeSuccess(c, gin.H{
"model_name": modelName,
"status": s.runtime.Get(),
"status": s.runtimeStatus(),
})
}
@@ -125,16 +215,18 @@ func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (str
return "", fmt.Errorf("model identifier is required")
}
modelName := extractPicoclawModelName(model)
if modelName == "" {
return "", fmt.Errorf("model identifier is required")
}
doc, err := loadPicoclawConfigDocument()
modelName, err := validatePicoclawModelIdentifier(model)
if err != nil {
return "", err
}
doc, err := loadOrInitializePicoclawConfigDocument()
if err != nil {
return "", err
}
doc.raw["version"] = currentPicoclawConfigVersion
modelListValue, ok := doc.raw["model_list"].([]any)
if !ok {
modelListValue = []any{}

View File

@@ -0,0 +1,170 @@
package picoclaw
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestUpdatePicoclawModelConfigInitializesMissingConfig(t *testing.T) {
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
previousOnboard := runPicoclawOnboardForConfig
onboardCalled := false
runPicoclawOnboardForConfig = func() (string, *PicoclawError) {
onboardCalled = true
configPath := filepath.Join(home, "config.json")
err := os.WriteFile(configPath, []byte(`{
"agents": {
"defaults": {}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"model_list": [],
"channel_list": {}
}`), 0o600)
if err != nil {
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
return "initialized", nil
}
t.Cleanup(func() {
runPicoclawOnboardForConfig = previousOnboard
})
modelName, err := updatePicoclawModelConfig(
"https://api.example.invalid",
"secret-key",
"openai/test-model",
)
if err != nil {
t.Fatal(err)
}
if !onboardCalled {
t.Fatal("missing config did not trigger PicoClaw onboard")
}
if modelName != "test-model" {
t.Fatalf("model name = %q, want test-model", modelName)
}
doc, err := loadPicoclawConfigDocument()
if err != nil {
t.Fatal(err)
}
if doc.config.Agents.Defaults.ModelName != "test-model" {
t.Fatalf("default model = %q, want test-model", doc.config.Agents.Defaults.ModelName)
}
if version, ok := doc.raw["version"].(float64); !ok || int(version) != currentPicoclawConfigVersion {
t.Fatalf("config version = %v, want %d", doc.raw["version"], currentPicoclawConfigVersion)
}
if !isPicoclawModelConfigured(doc.config, doc.security, "test-model") {
t.Fatalf("model was not configured: config=%+v security=%+v", doc.config.ModelList, doc.security.ModelList)
}
if len(doc.config.ModelList) != 1 || doc.config.ModelList[0].APIKey != "" || len(doc.config.ModelList[0].APIKeys) != 0 {
t.Fatalf("model API key leaked into config.json: %+v", doc.config.ModelList)
}
}
func TestUpdatePicoclawModelConfigReportsOnboardFailure(t *testing.T) {
t.Setenv("PICOCLAW_HOME", t.TempDir())
previousOnboard := runPicoclawOnboardForConfig
runPicoclawOnboardForConfig = func() (string, *PicoclawError) {
return "", newPicoclawError(CodeRuntimeUnavailable, "onboard boom")
}
t.Cleanup(func() {
runPicoclawOnboardForConfig = previousOnboard
})
_, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "openai/test-model")
if err == nil {
t.Fatal("expected onboard failure")
}
if !strings.Contains(err.Error(), "failed to initialize PicoClaw config before saving model config") {
t.Fatalf("error = %v, want initialization context", err)
}
}
func TestUpdatePicoclawModelConfigMigratesVersionAndKeepsUnknownFields(t *testing.T) {
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
configPath := filepath.Join(home, "config.json")
if err := os.WriteFile(configPath, []byte(`{
"version": 4,
"unknown_top_level": {
"keep": true
},
"agents": {
"defaults": {
"model_name": "old-model"
}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"model_list": [
{
"model_name": "old-model",
"model": "openai/old-model",
"api_base": "https://api.example.invalid"
}
],
"channel_list": {}
}`), 0o600); err != nil {
t.Fatal(err)
}
modelName, err := updatePicoclawModelConfig(
"https://api.example.invalid",
"secret-key",
"openai/new-model",
)
if err != nil {
t.Fatal(err)
}
if modelName != "new-model" {
t.Fatalf("model name = %q, want new-model", modelName)
}
doc, err := loadPicoclawConfigDocument()
if err != nil {
t.Fatal(err)
}
if version, ok := doc.raw["version"].(float64); !ok || int(version) != currentPicoclawConfigVersion {
t.Fatalf("config version = %v, want %d", doc.raw["version"], currentPicoclawConfigVersion)
}
unknown, ok := doc.raw["unknown_top_level"].(map[string]any)
if !ok || unknown["keep"] != true {
t.Fatalf("unknown fields were not preserved: %#v", doc.raw["unknown_top_level"])
}
}
func TestUpdatePicoclawModelConfigRejectsInvalidProvider(t *testing.T) {
t.Setenv("PICOCLAW_HOME", t.TempDir())
_, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "openao/deepseek-v4-flash")
if err == nil {
t.Fatal("expected invalid provider error")
}
if !strings.Contains(err.Error(), "did you mean openai") {
t.Fatalf("error = %v, want provider hint", err)
}
}
func TestUpdatePicoclawModelConfigRequiresProviderModelFormat(t *testing.T) {
t.Setenv("PICOCLAW_HOME", t.TempDir())
_, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "deepseek-v4-flash")
if err == nil {
t.Fatal("expected provider/model format error")
}
if !strings.Contains(err.Error(), "provider/model") {
t.Fatalf("error = %v, want provider/model format hint", err)
}
}

View File

@@ -7,6 +7,7 @@ import (
const (
picoclawBinaryPath = "/usr/bin/picoclaw"
picoclawCacheDir = "/root/.picoclaw-cache"
picoclawPIDFileName = ".picoclaw.pid"
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/picoclaw_Linux_riscv64.tar.gz"
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/sha512.txt"
etcInitPicoclawScript = "/etc/init.d/S96picoclaw"

View File

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

View File

@@ -1,44 +1,241 @@
package picoclaw
import (
"fmt"
"os"
"path/filepath"
"runtime/debug"
"time"
"NanoKVM-Server/service/controlmode"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
func (s *Service) StartRuntime(c *gin.Context) {
command, output, err := s.startRuntime()
if err != nil {
writePicoclawError(c, err)
defer s.recoverRuntimeHandler(c, "start")
s.ensureDependencies()
log.Info("picoclaw runtime start requested")
// Lock order: hold a stable PicoClaw control lease before entering the
// runtime lifecycle section, so MCP/off transitions wait instead of racing
// the start script and desired-running commit.
releaseControl, controlErr := s.acquireControlMode()
if controlErr != nil {
status := s.runtimeStatus()
log.WithFields(log.Fields{
"code": controlErr.Code,
"runtimeStatus": status.Status,
"ready": status.Ready,
"controlMode": status.ControlMode,
}).Warn("picoclaw runtime start rejected by control mode")
writePicoclawErrorWithData(c, controlErr, gin.H{"status": status})
return
}
defer releaseControl()
operationCtx, releaseOperation := s.beginRuntimeLifecycleOperation(c.Request.Context())
defer releaseOperation()
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
if lifecycleErr := runtimeLifecycleOperationError(operationCtx); lifecycleErr != nil {
writePicoclawErrorWithData(c, lifecycleErr, gin.H{"status": s.runtimeStatus()})
return
}
if currentStatus := s.runtime.Get(); currentStatus.Installing {
runtimeErr := newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
writePicoclawErrorWithData(c, runtimeErr, gin.H{"status": s.runtimeStatus()})
return
}
if readyErr := s.ensureRuntimeReadyForLifecycleContext(operationCtx); 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.startRuntimeContext(operationCtx)
if startErr != nil {
s.markRuntimeLifecycleCanceled(startErr)
s.setRuntimeIntentError(startErr.Message)
status := s.runtimeStatus()
log.WithFields(log.Fields{
"code": startErr.Code,
"runtimeStatus": status.Status,
"ready": status.Ready,
"controlMode": status.ControlMode,
}).Warn("picoclaw runtime start returning structured error")
writePicoclawErrorWithData(c, startErr, gin.H{
"command": command,
"output": output,
"status": status,
})
return
}
if err := s.control.RequireWrite(controlmode.ModePicoclaw); err != nil {
controlErr := s.controlWriteError(controlmode.ModePicoclaw, err)
s.setRuntimeIntentError(controlErr.Message)
status := s.runtimeStatus()
log.WithFields(log.Fields{
"code": controlErr.Code,
"runtimeStatus": status.Status,
"ready": status.Ready,
"controlMode": status.ControlMode,
}).Warn("picoclaw runtime start lost control before intent commit")
writePicoclawErrorWithData(c, controlErr, gin.H{
"command": command,
"output": output,
"status": status,
})
return
}
s.setRuntimeIntentDesired(true, "web")
status := s.runtimeStatus()
log.WithFields(log.Fields{
"runtimeStatus": status.Status,
"ready": status.Ready,
"controlMode": status.ControlMode,
}).Info("picoclaw runtime start returning success")
writeSuccess(c, RuntimeStartResult{
Started: true,
Command: command,
Output: output,
Status: s.runtime.Get(),
Status: status,
})
}
func (s *Service) StopRuntime(c *gin.Context) {
command, output, err := s.stopRuntime()
defer s.recoverRuntimeHandler(c, "stop")
s.ensureDependencies()
log.Info("picoclaw runtime stop requested")
switched, err := s.control.SwitchIfWithCleanup(
controlmode.ModePicoclaw,
controlmode.ModeOff,
func() error {
s.CancelActiveControlOperations()
return nil
},
s.releaseHID,
)
if err != nil {
writePicoclawError(c, err)
status := s.runtimeStatus()
picoclawErr := newPicoclawError(CodeRuntimeStartFailed, err.Error())
log.WithFields(log.Fields{
"code": picoclawErr.Code,
"runtimeStatus": status.Status,
"ready": status.Ready,
"controlMode": status.ControlMode,
}).Warn("picoclaw runtime stop returning structured error")
writePicoclawErrorWithData(c, picoclawErr, gin.H{"status": status})
return
}
if switched {
if status, statusErr := s.control.Status(); statusErr == nil {
s.PublishControlModeChangedFrom(status, "runtime_stop")
}
}
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
status.Status = "stopping"
status.LastError = ""
status.CheckedAt = time.Now()
})
s.setRuntimeIntentDesired(false, "web")
err = s.stopForRuntimeStop()
if err != nil {
s.setRuntimeIntentError(err.Error())
status := s.runtimeStatus()
picoclawErr := newPicoclawError(CodeRuntimeStartFailed, err.Error())
log.WithFields(log.Fields{
"code": picoclawErr.Code,
"runtimeStatus": status.Status,
"ready": status.Ready,
"controlMode": status.ControlMode,
}).Warn("picoclaw runtime stop returning structured error")
writePicoclawErrorWithData(c, picoclawErr, gin.H{"status": status})
return
}
status := s.runtimeStatus()
log.WithFields(log.Fields{
"runtimeStatus": status.Status,
"ready": status.Ready,
"controlMode": status.ControlMode,
}).Info("picoclaw runtime stop returning success")
writeSuccess(c, RuntimeStartResult{
Started: false,
Command: command,
Output: output,
Status: s.runtime.Get(),
Status: status,
})
}
func (s *Service) recoverRuntimeHandler(c *gin.Context, operation string) {
if recovered := recover(); recovered != nil {
message := fmt.Sprintf("picoclaw runtime %s panicked: %v", operation, recovered)
log.Errorf("%s\n%s", message, debug.Stack())
if c.Writer.Written() {
return
}
writePicoclawErrorWithData(
c,
newPicoclawError(CodeRuntimeStartFailed, message),
gin.H{"status": s.safeRuntimeStatusForRecovery()},
)
}
}
func (s *Service) safeRuntimeStatusForRecovery() (status RuntimeStatus) {
defer func() {
if recover() != nil {
status = RuntimeStatus{
Ready: false,
Installed: false,
InstallPath: picoclawBinaryPath,
Status: "error",
}
}
}()
if s == nil {
return RuntimeStatus{
Ready: false,
Installed: false,
InstallPath: picoclawBinaryPath,
Status: "error",
}
}
return s.runtimeStatus()
}
func (s *Service) InstallRuntime(c *gin.Context) {
s.ensureDependencies()
output, err := s.installRuntime()
if err != nil {
writePicoclawError(c, err)
@@ -51,11 +248,12 @@ func (s *Service) InstallRuntime(c *gin.Context) {
Binary: picoclawBinaryPath,
Download: picoclawDownloadURL,
Output: output,
Status: currentStatus,
Status: s.runtimeStatus(),
})
}
func (s *Service) UninstallRuntime(c *gin.Context) {
s.ensureDependencies()
currentStatus := s.runtime.Get()
uninstallOutput := "picoclaw uninstalled successfully"
if currentStatus.Installing {
@@ -63,17 +261,53 @@ func (s *Service) UninstallRuntime(c *gin.Context) {
return
}
switched, switchErr := s.control.SwitchIfWithCleanup(
controlmode.ModePicoclaw,
controlmode.ModeOff,
func() error {
s.CancelActiveControlOperations()
return nil
},
s.releaseHID,
)
if switchErr != nil {
status := s.runtimeStatus()
writePicoclawErrorWithData(c, newPicoclawError(CodeRuntimeStartFailed, "control release failed before uninstall: "+switchErr.Error()), gin.H{"status": status})
return
}
if switched {
if status, statusErr := s.control.Status(); statusErr == nil {
s.PublishControlModeChangedFrom(status, "runtime_uninstall")
}
}
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
currentStatus = s.runtime.Get()
if currentStatus.Installing {
writePicoclawError(c, newPicoclawError(CodeRuntimeStartFailed, "cannot uninstall while installation is in progress"))
return
}
running, err := isRuntimeRunning()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status"))
return
}
if running || currentStatus.Ready || currentStatus.Status == "ready" {
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Message + ")"
if running || currentStatus.Ready || currentStatus.Status == "ready" || isRuntimeLifecycleStatusPending(currentStatus) {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
status.Status = "stopping"
status.LastError = ""
status.CheckedAt = time.Now()
})
if stopErr := s.stopRuntimeAndCloseSessions(CloseCodeRuntimeStopped, "PicoClaw runtime stopped"); stopErr != nil {
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Error() + ")"
}
}
s.setRuntimeIntentDesired(false, "web")
if configPath, err := resolvePicoclawConfigPath(); err == nil {
_ = os.RemoveAll(filepath.Dir(configPath))
@@ -105,6 +339,6 @@ func (s *Service) UninstallRuntime(c *gin.Context) {
Binary: picoclawBinaryPath,
Download: picoclawDownloadURL,
Output: uninstallOutput,
Status: s.runtime.Get(),
Status: s.runtimeStatus(),
})
}

View File

@@ -19,6 +19,10 @@ import (
)
func (s *Service) installRuntime() (string, *PicoclawError) {
if s == nil {
return "", newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable")
}
s.ensureDependencies()
log.Debugf("picoclaw install: start, binary=%s, cache=%s", picoclawBinaryPath, picoclawCacheDir)
currentStatus := s.runtime.Get()
@@ -62,6 +66,7 @@ func (s *Service) installRuntime() (string, *PicoclawError) {
}
func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) {
s.ensureDependencies()
defer cancel()
_ = os.RemoveAll(picoclawCacheDir)
@@ -243,6 +248,7 @@ func copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader, total i
}
func (s *Service) setInstallProgress(stage string, progress int, lastError string) {
s.ensureDependencies()
if progress < 0 {
progress = 0
}
@@ -263,6 +269,7 @@ func (s *Service) setInstallProgress(stage string, progress int, lastError strin
}
func (s *Service) finishInstallFailure(status string, message string) {
s.ensureDependencies()
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,

View File

@@ -0,0 +1,436 @@
package picoclaw
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"NanoKVM-Server/service/controlmode"
log "github.com/sirupsen/logrus"
)
const RuntimeIntentFile = "/etc/kvm/picoclaw-runtime.json"
func NewRuntimeIntentStore(path string) *RuntimeIntentStore {
if path == "" {
path = RuntimeIntentFile
}
return &RuntimeIntentStore{path: path}
}
func (s *RuntimeIntentStore) Load() (RuntimeIntentStatus, error) {
if s == nil {
return RuntimeIntentStatus{}, nil
}
s.mu.Lock()
defer s.mu.Unlock()
return s.loadLocked()
}
func (s *RuntimeIntentStore) SetDesiredRunning(desired bool, updatedBy string) error {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
intent, err := s.loadLocked()
if err != nil {
intent = RuntimeIntentStatus{
DesiredRunning: false,
LastError: err.Error(),
}
}
now := time.Now().UTC().Format(time.RFC3339)
intent.DesiredRunning = desired
intent.UpdatedAt = now
intent.UpdatedBy = updatedBy
intent.LastError = ""
if desired {
intent.LastStartedAt = now
} else {
intent.LastStoppedAt = now
}
return s.saveLocked(intent)
}
func (s *RuntimeIntentStore) SetLastError(message string) error {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
intent, err := s.loadLocked()
if err != nil {
intent = RuntimeIntentStatus{
DesiredRunning: false,
}
}
intent.LastError = message
intent.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
if intent.UpdatedBy == "" {
intent.UpdatedBy = "system"
}
return s.saveLocked(intent)
}
func (s *RuntimeIntentStore) loadLocked() (RuntimeIntentStatus, error) {
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return RuntimeIntentStatus{DesiredRunning: false}, nil
}
return RuntimeIntentStatus{DesiredRunning: false}, fmt.Errorf("read PicoClaw runtime intent: %w", err)
}
var intent RuntimeIntentStatus
if err := json.Unmarshal(data, &intent); err != nil {
return RuntimeIntentStatus{
DesiredRunning: false,
LastError: err.Error(),
}, fmt.Errorf("parse PicoClaw runtime intent: %w", err)
}
return intent, nil
}
func (s *RuntimeIntentStore) saveLocked(intent RuntimeIntentStatus) error {
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create PicoClaw runtime intent directory: %w", err)
}
data, err := json.MarshalIndent(intent, "", " ")
if err != nil {
return fmt.Errorf("encode PicoClaw runtime intent: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(dir, ".picoclaw-runtime.*")
if err != nil {
return fmt.Errorf("create temporary PicoClaw runtime intent: %w", err)
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return fmt.Errorf("set PicoClaw runtime intent permissions: %w", err)
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write PicoClaw runtime intent: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync PicoClaw runtime intent: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close PicoClaw runtime intent: %w", err)
}
if err := os.Rename(tmpPath, s.path); err != nil {
return fmt.Errorf("replace PicoClaw runtime intent: %w", err)
}
directory, err := os.Open(dir)
if err == nil {
if syncErr := directory.Sync(); syncErr != nil {
_ = directory.Close()
return fmt.Errorf("sync PicoClaw runtime intent directory: %w", syncErr)
}
_ = directory.Close()
}
return nil
}
func (s *Service) setRuntimeIntentDesired(desired bool, updatedBy string) {
if s == nil {
return
}
s.ensureDependencies()
if err := s.runtimeIntent.SetDesiredRunning(desired, updatedBy); err != nil {
log.WithFields(log.Fields{
"desired_running": desired,
"updated_by": updatedBy,
}).WithError(err).Warn("failed to persist PicoClaw runtime intent")
}
}
func (s *Service) SetRuntimeIntentDesired(desired bool, updatedBy string) {
s.setRuntimeIntentDesired(desired, updatedBy)
}
func (s *Service) setRuntimeIntentError(message string) {
if s == nil || message == "" {
return
}
s.ensureDependencies()
if err := s.runtimeIntent.SetLastError(message); err != nil {
log.WithError(err).Warn("failed to persist PicoClaw runtime intent error")
}
}
func (s *Service) startRuntimeIntentReconcile() {
if s == nil {
return
}
s.ensureDependencies()
s.reconcileOnce.Do(func() {
go s.reconcileRuntimeIntent("startup")
})
}
func (s *Service) reconcileRuntimeIntent(source string) {
startedAt := time.Now()
s.ensureDependencies()
unlockLifecycle := s.lockRuntimeLifecycle()
defer unlockLifecycle()
intent, intentErr := s.runtimeIntent.Load()
if intentErr != nil {
s.setRuntimeIntentError(intentErr.Error())
log.WithError(intentErr).Warn("PicoClaw runtime restore skipped because intent is invalid")
return
}
modeStatus, modeErr := s.control.Status()
if modeErr != nil {
s.setRuntimeIntentError(modeErr.Error())
log.WithError(modeErr).Warn("PicoClaw runtime restore skipped because control mode is unavailable")
return
}
fields := log.Fields{
"source": source,
"desired_running": intent.DesiredRunning,
"control_mode": string(modeStatus.Mode),
}
if !intent.DesiredRunning {
s.reconcileDisabledRuntimeIntent(source)
log.WithFields(fields).Info("PicoClaw runtime restore skipped because desired_running is false")
return
}
if modeStatus.Mode == controlmode.ModeMCP {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "blocked_by_mcp"
status.CheckedAt = time.Now()
})
log.WithFields(fields).Info("PicoClaw runtime restore skipped because MCP owns device control")
return
}
if modeStatus.Mode != controlmode.ModePicoclaw {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "stopped"
status.CheckedAt = time.Now()
})
log.WithFields(fields).Info("PicoClaw runtime restore skipped because PicoClaw does not own device control")
return
}
releaseControl, controlErr := s.acquireControlMode()
if controlErr != nil {
s.setRuntimeIntentError(controlErr.Message)
log.WithFields(fields).Warn("PicoClaw runtime restore skipped because PicoClaw control is not stable")
return
}
defer releaseControl()
intent, intentErr = s.runtimeIntent.Load()
if intentErr != nil {
s.setRuntimeIntentError(intentErr.Error())
log.WithError(intentErr).Warn("PicoClaw runtime restore skipped because intent changed to an invalid state")
return
}
if !intent.DesiredRunning {
fields["desired_running"] = intent.DesiredRunning
log.WithFields(fields).Info("PicoClaw runtime restore skipped because desired_running changed while waiting")
return
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = true
status.Status = "restoring"
status.LastError = ""
status.CheckedAt = time.Now()
})
if readyErr := s.ensureRuntimeReadyForLifecycle(); readyErr == nil {
fields["elapsed_ms"] = time.Since(startedAt).Milliseconds()
s.runtime.Update(func(status *RuntimeStatus) {
status.Restoring = false
status.Status = "ready"
status.CheckedAt = time.Now()
})
log.WithFields(fields).Info("PicoClaw runtime restore found an already-ready runtime")
return
}
currentStatus := s.runtime.Get()
switch currentStatus.Status {
case "not_installed", "model_not_configured", "config_error":
message := currentStatus.LastError
if message == "" {
message = currentStatus.ConfigError
}
if message == "" {
message = "PicoClaw runtime restore prerequisites are not satisfied"
}
s.setRuntimeIntentError(message)
s.runtime.Update(func(status *RuntimeStatus) {
status.Restoring = false
status.LastError = message
status.CheckedAt = time.Now()
})
fields["elapsed_ms"] = time.Since(startedAt).Milliseconds()
fields["runtime_status"] = currentStatus.Status
log.WithFields(fields).Warn("PicoClaw runtime restore skipped because prerequisites are not satisfied")
return
}
command, output, startErr := s.startRuntime()
fields["elapsed_ms"] = time.Since(startedAt).Milliseconds()
fields["command"] = command
if output != "" {
fields["output"] = output
}
if startErr != nil {
s.setRuntimeIntentError(startErr.Message)
s.runtime.Update(func(status *RuntimeStatus) {
status.Restoring = false
if status.Status == "restoring" {
status.Status = "unavailable"
}
status.LastError = startErr.Message
status.CheckedAt = time.Now()
})
log.WithFields(fields).WithError(errors.New(startErr.Message)).Warn("PicoClaw runtime restore failed")
return
}
intent, intentErr = s.runtimeIntent.Load()
if intentErr != nil {
s.setRuntimeIntentError(intentErr.Error())
log.WithError(intentErr).Warn("PicoClaw runtime restore result discarded because intent became invalid")
return
}
if !intent.DesiredRunning {
log.WithFields(fields).Info("PicoClaw runtime restore result discarded because desired_running is now false")
return
}
if err := s.control.RequireWrite(controlmode.ModePicoclaw); err != nil {
controlErr := s.controlWriteError(controlmode.ModePicoclaw, err)
s.setRuntimeIntentError(controlErr.Message)
log.WithFields(fields).Warn("PicoClaw runtime restore result discarded because control changed")
return
}
s.setRuntimeIntentDesired(true, "restore")
s.runtime.Update(func(status *RuntimeStatus) {
status.Restoring = false
status.CheckedAt = time.Now()
})
log.WithFields(fields).Info("PicoClaw runtime restored from persisted intent")
}
func (s *Service) reconcileDisabledRuntimeIntent(source string) {
status := s.runtime.Get()
running, err := isRuntimeRunning()
fields := log.Fields{
"source": source,
"runtime_status": status.Status,
"ready": status.Ready,
}
if err != nil {
s.setRuntimeIntentError(err.Error())
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
status.Status = "unavailable"
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
log.WithFields(fields).WithError(err).Warn("PicoClaw disabled runtime intent could not check runtime")
return
}
if !running && !status.Ready && !isRuntimeLifecycleStatusPending(status) {
s.applyDisabledRuntimeIntentStatus()
log.WithFields(fields).Info("PicoClaw disabled runtime intent kept runtime stopped")
return
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
status.Status = "stopping"
status.LastError = ""
status.CheckedAt = time.Now()
})
if err := s.stopRuntimeAndCloseSessions(CloseCodeRuntimeStopped, "PicoClaw runtime disabled"); err != nil {
s.setRuntimeIntentError(err.Error())
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
status.Status = "unavailable"
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
log.WithFields(fields).WithError(err).Warn("PicoClaw disabled runtime intent failed to stop runtime")
return
}
s.applyDisabledRuntimeIntentStatus()
log.WithFields(fields).Info("PicoClaw disabled runtime intent stopped runtime")
}
func (s *Service) applyDisabledRuntimeIntentStatus() {
installed, installedKnown := picoclawInstalledState()
settings, settingsErr := loadPicoclawGatewaySettings()
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
status.CurrentSession = ""
status.CheckedAt = time.Now()
if installedKnown {
status.Installed = installed
}
if installedKnown && !installed {
status.ModelConfigured = false
status.ModelName = ""
status.Status = "not_installed"
status.LastError = ""
return
}
if settingsErr == nil {
status.ModelConfigured = settings.ModelConfigured
status.ModelName = settings.ModelName
if !settings.ModelConfigured {
status.ModelName = settings.TargetModelName
status.Status = "model_not_configured"
return
}
}
status.Status = "stopped"
if status.LastError == "picoclaw runtime is stopped" {
status.LastError = ""
}
})
}

View File

@@ -8,6 +8,7 @@ import (
)
func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
s.ensureDependencies()
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID"))
@@ -16,9 +17,8 @@ func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
if session, ok := GetSessionManager().Get(sessionID); ok {
s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released")
} else {
ReleaseSession(sessionID)
}
s.releaseCaptureLeasesForSession(sessionID)
status := s.runtime.Get()
status.CurrentSession = s.lock.Owner()

View File

@@ -0,0 +1,33 @@
package picoclaw
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"NanoKVM-Server/service/controlmode"
"github.com/gin-gonic/gin"
)
func TestReleaseRuntimeSessionAllowsMCPMode(t *testing.T) {
gin.SetMode(gin.TestMode)
service := &Service{
control: controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP),
}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodDelete, "/api/picoclaw/runtime/session", nil)
c.Request.Header.Set(sessionIDHeader, "stale-session")
service.ReleaseRuntimeSession(c)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
if strings.Contains(recorder.Body.String(), CodeControlModeConflict) {
t.Fatalf("body = %s, did not expect control conflict", recorder.Body.String())
}
}

View File

@@ -3,14 +3,32 @@ package picoclaw
import (
"context"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func (s *Service) startRuntime() (string, string, *PicoclawError) {
return s.startRuntimeContext(context.Background())
}
func (s *Service) startRuntimeContext(ctx context.Context) (string, string, *PicoclawError) {
if ctx == nil {
ctx = context.Background()
}
if s == nil {
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
}
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return "", "", lifecycleErr
}
s.ensureDependencies()
if installed, statErr := isPicoclawInstalled(); statErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
@@ -57,14 +75,27 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
if err := s.detectGatewayPortConflict(); err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "unavailable"
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
command := scriptPath + " start"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
startCtx, cancel := context.WithTimeout(ctx, picoclawStartTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
output, execErr := exec.CommandContext(startCtx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return command, trimmedOutput, lifecycleErr
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
@@ -78,16 +109,61 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
}
time.Sleep(picoclawStartWaitPeriod)
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawStartWaitPeriod); lifecycleErr != nil {
return command, trimmedOutput, lifecycleErr
}
if runtimeErr := s.waitForRuntimeReadyContext(ctx, picoclawStartTimeout); runtimeErr != nil {
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
failureStatus := "unavailable"
if runtimeErr.Code == CodeControlModeConflict {
startErr = runtimeErr
failureStatus = "starting"
} else {
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, nil
}
func (s *Service) markRuntimeLifecycleCanceled(err *PicoclawError) {
if s == nil || err == nil || err.Code != CodeControlModeConflict {
return
}
s.ensureDependencies()
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Restoring = false
if isRuntimeLifecycleStatusPending(*status) {
status.Status = "stopped"
}
status.LastError = err.Message
status.CurrentSession = ""
status.CheckedAt = time.Now()
})
}
func (s *Service) stopRuntime() (string, string, *PicoclawError) {
if s == nil {
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
}
s.ensureDependencies()
settings, _ := loadPicoclawGatewaySettings()
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
@@ -144,11 +220,23 @@ func (s *Service) stopRuntime() (string, string, *PicoclawError) {
}
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
return s.waitForRuntimeReadyContext(context.Background(), timeout)
}
func (s *Service) waitForRuntimeReadyContext(ctx context.Context, timeout time.Duration) *PicoclawError {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(timeout)
var lastErr *PicoclawError
for {
if runtimeErr := s.ensureRuntimeReady(); runtimeErr == nil {
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return lifecycleErr
}
runtimeErr := s.ensureRuntimeReadyForLifecycleContext(ctx)
if runtimeErr == nil {
return nil
} else {
lastErr = runtimeErr
@@ -158,7 +246,9 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
break
}
time.Sleep(picoclawReadyPollPeriod)
if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawReadyPollPeriod); lifecycleErr != nil {
return lifecycleErr
}
}
if lastErr != nil {
@@ -168,28 +258,41 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
}
func resolvePicoclawStartScript() (string, error) {
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
return etcInitPicoclawScript, nil
}
if _, err := os.Stat(kvmappPicoclawScript); err == nil {
return kvmappPicoclawScript, nil
}
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
return etcInitPicoclawScript, nil
}
return "", fmt.Errorf("picoclaw start script not found: %s or %s", etcInitPicoclawScript, kvmappPicoclawScript)
}
func runPicoclawOnboard() (string, *PicoclawError) {
return runPicoclawOnboardContext(context.Background())
}
func runPicoclawOnboardContext(ctx context.Context) (string, *PicoclawError) {
if ctx == nil {
ctx = context.Background()
}
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return "", lifecycleErr
}
command := scriptPath + " onboard"
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout)
onboardCtx, cancel := context.WithTimeout(ctx, picoclawOnboardTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
output, execErr := exec.CommandContext(onboardCtx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
return trimmedOutput, lifecycleErr
}
if trimmedOutput == "" {
trimmedOutput = execErr.Error()
}
@@ -199,6 +302,23 @@ func runPicoclawOnboard() (string, *PicoclawError) {
return trimmedOutput, nil
}
func sleepRuntimeLifecycleContext(ctx context.Context, delay time.Duration) *PicoclawError {
if ctx == nil {
ctx = context.Background()
}
if delay <= 0 {
return runtimeLifecycleOperationError(ctx)
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return runtimeLifecycleOperationError(ctx)
case <-timer.C:
return nil
}
}
func isPicoclawInstalled() (bool, error) {
info, err := os.Stat(picoclawBinaryPath)
if err == nil {
@@ -211,18 +331,162 @@ func isPicoclawInstalled() (bool, error) {
}
func isRuntimeRunning() (bool, error) {
binName := filepath.Base(picoclawBinaryPath)
if binName == "" || binName == "." || binName == string(filepath.Separator) {
return false, fmt.Errorf("invalid picoclaw binary path: %s", picoclawBinaryPath)
pid, err := runtimeGatewayPID()
if err != nil {
return false, err
}
return pid > 0, nil
}
func (s *Service) detectGatewayPortConflict() error {
running, err := isRuntimeRunning()
if err != nil {
return fmt.Errorf("check PicoClaw gateway process: %w", err)
}
if running {
return nil
}
command := exec.Command("pidof", binName)
if err := command.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
settings, err := loadPicoclawGatewaySettings()
if err != nil {
return nil
}
parsed, err := url.Parse(settings.GatewayURL)
if err != nil {
return nil
}
hostPort, err := gatewayHostPort(parsed)
if err != nil {
return nil
}
timeout := 2 * time.Second
if s != nil && s.config != nil {
if configured := time.Duration(s.config.Get().ConnectTimeoutMs) * time.Millisecond; configured > 0 {
timeout = configured
}
}
conn, err := net.DialTimeout("tcp", hostPort, timeout)
if err != nil {
return nil
}
_ = conn.Close()
return fmt.Errorf("gateway port %s is already in use by another process", hostPort)
}
func resolvePicoclawPIDPath() (string, error) {
home, err := resolvePicoclawHome()
if err != nil {
return "", err
}
return filepath.Join(home, picoclawPIDFileName), nil
}
func runtimeGatewayPID() (int, error) {
pidPath, err := resolvePicoclawPIDPath()
if err != nil {
return 0, err
}
pid, err := readRuntimePIDFile(pidPath)
if err == nil {
running, processErr := isPicoclawGatewayProcess(pid)
if processErr != nil {
return 0, processErr
}
if running {
return pid, nil
}
_ = os.Remove(pidPath)
} else if !os.IsNotExist(err) {
_ = os.Remove(pidPath)
}
pid, err = findPicoclawGatewayProcess()
if err != nil {
return 0, err
}
if pid > 0 {
_ = os.MkdirAll(filepath.Dir(pidPath), 0o755)
_ = os.WriteFile(pidPath, []byte(strconv.Itoa(pid)+"\n"), 0o600)
}
return pid, nil
}
func readRuntimePIDFile(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
if parseErr != nil || pid <= 0 {
return 0, fmt.Errorf("invalid PicoClaw pid file: %s", path)
}
return pid, nil
}
func isPicoclawGatewayProcess(pid int) (bool, error) {
if pid <= 0 {
return false, nil
}
cmdlinePath := filepath.Join("/proc", strconv.Itoa(pid), "cmdline")
data, err := os.ReadFile(cmdlinePath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
return true, nil
return isPicoclawGatewayCmdline(data), nil
}
func isPicoclawGatewayCmdline(data []byte) bool {
parts := strings.Split(string(data), "\x00")
args := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
args = append(args, part)
}
}
if len(args) < 2 {
return false
}
binName := filepath.Base(picoclawBinaryPath)
if binName == "" || binName == "." || binName == string(filepath.Separator) {
return false
}
return filepath.Base(args[0]) == binName && args[1] == "gateway"
}
func findPicoclawGatewayProcess() (int, error) {
entries, err := os.ReadDir("/proc")
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pid, err := strconv.Atoi(entry.Name())
if err != nil || pid <= 0 {
continue
}
running, err := isPicoclawGatewayProcess(pid)
if err != nil {
continue
}
if running {
return pid, nil
}
}
return 0, nil
}

View File

@@ -0,0 +1,171 @@
package picoclaw
import (
"bytes"
"os"
"path/filepath"
"testing"
"NanoKVM-Server/service/controlmode"
)
func TestSyncRuntimeConfigMetadataFromPicoclawReadsPersistedModelWithoutMutation(t *testing.T) {
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
configData := []byte(`{
"agents": {
"defaults": {
"model_name": "test-model"
}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"model_list": [
{
"model_name": "test-model",
"model": "provider/test-model",
"api_base": "https://example.invalid"
}
],
"channel_list": {
"pico": {
"type": "pico",
"enabled": false,
"settings": {}
}
}
}`)
configPath := filepath.Join(home, "config.json")
if err := os.WriteFile(configPath, configData, 0o600); err != nil {
t.Fatal(err)
}
securityData := []byte("model_list:\n test-model:\n api_keys:\n - test-key\n")
if err := os.WriteFile(filepath.Join(home, ".security.yml"), securityData, 0o600); err != nil {
t.Fatal(err)
}
service := &Service{
runtime: &RuntimeStore{status: RuntimeStatus{
Status: "model_not_configured",
LastError: "stale model error",
}},
}
if syncErr := service.syncRuntimeConfigMetadataFromPicoclaw(); syncErr != nil {
t.Fatal(syncErr)
}
status := service.runtime.Get()
if !status.ModelConfigured || status.ModelName != "test-model" {
t.Fatalf("runtime model metadata = configured:%v name:%q", status.ModelConfigured, status.ModelName)
}
if status.CheckedAt.IsZero() {
t.Fatal("runtime metadata refresh did not update checked_at")
}
if status.Status != "checking" || status.LastError != "" {
t.Fatalf("runtime kept stale model status: %+v", status)
}
after, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, configData) {
t.Fatalf("passive metadata refresh mutated config:\n%s", after)
}
for _, test := range []struct {
mode controlmode.Mode
wantStatus string
}{
{mode: controlmode.ModeOff, wantStatus: "checking"},
{mode: controlmode.ModeMCP, wantStatus: "checking"},
} {
rendered := applyControlModeStatus(status, controlmode.Status{Mode: test.mode})
if !rendered.ModelConfigured || rendered.ModelName != "test-model" {
t.Fatalf("mode %q lost model metadata: %+v", test.mode, rendered)
}
if rendered.Status != test.wantStatus {
t.Fatalf("mode %q status = %q, want %q", test.mode, rendered.Status, test.wantStatus)
}
if rendered.Ready {
t.Fatalf("mode %q unexpectedly changed ready to true", test.mode)
}
if rendered.Capabilities.DeviceWrite {
t.Fatalf("mode %q allows PicoClaw device writes", test.mode)
}
}
ready := status
ready.Ready = true
ready.Installed = true
ready.Status = "ready"
for _, test := range []struct {
name string
status controlmode.Status
wantChat bool
wantReadOnlyTools bool
wantDeviceWrite bool
}{
{
name: "picoclaw",
status: controlmode.Status{Mode: controlmode.ModePicoclaw},
wantChat: true,
wantReadOnlyTools: true,
wantDeviceWrite: true,
},
{name: "mcp", status: controlmode.Status{Mode: controlmode.ModeMCP}},
{
name: "off",
status: controlmode.Status{Mode: controlmode.ModeOff},
wantChat: true,
wantReadOnlyTools: true,
},
{
name: "transitioning",
status: controlmode.Status{Mode: controlmode.ModePicoclaw, Transitioning: true},
},
} {
rendered := applyControlModeStatus(ready, test.status)
if !rendered.Ready || rendered.Status != "ready" {
t.Fatalf("%s changed runtime readiness: %+v", test.name, rendered)
}
if rendered.Capabilities.Chat != test.wantChat ||
rendered.Capabilities.ReadOnlyTools != test.wantReadOnlyTools ||
rendered.Capabilities.DeviceWrite != test.wantDeviceWrite {
t.Fatalf("%s capabilities = %+v, want chat=%v readOnlyTools=%v deviceWrite=%v",
test.name,
rendered.Capabilities,
test.wantChat,
test.wantReadOnlyTools,
test.wantDeviceWrite,
)
}
}
}
func TestSyncRuntimeConfigMetadataFromPicoclawTreatsMissingConfigAsUnconfigured(t *testing.T) {
t.Setenv("PICOCLAW_HOME", t.TempDir())
service := &Service{
runtime: &RuntimeStore{status: RuntimeStatus{
ModelConfigured: true,
ModelName: "stale-model",
Status: "config_error",
ConfigError: "stale config error",
LastError: "stale config error",
}},
}
if syncErr := service.syncRuntimeConfigMetadataFromPicoclaw(); syncErr != nil {
t.Fatal(syncErr)
}
status := service.runtime.Get()
if status.ModelConfigured || status.ModelName != "" {
t.Fatalf("missing config kept stale model metadata: %+v", status)
}
if status.Status == "config_error" || status.ConfigError != "" || status.LastError != "" {
t.Fatalf("missing config reported a parse error: %+v", status)
}
}

Some files were not shown because too many files have changed in this diff Show More