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 - your domain(s), comma-separated
CORS_ORIGINS=http://localhost,https://your-domain.com 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) # Ports (for Docker)
PORT=80 PORT=80
SSL_PORT=443 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 { if err := runServiceCmd("stop"); err != nil {
p.log.Info("Service stop reported error (likely already stopped)", "error", err) 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 { if err := runServiceCmd("start"); err != nil {
p.log.Error("Failed to start service after pairing", "error", err) p.log.Error("Failed to start service after pairing", "error", err)
p.mu.Lock() p.mu.Lock()
@@ -213,8 +220,8 @@ type connStringRequest struct {
ConnectionString string `json:"connection_string"` ConnectionString string `json:"connection_string"`
} }
// handleConnectionString accepts a single sk_conn_v1.<...> blob, decodes // handleConnectionString accepts a single sk1://… connection string,
// it into a panel URL + registration token, and runs the legacy // decodes it into a panel URL + registration token, and runs the legacy
// /api/v1/servers/register flow against the panel — the same one the // /api/v1/servers/register flow against the panel — the same one the
// `serverkit-agent register` CLI uses. We reuse the existing pairer // `serverkit-agent register` CLI uses. We reuse the existing pairer
// state machine ("enrolling" -> "claimed") so the React wizard's polling // state machine ("enrolling" -> "claimed") so the React wizard's polling
@@ -49,6 +49,28 @@ func waitForServiceRunning(timeout time.Duration) error {
timeout, strings.TrimSpace(lastOut)) 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 // openTarget hands a path or URL to Explorer / the default browser via
// rundll32 + url.dll, which is the canonical "open this thing" entry point // rundll32 + url.dll, which is the canonical "open this thing" entry point
// on Windows. Faster than shelling out to cmd /c start. // 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 // Package connstring decodes the panel's "connection string" — a single
// base64-packed blob the user copies from the ServerKit panel and pastes // URL-shaped string the user copies from the ServerKit panel and pastes
// into the agent's pairing wizard. It bundles the panel URL plus a // into the agent's pairing wizard. It bundles the panel host plus a
// single-use registration token, replacing the older flow where the user // single-use registration token plus an optional expiry, replacing the
// typed those into separate fields. // 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 // The ``sk1://`` scheme self-identifies the format (you can recognise a
// instead of mis-parsing them. Today's payload is intentionally tiny — // ServerKit connection string at a glance) and gives us a version
// just the URL, the token, and an optional expiry the agent treats as // lever: any future format change goes out as ``sk2://``. The host is
// advisory (the panel is the authoritative source of truth on whether the // visible up front so the user can sanity-check which panel they're
// token is still good). // 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 package connstring
import ( import (
"encoding/base64"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strings" "strings"
"time" "time"
) )
const ( const (
// Prefix is the version tag that introduces every v1 connection string. // SchemePrefix is the scheme + separator that introduces every v1
Prefix = "sk_conn_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 { type Decoded struct {
URL string `json:"url"` URL string // reconstructed http/https URL of the panel
Token string `json:"token"` Token string // single-use registration token
ExpiresAt time.Time `json:"-"` // parsed from the raw payload below ExpiresAt time.Time // zero value means "never" or "unparseable"
// Raw expiry kept verbatim so we can show the user the panel's exact ExpiresAtRaw string // panel's exact expiry string (or "" if none)
// formatting (or "never") without re-stringifying.
ExpiresAtRaw string `json:"expires_at,omitempty"`
} }
// ErrUnknownVersion is returned when the prefix is missing or names a // ErrUnknownVersion is returned when the prefix is missing or names a
// version this build doesn't understand. Callers should surface this as // version this build doesn't understand. Callers should surface this as
// "your panel is newer than this agent" rather than a generic decode error // "your panel is newer than this agent" rather than a generic decode
// — it's almost always the cause. // error — it's almost always the cause.
var ErrUnknownVersion = errors.New("connstring: unknown version prefix") var ErrUnknownVersion = errors.New("connstring: unknown version prefix")
// Decode parses a connection string. Whitespace surrounding the input is // Decode parses a connection string. Whitespace surrounding the input
// stripped (paste-from-clipboard often picks up a trailing newline). // is stripped (paste-from-clipboard often picks up a trailing newline).
func Decode(s string) (*Decoded, error) { func Decode(s string) (*Decoded, error) {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if s == "" { if s == "" {
return nil, errors.New("connstring: empty input") return nil, errors.New("connstring: empty input")
} }
if !strings.HasPrefix(s, Prefix) { if !strings.HasPrefix(s, SchemePrefix) {
return nil, ErrUnknownVersion return nil, ErrUnknownVersion
} }
payload := strings.TrimPrefix(s, Prefix)
// We use the URL-safe alphabet without padding on the panel side; allow u, err := url.Parse(s)
// either padded or unpadded inputs here so a hand-edited string still
// decodes.
raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil { if err != nil {
raw, err = base64.URLEncoding.DecodeString(payload) return nil, fmt.Errorf("connstring: parse: %w", err)
} }
if err != nil { if u.Scheme != "sk1" {
return nil, fmt.Errorf("connstring: base64 decode: %w", err) return nil, ErrUnknownVersion
}
if u.Host == "" {
return nil, errors.New("connstring: missing host")
} }
// Parse twice — once into the public Decoded struct (gets URL+Token), token := strings.TrimPrefix(u.Path, "/")
// then into a small auxiliary struct to capture the expiry as both a if token == "" {
// time.Time and the original string. return nil, errors.New("connstring: missing token")
var out Decoded
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("connstring: json decode: %w", err)
} }
if out.URL == "" || out.Token == "" { // Tokens are url-safe by construction (panel uses secrets.token_urlsafe);
return nil, errors.New("connstring: payload missing url or token") // 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 { q := u.Query()
ExpiresAt string `json:"expires_at"` scheme := "https"
switch q.Get("insecure") {
case "1", "true", "yes":
scheme = "http"
} }
_ = json.Unmarshal(raw, &aux)
out.ExpiresAtRaw = aux.ExpiresAt out := &Decoded{
if aux.ExpiresAt != "" { URL: scheme + "://" + u.Host,
if t, perr := time.Parse(time.RFC3339, aux.ExpiresAt); perr == nil { 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 out.ExpiresAt = t
} }
} }
return out, nil
return &out, nil
} }
+63 -41
View File
@@ -1,22 +1,13 @@
package connstring package connstring
import ( import (
"encoding/base64"
"errors" "errors"
"strings" "strings"
"testing" "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) { 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("sk1://panel.example.com/sk_reg_abc?exp=2026-05-08T17:00:00Z")
got, err := Decode(s)
if err != nil { if err != nil {
t.Fatalf("decode: %v", err) 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) { func TestDecode_TrimsWhitespace(t *testing.T) {
s := encode(`{"url":"https://x","token":"t"}`)
// Clipboard pastes often have a trailing newline; verify Decode // Clipboard pastes often have a trailing newline; verify Decode
// doesn't reject a string just because of that. // 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) t.Fatalf("decode with whitespace: %v", err)
} }
} }
func TestDecode_RejectsUnknownVersion(t *testing.T) { func TestDecode_RejectsUnknownVersion(t *testing.T) {
_, err := Decode("sk_conn_v9.aaaa") cases := []string{
if !errors.Is(err, ErrUnknownVersion) { "sk2://panel.example.com/t",
t.Errorf("want ErrUnknownVersion, got %v", err) "sk_conn_v1.eyJ1cmwiOiJodHRwczovL3gifQ", // legacy format
"not-a-connection-string",
} }
for _, in := range cases {
_, err = Decode("not-a-connection-string") _, err := Decode(in)
if !errors.Is(err, ErrUnknownVersion) { if !errors.Is(err, ErrUnknownVersion) {
t.Errorf("want ErrUnknownVersion for missing prefix, got %v", err) 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{ cases := []string{
`{"token":"t"}`, "sk1://panel.example.com",
`{"url":"https://x"}`, "sk1://panel.example.com/",
`{}`,
} }
for _, payload := range cases { for _, in := range cases {
_, err := Decode(encode(payload)) _, err := Decode(in)
if err == nil { if err == nil {
t.Errorf("expected error for payload %q", payload) t.Errorf("Decode(%q): want error, got nil", in)
continue continue
} }
if !strings.Contains(err.Error(), "missing url or token") { if !strings.Contains(err.Error(), "token") {
t.Errorf("payload %q: unexpected error %v", payload, err) t.Errorf("Decode(%q): unexpected error %v", in, err)
} }
} }
} }
func TestDecode_RejectsCorruptBase64(t *testing.T) { func TestDecode_RejectsTokenWithSlash(t *testing.T) {
// Garbage after the prefix isn't valid base64 in either alphabet. _, err := Decode("sk1://panel.example.com/sk_reg/abc")
_, err := Decode(Prefix + "!!!not-base64!!!") if err == nil || !strings.Contains(err.Error(), "/") {
if err == nil { t.Errorf("expected error about '/', got %v", err)
t.Errorf("want error for corrupt base64")
} }
} }
func TestDecode_AcceptsPaddedBase64(t *testing.T) { func TestDecode_RejectsMissingHost(t *testing.T) {
// Some clipboards (notably pasting through certain terminals) round- _, err := Decode("sk1:///t")
// trip the URL-safe alphabet through a padded form. Decode tolerates if err == nil || !strings.Contains(err.Error(), "host") {
// either, so a hand-edited string still works. t.Errorf("expected host error, got %v", err)
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)
} }
} }
+2 -2
View File
@@ -47,7 +47,7 @@ function FormStage({ onConnectionString, onPairCode, busy, defaultName }) {
setError('Paste the connection string from your panel.'); setError('Paste the connection string from your panel.');
return; 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.'); setError('That doesn’t look like a connection string. Generate one from the panel’s Add Server screen.');
return; return;
} }
@@ -103,7 +103,7 @@ function FormStage({ onConnectionString, onPairCode, busy, defaultName }) {
<textarea <textarea
className="field__input field__input--mono" className="field__input field__input--mono"
rows={4} rows={4}
placeholder="sk_conn_v1...." placeholder="sk1://panel.example.com/sk_reg_..."
value={connString} value={connString}
onChange={(e) => setConnString(e.target.value)} onChange={(e) => setConnString(e.target.value)}
autoFocus autoFocus
+6
View File
@@ -37,3 +37,9 @@ SERVERKIT_GITHUB_REPO=jhd3197/ServerKit
# SERVERKIT_BACKUP_DIR=./dev-data/backups # SERVERKIT_BACKUP_DIR=./dev-data/backups
# SERVERKIT_CACHE_DIR=./dev-data/cache # SERVERKIT_CACHE_DIR=./dev-data/cache
# SERVERKIT_QUARANTINE_DIR=./dev-data/quarantine # 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. Connection string codec.
A connection string is a single pasteable blob the user copies from the A connection string is a single pasteable token the user copies from
panel and pastes into the agent's pairing wizard. It bundles the panel the panel and pastes into the agent's pairing wizard. It bundles the
URL + a single-use registration token + an optional expiry, replacing panel host + a single-use registration token + an optional expiry,
the older flow where the user typed the URL and token into separate replacing the older flow where the user typed the URL and token into
fields. 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 The ``sk1://`` scheme self-identifies the format (you can recognise a
agent must understand (a different signing scheme, a fleet ID, …) the ServerKit connection string at a glance) and gives us a version lever:
agent can refuse to decode unknown versions instead of silently any future format change goes out as ``sk2://``. The host is visible up
mis-parsing. Today's payload is deliberately tiny: 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.
"url": "https://panel.example.com",
"token": "sk_reg_xxx",
"expires_at": "2026-05-08T17:00:00Z" # or null for "never"
}
Note: the panel never *reads* connection strings — the agent does. Note: the panel never *reads* connection strings — the agent does.
``decode`` is exported only for tests and for symmetry, so the codec ``decode`` is exported only for tests and for symmetry, so the codec
has a single owner. has a single owner.
""" """
import base64
import json
from datetime import datetime from datetime import datetime
from typing import Optional 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: 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 The url's scheme (http vs https) is preserved via the ``insecure``
knob — the registration token is single-use either way, so a missing query param: omitted means https, ``insecure=1`` means http. We
expiry is safe). 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: if not url or not token:
raise ValueError("url and token are required") raise ValueError("url and token are required")
if "/" in token:
raise ValueError("token must not contain '/'")
payload = { parsed = urlparse(url)
"url": url.rstrip("/"), if not parsed.scheme or not parsed.netloc:
"token": token, raise ValueError(f"url must include scheme and host: {url!r}")
"expires_at": expires_at.isoformat() + "Z" if expires_at else None, if parsed.scheme not in ("http", "https"):
} raise ValueError(f"url scheme must be http or https: {parsed.scheme!r}")
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
encoded = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") host = parsed.netloc
return VERSION_PREFIX + encoded
# 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: 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 Returns a dict with keys ``url`` (reconstructed http/https URL),
latter as a datetime or None). ``token``, ``expires_at`` (datetime or None).
""" """
if not isinstance(s, str) or not s.startswith(VERSION_PREFIX): if not isinstance(s, str):
raise ValueError("not a v1 connection string") 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):] parsed = urlparse(s)
# urlsafe_b64decode requires correct padding; we strip it on encode if parsed.scheme != "sk1":
# to keep the string short, so re-pad here. raise ValueError(f"unexpected scheme: {parsed.scheme!r}")
pad = "=" * (-len(body) % 4) if not parsed.netloc:
try: raise ValueError("missing host")
raw = base64.urlsafe_b64decode(body + pad) if not parsed.path or parsed.path == "/":
except Exception as exc: raise ValueError("missing token")
raise ValueError(f"invalid base64: {exc}") from exc
try: token = parsed.path.lstrip("/")
data = json.loads(raw.decode("utf-8")) # Defensive: a stray slash in the path means the user mangled the
except Exception as exc: # string or we somehow encoded a token containing a slash. Either
raise ValueError(f"invalid json: {exc}") from exc # way the safe move is to fail rather than silently truncate.
if "/" in token:
raise ValueError("token must not contain '/'")
url = data.get("url") qs = parse_qs(parsed.query)
token = data.get("token") insecure_raw = qs.get("insecure", [""])[0]
if not isinstance(url, str) or not isinstance(token, str): scheme = "http" if insecure_raw in ("1", "true", "yes") else "https"
raise ValueError("missing url or token") url = f"{scheme}://{parsed.netloc}"
expires_raw = data.get("expires_at")
expires_at: Optional[datetime] = None expires_at: Optional[datetime] = None
if expires_raw is not None: exp_raw = qs.get("exp", [""])[0]
if not isinstance(expires_raw, str): if exp_raw:
raise ValueError("expires_at must be ISO 8601 string or null")
# Tolerate trailing Z (UTC marker) which datetime.fromisoformat # Tolerate trailing Z (UTC marker) which datetime.fromisoformat
# didn't accept until 3.11. # didn't accept until Python 3.11.
cleaned = expires_raw[:-1] if expires_raw.endswith("Z") else expires_raw cleaned = exp_raw[:-1] if exp_raw.endswith("Z") else exp_raw
try: try:
expires_at = datetime.fromisoformat(cleaned) expires_at = datetime.fromisoformat(cleaned)
except Exception as exc: 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} 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(): def test_encode_decode_round_trip():
expires = datetime(2026, 5, 8, 17, 0, 0) expires = datetime(2026, 5, 8, 17, 0, 0)
s = cs.encode(url='https://panel.example.com', token='sk_reg_abc', expires_at=expires) 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) decoded = cs.decode(s)
assert decoded['url'] == 'https://panel.example.com' 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(): 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) s = cs.encode(url='https://panel.example.com/', token='t', expires_at=None)
assert cs.decode(s)['url'] == 'https://panel.example.com' assert cs.decode(s)['url'] == 'https://panel.example.com'
def test_encode_with_no_expiry(): def test_encode_with_no_expiry():
s = cs.encode(url='https://panel.example.com', token='t', expires_at=None) s = cs.encode(url='https://panel.example.com', token='t', expires_at=None)
assert s == 'sk1://panel.example.com/t'
decoded = cs.decode(s) decoded = cs.decode(s)
assert decoded['expires_at'] is None 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(): def test_decode_rejects_missing_version_prefix():
# Plain base64 without our prefix should be rejected — protects with pytest.raises(ValueError, match='sk1'):
# agents from silently accepting a future format they don't understand.
with pytest.raises(ValueError, match='v1'):
cs.decode('not_a_connection_string') cs.decode('not_a_connection_string')
def test_decode_rejects_unknown_version(): def test_decode_rejects_old_v1_format():
with pytest.raises(ValueError, match='v1'): # The pre-sk1 base64-blob format must not silently decode — agents
cs.decode('sk_conn_v2.abc') # 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(): def test_decode_rejects_unknown_scheme():
with pytest.raises(ValueError, match='base64'): with pytest.raises(ValueError, match='sk1'):
cs.decode('sk_conn_v1.!!!not_base64!!!') cs.decode('sk2://panel.example.com/token')
def test_decode_rejects_missing_fields(): def test_decode_rejects_missing_host():
import base64 with pytest.raises(ValueError, match='host'):
import json cs.decode('sk1:///token')
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_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): with pytest.raises(ValueError):
cs.encode(url='', token='t', expires_at=None) cs.decode('')
with pytest.raises(ValueError): with pytest.raises(ValueError):
cs.encode(url='https://x', token='', expires_at=None) cs.decode(' ')
def test_decode_handles_iso_with_z_suffix(): 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) expires = datetime(2026, 5, 8, 17, 0, 0)
s = cs.encode(url='https://x', token='t', expires_at=expires) s = cs.encode(url='https://x', token='t', expires_at=expires)
assert cs.decode(s)['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() data = resp.get_json()
assert 'connection_string' in data 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 (') assert data['name'].startswith('Pending pairing (')
# Token in the connection string matches the registration_token in the response # 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 assert regen_resp.status_code == 200
body = regen_resp.get_json() body = regen_resp.get_json()
assert body['connection_string'].startswith('sk_conn_v1.') assert body['connection_string'].startswith('sk1://')
new_token = body['registration_token'] new_token = body['registration_token']
assert new_token != original_token assert new_token != original_token
+12 -1
View File
@@ -7,6 +7,17 @@
"version": "1.0.0", "version": "1.0.0",
"frontend_entry": "", "frontend_entry": "",
"path": "serverkit-git" "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 frontendPort = Number(env.SERVERKIT_FRONTEND_PORT) || 41921
const apiTarget = (env.VITE_API_URL || 'http://localhost:47927/api/v1').replace(/\/api\/v1\/?$/, '') 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 { return {
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
@@ -22,6 +30,7 @@ export default defineConfig(({ mode }) => {
}, },
server: { server: {
port: frontendPort, port: frontendPort,
...(allowedHosts !== undefined ? { allowedHosts } : {}),
proxy: { proxy: {
'/api': { '/api': {
target: apiTarget, target: apiTarget,