openvibers/openvibe.link Commits

Link's window: the daemon serves the panel on loopback and answers what it already knows (#126)

OP OpenVibers committed 6 days ago · 735e553e7c · 12 files, +1574 −41

* Link's window: the daemon serves the panel on loopback, and answers what it already knows

internal/panel embeds ui/ (index.html, panel.css, panel.js; testdata and the README stay out of the binary) and serves
it at http://127.0.0.1:<free port>/panel/ with a per-run 32-byte token in the fragment. Every /panel/api/* call needs
X-Link-Panel == the token and, when an Origin is present, the panel's own origin; static files need no token and no CORS
header is ever written.

The API is backed by the daemon: machine, running (waiting approvals, jobs, connected shares, desks), capabilities (the
computer and ai plugins' switches), shares and activity are real; devices, gpu and earnings_today are omitted because
the Node does not read them. Pause is the same local latch as the tray and `openvibe-node stop`; a stop ends an
approval, a job or a share. Capability switches add a local restriction the Node enforces on command kinds; Desktop and
Terminal may be turned off but never on (gated on the 11-05 review fixes), and level observe refuses every on. Files
answer 501 with a reason. Connect shares move into the Node so the window can start and end one while it runs.

The tray gains Open Link at the top (and opens the window once on its first run), keeping Open my machines page; a new
`openvibe-node panel` prints the address. docs/panel.md describes it and its security rules.

* Merge remote-tracking branch 'origin/main' into link/panel-serve

Changes

ddiff --git a/README.md b/README.md
iindex 13a41e1..ce39f66 100644
---- a/README.md
++++ b/README.md
@@@ -100,8 +100,15 @@ the ones this machine was installed for: the job worker starts if the local conf
plainly which role it could not honour. The local configuration always wins: a role never installs anything and never
turns on a feature you turned off here.
++**Link's own window:** the tray's **Open Link** (or `openvibe-node panel`) opens a small page on the machine itself
++at `127.0.0.1` that shows what runs here, what this computer allows, your shares and what happened, and lets you pause
++everything, stop one thing, turn a capability off, share a port, or send files (not in this version). It is served
++only on loopback, with a per-run token in the address fragment that every API call must send; see
++[docs/panel.md](docs/panel.md) for the security rules.
++
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
++[panel](docs/panel.md) (Link's own window and its security rules),
[memory](docs/memory.md) (what the coding tools on a machine remember, how the Node reads it and how it is uploaded to
your own memory store), [skills](docs/skills.md) (the skills hub: which folders each tool reads, the marked block, the
dry run and the undo).
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex ccd2a94..1556094 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -77,7 +77,8 @@ Usage:
openvibe-node deny <ID> refuse it
openvibe-node tray [--autostart on|off]
the Node in your notification area (Linux, Windows): what waits with Approve and Deny, stop,
-- resume; --autostart starts it when you sign in
++ resume, Open Link; --autostart starts it when you sign in
++ openvibe-node panel print Link's own window's address (loopback, this run's token)
openvibe-node profile verify community.<name> [--server URL] [--key-id ID]
check a community robot profile against the signature Bot made when it was reviewed
openvibe-node desk-sandbox [--memory-mb N] [--cpu-millis N] [--bind PATH]... [--egress public] -- PROGRAM [ARG]...
@@@ -276,6 +277,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
err = cmdAnswer(g, cmd, positional[0], stdout)
case "tray":
err = cmdTray(g, *autoArg, stdout)
++ case "panel":
++ err = cmdPanel(g, stdout)
case "profile":
if len(positional) != 2 || positional[0] != "verify" {
fmt.Fprintln(stderr, "usage: openvibe-node profile verify community.<name> [--server URL] [--key-id ID]")
@@@ -643,21 +646,12 @@ func cmdRun(g *globals, dryRun bool) error {
}
tokens = ts
}
-- n, err := node.New(node.Options{Config: cfg, Paths: g.paths, Creds: creds, Tokens: tokens, Log: g.log, Version: version, CanQuit: service.Interactive()})
++ n, err := node.New(node.Options{Config: cfg, Paths: g.paths, Creds: creds, Tokens: tokens, Log: g.log, Version: version, CanQuit: service.Interactive(), NoShares: dryRun})
if err != nil {
return err
}
g.log.Info("openvibe-node starting", "version", version, "config", g.paths.ConfigDir, "plugins", len(cfg.Plugins))
return service.Run(serviceOptions(g, ""), func(ctx context.Context) error {
-- if !dryRun {
-- // OpenVibe Connect: every saved share stays up while the Node runs (openvibe-node share add).
-- // A share bound to this computer (no key) signs in with the machine's own node token (CON-4).
-- co := connect.Options{Log: g.log, Version: version}
-- if creds.NetworkPaired() {
-- co.NodeToken = connectNodeToken(creds)
-- }
-- go connect.RunAll(ctx, g.paths.ConfigDir, co)
-- }
if err := n.Run(ctx); !errors.Is(err, node.ErrQuit) {
return err
}
ddiff --git a/cmd/openvibe-node/panel.go b/cmd/openvibe-node/panel.go
nnew file mode 100644
iindex 0000000..185b694
---- /dev/null
++++ b/cmd/openvibe-node/panel.go
@@@ -0,0 +1,28 @@
++package main
++
++import (
++ "encoding/json"
++ "errors"
++ "fmt"
++ "io"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
++)
++
++// cmdPanel prints where Link's own window is on this machine: a loopback URL with this run's token in the fragment.
++// The tray's "Open Link" opens the same address.
++func cmdPanel(g *globals, stdout io.Writer) error {
++ resp, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdPanel}, 2*time.Second)
++ if err != nil {
++ return errors.New("the Node is not running: start it, then open Open Link from the tray or run this again")
++ }
++ var v struct {
++ URL string `json:"url"`
++ }
++ if err := json.Unmarshal(resp.Data, &v); err != nil || v.URL == "" {
++ return errors.New("the Node did not answer where Link's window is")
++ }
++ fmt.Fprintln(stdout, v.URL)
++ return nil
++}
ddiff --git a/cmd/openvibe-node/tray_systray.go b/cmd/openvibe-node/tray_systray.go
iindex 09fc5d6..a06b05c 100644
---- a/cmd/openvibe-node/tray_systray.go
++++ b/cmd/openvibe-node/tray_systray.go
@@@ -3,6 +3,8 @@
package main
import (
++ "encoding/json"
++ "errors"
"fmt"
"io"
"os"
@@@ -16,6 +18,8 @@ import (
"time"
"fyne.io/systray"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
)
// The notification area: a StatusNotifierItem over D-Bus on Linux (KDE, XFCE, Cinnamon, MATE, GNOME with the
@@@ -24,7 +28,8 @@ import (
type systrayUI struct {
mu sync.Mutex
header, level *systray.MenuItem
-- stop, resume, panel *systray.MenuItem
++ link, stop, resume *systray.MenuItem
++ panel *systray.MenuItem
quit *systray.MenuItem
requests *systray.MenuItem
slots []*systray.MenuItem
@@@ -36,6 +41,8 @@ type systrayUI struct {
}
func (u *systrayUI) build() {
++ u.link = systray.AddMenuItem("Open Link", "Link's own window: what runs here, what you allow, your shares")
++ systray.AddSeparator()
u.header = systray.AddMenuItem("OpenVibe Node", "")
u.header.Disable()
u.level = systray.AddMenuItem("", "")
@@@ -181,6 +188,12 @@ func runTray(g *globals) error {
systray.SetTitle("OpenVibe Node")
ui.build()
core.tick()
++ // The first time the tray runs, Link's window opens by itself: the person sees what the machine does.
++ if firstPanelOpen(g) {
++ if err := core.openPanel(); err != nil {
++ g.log.Info("Link's window did not open", "err", err)
++ }
++ }
go trayLoop(core, ui, stopSig, g)
}, core.quit)
return nil
@@@ -208,6 +221,10 @@ func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *global
core.note.Info("OpenVibe Node", "Could not stop: "+err.Error())
}
core.tick()
++ case <-ui.link.ClickedCh:
++ if err := core.openPanel(); err != nil {
++ core.note.Info("OpenVibe Node", err.Error())
++ }
case <-ui.resume.ClickedCh:
if err := core.resume(); err != nil {
core.note.Info("OpenVibe Node", "Could not resume: "+err.Error())
@@@ -227,6 +244,34 @@ func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *global
}
}
++// openPanel opens Link's own window: the Node's loopback URL, with this run's token in the fragment.
++func (t *trayCore) openPanel() error {
++ resp, err := t.call(localctl.Request{Cmd: localctl.CmdPanel})
++ if err != nil {
++ return err
++ }
++ var v struct {
++ URL string `json:"url"`
++ }
++ if err := json.Unmarshal(resp.Data, &v); err != nil || v.URL == "" {
++ return errors.New("Link's window is not running")
++ }
++ openURL(v.URL)
++ return nil
++}
++
++// firstPanelOpen is true once: the first time this tray runs, Link's window opens by itself.
++func firstPanelOpen(g *globals) bool {
++ path := filepath.Join(g.paths.StateDir, "panel-opened")
++ if _, err := os.Stat(path); err == nil {
++ return false
++ }
++ if err := os.WriteFile(path, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o600); err != nil {
++ return false
++ }
++ return true
++}
++
// openURL opens the machines page in the default browser.
func openURL(u string) {
var c *exec.Cmd
ddiff --git a/docs/panel.md b/docs/panel.md
nnew file mode 100644
iindex 0000000..1cb961b
---- /dev/null
++++ b/docs/panel.md
@@@ -0,0 +1,58 @@
++# Link's own window
++
++`openvibe-node` serves a small page on the machine itself: what runs here, what this computer allows, the shares it
++keeps, and what happened. The design and the JSON it reads are in
++[`internal/panel/ui/README.md`](../internal/panel/ui/README.md); this page is how it is served and what protects it.
++
++## Opening it
++
++- The tray's **Open Link** (the first item) opens it. The first time the tray runs, it opens by itself.
++- `openvibe-node panel` prints its address for a terminal without a tray.
++
++The address is `http://127.0.0.1:<port>/panel/#t=<token>`: the port is picked free at each start and the token is new
++for every run (32 random bytes). The token is in the fragment, so the browser never sends it in a request or in a
++`Referer`.
++
++## What protects it
++
++- **Loopback only.** It binds `127.0.0.1`; it never binds `0.0.0.0` or another interface. There is no TLS: the traffic
++ does not leave the machine.
++- **A token on every API call.** `/panel/api/*` needs `X-Link-Panel: <token>`; without it, or with another value, the
++ answer is `403`. Static files (`/panel/`, `panel.css`, `panel.js`) need no token: they are public code.
++- **Its own origin only.** When a request carries an `Origin`, it must be the panel's own loopback origin. A page on
++ another site cannot call the API, even if it guesses the port.
++- **No CORS.** The daemon writes no `Access-Control-*` header at all, so no other origin can read an answer.
++- **No listing.** Only the three files are served; the folder's `README.md` and `testdata/` are not embedded in the
++ binary and answer `404`.
++
++A restart makes a new port and a new token: an old link stops working.
++
++## The API
++
++| Call | What it does | Today |
++|---|---|---|
++| `GET /panel/api/state` | Everything the page shows | Machine, running (waiting approvals, jobs, connected shares, desks), capabilities, shares, activity |
++| `POST /panel/api/pause` `{ "paused": true }` | The local kill switch, the same latch as `openvibe-node stop` / the tray | Real |
++| `POST /panel/api/running/:id/stop` | Stops one running thing | Waiting approvals (deny), jobs (cancel), shares (end) |
++| `POST /panel/api/capabilities/:id` `{ "on": false }` | Turns a capability off here | Real; persisted in `<state>/panel.json` |
++| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
++| `DELETE /panel/api/shares/:port` | Ends that share | Real |
++| `POST /panel/api/files` | Sends files to your other devices | `501` with a reason: not in the daemon yet |
++
++`state` omits what the Node cannot fill truthfully: `devices` (the paired account's machine list is not read here),
++`gpu` (no GPU detection), and `earnings_today`. `activity` is the Node's own record of what it saw (an approval
++waiting or answered, a pause, a share, a capability), newest first, at most 30.
++
++## Capabilities
++
++Each card is a permission switch the daemon already has, from the `computer` plugin's `describe` (`terminal`, `files`,
++`screen`, `input`, `browser`, `desk`) and the `ai` plugin (`ai`). A switch is **on** when the plugin offers it, the
++computer is not at level `observe`, and it was not turned off here.
++
++Turning one **off** adds a local restriction the Node enforces itself: a command of that kind is refused with
++`not_allowed` before it reaches the plugin. Turning it **on** removes only that restriction; it never grants more than
++the plugin already offers, and it cannot raise the computer's level.
++
++**Desktop (`desk`) and Terminal (`terminal`) are gated** on the 11-05 review fixes: they may be turned off here but
++never on (`409` with the reason). At level `observe` nothing can be turned on from the panel: the level is set on the
++machine.
ddiff --git a/internal/localctl/localctl.go b/internal/localctl/localctl.go
iindex 47f37e2..0c065ea 100644
---- a/internal/localctl/localctl.go
++++ b/internal/localctl/localctl.go
@@@ -32,6 +32,9 @@ const (
// device link: the installer starts the service before the guide runs, and a second device connection would
// replace it.
CmdFirstRun = "first_run"
++ // CmdPanel answers where OpenVibe Link's own window is: its loopback URL with the per-run token in the fragment.
++ // It goes to the tray (or `openvibe-node panel`), which runs as the Node's owner, never into a status line.
++ CmdPanel = "panel"
)
type Request struct {
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 06b3063..44b5a64 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -33,6 +33,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/mono"
++ "github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/safety"
@@@ -56,6 +57,9 @@ type Options struct {
// under a service manager, which would only start it again.
CanQuit bool
++ // NoShares keeps saved Connect shares down (the guided dry run, which must not open a tunnel).
++ NoShares bool
++
// For tests.
ReauthInterval time.Duration
HeartbeatInterval time.Duration
@@@ -132,6 +136,14 @@ type Node struct {
inputs map[string]bool
inputEnded time.Time
trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
++
++ // panel is Link's own window (nil until Run started it). panelOff are the capability switches turned off in it,
++ // kept in state/panel.json; activity is what "What happened here" lists, newest first.
++ panel *panel.Server
++ panelOff map[string]bool
++ activity []panel.Activity
++ // shares keeps OpenVibe Connect shares up and lets the window add or end one.
++ shares *shareSet
}
// ErrQuit is what Run returns after `openvibe-node quit`: every actuator stopped, nothing latched, the process may exit.
@@@ -172,7 +184,8 @@ func New(opt Options) (*Node, error) {
opt.Log.Error("latch", "err", err)
}
n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
-- jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{}}
++ jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{},
++ panelOff: loadPanelOff(opt.Paths.StateDir)}
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
@@@ -294,6 +307,13 @@ func (n *Node) Run(parent context.Context) error {
}
n.applyPluginLatch(n.latch.State(), false)
++ // Link's own window, and the Connect shares it can add or end, start with the Node.
++ n.shares = newShareSet(ctx, n.opt.Paths.ConfigDir, n.shareOptions())
++ if !n.opt.NoShares {
++ n.shares.StartAll()
++ }
++ n.startPanel(ctx)
++
n.mgr.Start(pctx)
var wg sync.WaitGroup
@@@ -664,6 +684,10 @@ func (n *Node) command(c protocol.Command, sentMS int64, seq uint64, received mo
nack(protocol.FaultNotAllowed, c.Kind+" is not in this robot's allowed_commands")
return
}
++ if n.capabilityOff(c.Kind) {
++ nack(protocol.FaultNotAllowed, "turned off in Link: "+capabilityForKind[c.Kind]+" is off on this computer")
++ return
++ }
if protocol.ComputerKinds[c.Kind] {
n.computer(c, entry, sentMS, received, nack)
return
@@@ -1414,6 +1438,11 @@ func (n *Node) trackApproval(plugin, name string, data map[string]any, at time.T
a.ExpiresS = int64(e)
}
n.approvals[id] = a
++ what := strings.TrimSpace(safety.DisplayText(a.Summary))
++ if what == "" {
++ what = strings.TrimSpace(safety.DisplayText(a.Kind))
++ }
++ n.appendActivityLocked("Your agent is waiting for you: " + what)
}
// Approvals lists the computer commands waiting for an answer on the machine, oldest first.
@@@ -1478,31 +1507,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
}
return localctl.Response{OK: true, Data: b}
case localctl.CmdApprove, localctl.CmdDeny:
-- n.mu.Lock()
-- var a Approval
-- ok := false
-- for _, candidate := range n.approvals {
-- if candidate.ID == r.ID {
-- a, ok = candidate, true
-- break
-- }
-- }
-- if ok && r.Cmd == localctl.CmdApprove && n.drivingLocked() {
-- n.mu.Unlock()
-- return localctl.Response{Error: "the agent is using the mouse or keyboard (or just did): answer again in a moment, so that its click cannot approve this"}
-- }
-- if ok {
-- delete(n.approvals, a.CommandID)
-- }
-- n.mu.Unlock()
-- if !ok {
-- return localctl.Response{Error: fmt.Sprintf("nothing with the id %q waits for approval", strings.TrimSpace(r.ID))}
-- }
-- p := n.mgr.Get(a.Plugin)
-- if p == nil {
-- return localctl.Response{Error: "the plugin that asked is gone"}
-- }
-- if err := p.Answer(a.binding, r.Cmd == localctl.CmdApprove); err != nil {
++ if err := n.answerApproval(r.ID, r.Cmd == localctl.CmdApprove); err != nil {
return localctl.Response{Error: err.Error()}
}
return localctl.Response{OK: true}
@@@ -1536,8 +1541,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
}
return localctl.Response{OK: true}
case localctl.CmdStop:
-- n.mgr.EstopAll(plugins.EstopLocal, "openvibe-node stop") // at once, then persist
-- if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
++ if err := n.stopEverything("openvibe-node stop"); err != nil {
return localctl.Response{Error: "stopped, but the latch could not be saved: " + err.Error()}
}
return localctl.Response{OK: true}
@@@ -1554,10 +1558,20 @@ func (n *Node) control(r localctl.Request) localctl.Response {
go func() { time.Sleep(100 * time.Millisecond); n.quit() }() // after this answer is written
return localctl.Response{OK: true}
case localctl.CmdResume:
-- if _, err := n.latch.Resume(); err != nil {
++ if err := n.resumeEverything(); err != nil {
return localctl.Response{Error: err.Error()}
}
return localctl.Response{OK: true}
++ case localctl.CmdPanel:
++ u := n.PanelURL()
++ if u == "" {
++ return localctl.Response{Error: "Link's window is not running on this Node"}
++ }
++ b, err := json.Marshal(map[string]string{"url": u})
++ if err != nil {
++ return localctl.Response{Error: err.Error()}
++ }
++ return localctl.Response{OK: true, Data: b}
case localctl.CmdStatus, localctl.CmdPlugins:
b, err := json.Marshal(n.Status())
if err != nil {
ddiff --git a/internal/node/panel.go b/internal/node/panel.go
nnew file mode 100644
iindex 0000000..74c4daa
---- /dev/null
++++ b/internal/node/panel.go
@@@ -0,0 +1,426 @@
++package node
++
++// Link's own window (internal/panel): the Node serves the embedded UI on 127.0.0.1 with a per-run token, and answers
++// the small API it polls from what the Node already knows. Nothing here invents a value: a section the Node cannot
++// fill truthfully is left out.
++
++import (
++ "context"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "os"
++ "path/filepath"
++ "runtime"
++ "strconv"
++ "strings"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/panel"
++ "github.com/OpenVibers/OpenVibe.Node/internal/plugins"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++// panelCapabilities are the switches the window shows, in its order. Every one is a switch the daemon already has:
++// the computer plugin's own capabilities and the AI plugin's offer. Turning one off adds a local restriction; turning
++// it on removes it. A switch never grants more than the plugin already offers.
++var panelCapabilityList = []struct {
++ ID, Name, Who string
++ computer bool
++}{
++ {"files", "Your files on this computer", "Your agents, in the folders you allowed", true},
++ {"screen", "Your screen", "You and your agents, at the level you set", true},
++ {"input", "Your mouse and keyboard", "Your agents, after you approve", true},
++ {"browser", "A browser for your agents", "Your agents, after you approve", true},
++ {"terminal", "A terminal on this computer", "Your agents, at the level you set", true},
++ {"desk", "A desk for your agents", "Your agents, off your own screen", true},
++ {"ai", "Run AI models here", "Only you, from your other devices", false},
++}
++
++// panelGated are the switches Link may turn off but never on: Desktop and Terminal wait for the 11-05 review fixes.
++var panelGated = map[string]bool{"desk": true, "terminal": true}
++
++// capabilityForKind maps a command kind to the switch that can refuse it.
++var capabilityForKind = map[string]string{
++ protocol.KindExec: "terminal",
++ protocol.KindFS: "files",
++ protocol.KindScreen: "screen",
++ protocol.KindScreenStream: "screen",
++ protocol.KindInput: "input",
++ protocol.KindBrowser: "browser",
++ protocol.KindDesk: "desk",
++ protocol.KindDeskStream: "desk",
++ protocol.KindAI: "ai",
++}
++
++// panelFile is where the switches the person turned off are kept, so a restart keeps them.
++const panelFile = "panel.json"
++
++type panelSaved struct {
++ Off []string `json:"off"`
++}
++
++func loadPanelOff(dir string) map[string]bool {
++ off := map[string]bool{}
++ b, err := os.ReadFile(filepath.Join(dir, panelFile))
++ if err != nil {
++ return off
++ }
++ var s panelSaved
++ if json.Unmarshal(b, &s) != nil {
++ return off
++ }
++ for _, id := range s.Off {
++ off[id] = true
++ }
++ return off
++}
++
++func savePanelOff(dir string, off map[string]bool) error {
++ s := panelSaved{Off: []string{}}
++ for id := range off {
++ s.Off = append(s.Off, id)
++ }
++ b, err := json.MarshalIndent(s, "", " ")
++ if err != nil {
++ return err
++ }
++ tmp := filepath.Join(dir, panelFile+".tmp")
++ if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
++ return err
++ }
++ return os.Rename(tmp, filepath.Join(dir, panelFile))
++}
++
++// startPanel serves Link's window until ctx ends. A failure leaves the Node running without it.
++func (n *Node) startPanel(ctx context.Context) {
++ token, err := panel.NewToken()
++ if err != nil {
++ n.log.Warn("Link's window off: no token", "err", err)
++ return
++ }
++ srv, err := panel.Listen(panel.Options{
++ Token: token,
++ State: n.panelState,
++ Pause: n.panelPause,
++ Stop: n.panelStop,
++ SetCapability: n.setCapability,
++ Share: n.shareAdd,
++ Unshare: n.shareRemove,
++ Log: n.log,
++ })
++ if err != nil {
++ n.log.Warn("Link's window off: cannot listen on 127.0.0.1", "err", err)
++ return
++ }
++ n.mu.Lock()
++ n.panel = srv
++ n.mu.Unlock()
++ n.log.Info("Link's window is open on 127.0.0.1", "port", srv.Addr())
++ go func() {
++ <-ctx.Done()
++ _ = srv.Close()
++ }()
++ go func() {
++ if err := srv.Serve(); err != nil {
++ n.log.Warn("Link's window stopped", "err", err)
++ }
++ }()
++}
++
++// PanelURL is where the tray opens Link's window; "" when the Node did not start it.
++func (n *Node) PanelURL() string {
++ n.mu.Lock()
++ defer n.mu.Unlock()
++ if n.panel == nil {
++ return ""
++ }
++ return n.panel.URL()
++}
++
++// panelState is GET /panel/api/state, from what the Node already knows.
++func (n *Node) panelState() panel.State {
++ st := n.Status()
++ host, _ := os.Hostname()
++ out := panel.State{
++ Machine: &panel.Machine{
++ Name: host,
++ OS: runtime.GOOS,
++ Online: st.Link.Connected,
++ Paused: st.Latch.Stopped(),
++ NeedsYou: len(n.Approvals()),
++ },
++ Running: n.panelRunning(st),
++ Capabilities: n.panelCapabilities(st),
++ Shares: n.panelShares(st),
++ }
++ n.mu.Lock()
++ out.Activity = append([]panel.Activity(nil), n.activity...)
++ n.mu.Unlock()
++ return out
++}
++
++// panelRunning lists what the tray already shows as running or waiting: approvals, jobs, connected shares and desks.
++func (n *Node) panelRunning(st Status) []panel.Running {
++ out := []panel.Running{}
++ for _, a := range n.Approvals() {
++ title := strings.TrimSpace(a.Summary)
++ if title == "" {
++ title = "Waiting for your approval"
++ }
++ out = append(out, panel.Running{ID: "approval:" + a.ID, Kind: "agent", Title: title,
++ Detail: "Waiting for your approval", StartedAt: time.UnixMilli(a.At).UTC()})
++ }
++ for _, j := range st.Jobs {
++ r := panel.Running{ID: "job:" + j.ID, Kind: "agent", Title: j.Function, Detail: j.State}
++ if j.StartedMS > 0 {
++ r.StartedAt = time.UnixMilli(j.StartedMS).UTC()
++ }
++ out = append(out, r)
++ }
++ for _, sh := range st.Shares {
++ if sh.Connected {
++ out = append(out, panel.Running{ID: "share:" + strconv.Itoa(sh.Port), Kind: "share",
++ Title: fmt.Sprintf("Port %d shared", sh.Port), Detail: sh.URL})
++ }
++ }
++ if desks := sensorInt(st.Telemetry.Sensors["computer_desks"]); desks > 0 {
++ title := fmt.Sprintf("%d desks for your agents", desks)
++ if desks == 1 {
++ title = "A desk for your agents"
++ }
++ out = append(out, panel.Running{ID: "desks", Kind: "desk", Title: title, Detail: "Desktop"})
++ }
++ return out
++}
++
++// panelShares lists the connected shares with the address Connect gave them.
++func (n *Node) panelShares(st Status) []panel.Share {
++ out := []panel.Share{}
++ for _, sh := range st.Shares {
++ out = append(out, panel.Share{Port: sh.Port, URL: sh.URL})
++ }
++ return out
++}
++
++// panelCapabilities maps the daemon's permission switches to the window's list. A capability is on when a plugin
++// offers it, the computer is not at level observe, and the person has not turned it off here.
++func (n *Node) panelCapabilities(st Status) []panel.Capability {
++ offered := map[string]bool{}
++ for _, p := range st.Plugins {
++ for id := range p.Capabilities {
++ offered[id] = true
++ }
++ }
++ level, _ := st.Telemetry.Sensors["computer_level"].(string)
++ n.mu.Lock()
++ off := make(map[string]bool, len(n.panelOff))
++ for id := range n.panelOff {
++ off[id] = true
++ }
++ n.mu.Unlock()
++ out := []panel.Capability{}
++ for _, c := range panelCapabilityList {
++ if !offered[c.ID] {
++ continue
++ }
++ on := !off[c.ID]
++ if c.computer && (level == "" || level == "observe") {
++ on = false
++ }
++ out = append(out, panel.Capability{ID: c.ID, Name: c.Name, Who: c.Who, On: on})
++ }
++ return out
++}
++
++// setCapability turns one switch off (a local restriction) or back on. It persists the choice.
++func (n *Node) setCapability(id string, on bool) error {
++ known, computer := false, false
++ for _, c := range panelCapabilityList {
++ if c.ID == id {
++ known, computer = true, c.computer
++ }
++ }
++ if !known {
++ return fmt.Errorf("no capability %q on this computer", id)
++ }
++ if on && panelGated[id] {
++ return fmt.Errorf("%s is gated until the 11-05 review fixes are in: it cannot be turned on from Link", id)
++ }
++ if on && computer && n.computerLevel() == "observe" {
++ return errors.New("this computer is at level observe: set its level on the machine first")
++ }
++ n.mu.Lock()
++ next := make(map[string]bool, len(n.panelOff)+1)
++ for k, v := range n.panelOff {
++ next[k] = v
++ }
++ if on {
++ delete(next, id)
++ } else {
++ next[id] = true
++ }
++ err := savePanelOff(n.opt.Paths.StateDir, next)
++ if err == nil {
++ n.panelOff = next
++ }
++ n.mu.Unlock()
++ if err != nil {
++ return err
++ }
++ if on {
++ n.addActivity("Turned " + id + " back on")
++ } else {
++ n.addActivity("Turned " + id + " off")
++ }
++ return nil
++}
++
++// capabilityOff reports whether a command kind is refused by a switch turned off here.
++func (n *Node) capabilityOff(kind string) bool {
++ id := capabilityForKind[kind]
++ if id == "" {
++ return false
++ }
++ n.mu.Lock()
++ defer n.mu.Unlock()
++ return n.panelOff[id]
++}
++
++// computerLevel is the computer plugin's level, "" without it.
++func (n *Node) computerLevel() string {
++ n.mu.Lock()
++ defer n.mu.Unlock()
++ tv := n.telemetry["computer"]
++ if tv == nil {
++ return ""
++ }
++ sensors, _ := tv["sensors"].(map[string]any)
++ lv, _ := sensors["computer_level"].(string)
++ return lv
++}
++
++// sensorInt reads a sensor that travelled as JSON (float64) or was set in process.
++func sensorInt(v any) int {
++ switch n := v.(type) {
++ case float64:
++ return int(n)
++ case int:
++ return n
++ case int64:
++ return int(n)
++ case json.Number:
++ i, _ := n.Int64()
++ return int(i)
++ }
++ return 0
++}
++
++// addActivity records one line for "What happened here"; the newest 30 are kept.
++func (n *Node) addActivity(text string) {
++ n.mu.Lock()
++ defer n.mu.Unlock()
++ n.appendActivityLocked(text)
++}
++
++func (n *Node) appendActivityLocked(text string) {
++ text = strings.TrimSpace(text)
++ if text == "" {
++ return
++ }
++ n.activity = append([]panel.Activity{{At: time.Now().UTC(), Text: text}}, n.activity...)
++ if len(n.activity) > 30 {
++ n.activity = n.activity[:30]
++ }
++}
++
++// panelPause is the local kill switch, the same action as the tray's Stop everything and Resume.
++func (n *Node) panelPause(paused bool) error {
++ if paused {
++ if err := n.stopEverything("Link panel"); err != nil {
++ return err
++ }
++ n.addActivity("Everything stopped (Pause everything)")
++ return nil
++ }
++ if err := n.resumeEverything(); err != nil {
++ return err
++ }
++ n.addActivity("Resumed")
++ return nil
++}
++
++// stopEverything is the local kill switch: estop every plugin at once, then persist the latch.
++func (n *Node) stopEverything(reason string) error {
++ n.mgr.EstopAll(plugins.EstopLocal, reason)
++ _, err := n.latch.SetLocalWith(reason)
++ return err
++}
++
++// resumeEverything releases the local kill switch (and a server e-stop).
++func (n *Node) resumeEverything() error {
++ _, err := n.latch.Resume()
++ return err
++}
++
++// panelStop stops one running thing by the id panelRunning gave it.
++func (n *Node) panelStop(id string) error {
++ switch {
++ case strings.HasPrefix(id, "approval:"):
++ return n.answerApproval(strings.TrimPrefix(id, "approval:"), false)
++ case strings.HasPrefix(id, "job:"):
++ jid := strings.TrimPrefix(id, "job:")
++ if n.worker != nil && n.worker.Cancel(jid) {
++ n.addActivity("Stopped a job")
++ return nil
++ }
++ n.jobCancel(protocol.JobCancel{ID: jid})
++ return nil
++ case strings.HasPrefix(id, "share:"):
++ port, err := strconv.Atoi(strings.TrimPrefix(id, "share:"))
++ if err != nil {
++ break
++ }
++ return n.shareRemove(port)
++ case id == "desks":
++ return errors.New("a desk is stopped by the agent that started it, or by Pause everything")
++ }
++ return fmt.Errorf("nothing is running with the id %q", id)
++}
++
++// answerApproval lets one waiting request run or refuses it; the tray, the CLI and the window all end here.
++func (n *Node) answerApproval(nonce string, approved bool) error {
++ n.mu.Lock()
++ var a Approval
++ ok := false
++ for _, candidate := range n.approvals {
++ if candidate.ID == nonce {
++ a, ok = candidate, true
++ break
++ }
++ }
++ if ok && approved && n.drivingLocked() {
++ n.mu.Unlock()
++ return errors.New("the agent is using the mouse or keyboard (or just did): answer again in a moment, so that its click cannot approve this")
++ }
++ if ok {
++ delete(n.approvals, a.CommandID)
++ }
++ n.mu.Unlock()
++ if !ok {
++ return fmt.Errorf("nothing with the id %q waits for approval", strings.TrimSpace(nonce))
++ }
++ p := n.mgr.Get(a.Plugin)
++ if p == nil {
++ return errors.New("the plugin that asked is gone")
++ }
++ if err := p.Answer(a.binding, approved); err != nil {
++ return err
++ }
++ verb := "denied"
++ if approved {
++ verb = "approved"
++ }
++ n.addActivity(fmt.Sprintf("You %s: %s", verb, strings.TrimSpace(a.Summary)))
++ return nil
++}
ddiff --git a/internal/node/panel_test.go b/internal/node/panel_test.go
nnew file mode 100644
iindex 0000000..a9c83c6
---- /dev/null
++++ b/internal/node/panel_test.go
@@@ -0,0 +1,165 @@
++//go:build !windows
++
++package node
++
++import (
++ "context"
++ "encoding/json"
++ "io"
++ "log/slog"
++ "os"
++ "strings"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/connect"
++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++func panelTestNode(t *testing.T, paths config.Paths) *Node {
++ t.Helper()
++ n, err := New(Options{Config: &config.Config{}, Paths: paths, Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
++ if err != nil {
++ t.Fatal(err)
++ }
++ return n
++}
++
++func TestPanelPauseIsTheLocalKillSwitch(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovp") // short: the control socket path is limited
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ n := panelTestNode(t, config.HomePaths(dir))
++
++ if err := n.panelPause(true); err != nil {
++ t.Fatal(err)
++ }
++ if st := n.latch.State(); !st.Local || !st.Stopped() {
++ t.Fatalf("pause did not latch the local stop: %+v", st)
++ }
++ // The tray's Resume and the window's resume are the same latch.
++ if resp := n.control(localctl.Request{Cmd: localctl.CmdResume}); !resp.OK {
++ t.Fatal(resp.Error)
++ }
++ if st := n.latch.State(); st.Local || st.Stopped() {
++ t.Fatalf("resume did not clear the local stop: %+v", st)
++ }
++ // The CLI's stop and the window's pause latch the same thing.
++ if resp := n.control(localctl.Request{Cmd: localctl.CmdStop}); !resp.OK {
++ t.Fatal(resp.Error)
++ }
++ if st := n.latch.State(); !st.Local {
++ t.Fatalf("openvibe-node stop did not latch: %+v", st)
++ }
++ if err := n.panelPause(false); err != nil {
++ t.Fatal(err)
++ }
++ if st := n.latch.State(); st.Local {
++ t.Fatalf("the window's resume did not clear the CLI's stop: %+v", st)
++ }
++}
++
++func TestCapabilitySwitchIsARealRestriction(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovp")
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ paths := config.HomePaths(dir)
++ n := panelTestNode(t, paths)
++
++ if err := n.setCapability("files", false); err != nil {
++ t.Fatal(err)
++ }
++ if !n.capabilityOff(protocol.KindFS) {
++ t.Fatal("files off does not refuse fs commands")
++ }
++ if n.capabilityOff(protocol.KindExec) {
++ t.Fatal("turning files off also refused exec")
++ }
++ // It survives a restart.
++ n2 := panelTestNode(t, paths)
++ if !n2.capabilityOff(protocol.KindFS) {
++ t.Fatal("the switch was not kept")
++ }
++ if err := n2.setCapability("files", true); err != nil {
++ t.Fatal(err)
++ }
++ if n2.capabilityOff(protocol.KindFS) {
++ t.Fatal("files on still refuses fs commands")
++ }
++ // Desktop and Terminal may be turned off but never on from Link.
++ if err := n2.setCapability("terminal", false); err != nil {
++ t.Fatal(err)
++ }
++ if err := n2.setCapability("terminal", true); err == nil || !strings.Contains(err.Error(), "gated") {
++ t.Fatalf("terminal on: %v", err)
++ }
++ if err := n2.setCapability("desk", true); err == nil || !strings.Contains(err.Error(), "gated") {
++ t.Fatalf("desk on: %v", err)
++ }
++ if err := n2.setCapability("nope", true); err == nil {
++ t.Fatal("an unknown capability was accepted")
++ }
++}
++
++func TestPanelStateComesFromTheNode(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovp")
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ n := panelTestNode(t, config.HomePaths(dir))
++ if err := n.panelPause(true); err != nil {
++ t.Fatal(err)
++ }
++ st := n.panelState()
++ if st.Machine == nil || st.Machine.Name == "" || !st.Machine.Paused {
++ t.Fatalf("machine: %+v", st.Machine)
++ }
++ if len(st.Activity) == 0 || !strings.Contains(st.Activity[0].Text, "stopped") {
++ t.Fatalf("activity: %+v", st.Activity)
++ }
++ // It round-trips as the contract's JSON, with no device or GPU invented.
++ b, err := json.Marshal(st)
++ if err != nil {
++ t.Fatal(err)
++ }
++ for _, absent := range []string{`"devices"`, `"gpu"`, `"earnings_today"`} {
++ if strings.Contains(string(b), absent) {
++ t.Fatalf("%s was invented: %s", absent, b)
++ }
++ }
++}
++
++func TestPanelURLIsEmptyBeforeRun(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovp")
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ n := panelTestNode(t, config.HomePaths(dir))
++ if u := n.PanelURL(); u != "" {
++ t.Fatalf("url before Run: %q", u)
++ }
++ resp := n.control(localctl.Request{Cmd: localctl.CmdPanel})
++ if resp.OK || resp.Error == "" {
++ t.Fatalf("panel before Run: %+v", resp)
++ }
++}
++
++func TestShareAddWithoutAMachineIsRefused(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovp")
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ n := panelTestNode(t, config.HomePaths(dir))
++ n.shares = newShareSet(context.Background(), dir, connect.Options{})
++ if _, err := n.shareAdd(3000); err == nil || !strings.Contains(err.Error(), "not paired") {
++ t.Fatalf("share without pairing: %v", err)
++ }
++}
ddiff --git a/internal/node/shares.go b/internal/node/shares.go
nnew file mode 100644
iindex 0000000..301da66
---- /dev/null
++++ b/internal/node/shares.go
@@@ -0,0 +1,173 @@
++package node
++
++// OpenVibe Connect shares, kept in the Node's own process so Link's window can start and end one while it runs. The
++// saved list is the same one `openvibe-node share add|list|remove` uses.
++
++import (
++ "context"
++ "errors"
++ "fmt"
++ "strconv"
++ "sync"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/connect"
++ "github.com/OpenVibers/OpenVibe.Node/internal/link"
++ "github.com/OpenVibers/OpenVibe.Node/internal/panel"
++)
++
++// shareOptions builds the Connect client: a Network-paired machine buys its own node token for a share it serves.
++func (n *Node) shareOptions() connect.Options {
++ o := connect.Options{Log: n.log, Version: n.opt.Version}
++ if n.opt.Creds != nil && n.opt.Creds.NetworkPaired() {
++ ts := link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-node/"+n.opt.Version, link.ConnectAudience)
++ o.NodeToken = func(ctx context.Context) (string, error) {
++ tok, err := ts.Token(ctx)
++ if errors.Is(err, link.ErrNodeRevoked) {
++ return "", fmt.Errorf("%w: %v", connect.ErrStopped, err)
++ }
++ if err != nil {
++ return "", err
++ }
++ return tok.Reveal(), nil
++ }
++ }
++ return o
++}
++
++// shareSet keeps every saved share connected, and can add or remove one at runtime.
++type shareSet struct {
++ ctx context.Context
++ dir string
++ opts connect.Options
++ mu sync.Mutex
++ // running is one cancel per port this process serves.
++ running map[int]context.CancelFunc
++}
++
++func newShareSet(ctx context.Context, dir string, opts connect.Options) *shareSet {
++ return &shareSet{ctx: ctx, dir: dir, opts: opts, running: map[int]context.CancelFunc{}}
++}
++
++// StartAll connects every saved share, as `openvibe-node run` always did.
++func (s *shareSet) StartAll() {
++ shares, err := connect.Load(s.dir)
++ if err != nil {
++ if s.opts.Log != nil {
++ s.opts.Log.Warn("connect: saved shares unreadable", "err", err)
++ }
++ return
++ }
++ for _, sh := range shares {
++ s.start(sh)
++ }
++}
++
++func (s *shareSet) start(sh connect.Share) {
++ s.mu.Lock()
++ if _, ok := s.running[sh.Port]; ok {
++ s.mu.Unlock()
++ return
++ }
++ ctx, cancel := context.WithCancel(s.ctx)
++ s.running[sh.Port] = cancel
++ s.mu.Unlock()
++ go func() {
++ err := connect.Run(ctx, sh, s.opts)
++ s.mu.Lock()
++ if cancel, ok := s.running[sh.Port]; ok {
++ cancel()
++ delete(s.running, sh.Port)
++ }
++ s.mu.Unlock()
++ if errors.Is(err, connect.ErrStopped) && s.opts.Log != nil {
++ s.opts.Log.Warn("connect: share stopped; remove it with `openvibe-node share remove`", "port", sh.Port, "share", sh.Label())
++ }
++ }()
++}
++
++// add serves a port this computer was picked for on OpenVibe Connect.
++func (s *shareSet) add(port int) (panel.Share, error) {
++ if port < 1 || port > 65535 {
++ return panel.Share{}, errors.New("port is 1 to 65535")
++ }
++ if s.opts.NodeToken == nil {
++ return panel.Share{}, errors.New("this computer is not paired with OpenVibe: add it on openvibe.bot/machines first")
++ }
++ s.mu.Lock()
++ _, running := s.running[port]
++ s.mu.Unlock()
++ if running {
++ return panel.Share{}, fmt.Errorf("port %d is already shared", port)
++ }
++ tok, err := s.opts.NodeToken(s.ctx)
++ if err != nil {
++ return panel.Share{}, err
++ }
++ mine, err := connect.NodeShares(s.ctx, nil, connect.DefaultServer, tok, "openvibe-node/"+s.opts.Version)
++ if err != nil {
++ return panel.Share{}, err
++ }
++ var found *connect.NodeShare
++ for i := range mine {
++ if mine[i].LocalPort != port {
++ continue
++ }
++ if found != nil {
++ return panel.Share{}, fmt.Errorf("Connect has more than one share for this computer on port %d", port)
++ }
++ found = &mine[i]
++ }
++ if found == nil {
++ return panel.Share{}, fmt.Errorf("Connect has no share for this computer on port %d: pick this computer on the share's page first", port)
++ }
++ sh := connect.Share{ID: found.ID, Port: port, Server: connect.DefaultServer, Name: found.Host}
++ if err := connect.Add(s.dir, sh); err != nil {
++ return panel.Share{}, err
++ }
++ s.start(sh)
++ return panel.Share{Port: port, URL: found.URL}, nil
++}
++
++// remove ends a share and forgets it from the saved list.
++func (s *shareSet) remove(port int) error {
++ s.mu.Lock()
++ cancel := s.running[port]
++ delete(s.running, port)
++ s.mu.Unlock()
++ n, err := connect.Remove(s.dir, strconv.Itoa(port))
++ if err != nil {
++ return err
++ }
++ if cancel != nil {
++ cancel()
++ }
++ if cancel == nil && n == 0 {
++ return fmt.Errorf("port %d is not shared", port)
++ }
++ return nil
++}
++
++// shareAdd is the window's POST /panel/api/shares.
++func (n *Node) shareAdd(port int) (panel.Share, error) {
++ if n.shares == nil {
++ return panel.Share{}, errors.New("this Node is not keeping shares up")
++ }
++ sh, err := n.shares.add(port)
++ if err != nil {
++ return panel.Share{}, err
++ }
++ n.addActivity(fmt.Sprintf("Port %d shared through OpenVibe Connect", port))
++ return sh, nil
++}
++
++// shareRemove is the window's DELETE /panel/api/shares/:port.
++func (n *Node) shareRemove(port int) error {
++ if n.shares == nil {
++ return errors.New("this Node is not keeping shares up")
++ }
++ if err := n.shares.remove(port); err != nil {
++ return err
++ }
++ n.addActivity(fmt.Sprintf("Port %d is no longer shared", port))
++ return nil
++}
ddiff --git a/internal/panel/panel.go b/internal/panel/panel.go
nnew file mode 100644
iindex 0000000..b09dee8
---- /dev/null
++++ b/internal/panel/panel.go
@@@ -0,0 +1,361 @@
++// Package panel serves OpenVibe Link's own window: the static UI embedded here and the small JSON API it polls, on a
++// loopback-only HTTP server with a per-run token.
++//
++// The UI is the one designed in internal/panel/ui (see its README for the JSON contract). Every /panel/api/* request
++// must carry the token as X-Link-Panel and, when it sends an Origin, send this server's own origin; a request without
++// the token or from another origin is refused. Static files need no token. No CORS header is ever written.
++package panel
++
++import (
++ "crypto/rand"
++ "crypto/subtle"
++ "embed"
++ "encoding/base64"
++ "encoding/json"
++ "errors"
++ "io"
++ "io/fs"
++ "log/slog"
++ "net"
++ "net/http"
++ "strconv"
++ "strings"
++ "time"
++)
++
++//go:embed ui/index.html ui/panel.css ui/panel.js
++var assets embed.FS
++
++// Assets is the UI as it ships in the binary: index.html, panel.css and panel.js. testdata and the README are not
++// embedded (they are documentation and fixtures, not something to serve).
++func Assets() fs.FS { return assets }
++
++// ErrNotImplemented is what a handler returns for a request this version of Link cannot serve.
++var ErrNotImplemented = errors.New("not implemented in this version of Link")
++
++// Machine is what the window's header shows.
++type Machine struct {
++ Name string `json:"name"`
++ OS string `json:"os,omitempty"`
++ Online bool `json:"online"`
++ Paused bool `json:"paused"`
++ NeedsYou int `json:"needs_you,omitempty"`
++}
++
++// Running is one thing the machine is doing: an agent, a desk, a share or a model.
++type Running struct {
++ ID string `json:"id"`
++ Kind string `json:"kind"`
++ Title string `json:"title"`
++ Detail string `json:"detail,omitempty"`
++ StartedAt time.Time `json:"started_at,omitempty"`
++}
++
++// Capability is one permission switch: what this computer allows, for whom.
++type Capability struct {
++ ID string `json:"id"`
++ Name string `json:"name"`
++ Who string `json:"who,omitempty"`
++ On bool `json:"on"`
++}
++
++// Share is one port put on the web through OpenVibe Connect.
++type Share struct {
++ Port int `json:"port"`
++ URL string `json:"url,omitempty"`
++ ExpiresAt time.Time `json:"expires_at,omitempty"`
++}
++
++// Device is one of the person's machines.
++type Device struct {
++ Name string `json:"name"`
++ Kind string `json:"kind,omitempty"`
++ Online bool `json:"online"`
++ Here bool `json:"here,omitempty"`
++}
++
++// Model is one local model offered by a GPU.
++type Model struct {
++ Name string `json:"name"`
++ TPS int `json:"tps,omitempty"`
++ Loaded bool `json:"loaded,omitempty"`
++}
++
++// GPU is the machine's graphics card and the models it serves.
++type GPU struct {
++ Name string `json:"name"`
++ VRAMGB int `json:"vram_gb,omitempty"`
++ Models []Model `json:"models,omitempty"`
++}
++
++// Earnings is what this machine earned today.
++type Earnings struct {
++ Coins int `json:"coins"`
++}
++
++// Activity is one thing that happened on this machine, as the window lists it.
++type Activity struct {
++ At time.Time `json:"at"`
++ Text string `json:"text"`
++}
++
++// State is GET /panel/api/state. Every key is optional: a section with nothing in it is left out.
++type State struct {
++ Machine *Machine `json:"machine,omitempty"`
++ Running []Running `json:"running,omitempty"`
++ Capabilities []Capability `json:"capabilities,omitempty"`
++ Shares []Share `json:"shares,omitempty"`
++ Devices []Device `json:"devices,omitempty"`
++ GPU *GPU `json:"gpu,omitempty"`
++ EarningsToday *Earnings `json:"earnings_today,omitempty"`
++ Activity []Activity `json:"activity,omitempty"`
++}
++
++// Options are what the daemon gives the panel: its state, and the actions the window can ask for.
++type Options struct {
++ // Token is the per-run secret the page sends as X-Link-Panel. Required.
++ Token string
++ // State answers GET /panel/api/state.
++ State func() State
++ // Pause is the local kill switch, the same one the tray's Stop everything and Resume use.
++ Pause func(paused bool) error
++ // Stop stops one running thing by the id State used.
++ Stop func(id string) error
++ // SetCapability turns one capability switch on or off.
++ SetCapability func(id string, on bool) error
++ // Share shares a local port through Connect; Unshare ends that share.
++ Share func(port int) (Share, error)
++ Unshare func(port int) error
++ Log *slog.Logger
++}
++
++// Server is one running panel server.
++type Server struct {
++ ln net.Listener
++ origin string
++ token []byte
++ opt Options
++}
++
++// NewToken is a per-run panel token: 32 random bytes, base64url without padding.
++func NewToken() (string, error) {
++ b := make([]byte, 32)
++ if _, err := rand.Read(b); err != nil {
++ return "", err
++ }
++ return base64.RawURLEncoding.EncodeToString(b), nil
++}
++
++// Listen binds the panel on 127.0.0.1 at a free port. It never binds another address.
++func Listen(opt Options) (*Server, error) {
++ if len(opt.Token) < 16 {
++ return nil, errors.New("panel: a token of at least 16 characters is required")
++ }
++ ln, err := net.Listen("tcp", "127.0.0.1:0")
++ if err != nil {
++ return nil, err
++ }
++ return &Server{ln: ln, origin: "http://" + ln.Addr().String(), token: []byte(opt.Token), opt: opt}, nil
++}
++
++// Addr is the address it listens on (127.0.0.1:port).
++func (s *Server) Addr() string { return s.ln.Addr().String() }
++
++// Origin is the panel's own origin (http://127.0.0.1:port).
++func (s *Server) Origin() string { return s.origin }
++
++// URL is where to open the window: the static page with the token in its fragment, which browsers never send.
++func (s *Server) URL() string { return s.origin + "/panel/#t=" + s.opt.Token }
++
++// Serve answers requests until Close.
++func (s *Server) Serve() error {
++ err := http.Serve(s.ln, s.Handler())
++ if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) {
++ return nil
++ }
++ return err
++}
++
++// Close stops the server.
++func (s *Server) Close() error { return s.ln.Close() }
++
++// Handler is the panel's routes: /panel/ for the files, /panel/api/ for the JSON.
++func (s *Server) Handler() http.Handler {
++ mux := http.NewServeMux()
++ mux.Handle("/panel/api/", s.api())
++ mux.Handle("/panel/", s.files())
++ return mux
++}
++
++// files serves the three UI files. No token is needed (they are public code), and nothing else in the folder is
++// reachable.
++func (s *Server) files() http.Handler {
++ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ if r.Method != http.MethodGet && r.Method != http.MethodHead {
++ w.Header().Set("Allow", "GET, HEAD")
++ writeJSON(w, http.StatusMethodNotAllowed, errBody("only GET and HEAD"))
++ return
++ }
++ name := strings.TrimPrefix(r.URL.Path, "/panel/")
++ typ := ""
++ switch name {
++ case "", "index.html":
++ name, typ = "index.html", "text/html; charset=utf-8"
++ case "panel.css":
++ typ = "text/css; charset=utf-8"
++ case "panel.js":
++ typ = "text/javascript; charset=utf-8"
++ default:
++ http.NotFound(w, r)
++ return
++ }
++ b, err := assets.ReadFile("ui/" + name)
++ if err != nil {
++ http.NotFound(w, r)
++ return
++ }
++ w.Header().Set("Content-Type", typ)
++ w.Header().Set("Cache-Control", "no-store")
++ w.Header().Set("X-Content-Type-Options", "nosniff")
++ _, _ = w.Write(b)
++ })
++}
++
++// api checks the token and origin before every call, then dispatches.
++func (s *Server) api() http.Handler {
++ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
++ if !s.authorized(r) {
++ writeJSON(w, http.StatusForbidden, errBody("forbidden"))
++ return
++ }
++ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
++ path := strings.TrimPrefix(r.URL.Path, "/panel/api/")
++ switch {
++ case path == "state" && r.Method == http.MethodGet:
++ st := State{}
++ if s.opt.State != nil {
++ st = s.opt.State()
++ }
++ writeJSON(w, http.StatusOK, st)
++ case path == "pause" && r.Method == http.MethodPost:
++ var body struct {
++ Paused *bool `json:"paused"`
++ }
++ if !decode(w, r, &body) || body.Paused == nil {
++ writeJSON(w, http.StatusBadRequest, errBody("paused is true or false"))
++ return
++ }
++ if s.opt.Pause == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot pause this Node"))
++ return
++ }
++ if err := s.opt.Pause(*body.Paused); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]bool{"paused": *body.Paused})
++ case strings.HasPrefix(path, "running/") && strings.HasSuffix(path, "/stop") && r.Method == http.MethodPost:
++ id := strings.TrimSuffix(strings.TrimPrefix(path, "running/"), "/stop")
++ if id == "" || strings.Contains(id, "/") {
++ http.NotFound(w, r)
++ return
++ }
++ if s.opt.Stop == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot stop this"))
++ return
++ }
++ if err := s.opt.Stop(id); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]string{"stopped": id})
++ case strings.HasPrefix(path, "capabilities/") && r.Method == http.MethodPost:
++ id := strings.TrimPrefix(path, "capabilities/")
++ if id == "" || strings.Contains(id, "/") {
++ http.NotFound(w, r)
++ return
++ }
++ var body struct {
++ On *bool `json:"on"`
++ }
++ if !decode(w, r, &body) || body.On == nil {
++ writeJSON(w, http.StatusBadRequest, errBody("on is true or false"))
++ return
++ }
++ if s.opt.SetCapability == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot change this switch"))
++ return
++ }
++ if err := s.opt.SetCapability(id, *body.On); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]any{"id": id, "on": *body.On})
++ case path == "shares" && r.Method == http.MethodPost:
++ var body struct {
++ Port int `json:"port"`
++ }
++ if !decode(w, r, &body) || body.Port < 1 || body.Port > 65535 {
++ writeJSON(w, http.StatusBadRequest, errBody("port is 1 to 65535"))
++ return
++ }
++ if s.opt.Share == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot share a port"))
++ return
++ }
++ sh, err := s.opt.Share(body.Port)
++ if err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, sh)
++ case strings.HasPrefix(path, "shares/") && r.Method == http.MethodDelete:
++ port, err := strconv.Atoi(strings.TrimPrefix(path, "shares/"))
++ if err != nil || port < 1 || port > 65535 {
++ http.NotFound(w, r)
++ return
++ }
++ if s.opt.Unshare == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot end a share"))
++ return
++ }
++ if err := s.opt.Unshare(port); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ w.WriteHeader(http.StatusNoContent)
++ case path == "files" && r.Method == http.MethodPost:
++ // Sending files between devices is not in the daemon yet; the window already says it could not send them.
++ writeJSON(w, http.StatusNotImplemented, errBody("sending files between devices is not available in this version of Link"))
++ default:
++ http.NotFound(w, r)
++ }
++ })
++}
++
++// authorized: the token must match, and an Origin, when present, must be this server's own.
++func (s *Server) authorized(r *http.Request) bool {
++ got := []byte(r.Header.Get("X-Link-Panel"))
++ if subtle.ConstantTimeCompare(got, s.token) != 1 {
++ return false
++ }
++ if o := r.Header.Get("Origin"); o != "" && o != s.origin {
++ return false
++ }
++ return true
++}
++
++func decode(w http.ResponseWriter, r *http.Request, out any) bool {
++ dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
++ dec.DisallowUnknownFields()
++ return dec.Decode(out) == nil
++}
++
++func errBody(msg string) map[string]string { return map[string]string{"error": msg} }
++
++func writeJSON(w http.ResponseWriter, status int, v any) {
++ w.Header().Set("Content-Type", "application/json; charset=utf-8")
++ w.Header().Set("X-Content-Type-Options", "nosniff")
++ w.WriteHeader(status)
++ _ = json.NewEncoder(w).Encode(v)
++}
ddiff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go
nnew file mode 100644
iindex 0000000..0ed1c88
---- /dev/null
++++ b/internal/panel/panel_test.go
@@@ -0,0 +1,259 @@
++package panel
++
++import (
++ "encoding/json"
++ "io/fs"
++ "net"
++ "net/http"
++ "net/http/httptest"
++ "os"
++ "reflect"
++ "sort"
++ "strings"
++ "testing"
++ "time"
++)
++
++const testToken = "test-token-for-the-panel"
++
++func testServer(t *testing.T, opt Options) *Server {
++ t.Helper()
++ if opt.Token == "" {
++ opt.Token = testToken
++ }
++ srv, err := Listen(opt)
++ if err != nil {
++ t.Fatal(err)
++ }
++ t.Cleanup(func() { _ = srv.Close() })
++ return srv
++}
++
++func do(t *testing.T, srv *Server, method, path, token, origin, body string) *httptest.ResponseRecorder {
++ t.Helper()
++ var r *http.Request
++ if body != "" {
++ r = httptest.NewRequest(method, path, strings.NewReader(body))
++ } else {
++ r = httptest.NewRequest(method, path, nil)
++ }
++ if token != "" {
++ r.Header.Set("X-Link-Panel", token)
++ }
++ if origin != "" {
++ r.Header.Set("Origin", origin)
++ }
++ w := httptest.NewRecorder()
++ srv.Handler().ServeHTTP(w, r)
++ return w
++}
++
++func TestListenIsLoopbackOnly(t *testing.T) {
++ srv := testServer(t, Options{State: func() State { return State{} }})
++ host, port, err := net.SplitHostPort(srv.Addr())
++ if err != nil {
++ t.Fatal(err)
++ }
++ if host != "127.0.0.1" || port == "0" || port == "" {
++ t.Fatalf("addr %q is not a free loopback port", srv.Addr())
++ }
++ if srv.Origin() != "http://"+srv.Addr() {
++ t.Fatalf("origin %q", srv.Origin())
++ }
++ if !strings.HasPrefix(srv.URL(), srv.Origin()+"/panel/#t="+testToken) {
++ t.Fatalf("url %q", srv.URL())
++ }
++}
++
++func TestAPIRequiresTheToken(t *testing.T) {
++ srv := testServer(t, Options{State: func() State { return State{Machine: &Machine{Name: "here"}} }})
++ if w := do(t, srv, http.MethodGet, "/panel/api/state", "", "", ""); w.Code != http.StatusForbidden {
++ t.Fatalf("no token: %d", w.Code)
++ }
++ if w := do(t, srv, http.MethodGet, "/panel/api/state", "wrong-token-wrong-token", "", ""); w.Code != http.StatusForbidden {
++ t.Fatalf("wrong token: %d", w.Code)
++ }
++ w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, "", "")
++ if w.Code != http.StatusOK {
++ t.Fatalf("token: %d %s", w.Code, w.Body)
++ }
++ var st State
++ if err := json.Unmarshal(w.Body.Bytes(), &st); err != nil || st.Machine == nil || st.Machine.Name != "here" {
++ t.Fatalf("state: %v %+v", err, st)
++ }
++}
++
++func TestAPIRequiresItsOwnOrigin(t *testing.T) {
++ srv := testServer(t, Options{State: func() State { return State{} }})
++ if w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, "https://evil.example", ""); w.Code != http.StatusForbidden {
++ t.Fatalf("wrong origin: %d", w.Code)
++ }
++ w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, srv.Origin(), "")
++ if w.Code != http.StatusOK {
++ t.Fatalf("own origin: %d", w.Code)
++ }
++ for k := range w.Header() {
++ if strings.HasPrefix(k, "Access-Control-") {
++ t.Fatalf("a CORS header was written: %s", k)
++ }
++ }
++}
++
++func TestStaticFilesNeedNoToken(t *testing.T) {
++ srv := testServer(t, Options{})
++ w := do(t, srv, http.MethodGet, "/panel/", "", "", "")
++ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "OpenVibe Link") {
++ t.Fatalf("index: %d %s", w.Code, w.Body.String())
++ }
++ if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
++ t.Fatalf("index content type %q", ct)
++ }
++ for _, p := range []string{"/panel/panel.css", "/panel/panel.js"} {
++ if w := do(t, srv, http.MethodGet, p, "", "", ""); w.Code != http.StatusOK {
++ t.Fatalf("%s: %d", p, w.Code)
++ }
++ }
++}
++
++func TestTestdataAndReadmeAreNotServedNorEmbedded(t *testing.T) {
++ srv := testServer(t, Options{})
++ for _, p := range []string{"/panel/testdata/state.json", "/panel/README.md", "/panel/panel.go"} {
++ if w := do(t, srv, http.MethodGet, p, "", "", ""); w.Code != http.StatusNotFound {
++ t.Fatalf("%s answered %d", p, w.Code)
++ }
++ }
++ for _, p := range []string{"ui/testdata/state.json", "ui/README.md"} {
++ if _, err := fs.Stat(Assets(), p); err == nil {
++ t.Fatalf("%s is embedded; it must not be", p)
++ }
++ }
++ if _, err := fs.Stat(Assets(), "ui/index.html"); err != nil {
++ t.Fatalf("index.html is not embedded: %v", err)
++ }
++}
++
++// The window's JSON contract: a fully filled State has exactly the keys testdata/state.json has.
++func TestStateShapeMatchesTheDesign(t *testing.T) {
++ raw, err := os.ReadFile("ui/testdata/state.json")
++ if err != nil {
++ t.Fatal(err)
++ }
++ var want map[string]json.RawMessage
++ if err := json.Unmarshal(raw, &want); err != nil {
++ t.Fatal(err)
++ }
++ now := time.Date(2026, 11, 7, 10, 2, 0, 0, time.UTC)
++ full := State{
++ Machine: &Machine{Name: "Workshop desktop", OS: "linux", Online: true, Paused: false, NeedsYou: 1},
++ Running: []Running{{ID: "r_1", Kind: "agent", Title: "t", Detail: "d", StartedAt: now}},
++ Capabilities: []Capability{{ID: "files", Name: "n", Who: "w", On: true}},
++ Shares: []Share{{Port: 3000, URL: "https://x", ExpiresAt: now}},
++ Devices: []Device{{Name: "d", Kind: "desktop", Online: true, Here: true}},
++ GPU: &GPU{Name: "RTX", VRAMGB: 24, Models: []Model{{Name: "m", TPS: 41, Loaded: true}}},
++ EarningsToday: &Earnings{Coins: 120},
++ Activity: []Activity{{At: now, Text: "x"}},
++ }
++ b, err := json.Marshal(full)
++ if err != nil {
++ t.Fatal(err)
++ }
++ var got map[string]json.RawMessage
++ if err := json.Unmarshal(b, &got); err != nil {
++ t.Fatal(err)
++ }
++ gotKeys, wantKeys := keysOf(got), keysOf(want)
++ if !reflect.DeepEqual(gotKeys, wantKeys) {
++ t.Fatalf("state keys:\n got %v\nwant %v", gotKeys, wantKeys)
++ }
++}
++
++func keysOf(m map[string]json.RawMessage) []string {
++ out := make([]string, 0, len(m))
++ for k := range m {
++ out = append(out, k)
++ }
++ sort.Strings(out)
++ return out
++}
++
++func TestEmptySectionsAreOmitted(t *testing.T) {
++ srv := testServer(t, Options{State: func() State { return State{Machine: &Machine{Name: "x"}} }})
++ w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, "", "")
++ for _, k := range []string{"running", "capabilities", "shares", "devices", "gpu", "earnings_today", "activity"} {
++ if strings.Contains(w.Body.String(), `"`+k+`"`) {
++ t.Fatalf("an empty section (%s) was written: %s", k, w.Body)
++ }
++ }
++}
++
++func TestPauseCallsTheKillSwitch(t *testing.T) {
++ var calls []bool
++ srv := testServer(t, Options{Pause: func(p bool) error { calls = append(calls, p); return nil }})
++ if w := do(t, srv, http.MethodPost, "/panel/api/pause", testToken, srv.Origin(), `{"paused":true}`); w.Code != http.StatusOK {
++ t.Fatalf("pause: %d %s", w.Code, w.Body)
++ }
++ if w := do(t, srv, http.MethodPost, "/panel/api/pause", testToken, "", `{"paused":false}`); w.Code != http.StatusOK {
++ t.Fatalf("resume: %d %s", w.Code, w.Body)
++ }
++ if w := do(t, srv, http.MethodPost, "/panel/api/pause", testToken, "", `{}`); w.Code != http.StatusBadRequest {
++ t.Fatalf("no paused: %d", w.Code)
++ }
++ if !reflect.DeepEqual(calls, []bool{true, false}) {
++ t.Fatalf("calls %v", calls)
++ }
++}
++
++func TestStopCapabilityAndSharesReachTheDaemon(t *testing.T) {
++ stopped, caps, shared, unshared := "", "", 0, 0
++ srv := testServer(t, Options{
++ Stop: func(id string) error { stopped = id; return nil },
++ SetCapability: func(id string, on bool) error { caps = id; return nil },
++ Share: func(port int) (Share, error) { shared = port; return Share{Port: port}, nil },
++ Unshare: func(port int) error { unshared = port; return nil },
++ })
++ if w := do(t, srv, http.MethodPost, "/panel/api/running/approval:a1/stop", testToken, "", ""); w.Code != http.StatusOK {
++ t.Fatalf("stop: %d %s", w.Code, w.Body)
++ }
++ if stopped != "approval:a1" {
++ t.Fatalf("stopped %q", stopped)
++ }
++ if w := do(t, srv, http.MethodPost, "/panel/api/capabilities/files", testToken, "", `{"on":false}`); w.Code != http.StatusOK {
++ t.Fatalf("capability: %d %s", w.Code, w.Body)
++ }
++ if caps != "files" {
++ t.Fatalf("capability %q", caps)
++ }
++ if w := do(t, srv, http.MethodPost, "/panel/api/shares", testToken, "", `{"port":3000}`); w.Code != http.StatusOK {
++ t.Fatalf("share: %d %s", w.Code, w.Body)
++ }
++ if shared != 3000 {
++ t.Fatalf("shared %d", shared)
++ }
++ if w := do(t, srv, http.MethodDelete, "/panel/api/shares/3000", testToken, "", ""); w.Code != http.StatusNoContent {
++ t.Fatalf("unshare: %d %s", w.Code, w.Body)
++ }
++ if unshared != 3000 {
++ t.Fatalf("unshared %d", unshared)
++ }
++}
++
++func TestFilesAnswer501WithAReason(t *testing.T) {
++ srv := testServer(t, Options{})
++ w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", "")
++ if w.Code != http.StatusNotImplemented {
++ t.Fatalf("files: %d", w.Code)
++ }
++ var body struct {
++ Error string `json:"error"`
++ }
++ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || !strings.Contains(body.Error, "files") {
++ t.Fatalf("reason: %q %v", w.Body, err)
++ }
++}
++
++func TestAPIUnknownPathIs404(t *testing.T) {
++ srv := testServer(t, Options{})
++ if w := do(t, srv, http.MethodGet, "/panel/api/nope", testToken, "", ""); w.Code != http.StatusNotFound {
++ t.Fatalf("unknown: %d", w.Code)
++ }
++}