Introduce sk1 connection string format and GUI plugin

Switch connection string encoding from the old base64 blob to a readable sk1:// URL format (host/token[?exp&insecure]) across agent, backend, and frontend. Update encode/decode logic and extensive tests to validate port preservation, insecure/http handling, expiry parsing, and input validation; adjust agent pairing UI to accept the new scheme. Add ServerKit Agent GUI plugin: backend blueprint endpoints for capabilities, frame capture and synthetic desktop, plugin metadata; frontend plugin components (ServerGui, Launcher, SyntheticDesktop), styles, and manifest entry to enable streaming screenshots with a synthetic fallback. Also add Windows service stop waiting to avoid start races after pairing, and document SERVERKIT_PUBLIC_URL in env examples.
This commit is contained in:
Juan Denis committed 2026-05-05 14:03:31 -04:00
1 parent 3c4a2c0c96
commit 4db9300001
21 files changed
+1444 -178

No files matched your search

+7
View File
@@ -30,6 +30,13 @@ DATABASE_URL=sqlite:///serverkit.db
# CORS Origins - your domain(s), comma-separated
CORS_ORIGINS=http://localhost,https://your-domain.com
# Public URL that agents should dial back to. Set this whenever the panel
# sits behind a reverse proxy / tunnel / custom domain — the connection
# string the panel issues to agents will embed this URL instead of the
# request's Host header (which would otherwise be the internal IP/port).
# Leave commented out for plain localhost development.
# SERVERKIT_PUBLIC_URL=https://panel.your-domain.com
# Ports (for Docker)
PORT=80
SSL_PORT=443
+9 -2
View File
@@ -131,6 +131,13 @@ func (p *pairer) handleStart(w http.ResponseWriter, r *http.Request) {
if err := runServiceCmd("stop"); err != nil {
p.log.Info("Service stop reported error (likely already stopped)", "error", err)
}
// sc.exe returns once SCM accepts the stop request, not when the
// service is fully stopped. Issuing sc start while the service is
// still STOP_PENDING fails with 1056 ("instance already running"),
// so wait until SCM reports STOPPED before starting.
if err := waitForServiceStopped(15 * time.Second); err != nil {
p.log.Info("Service did not reach STOPPED before start", "error", err)
}
if err := runServiceCmd("start"); err != nil {
p.log.Error("Failed to start service after pairing", "error", err)
p.mu.Lock()
@@ -213,8 +220,8 @@ type connStringRequest struct {
ConnectionString string `json:"connection_string"`
}
// handleConnectionString accepts a single sk_conn_v1.<...> blob, decodes
// it into a panel URL + registration token, and runs the legacy
// handleConnectionString accepts a single sk1://… connection string,
// decodes it into a panel URL + registration token, and runs the legacy
// /api/v1/servers/register flow against the panel — the same one the
// `serverkit-agent register` CLI uses. We reuse the existing pairer
// state machine ("enrolling" -> "claimed") so the React wizard's polling
@@ -49,6 +49,28 @@ func waitForServiceRunning(timeout time.Duration) error {
timeout, strings.TrimSpace(lastOut))
}
// waitForServiceStopped polls `sc query` until the service reports state
// 1 (STOPPED) or the deadline passes. `sc stop` returns as soon as SCM
// accepts the stop request — issuing `sc start` while the service is
// still STOP_PENDING returns error 1056 ("instance already running"),
// which is exactly what users hit on the post-pair restart.
func waitForServiceStopped(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var lastOut string
for time.Now().Before(deadline) {
cmd := exec.Command("sc.exe", "query", "ServerKitAgent")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
out, err := cmd.CombinedOutput()
lastOut = string(out)
if err == nil && strings.Contains(lastOut, "STOPPED") {
return nil
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("service did not reach STOPPED within %s (last sc query: %s)",
timeout, strings.TrimSpace(lastOut))
}
// openTarget hands a path or URL to Explorer / the default browser via
// rundll32 + url.dll, which is the canonical "open this thing" entry point
// on Windows. Faster than shelling out to cmd /c start.
+60 -50
View File
@@ -1,92 +1,102 @@
// Package connstring decodes the panel's "connection string" — a single
// base64-packed blob the user copies from the ServerKit panel and pastes
// into the agent's pairing wizard. It bundles the panel URL plus a
// single-use registration token, replacing the older flow where the user
// typed those into separate fields.
// URL-shaped string the user copies from the ServerKit panel and pastes
// into the agent's pairing wizard. It bundles the panel host plus a
// single-use registration token plus an optional expiry, replacing the
// older flow where the user typed those into separate fields.
//
// Format: ``sk_conn_v1.<base64url(json_payload)>``
// Format: ``sk1://<host>[:<port>]/<token>[?exp=<ISO8601>][&insecure=1]``
//
// The version prefix exists so older agents reject newer payloads cleanly
// instead of mis-parsing them. Today's payload is intentionally tiny —
// just the URL, the token, and an optional expiry the agent treats as
// advisory (the panel is the authoritative source of truth on whether the
// token is still good).
// The ``sk1://`` scheme self-identifies the format (you can recognise a
// ServerKit connection string at a glance) and gives us a version
// lever: any future format change goes out as ``sk2://``. The host is
// visible up front so the user can sanity-check which panel they're
// pointing at before pasting. ``insecure=1`` flips the implied scheme
// from https to http for dev / local-network use; absent it, https is
// implied. Older agents reject newer-versioned payloads cleanly with
// ErrUnknownVersion instead of mis-parsing them.
package connstring
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
const (
// Prefix is the version tag that introduces every v1 connection string.
Prefix = "sk_conn_v1."
// SchemePrefix is the scheme + separator that introduces every v1
// connection string. Used to fail fast on legacy or unrelated input
// before we try to parse it as a URL.
SchemePrefix = "sk1://"
)
// Decoded is the payload encoded inside a connection string.
// Decoded is the data extracted from a connection string.
type Decoded struct {
URL string `json:"url"`
Token string `json:"token"`
ExpiresAt time.Time `json:"-"` // parsed from the raw payload below
// Raw expiry kept verbatim so we can show the user the panel's exact
// formatting (or "never") without re-stringifying.
ExpiresAtRaw string `json:"expires_at,omitempty"`
URL string // reconstructed http/https URL of the panel
Token string // single-use registration token
ExpiresAt time.Time // zero value means "never" or "unparseable"
ExpiresAtRaw string // panel's exact expiry string (or "" if none)
}
// ErrUnknownVersion is returned when the prefix is missing or names a
// version this build doesn't understand. Callers should surface this as
// "your panel is newer than this agent" rather than a generic decode error
// — it's almost always the cause.
// "your panel is newer than this agent" rather than a generic decode
// error — it's almost always the cause.
var ErrUnknownVersion = errors.New("connstring: unknown version prefix")
// Decode parses a connection string. Whitespace surrounding the input is
// stripped (paste-from-clipboard often picks up a trailing newline).
// Decode parses a connection string. Whitespace surrounding the input
// is stripped (paste-from-clipboard often picks up a trailing newline).
func Decode(s string) (*Decoded, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, errors.New("connstring: empty input")
}
if !strings.HasPrefix(s, Prefix) {
if !strings.HasPrefix(s, SchemePrefix) {
return nil, ErrUnknownVersion
}
payload := strings.TrimPrefix(s, Prefix)
// We use the URL-safe alphabet without padding on the panel side; allow
// either padded or unpadded inputs here so a hand-edited string still
// decodes.
raw, err := base64.RawURLEncoding.DecodeString(payload)
u, err := url.Parse(s)
if err != nil {
raw, err = base64.URLEncoding.DecodeString(payload)
return nil, fmt.Errorf("connstring: parse: %w", err)
}
if err != nil {
return nil, fmt.Errorf("connstring: base64 decode: %w", err)
if u.Scheme != "sk1" {
return nil, ErrUnknownVersion
}
if u.Host == "" {
return nil, errors.New("connstring: missing host")
}
// Parse twice — once into the public Decoded struct (gets URL+Token),
// then into a small auxiliary struct to capture the expiry as both a
// time.Time and the original string.
var out Decoded
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("connstring: json decode: %w", err)
token := strings.TrimPrefix(u.Path, "/")
if token == "" {
return nil, errors.New("connstring: missing token")
}
if out.URL == "" || out.Token == "" {
return nil, errors.New("connstring: payload missing url or token")
// Tokens are url-safe by construction (panel uses secrets.token_urlsafe);
// a stray slash means the user mangled the string. Better to fail
// loudly than silently truncate.
if strings.Contains(token, "/") {
return nil, errors.New("connstring: token must not contain '/'")
}
var aux struct {
ExpiresAt string `json:"expires_at"`
q := u.Query()
scheme := "https"
switch q.Get("insecure") {
case "1", "true", "yes":
scheme = "http"
}
_ = json.Unmarshal(raw, &aux)
out.ExpiresAtRaw = aux.ExpiresAt
if aux.ExpiresAt != "" {
if t, perr := time.Parse(time.RFC3339, aux.ExpiresAt); perr == nil {
out := &Decoded{
URL: scheme + "://" + u.Host,
Token: token,
}
if exp := q.Get("exp"); exp != "" {
out.ExpiresAtRaw = exp
// Best-effort parse — if the panel sends a format we don't
// understand we still surface the raw value, and the panel
// remains the source of truth on whether the token is good.
if t, perr := time.Parse(time.RFC3339, exp); perr == nil {
out.ExpiresAt = t
}
}
return &out, nil
return out, nil
}
+63 -41
View File
@@ -1,22 +1,13 @@
package connstring
import (
"encoding/base64"
"errors"
"strings"
"testing"
)
// helper: encode the panel's payload format so the test stays readable
// even as we tweak fields. Mirrors backend/app/services/connection_string.py
// — keep the two in sync.
func encode(payload string) string {
return Prefix + base64.RawURLEncoding.EncodeToString([]byte(payload))
}
func TestDecode_Roundtrip(t *testing.T) {
s := encode(`{"url":"https://panel.example.com","token":"sk_reg_abc","expires_at":"2026-05-08T17:00:00Z"}`)
got, err := Decode(s)
got, err := Decode("sk1://panel.example.com/sk_reg_abc?exp=2026-05-08T17:00:00Z")
if err != nil {
t.Fatalf("decode: %v", err)
}
@@ -34,24 +25,61 @@ func TestDecode_Roundtrip(t *testing.T) {
}
}
func TestDecode_PreservesPort(t *testing.T) {
got, err := Decode("sk1://panel.example.com:9443/t")
if err != nil {
t.Fatalf("decode: %v", err)
}
if got.URL != "https://panel.example.com:9443" {
t.Errorf("url = %q", got.URL)
}
}
func TestDecode_HonoursInsecureFlag(t *testing.T) {
// http panels (typically dev / local-network) round-trip via the
// insecure=1 flag. Without honouring it, the agent would default
// to https and fail TLS against an http-only backend.
got, err := Decode("sk1://localhost:47927/t?insecure=1")
if err != nil {
t.Fatalf("decode: %v", err)
}
if got.URL != "http://localhost:47927" {
t.Errorf("url = %q (want http scheme)", got.URL)
}
}
func TestDecode_NoExpiry(t *testing.T) {
got, err := Decode("sk1://panel.example.com/t")
if err != nil {
t.Fatalf("decode: %v", err)
}
if got.ExpiresAtRaw != "" {
t.Errorf("expected no raw expiry, got %q", got.ExpiresAtRaw)
}
if !got.ExpiresAt.IsZero() {
t.Errorf("expected zero ExpiresAt, got %v", got.ExpiresAt)
}
}
func TestDecode_TrimsWhitespace(t *testing.T) {
s := encode(`{"url":"https://x","token":"t"}`)
// Clipboard pastes often have a trailing newline; verify Decode
// doesn't reject a string just because of that.
if _, err := Decode("\n " + s + " \n"); err != nil {
if _, err := Decode("\n sk1://panel.example.com/t \n"); err != nil {
t.Fatalf("decode with whitespace: %v", err)
}
}
func TestDecode_RejectsUnknownVersion(t *testing.T) {
_, err := Decode("sk_conn_v9.aaaa")
if !errors.Is(err, ErrUnknownVersion) {
t.Errorf("want ErrUnknownVersion, got %v", err)
cases := []string{
"sk2://panel.example.com/t",
"sk_conn_v1.eyJ1cmwiOiJodHRwczovL3gifQ", // legacy format
"not-a-connection-string",
}
_, err = Decode("not-a-connection-string")
if !errors.Is(err, ErrUnknownVersion) {
t.Errorf("want ErrUnknownVersion for missing prefix, got %v", err)
for _, in := range cases {
_, err := Decode(in)
if !errors.Is(err, ErrUnknownVersion) {
t.Errorf("Decode(%q): want ErrUnknownVersion, got %v", in, err)
}
}
}
@@ -64,39 +92,33 @@ func TestDecode_RejectsEmpty(t *testing.T) {
}
}
func TestDecode_RejectsMissingFields(t *testing.T) {
func TestDecode_RejectsMissingToken(t *testing.T) {
cases := []string{
`{"token":"t"}`,
`{"url":"https://x"}`,
`{}`,
"sk1://panel.example.com",
"sk1://panel.example.com/",
}
for _, payload := range cases {
_, err := Decode(encode(payload))
for _, in := range cases {
_, err := Decode(in)
if err == nil {
t.Errorf("expected error for payload %q", payload)
t.Errorf("Decode(%q): want error, got nil", in)
continue
}
if !strings.Contains(err.Error(), "missing url or token") {
t.Errorf("payload %q: unexpected error %v", payload, err)
if !strings.Contains(err.Error(), "token") {
t.Errorf("Decode(%q): unexpected error %v", in, err)
}
}
}
func TestDecode_RejectsCorruptBase64(t *testing.T) {
// Garbage after the prefix isn't valid base64 in either alphabet.
_, err := Decode(Prefix + "!!!not-base64!!!")
if err == nil {
t.Errorf("want error for corrupt base64")
func TestDecode_RejectsTokenWithSlash(t *testing.T) {
_, err := Decode("sk1://panel.example.com/sk_reg/abc")
if err == nil || !strings.Contains(err.Error(), "/") {
t.Errorf("expected error about '/', got %v", err)
}
}
func TestDecode_AcceptsPaddedBase64(t *testing.T) {
// Some clipboards (notably pasting through certain terminals) round-
// trip the URL-safe alphabet through a padded form. Decode tolerates
// either, so a hand-edited string still works.
payload := []byte(`{"url":"https://x","token":"t"}`)
padded := Prefix + base64.URLEncoding.EncodeToString(payload)
if _, err := Decode(padded); err != nil {
t.Errorf("padded decode: %v", err)
func TestDecode_RejectsMissingHost(t *testing.T) {
_, err := Decode("sk1:///t")
if err == nil || !strings.Contains(err.Error(), "host") {
t.Errorf("expected host error, got %v", err)
}
}
+2 -2
View File
@@ -47,7 +47,7 @@ function FormStage({ onConnectionString, onPairCode, busy, defaultName }) {
setError('Paste the connection string from your panel.');
return;
}
if (!trimmed.startsWith('sk_conn_')) {
if (!trimmed.startsWith('sk1://')) {
setError('That doesn’t look like a connection string. Generate one from the panel’s Add Server screen.');
return;
}
@@ -103,7 +103,7 @@ function FormStage({ onConnectionString, onPairCode, busy, defaultName }) {
<textarea
className="field__input field__input--mono"
rows={4}
placeholder="sk_conn_v1...."
placeholder="sk1://panel.example.com/sk_reg_..."
value={connString}
onChange={(e) => setConnString(e.target.value)}
autoFocus
+6
View File
@@ -37,3 +37,9 @@ SERVERKIT_GITHUB_REPO=jhd3197/ServerKit
# SERVERKIT_BACKUP_DIR=./dev-data/backups
# SERVERKIT_CACHE_DIR=./dev-data/cache
# SERVERKIT_QUARANTINE_DIR=./dev-data/quarantine
# Public URL agents should dial back to. Set this whenever the panel sits
# behind a reverse proxy / tunnel / custom domain — the connection string
# the panel issues to agents will embed this URL instead of the request's
# Host header (which would otherwise be the internal IP/port).
# SERVERKIT_PUBLIC_URL=https://panel.your-domain.com
@@ -0,0 +1,3 @@
from .blueprint import gui_bp
__all__ = ["gui_bp"]
@@ -0,0 +1,199 @@
"""ServerKit Agent GUI plugin — panel-side blueprint.
Acts as a thin proxy between the frontend and the agent's gui:* actions.
No frame data is stored; everything is forwarded through agent_registry.send_command.
"""
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services.agent_registry import agent_registry
from app.models.server import Server
from app.models.user import User
gui_bp = Blueprint("server_gui", __name__)
DEFAULT_FRAME_TIMEOUT = 8.0
MAX_FRAME_TIMEOUT = 15.0
def _current_user():
return User.query.get(get_jwt_identity())
def _server_or_404(server_id: str):
server = Server.query.get(server_id)
if not server:
return None, (jsonify({"error": "Server not found"}), 404)
return server, None
@gui_bp.route("/<server_id>/capabilities", methods=["GET"])
@jwt_required()
def capabilities(server_id):
"""Ask the agent what it can capture (display server, resolution, fps cap)."""
user = _current_user()
server, err = _server_or_404(server_id)
if err:
return err
if server.status != "online":
return jsonify({
"capability": "none",
"reason": "agent_offline",
"synthetic_fallback": True,
})
result = agent_registry.send_command(
server_id=server_id,
action="gui:capabilities",
params={},
timeout=5.0,
user_id=user.id if user else None,
)
if not result.get("success"):
# Agent doesn't implement gui:capabilities — that's fine, fall back.
return jsonify({
"capability": "none",
"reason": result.get("error", "unsupported"),
"synthetic_fallback": True,
})
data = result.get("data") or {}
data.setdefault("synthetic_fallback", data.get("capability") in (None, "none"))
return jsonify(data)
@gui_bp.route("/<server_id>/frame", methods=["GET"])
@jwt_required()
def frame(server_id):
"""Capture and return a single frame.
Query params:
scale float 0.1..1.0 server-side downscale before encoding
quality int 10..95 JPEG quality (PNG ignores this)
format png|jpeg encoding hint
"""
user = _current_user()
server, err = _server_or_404(server_id)
if err:
return err
if server.status != "online":
return jsonify({"error": "agent offline", "code": "AGENT_OFFLINE"}), 503
try:
scale = float(request.args.get("scale", "0.75"))
quality = int(request.args.get("quality", "70"))
except ValueError:
return jsonify({"error": "scale/quality must be numeric"}), 400
scale = max(0.1, min(scale, 1.0))
quality = max(10, min(quality, 95))
fmt = request.args.get("format", "jpeg").lower()
if fmt not in ("jpeg", "png"):
fmt = "jpeg"
result = agent_registry.send_command(
server_id=server_id,
action="gui:screenshot",
params={"scale": scale, "quality": quality, "format": fmt},
timeout=DEFAULT_FRAME_TIMEOUT,
user_id=user.id if user else None,
)
if not result.get("success"):
return jsonify({
"error": result.get("error", "capture failed"),
"code": result.get("code", "CAPTURE_FAILED"),
}), 502
data = result.get("data") or {}
# Expected agent shape:
# { "image_base64": "...", "format": "jpeg", "width": 1920, "height": 1080,
# "captured_at": "2026-05-01T12:34:56Z" }
if "image_base64" not in data:
return jsonify({"error": "agent returned no frame"}), 502
return jsonify(data)
@gui_bp.route("/<server_id>/synthetic", methods=["GET"])
@jwt_required()
def synthetic(server_id):
"""Return data the frontend uses to render the headless 'fake desktop'.
No new agent action — we reuse data the agent already exposes via
existing actions. Cheap and always available.
"""
user = _current_user()
server, err = _server_or_404(server_id)
if err:
return err
if server.status != "online":
return jsonify({
"windows": [],
"taskbar": [],
"drives": [],
"offline": True,
})
sysinfo = agent_registry.send_command(
server_id=server_id,
action="system:info",
params={},
timeout=5.0,
user_id=user.id if user else None,
)
procs = agent_registry.send_command(
server_id=server_id,
action="system:processes",
params={"limit": 12},
timeout=5.0,
user_id=user.id if user else None,
)
info = (sysinfo.get("data") or {}) if sysinfo.get("success") else {}
plist = (procs.get("data") or []) if procs.get("success") else []
windows = [
{
"id": "system",
"title": f"System — {info.get('hostname', server.name)}",
"icon": "monitor",
"body": {
"OS": f"{info.get('os', 'Unknown')} {info.get('os_version', '')}".strip(),
"Arch": info.get("architecture", "?"),
"CPU": info.get("cpu_model", "?"),
"Cores": info.get("cpu_cores", "?"),
},
},
{
"id": "processes",
"title": "Top processes",
"icon": "activity",
"body": [
{"name": p.get("name"), "cpu": p.get("cpu_percent"), "mem": p.get("memory_percent")}
for p in plist[:8]
],
},
]
taskbar = [
{"id": p.get("pid"), "name": p.get("name", "?")}
for p in plist[:6]
]
drives = [
{"path": d.get("mountpoint"), "used": d.get("used_percent")}
for d in (info.get("disks") or [])
]
return jsonify({
"windows": windows,
"taskbar": taskbar,
"drives": drives,
"hostname": info.get("hostname") or server.name,
"offline": False,
})
@@ -0,0 +1,31 @@
{
"name": "serverkit-gui",
"display_name": "ServerKit Agent GUI",
"version": "0.1.0",
"description": "Agent-powered desktop view for managed servers. Streams live Windows/Linux screenshots when available, with a synthetic desktop fallback for headless hosts.",
"author": "Juan Denis",
"homepage": "https://github.com/jhd3197/serverkit-gui",
"repository": "https://github.com/jhd3197/serverkit-gui",
"license": "MIT",
"category": "monitoring",
"entry_point": "blueprint:gui_bp",
"url_prefix": "/api/v1/server-gui",
"frontend_entry": "components/ServerGui.jsx",
"requires_agent_actions": [
"gui:screenshot",
"gui:capabilities"
],
"minimum_agent_version": "0.4.0",
"permissions": [
"agent.command:gui:screenshot",
"agent.command:gui:capabilities"
],
"contributions": {
"widgets": [
{
"slot": "global",
"component": "default"
}
]
}
}
+76 -59
View File
@@ -1,98 +1,115 @@
"""
Connection string codec.
A connection string is a single pasteable blob the user copies from the
panel and pastes into the agent's pairing wizard. It bundles the panel
URL + a single-use registration token + an optional expiry, replacing
the older flow where the user typed the URL and token into separate
fields.
A connection string is a single pasteable token the user copies from
the panel and pastes into the agent's pairing wizard. It bundles the
panel host + a single-use registration token + an optional expiry,
replacing the older flow where the user typed the URL and token into
separate fields.
Format: ``sk_conn_v1.<base64url(json_payload)>``
Format: ``sk1://<host>[:<port>]/<token>[?exp=<ISO8601>][&insecure=1]``
The version prefix is intentional — if we ever need to add fields the
agent must understand (a different signing scheme, a fleet ID, …) the
agent can refuse to decode unknown versions instead of silently
mis-parsing. Today's payload is deliberately tiny:
{
"url": "https://panel.example.com",
"token": "sk_reg_xxx",
"expires_at": "2026-05-08T17:00:00Z" # or null for "never"
}
The ``sk1://`` scheme self-identifies the format (you can recognise a
ServerKit connection string at a glance) and gives us a version lever:
any future format change goes out as ``sk2://``. The host is visible up
front so the user can sanity-check which panel they're pointing at
before pasting. ``insecure=1`` flips the implied scheme from https to
http for dev / local-network use; absent it, https is implied.
Note: the panel never *reads* connection strings — the agent does.
``decode`` is exported only for tests and for symmetry, so the codec
has a single owner.
"""
import base64
import json
from datetime import datetime
from typing import Optional
from urllib.parse import urlparse, parse_qs
VERSION_PREFIX = "sk_conn_v1."
SCHEME_PREFIX = "sk1://"
def encode(url: str, token: str, expires_at: Optional[datetime]) -> str:
"""Pack the three fields into a single sk_conn_v1.<...> string.
"""Pack panel URL + token (+ optional expiry) into a single
``sk1://<host>/<token>[?…]`` string.
expires_at=None means the token has no expiry (panel-side feature
knob — the registration token is single-use either way, so a missing
expiry is safe).
The url's scheme (http vs https) is preserved via the ``insecure``
query param: omitted means https, ``insecure=1`` means http. We
only allow http/https because those are the only schemes agents
speak; a typo'd input fails loudly here rather than producing a
broken connection string.
"""
if not url or not token:
raise ValueError("url and token are required")
if "/" in token:
raise ValueError("token must not contain '/'")
payload = {
"url": url.rstrip("/"),
"token": token,
"expires_at": expires_at.isoformat() + "Z" if expires_at else None,
}
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
encoded = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
return VERSION_PREFIX + encoded
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
raise ValueError(f"url must include scheme and host: {url!r}")
if parsed.scheme not in ("http", "https"):
raise ValueError(f"url scheme must be http or https: {parsed.scheme!r}")
host = parsed.netloc
# Build the query manually to keep ISO timestamps human-readable —
# urlencode would percent-encode the colons in `2126-04-10T02:27:24Z`,
# which is correct but uglier and harder to eyeball.
query_parts = []
if expires_at is not None:
query_parts.append(f"exp={expires_at.isoformat()}Z")
if parsed.scheme == "http":
query_parts.append("insecure=1")
suffix = ("?" + "&".join(query_parts)) if query_parts else ""
return f"{SCHEME_PREFIX}{host}/{token}{suffix}"
def decode(s: str) -> dict:
"""Reverse of encode. Raises ValueError on any parse / version error.
"""Reverse of encode. Raises ValueError on any parse error.
Returns a dict with keys ``url``, ``token``, ``expires_at`` (the
latter as a datetime or None).
Returns a dict with keys ``url`` (reconstructed http/https URL),
``token``, ``expires_at`` (datetime or None).
"""
if not isinstance(s, str) or not s.startswith(VERSION_PREFIX):
raise ValueError("not a v1 connection string")
if not isinstance(s, str):
raise ValueError("connection string must be a string")
s = s.strip()
if not s:
raise ValueError("connection string is empty")
if not s.startswith(SCHEME_PREFIX):
raise ValueError(
f"not a sk1 connection string (expected prefix {SCHEME_PREFIX!r})"
)
body = s[len(VERSION_PREFIX):]
# urlsafe_b64decode requires correct padding; we strip it on encode
# to keep the string short, so re-pad here.
pad = "=" * (-len(body) % 4)
try:
raw = base64.urlsafe_b64decode(body + pad)
except Exception as exc:
raise ValueError(f"invalid base64: {exc}") from exc
parsed = urlparse(s)
if parsed.scheme != "sk1":
raise ValueError(f"unexpected scheme: {parsed.scheme!r}")
if not parsed.netloc:
raise ValueError("missing host")
if not parsed.path or parsed.path == "/":
raise ValueError("missing token")
try:
data = json.loads(raw.decode("utf-8"))
except Exception as exc:
raise ValueError(f"invalid json: {exc}") from exc
token = parsed.path.lstrip("/")
# Defensive: a stray slash in the path means the user mangled the
# string or we somehow encoded a token containing a slash. Either
# way the safe move is to fail rather than silently truncate.
if "/" in token:
raise ValueError("token must not contain '/'")
url = data.get("url")
token = data.get("token")
if not isinstance(url, str) or not isinstance(token, str):
raise ValueError("missing url or token")
qs = parse_qs(parsed.query)
insecure_raw = qs.get("insecure", [""])[0]
scheme = "http" if insecure_raw in ("1", "true", "yes") else "https"
url = f"{scheme}://{parsed.netloc}"
expires_raw = data.get("expires_at")
expires_at: Optional[datetime] = None
if expires_raw is not None:
if not isinstance(expires_raw, str):
raise ValueError("expires_at must be ISO 8601 string or null")
exp_raw = qs.get("exp", [""])[0]
if exp_raw:
# Tolerate trailing Z (UTC marker) which datetime.fromisoformat
# didn't accept until 3.11.
cleaned = expires_raw[:-1] if expires_raw.endswith("Z") else expires_raw
# didn't accept until Python 3.11.
cleaned = exp_raw[:-1] if exp_raw.endswith("Z") else exp_raw
try:
expires_at = datetime.fromisoformat(cleaned)
except Exception as exc:
raise ValueError(f"invalid expires_at: {exc}") from exc
raise ValueError(f"invalid exp: {exc}") from exc
return {"url": url, "token": token, "expires_at": expires_at}
+87 -21
View File
@@ -10,7 +10,8 @@ from app.services import connection_string as cs
def test_encode_decode_round_trip():
expires = datetime(2026, 5, 8, 17, 0, 0)
s = cs.encode(url='https://panel.example.com', token='sk_reg_abc', expires_at=expires)
assert s.startswith('sk_conn_v1.')
assert s.startswith('sk1://')
assert s == 'sk1://panel.example.com/sk_reg_abc?exp=2026-05-08T17:00:00Z'
decoded = cs.decode(s)
assert decoded['url'] == 'https://panel.example.com'
@@ -19,48 +20,91 @@ def test_encode_decode_round_trip():
def test_encode_strips_trailing_slash_from_url():
# urlparse already discards the trailing slash on a host-only URL,
# so the reconstructed URL never has one. Lock that in.
s = cs.encode(url='https://panel.example.com/', token='t', expires_at=None)
assert cs.decode(s)['url'] == 'https://panel.example.com'
def test_encode_with_no_expiry():
s = cs.encode(url='https://panel.example.com', token='t', expires_at=None)
assert s == 'sk1://panel.example.com/t'
decoded = cs.decode(s)
assert decoded['expires_at'] is None
def test_encode_preserves_port():
s = cs.encode(url='https://panel.example.com:9443', token='t', expires_at=None)
assert s == 'sk1://panel.example.com:9443/t'
assert cs.decode(s)['url'] == 'https://panel.example.com:9443'
def test_encode_marks_http_as_insecure():
# http panels (typically dev / local-network) emit insecure=1 so the
# decoder can reconstruct the right scheme. Without this, an http
# panel would round-trip as https and the agent would fail TLS.
s = cs.encode(url='http://localhost:47927', token='t', expires_at=None)
assert s == 'sk1://localhost:47927/t?insecure=1'
assert cs.decode(s)['url'] == 'http://localhost:47927'
def test_encode_combines_expiry_and_insecure():
expires = datetime(2026, 5, 8, 17, 0, 0)
s = cs.encode(url='http://localhost:47927', token='t', expires_at=expires)
assert s == 'sk1://localhost:47927/t?exp=2026-05-08T17:00:00Z&insecure=1'
decoded = cs.decode(s)
assert decoded['url'] == 'http://localhost:47927'
assert decoded['expires_at'] == expires
def test_decode_strips_whitespace():
# Clipboard pastes pick up trailing newlines all the time.
s = cs.encode(url='https://x', token='t', expires_at=None)
assert cs.decode('\n ' + s + ' \n')['token'] == 't'
def test_decode_rejects_missing_version_prefix():
# Plain base64 without our prefix should be rejected — protects
# agents from silently accepting a future format they don't understand.
with pytest.raises(ValueError, match='v1'):
with pytest.raises(ValueError, match='sk1'):
cs.decode('not_a_connection_string')
def test_decode_rejects_unknown_version():
with pytest.raises(ValueError, match='v1'):
cs.decode('sk_conn_v2.abc')
def test_decode_rejects_old_v1_format():
# The pre-sk1 base64-blob format must not silently decode — agents
# speaking the new protocol need to reject old payloads cleanly so
# the user knows to regenerate from a freshly-deployed panel.
with pytest.raises(ValueError, match='sk1'):
cs.decode('sk_conn_v1.eyJ1cmwiOiJodHRwczovL3gifQ')
def test_decode_rejects_bad_base64():
with pytest.raises(ValueError, match='base64'):
cs.decode('sk_conn_v1.!!!not_base64!!!')
def test_decode_rejects_unknown_scheme():
with pytest.raises(ValueError, match='sk1'):
cs.decode('sk2://panel.example.com/token')
def test_decode_rejects_missing_fields():
import base64
import json
raw = json.dumps({'url': 'https://x'}).encode() # no token
body = base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
with pytest.raises(ValueError, match='missing url or token'):
cs.decode('sk_conn_v1.' + body)
def test_decode_rejects_missing_host():
with pytest.raises(ValueError, match='host'):
cs.decode('sk1:///token')
def test_encode_rejects_empty_inputs():
def test_decode_rejects_missing_token():
with pytest.raises(ValueError, match='token'):
cs.decode('sk1://panel.example.com')
with pytest.raises(ValueError, match='token'):
cs.decode('sk1://panel.example.com/')
def test_decode_rejects_token_with_path_separator():
# Tokens are url-safe by construction (secrets.token_urlsafe), so a
# slash in the path means a mangled string — better to fail loudly.
with pytest.raises(ValueError, match='/'):
cs.decode('sk1://panel.example.com/sk_reg/abc')
def test_decode_rejects_empty():
with pytest.raises(ValueError):
cs.encode(url='', token='t', expires_at=None)
cs.decode('')
with pytest.raises(ValueError):
cs.encode(url='https://x', token='', expires_at=None)
cs.decode(' ')
def test_decode_handles_iso_with_z_suffix():
@@ -70,3 +114,25 @@ def test_decode_handles_iso_with_z_suffix():
expires = datetime(2026, 5, 8, 17, 0, 0)
s = cs.encode(url='https://x', token='t', expires_at=expires)
assert cs.decode(s)['expires_at'] == expires
def test_encode_rejects_empty_inputs():
with pytest.raises(ValueError):
cs.encode(url='', token='t', expires_at=None)
with pytest.raises(ValueError):
cs.encode(url='https://x', token='', expires_at=None)
def test_encode_rejects_non_http_scheme():
with pytest.raises(ValueError, match='http or https'):
cs.encode(url='ftp://panel.example.com', token='t', expires_at=None)
def test_encode_rejects_url_without_scheme():
with pytest.raises(ValueError, match='scheme and host'):
cs.encode(url='panel.example.com', token='t', expires_at=None)
def test_encode_rejects_token_with_slash():
with pytest.raises(ValueError, match='/'):
cs.encode(url='https://x', token='bad/token', expires_at=None)
+2 -2
View File
@@ -25,7 +25,7 @@ def test_create_server_without_name_returns_connection_string(client, auth_heade
data = resp.get_json()
assert 'connection_string' in data
assert data['connection_string'].startswith('sk_conn_v1.')
assert data['connection_string'].startswith('sk1://')
assert data['name'].startswith('Pending pairing (')
# Token in the connection string matches the registration_token in the response
@@ -115,7 +115,7 @@ def test_regenerate_token_returns_connection_string(client, auth_headers, app):
)
assert regen_resp.status_code == 200
body = regen_resp.get_json()
assert body['connection_string'].startswith('sk_conn_v1.')
assert body['connection_string'].startswith('sk1://')
new_token = body['registration_token']
assert new_token != original_token
+12 -1
View File
@@ -7,6 +7,17 @@
"version": "1.0.0",
"frontend_entry": "",
"path": "serverkit-git"
},
{
"name": "serverkit-gui",
"slug": "serverkit-gui",
"display_name": "ServerKit Agent GUI",
"version": "0.1.0",
"frontend_entry": "components/ServerGui.jsx",
"path": "serverkit-gui",
"styles": [
"plugins/serverkit-gui/styles/server-gui.css"
]
}
]
}
}
@@ -0,0 +1,273 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import SyntheticDesktop from './SyntheticDesktop.jsx';
const FRAME_INTERVAL_MS_DEFAULT = 700;
const STORAGE_KEY = 'sk-gui:prefs';
const MODES = {
AUTO: 'auto',
SCREENSHOT: 'screenshot',
SYNTHETIC: 'synthetic',
};
function loadPrefs() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
} catch {
return {};
}
}
function savePrefs(prefs) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); } catch { /* ignore */ }
}
/**
* Top-level desktop view. Picks between screenshot streaming and synthetic
* desktop based on user preference and what the agent reports.
*/
export default function ServerGui({ api, serverId }) {
const initial = loadPrefs();
const [mode, setMode] = useState(initial.mode || MODES.AUTO);
const [intervalMs, setIntervalMs] = useState(initial.intervalMs || FRAME_INTERVAL_MS_DEFAULT);
const [scale, setScale] = useState(initial.scale ?? 0.75);
const [quality, setQuality] = useState(initial.quality ?? 70);
const [caps, setCaps] = useState(null);
const [error, setError] = useState(null);
const baseUrl = `/api/v1/server-gui/${serverId}`;
// Persist prefs whenever any of them changes.
useEffect(() => {
savePrefs({ mode, intervalMs, scale, quality });
}, [mode, intervalMs, scale, quality]);
const fetchJson = useCallback(async (path) => {
if (api && typeof api.request === 'function') {
return api.request(path);
}
const token = localStorage.getItem('access_token');
const r = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}, [api]);
// Probe capabilities once — drives auto mode and the toolbar caption.
useEffect(() => {
let cancelled = false;
setCaps(null);
setError(null);
fetchJson(`${baseUrl}/capabilities`)
.then(d => { if (!cancelled) setCaps(d); })
.catch(e => { if (!cancelled) setError(e.message); });
return () => { cancelled = true; };
}, [baseUrl, fetchJson]);
// Resolve the active mode: AUTO defers to capabilities.
const effectiveMode = (() => {
if (mode === MODES.SCREENSHOT) return MODES.SCREENSHOT;
if (mode === MODES.SYNTHETIC) return MODES.SYNTHETIC;
if (!caps) return null;
return caps.capability === 'none' ? MODES.SYNTHETIC : MODES.SCREENSHOT;
})();
return (
<div className="sk-gui">
<Toolbar
caps={caps}
mode={mode}
onModeChange={setMode}
intervalMs={intervalMs}
onIntervalChange={setIntervalMs}
scale={scale}
onScaleChange={setScale}
quality={quality}
onQualityChange={setQuality}
effectiveMode={effectiveMode}
/>
{error && <div className="sk-gui__banner sk-gui__banner--error">{error}</div>}
{!caps && !error && (
<div className="sk-gui__loading">Probing display capability…</div>
)}
{effectiveMode === MODES.SCREENSHOT && caps && (
<ScreenshotView
baseUrl={baseUrl}
fetchJson={fetchJson}
intervalMs={intervalMs}
scale={scale}
quality={quality}
/>
)}
{effectiveMode === MODES.SYNTHETIC && (
<SyntheticDesktop
api={api}
serverId={serverId}
fetchJson={fetchJson}
/>
)}
</div>
);
}
function Toolbar({
caps, mode, onModeChange,
intervalMs, onIntervalChange,
scale, onScaleChange,
quality, onQualityChange,
effectiveMode,
}) {
const screenshotDisabled = caps && caps.capability === 'none';
return (
<div className="sk-gui__toolbar">
<div className="sk-gui__mode">
<ModeBtn
label="Auto"
active={mode === MODES.AUTO}
onClick={() => onModeChange(MODES.AUTO)}
/>
<ModeBtn
label="Screenshot"
active={mode === MODES.SCREENSHOT}
disabled={screenshotDisabled}
title={screenshotDisabled ? `Unavailable: ${caps.reason || 'no display'}` : ''}
onClick={() => onModeChange(MODES.SCREENSHOT)}
/>
<ModeBtn
label="Synthetic"
active={mode === MODES.SYNTHETIC}
onClick={() => onModeChange(MODES.SYNTHETIC)}
/>
</div>
<span className="sk-gui__cap">
{caps ? (
<>
{caps.capability === 'none'
? `headless${caps.reason ? ` · ${caps.reason}` : ''}`
: `${caps.capability}${caps.resolution ? ` · ${caps.resolution}` : ''}`}
{effectiveMode && effectiveMode !== mode && mode === MODES.AUTO && (
<span className="sk-gui__cap-active"> → {effectiveMode}</span>
)}
</>
) : '—'}
</span>
{effectiveMode === MODES.SCREENSHOT && (
<>
<label className="sk-gui__field">
Rate
<select value={intervalMs} onChange={e => onIntervalChange(Number(e.target.value))}>
<option value={2000}>0.5 fps</option>
<option value={1000}>1 fps</option>
<option value={700}>1.5 fps</option>
<option value={500}>2 fps</option>
<option value={300}>3 fps</option>
</select>
</label>
<label className="sk-gui__field">
Scale
<select value={scale} onChange={e => onScaleChange(Number(e.target.value))}>
<option value={0.5}>50%</option>
<option value={0.75}>75%</option>
<option value={1}>100%</option>
</select>
</label>
<label className="sk-gui__field">
Quality
<input
type="range"
min="20"
max="95"
value={quality}
onChange={e => onQualityChange(Number(e.target.value))}
/>
<span className="sk-gui__field-num">{quality}</span>
</label>
</>
)}
</div>
);
}
function ModeBtn({ label, active, disabled, title, onClick }) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className={`sk-gui__mode-btn ${active ? 'sk-gui__mode-btn--active' : ''}`}
>
{label}
</button>
);
}
function ScreenshotView({ baseUrl, fetchJson, intervalMs, scale, quality }) {
const [frame, setFrame] = useState(null);
const [error, setError] = useState(null);
const [paused, setPaused] = useState(false);
const inflight = useRef(false);
useEffect(() => {
if (paused) return undefined;
let cancelled = false;
const timer = { id: null };
const tick = async () => {
if (cancelled || inflight.current) return;
inflight.current = true;
try {
const data = await fetchJson(
`${baseUrl}/frame?scale=${scale}&quality=${quality}&format=jpeg`
);
if (cancelled) return;
setFrame(data);
setError(null);
} catch (e) {
if (cancelled) return;
setError(e.message);
} finally {
inflight.current = false;
}
};
tick();
timer.id = setInterval(tick, intervalMs);
return () => {
cancelled = true;
if (timer.id) clearInterval(timer.id);
};
}, [baseUrl, fetchJson, intervalMs, scale, quality, paused]);
const imgSrc = frame
? `data:image/${frame.format || 'jpeg'};base64,${frame.image_base64}`
: null;
return (
<div className="sk-gui__viewport">
<button
className="sk-gui__viewport-btn"
onClick={() => setPaused(p => !p)}
>
{paused ? 'Resume' : 'Pause'}
</button>
{error && <div className="sk-gui__banner sk-gui__banner--error">{error}</div>}
{imgSrc ? (
<img className="sk-gui__frame" src={imgSrc} alt="Remote desktop frame" />
) : (
<div className="sk-gui__loading">Waiting for first frame…</div>
)}
{frame?.captured_at && (
<div className="sk-gui__stamp">
{frame.width}×{frame.height} · {new Date(frame.captured_at).toLocaleTimeString()}
</div>
)}
</div>
);
}
@@ -0,0 +1,68 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import ServerGui from './ServerGui.jsx';
/**
* Floating launcher that appears only on /servers/:id pages.
* Opens the streaming desktop in a modal.
*
* This is a temporary integration until the PluginLoader exposes a
* `serverDetailTab` extension point — at which point we'll mount
* ServerGui directly as a tab.
*/
const SERVER_DETAIL_RE = /^\/servers\/([^/]+)(\/[^/]*)?$/;
export default function ServerGuiLauncher({ api }) {
const location = useLocation();
const [open, setOpen] = useState(false);
const match = SERVER_DETAIL_RE.exec(location.pathname);
const serverId = match ? match[1] : null;
useEffect(() => {
if (!serverId) setOpen(false);
}, [serverId]);
if (!serverId) return null;
return (
<>
<button
className="sk-gui-launcher"
onClick={() => setOpen(true)}
title="Open desktop view"
>
<DesktopIcon />
<span>Desktop</span>
</button>
{open && (
<div className="sk-gui-modal" onClick={() => setOpen(false)}>
<div className="sk-gui-modal__inner" onClick={e => e.stopPropagation()}>
<header className="sk-gui-modal__header">
<h3>Desktop view</h3>
<button
className="sk-gui-modal__close"
onClick={() => setOpen(false)}
aria-label="Close"
>
×
</button>
</header>
<div className="sk-gui-modal__body">
<ServerGui api={api} serverId={serverId} />
</div>
</div>
</div>
)}
</>
);
}
const DesktopIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="4" width="20" height="14" rx="2" />
<line x1="8" y1="22" x2="16" y2="22" />
<line x1="12" y1="18" x2="12" y2="22" />
</svg>
);
@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
/**
* Fake desktop UI rendered from agent data, used when the host has no
* display server. Each "window" is just data the agent already exposes.
*/
export default function SyntheticDesktop({ api, serverId, fetchJson }) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const load = () => {
fetchJson(`/api/v1/server-gui/${serverId}/synthetic`)
.then(d => { if (!cancelled) setData(d); })
.catch(err => { if (!cancelled) setError(err.message); });
};
load();
const id = setInterval(load, 4000);
return () => { cancelled = true; clearInterval(id); };
}, [serverId, fetchJson]);
if (error) return <div className="sk-gui__banner sk-gui__banner--error">{error}</div>;
if (!data) return <div className="sk-gui__loading">Loading synthetic desktop…</div>;
return (
<div className="sk-synth">
<div className="sk-synth__wallpaper">
<div className="sk-synth__hostname">{data.hostname || 'host'}</div>
<div className="sk-synth__windows">
{data.windows?.map(w => <SynthWindow key={w.id} win={w} />)}
</div>
</div>
<div className="sk-synth__taskbar">
<span className="sk-synth__start">≡</span>
{data.taskbar?.map(t => (
<span key={t.id} className="sk-synth__task" title={t.name}>
{t.name}
</span>
))}
<span className="sk-synth__clock">
{new Date().toLocaleTimeString()}
</span>
</div>
</div>
);
}
function SynthWindow({ win }) {
return (
<div className="sk-synth__window">
<div className="sk-synth__titlebar">
<span className="sk-synth__title">{win.title}</span>
<span className="sk-synth__controls">— □ ×</span>
</div>
<div className="sk-synth__body">
<WindowBody body={win.body} />
</div>
</div>
);
}
function WindowBody({ body }) {
if (Array.isArray(body)) {
return (
<ul className="sk-synth__list">
{body.map((row, i) => (
<li key={i}>
<span>{row.name}</span>
<span>{row.cpu != null ? `${row.cpu.toFixed?.(1) ?? row.cpu}% cpu` : ''}</span>
<span>{row.mem != null ? `${row.mem.toFixed?.(1) ?? row.mem}% mem` : ''}</span>
</li>
))}
</ul>
);
}
if (body && typeof body === 'object') {
return (
<dl className="sk-synth__kv">
{Object.entries(body).map(([k, v]) => (
<React.Fragment key={k}>
<dt>{k}</dt>
<dd>{String(v)}</dd>
</React.Fragment>
))}
</dl>
);
}
return <div>{String(body ?? '')}</div>;
}
@@ -0,0 +1,16 @@
// Plugin entry — discovered by ServerKit's PluginLoader.
// Exports the default widget component used as a global drop-in.
//
// We currently render a floating launcher that pops the Agent GUI viewer in a
// modal scoped to whichever server detail page is open. Embedding inside the
// server detail page as a real tab requires a tiny core change to ServerKit
// (extension points in PluginLoader); see README "Roadmap".
import ServerGuiLauncher from './components/ServerGuiLauncher.jsx';
import './styles/server-gui.css';
export default ServerGuiLauncher;
// Also export the inline tab component so a future PluginLoader extension
// point can mount it directly inside ServerDetail.
export { default as ServerGuiTab } from './components/ServerGui.jsx';
@@ -0,0 +1,31 @@
{
"name": "serverkit-gui",
"display_name": "ServerKit Agent GUI",
"version": "0.1.0",
"description": "Agent-powered desktop view for managed servers. Streams live Windows/Linux screenshots when available, with a synthetic desktop fallback for headless hosts.",
"author": "Juan Denis",
"homepage": "https://github.com/jhd3197/serverkit-gui",
"repository": "https://github.com/jhd3197/serverkit-gui",
"license": "MIT",
"category": "monitoring",
"entry_point": "blueprint:gui_bp",
"url_prefix": "/api/v1/server-gui",
"frontend_entry": "components/ServerGui.jsx",
"requires_agent_actions": [
"gui:screenshot",
"gui:capabilities"
],
"minimum_agent_version": "0.4.0",
"permissions": [
"agent.command:gui:screenshot",
"agent.command:gui:capabilities"
],
"contributions": {
"widgets": [
{
"slot": "global",
"component": "default"
}
]
}
}
@@ -0,0 +1,377 @@
/* ServerKit Agent GUI plugin styles.
Uses ServerKit's design system variables when available; otherwise falls
back to safe defaults so the plugin renders even outside the panel theme. */
.sk-gui-launcher,
.sk-gui-modal,
.sk-gui,
.sk-synth {
--sk-gui-bg: var(--card-bg, #1a1d24);
--sk-gui-fg: var(--text-color, #e6e8eb);
--sk-gui-muted: var(--text-muted, #8a8f99);
--sk-gui-accent: var(--primary-color, #6366f1);
--sk-gui-border: var(--border-color, #2a2e36);
--sk-gui-radius: 8px;
--sk-gui-pad: 12px;
}
.sk-gui-launcher {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--sk-gui-accent);
color: #fff;
border: none;
border-radius: 999px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.sk-gui-launcher:hover { filter: brightness(1.1); }
.sk-gui-launcher svg { display: block; }
.sk-gui-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 24px;
}
.sk-gui-modal__inner {
background: var(--sk-gui-bg);
color: var(--sk-gui-fg);
border-radius: var(--sk-gui-radius);
width: min(1280px, 100%);
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.sk-gui-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--sk-gui-border);
}
.sk-gui-modal__header h3 {
margin: 0;
font-size: 15px;
}
.sk-gui-modal__close {
background: none;
border: none;
color: var(--sk-gui-muted);
font-size: 24px;
cursor: pointer;
line-height: 1;
}
.sk-gui-modal__close:hover { color: var(--sk-gui-fg); }
.sk-gui-modal__body {
padding: var(--sk-gui-pad);
overflow: auto;
}
.sk-gui {
display: flex;
flex-direction: column;
gap: var(--sk-gui-pad);
}
.sk-gui__loading {
padding: 32px;
text-align: center;
color: var(--sk-gui-muted);
}
.sk-gui__banner {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
}
.sk-gui__banner--info {
background: rgba(99, 102, 241, 0.15);
color: var(--sk-gui-fg);
}
.sk-gui__banner--error {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
}
.sk-gui__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
}
.sk-gui__mode {
display: inline-flex;
gap: 0;
background: var(--sk-gui-border);
border-radius: 6px;
overflow: hidden;
padding: 2px;
}
.sk-gui__mode-btn {
padding: 4px 12px;
background: transparent;
border: none;
color: var(--sk-gui-muted);
font-size: 12px;
font-weight: 600;
cursor: pointer;
border-radius: 4px;
}
.sk-gui__mode-btn:hover:not(:disabled) { color: var(--sk-gui-fg); }
.sk-gui__mode-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sk-gui__mode-btn--active {
background: var(--sk-gui-accent);
color: #fff;
}
.sk-gui__cap-active {
color: var(--sk-gui-accent);
font-weight: 600;
}
.sk-gui__viewport-btn {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.sk-gui__viewport-btn:hover { background: rgba(0, 0, 0, 0.8); }
.sk-gui__cap {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--sk-gui-muted);
}
.sk-gui__btn {
padding: 6px 12px;
background: var(--sk-gui-border);
border: 1px solid transparent;
border-radius: 6px;
color: var(--sk-gui-fg);
font-size: 13px;
cursor: pointer;
}
.sk-gui__btn:hover { border-color: var(--sk-gui-accent); }
.sk-gui__field {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--sk-gui-muted);
}
.sk-gui__field select,
.sk-gui__field input[type="range"] {
background: var(--sk-gui-border);
color: var(--sk-gui-fg);
border: 1px solid transparent;
border-radius: 4px;
padding: 4px 6px;
font-size: 12px;
}
.sk-gui__field-num {
font-family: ui-monospace, monospace;
color: var(--sk-gui-fg);
min-width: 22px;
text-align: right;
}
.sk-gui__viewport {
position: relative;
background: #000;
border-radius: 6px;
min-height: 360px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.sk-gui__frame {
max-width: 100%;
max-height: calc(100vh - 220px);
display: block;
}
.sk-gui__stamp {
position: absolute;
bottom: 6px;
right: 8px;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.6);
color: #cbd5e1;
font-family: ui-monospace, monospace;
font-size: 11px;
border-radius: 4px;
}
.sk-synth {
display: flex;
flex-direction: column;
height: 540px;
border-radius: 6px;
overflow: hidden;
background: linear-gradient(135deg, #1e293b, #0f172a);
color: var(--sk-gui-fg);
}
.sk-synth__wallpaper {
flex: 1;
position: relative;
padding: 16px;
overflow: auto;
}
.sk-synth__hostname {
position: absolute;
top: 12px;
right: 16px;
font-family: ui-monospace, monospace;
color: var(--sk-gui-muted);
font-size: 12px;
}
.sk-synth__windows {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
margin-top: 24px;
}
.sk-synth__window {
background: var(--sk-gui-bg);
border: 1px solid var(--sk-gui-border);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.sk-synth__titlebar {
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.04);
font-size: 12px;
}
.sk-synth__title { font-weight: 600; }
.sk-synth__controls {
color: var(--sk-gui-muted);
font-family: ui-monospace, monospace;
}
.sk-synth__body {
padding: 10px;
font-size: 12px;
max-height: 220px;
overflow: auto;
}
.sk-synth__list {
list-style: none;
margin: 0;
padding: 0;
}
.sk-synth__list li {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid var(--sk-gui-border);
font-family: ui-monospace, monospace;
}
.sk-synth__list li:last-child { border-bottom: none; }
.sk-synth__kv {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 10px;
margin: 0;
}
.sk-synth__kv dt { color: var(--sk-gui-muted); }
.sk-synth__kv dd {
margin: 0;
font-family: ui-monospace, monospace;
}
.sk-synth__taskbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 0, 0, 0.45);
border-top: 1px solid var(--sk-gui-border);
font-size: 12px;
}
.sk-synth__start {
font-size: 18px;
color: var(--sk-gui-accent);
margin-right: 4px;
}
.sk-synth__task {
padding: 3px 8px;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
.sk-synth__clock {
margin-left: auto;
color: var(--sk-gui-muted);
font-family: ui-monospace, monospace;
}
+9
View File
@@ -8,6 +8,14 @@ export default defineConfig(({ mode }) => {
const frontendPort = Number(env.SERVERKIT_FRONTEND_PORT) || 41921
const apiTarget = (env.VITE_API_URL || 'http://localhost:47927/api/v1').replace(/\/api\/v1\/?$/, '')
const rawAllowedHosts = (env.VITE_ALLOWED_HOSTS || '').trim()
let allowedHosts
if (rawAllowedHosts === 'all') {
allowedHosts = true
} else if (rawAllowedHosts) {
allowedHosts = rawAllowedHosts.split(',').map(s => s.trim()).filter(Boolean)
}
return {
plugins: [react(), tailwindcss()],
resolve: {
@@ -22,6 +30,7 @@ export default defineConfig(({ mode }) => {
},
server: {
port: frontendPort,
...(allowedHosts !== undefined ? { allowedHosts } : {}),
proxy: {
'/api': {
target: apiTarget,