openvibers/openvibe.link Commits
Link's window: the daemon serves the panel on loopback and answers what it already knows (#126)
Changes
@@@ -100,8 +100,15 @@ the ones this machine was installed for: the job worker starts if the local confplainly which role it could not honour. The local configuration always wins: a role never installs anything and neverturns 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 driverprotocol, 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 toyour own memory store), [skills](docs/skills.md) (the skills hub: which folders each tool reads, the marked block, thedry run and the undo).@@@ -77,7 +77,8 @@ Usage:openvibe-node deny <ID> refuse itopenvibe-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 reviewedopenvibe-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) interr = 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}nnew file mode 100644@@@ -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++}@@@ -3,6 +3,8 @@package mainimport (++ "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.Mutexheader, level *systray.MenuItem-- stop, resume, panel *systray.MenuItem++ link, stop, resume *systray.MenuItem++ panel *systray.MenuItemquit *systray.MenuItemrequests *systray.MenuItemslots []*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 *globalcore.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.Cmdnnew file mode 100644@@@ -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.@@@ -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 {@@@ -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.DurationHeartbeatInterval time.Duration@@@ -132,6 +136,14 @@ type Node struct {inputs map[string]boolinputEnded time.TimetrayToldAt 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 monack(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.Ta.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 writtenreturn 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 {nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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)++ }++}nnew file mode 100644@@@ -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++}nnew file mode 100644@@@ -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)++}nnew file mode 100644@@@ -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)++ }++}
@@ -100,8 +100,15 @@ the ones this machine was installed for: the job worker starts if the local conf
@@ -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
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.
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
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),
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
[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
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).
dry run and the undo).
@@ -77,7 +77,8 @@ Usage:
@@ -77,7 +77,8 @@ Usage:
openvibe-node deny <ID> refuse it
openvibe-node deny <ID> refuse it
openvibe-node tray [--autostart on|off]
openvibe-node tray [--autostart on|off]
the Node in your notification area (Linux, Windows): what waits with Approve and Deny, stop,
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]
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
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]...
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
@@ -276,6 +277,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
err = cmdAnswer(g, cmd, positional[0], stdout)
err = cmdAnswer(g, cmd, positional[0], stdout)
case "tray":
case "tray":
err = cmdTray(g, *autoArg, stdout)
err = cmdTray(g, *autoArg, stdout)
+ case "panel":
+ err = cmdPanel(g, stdout)
case "profile":
case "profile":
if len(positional) != 2 || positional[0] != "verify" {
if len(positional) != 2 || positional[0] != "verify" {
fmt.Fprintln(stderr, "usage: openvibe-node profile verify community.<name> [--server URL] [--key-id ID]")
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 {
@@ -643,21 +646,12 @@ func cmdRun(g *globals, dryRun bool) error {
}
}
tokens = ts
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 {
if err != nil {
return err
return err
}
}
g.log.Info("openvibe-node starting", "version", version, "config", g.paths.ConfigDir, "plugins", len(cfg.Plugins))
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 {
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) {
if err := n.Run(ctx); !errors.Is(err, node.ErrQuit) {
return err
return err
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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
+}
@@ -3,6 +3,8 @@
@@ -3,6 +3,8 @@
package main
package main
import (
import (
+ "encoding/json"
+ "errors"
"fmt"
"fmt"
"io"
"io"
"os"
"os"
@@ -16,6 +18,8 @@ import (
@@ -16,6 +18,8 @@ import (
"time"
"time"
"fyne.io/systray"
"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
// The notification area: a StatusNotifierItem over D-Bus on Linux (KDE, XFCE, Cinnamon, MATE, GNOME with the
@@ -24,7 +28,8 @@ import (
@@ -24,7 +28,8 @@ import (
type systrayUI struct {
type systrayUI struct {
mu sync.Mutex
mu sync.Mutex
header, level *systray.MenuItem
header, level *systray.MenuItem
- stop, resume, panel *systray.MenuItem
+ link, stop, resume *systray.MenuItem
+ panel *systray.MenuItem
quit *systray.MenuItem
quit *systray.MenuItem
requests *systray.MenuItem
requests *systray.MenuItem
slots []*systray.MenuItem
slots []*systray.MenuItem
@@ -36,6 +41,8 @@ type systrayUI struct {
@@ -36,6 +41,8 @@ type systrayUI struct {
}
}
func (u *systrayUI) build() {
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 = systray.AddMenuItem("OpenVibe Node", "")
u.header.Disable()
u.header.Disable()
u.level = systray.AddMenuItem("", "")
u.level = systray.AddMenuItem("", "")
@@ -181,6 +188,12 @@ func runTray(g *globals) error {
@@ -181,6 +188,12 @@ func runTray(g *globals) error {
systray.SetTitle("OpenVibe Node")
systray.SetTitle("OpenVibe Node")
ui.build()
ui.build()
core.tick()
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)
go trayLoop(core, ui, stopSig, g)
}, core.quit)
}, core.quit)
return nil
return nil
@@ -208,6 +221,10 @@ func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *global
@@ -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.note.Info("OpenVibe Node", "Could not stop: "+err.Error())
}
}
core.tick()
core.tick()
+ case <-ui.link.ClickedCh:
+ if err := core.openPanel(); err != nil {
+ core.note.Info("OpenVibe Node", err.Error())
+ }
case <-ui.resume.ClickedCh:
case <-ui.resume.ClickedCh:
if err := core.resume(); err != nil {
if err := core.resume(); err != nil {
core.note.Info("OpenVibe Node", "Could not resume: "+err.Error())
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
@@ -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.
// openURL opens the machines page in the default browser.
func openURL(u string) {
func openURL(u string) {
var c *exec.Cmd
var c *exec.Cmd
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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.
@@ -32,6 +32,9 @@ const (
@@ -32,6 +32,9 @@ const (
// device link: the installer starts the service before the guide runs, and a second device connection would
// device link: the installer starts the service before the guide runs, and a second device connection would
// replace it.
// replace it.
CmdFirstRun = "first_run"
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 {
type Request struct {
@@ -33,6 +33,7 @@ import (
@@ -33,6 +33,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/link"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/mono"
"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/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/safety"
"github.com/OpenVibers/OpenVibe.Node/internal/safety"
@@ -56,6 +57,9 @@ type Options struct {
@@ -56,6 +57,9 @@ type Options struct {
// under a service manager, which would only start it again.
// under a service manager, which would only start it again.
CanQuit bool
CanQuit bool
+ // NoShares keeps saved Connect shares down (the guided dry run, which must not open a tunnel).
+ NoShares bool
+
// For tests.
// For tests.
ReauthInterval time.Duration
ReauthInterval time.Duration
HeartbeatInterval time.Duration
HeartbeatInterval time.Duration
@@ -132,6 +136,14 @@ type Node struct {
@@ -132,6 +136,14 @@ type Node struct {
inputs map[string]bool
inputs map[string]bool
inputEnded time.Time
inputEnded time.Time
trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
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.
// 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) {
@@ -172,7 +184,8 @@ func New(opt Options) (*Node, error) {
opt.Log.Error("latch", "err", err)
opt.Log.Error("latch", "err", err)
}
}
n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
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)
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
var specs []plugins.Spec
@@ -294,6 +307,13 @@ func (n *Node) Run(parent context.Context) error {
@@ -294,6 +307,13 @@ func (n *Node) Run(parent context.Context) error {
}
}
n.applyPluginLatch(n.latch.State(), false)
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)
n.mgr.Start(pctx)
var wg sync.WaitGroup
var wg sync.WaitGroup
@@ -664,6 +684,10 @@ func (n *Node) command(c protocol.Command, sentMS int64, seq uint64, received mo
@@ -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")
nack(protocol.FaultNotAllowed, c.Kind+" is not in this robot's allowed_commands")
return
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] {
if protocol.ComputerKinds[c.Kind] {
n.computer(c, entry, sentMS, received, nack)
n.computer(c, entry, sentMS, received, nack)
return
return
@@ -1414,6 +1438,11 @@ func (n *Node) trackApproval(plugin, name string, data map[string]any, at time.T
@@ -1414,6 +1438,11 @@ func (n *Node) trackApproval(plugin, name string, data map[string]any, at time.T
a.ExpiresS = int64(e)
a.ExpiresS = int64(e)
}
}
n.approvals[id] = a
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.
// 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 {
@@ -1478,31 +1507,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
}
}
return localctl.Response{OK: true, Data: b}
return localctl.Response{OK: true, Data: b}
case localctl.CmdApprove, localctl.CmdDeny:
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{Error: err.Error()}
}
}
return localctl.Response{OK: true}
return localctl.Response{OK: true}
@@ -1536,8 +1541,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -1536,8 +1541,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
}
}
return localctl.Response{OK: true}
return localctl.Response{OK: true}
case localctl.CmdStop:
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{Error: "stopped, but the latch could not be saved: " + err.Error()}
}
}
return localctl.Response{OK: true}
return localctl.Response{OK: true}
@@ -1554,10 +1558,20 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -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
go func() { time.Sleep(100 * time.Millisecond); n.quit() }() // after this answer is written
return localctl.Response{OK: true}
return localctl.Response{OK: true}
case localctl.CmdResume:
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{Error: err.Error()}
}
}
return localctl.Response{OK: true}
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:
case localctl.CmdStatus, localctl.CmdPlugins:
b, err := json.Marshal(n.Status())
b, err := json.Marshal(n.Status())
if err != nil {
if err != nil {
new file mode 100644
new file mode 100644
@@ -0,0 +1,426 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -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)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -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
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,361 @@
@@ -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)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,259 @@
@@ -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)
+ }
+}