openvibers/openvibe.link Commits
Link Send: resume a dropped transfer, hand over on the LAN, and make clipboard opt-in (#151)
Changes
@@@ -113,8 +113,10 @@ turns on a feature you turned off here.**Link's own window:** the tray's **Open Link** (or `openvibe-link panel`) opens a small page on the machine itselfat `127.0.0.1` that shows what runs here, what this computer allows, your shares, the coding-agent CLIs and the Codestasks this machine ran today, and what happened, and lets you pause everything, stop one thing, turn a capability off,--share a port, or send a file or a note to another of your machines (sealed on this computer with--openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). It is served only on++share a port, or send a file, a note or copied text to another of your machines (sealed on this computer with++openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). A transfer is uploaded in++parts, so a dropped connection continues where it stopped; on the same network it is handed over directly, and++otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only. It is served only onloopback, 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.@@@ -39,14 +39,21 @@ A restart makes a new port and a new token: an old link stops working.| `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/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |++| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json` |++| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json` || `GET /panel/api/send/targets` | Your other active machines that have a receive key | Real for Network-paired machines || `GET /panel/api/send/inbox` | Pending encrypted items | Real; the envelope remains ciphertext until you accept || `GET /panel/api/send/key` | This machine's receive key for opening an accepted envelope | Real; loopback panel token required |--| `POST /panel/api/files` `{ "target_node": "lnk_…", "envelope": "…" }` | Relays an already encrypted item to one of your other machines | Real for Network-paired machines; 20 MB file limit |++| `POST /panel/api/files` `{ "target_node": "lnk_…", "envelope": "…" }` | Sends an already encrypted item to one of your other machines | Direct on the same network when it can, otherwise uploaded in parts through the relay; 20 MB file limit || `POST /panel/api/files/:id/accept` | Gets the pending envelope after you choose Accept | Real; the window opens it locally and downloads it || `POST /panel/api/files/:id/decline` | Removes a pending item | Real || `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |++`state.clipboard` and `state.auto_accept` are the two per-machine switches; both are `false` until the person turns++them on, and `panel.json` keeps them with the capability switches. A direct transfer on the same network is queued in++memory by this machine's listener and appears in `send/inbox` beside the relay's items; the listener accepts only an++HMAC from the owner's LAN secret, so another machine on the network cannot put anything there.++`state` omits what the Link cannot fill truthfully: `devices` (the paired account's machine list is not read here),`gpu` (no GPU detection), and `earnings_today`. `activity` is the Link's own record of what it saw (an approvalwaiting or answered, a pause, a share, a capability), newest first, at most 30.@@@ -145,10 +145,12 @@ type Agent struct {// panel is Link's own window (nil until Run started it). panelOff are the capability switches turned off in it and// panelFirst is its first-run answer, both kept in state/panel.json; activity is what "What happened here" lists,// newest first.-- panel *panel.Server-- panelOff map[string]bool-- panelFirst *panelFirstRun-- activity []panel.Activity++ panel *panel.Server++ panelOff map[string]bool++ panelFirst *panelFirstRun++ panelClipboard bool++ panelAutoAccept bool++ activity []panel.Activity// codes is what the Codes worker ran here today; codesAgents caches the coding-agent CLIs the window shows.codes *codesTaskStatecodesAgents codesAgentsCache@@@ -194,9 +196,10 @@ func New(opt Options) (*Agent, error) {opt.Log.Error("latch", "err", err)}panelOff, panelFirst := loadPanel(opt.Paths.StateDir, opt.Creds != nil)++ panelClipboard, panelAutoAccept := loadPanelSettings(opt.Paths.StateDir)n := &Agent{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{},-- panelOff: panelOff, panelFirst: panelFirst}++ panelOff: panelOff, panelFirst: panelFirst, panelClipboard: panelClipboard, panelAutoAccept: panelAutoAccept}home, _ := os.UserHomeDir()n.codes = loadCodesTasks(opt.Paths.StateDir, home, opt.Log)n.codesAgents.fn = opt.CodesAgents@@@ -93,12 +93,29 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {}client := &send.Client{BaseURL: n.opt.Creds.Network,Tokens: link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-link/"+n.opt.Version, "openvibe.network"),-- Agent: "openvibe-link/" + n.opt.Version}++ Agent: "openvibe-link/" + n.opt.Version, Self: n.opt.Creds.Principal}++ // A random local port accepts a sealed envelope from another of the owner's machines on this network. The relay++ // stays the fallback, and nothing unauthenticated can reach the inbox.++ lan := send.NewLAN(n.opt.Creds.Principal)++ if err := lan.Listen(""); err != nil {++ n.log.Warn("Link Send stays on the relay: no local listening port", "err", err)++ } else {++ go func() {++ <-ctx.Done()++ _ = lan.Close()++ }()++ }go func() {t := time.NewTicker(time.Minute)defer t.Stop()for {-- if err := client.Register(ctx, key); err == nil {++ secretOK := false++ if secret, err := client.LANKey(ctx); err == nil {++ if err := lan.SetSecret(secret); err == nil {++ secretOK = true++ }++ }++ if err := client.Register(ctx, key, lan.URLs()...); err == nil && secretOK {return}select {@@@ -129,15 +146,33 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {if err != nil {return nil, err}-- out := make([]panel.IncomingSend, 0, len(items))++ out := make([]panel.IncomingSend, 0, len(items)+len(lan.Items()))for _, item := range items {out = append(out, panel.IncomingSend{ID: item.ID, Sender: item.Sender, SenderName: item.SenderName, Bytes: item.Bytes, CreatedAt: item.CreatedAt})}++ for _, item := range lan.Items() {++ out = append(out, panel.IncomingSend{ID: item.ID, Sender: item.Sender, SenderName: "one of your machines", Bytes: item.Bytes, CreatedAt: item.CreatedAt})++ }return out, nil},-- accept: func(id string) (string, error) { return client.Accept(ctx, id) },-- decline: func(id string) error { return client.Decline(ctx, id) },-- complete: func(id string) error { return client.Complete(ctx, id) },++ accept: func(id string) (string, error) {++ if strings.HasPrefix(id, "lan_") {++ return lan.Accept(id)++ }++ return client.Accept(ctx, id)++ },++ decline: func(id string) error {++ if strings.HasPrefix(id, "lan_") {++ return lan.Decline(id)++ }++ return client.Decline(ctx, id)++ },++ complete: func(id string) error {++ if strings.HasPrefix(id, "lan_") {++ return lan.Complete(id)++ }++ return client.Complete(ctx, id)++ },}}@@@ -145,19 +180,31 @@ type panelSaved struct {Off []string `json:"off"`// FirstRun is absent in a file written before the question existed.FirstRun *panelFirstRun `json:"first_run,omitempty"`++ // Clipboard sync is off until the person turns it on, on this machine. It is text only.++ Clipboard bool `json:"clipboard,omitempty"`++ // AutoAccept saves incoming items from the person's own machines without asking, per machine.++ AutoAccept bool `json:"auto_accept,omitempty"`}--// loadPanel reads the switches turned off and the first-run answer. A file written before the question existed, on a--// machine that is paired, is a Agent that was set up before this change (paired, its capabilities already saved): it--// is not asked. A machine that never answered and has no such file is asked once.--func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {-- off := map[string]bool{}++func readPanel(dir string) (panelSaved, bool) {b, err := os.ReadFile(filepath.Join(dir, panelFile))if err != nil {-- return off, nil++ return panelSaved{}, false}var s panelSavedif json.Unmarshal(b, &s) != nil {++ return panelSaved{}, false++ }++ return s, true++}++++// loadPanel reads the switches turned off and the first-run answer. A file written before the question existed, on a++// machine that is paired, is a Agent that was set up before this change (paired, its capabilities already saved): it++// is not asked. A machine that never answered and has no such file is asked once.++func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {++ off := map[string]bool{}++ s, ok := readPanel(dir)++ if !ok {return off, nil}for _, id := range s.Off {@@@ -172,11 +219,17 @@ func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {return off, nil}--func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {-- s := panelSaved{Off: []string{}, FirstRun: first}-- for id := range off {-- s.Off = append(s.Off, id)++// loadPanelSettings reads the two transfer switches. Both are false when the file has neither: clipboard sync and++// auto-accept are off by default and only this machine's person turns them on.++func loadPanelSettings(dir string) (clipboard, autoAccept bool) {++ s, ok := readPanel(dir)++ if !ok {++ return false, false}++ return s.Clipboard, s.AutoAccept++}++++func writePanel(dir string, s panelSaved) error {b, err := json.MarshalIndent(s, "", " ")if err != nil {return err@@@ -188,6 +241,22 @@ func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {return os.Rename(tmp, filepath.Join(dir, panelFile))}++func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {++ // Keep the transfer switches, which this call does not name.++ old, _ := readPanel(dir)++ s := panelSaved{Off: []string{}, FirstRun: first, Clipboard: old.Clipboard, AutoAccept: old.AutoAccept}++ for id := range off {++ s.Off = append(s.Off, id)++ }++ return writePanel(dir, s)++}++++func savePanelSettings(dir string, clipboard, autoAccept bool) error {++ old, _ := readPanel(dir)++ s := panelSaved{Off: old.Off, FirstRun: old.FirstRun, Clipboard: clipboard, AutoAccept: autoAccept}++ return writePanel(dir, s)++}++// startPanel serves Link's window until ctx ends. A failure leaves the Agent running without it.func (n *Agent) startPanel(ctx context.Context) {if !n.opt.NoShares {@@@ -208,6 +277,8 @@ func (n *Agent) startPanel(ctx context.Context) {Share: n.shareAdd,Unshare: n.shareRemove,FirstRun: n.applyFirstRun,++ SetClipboard: n.setClipboard,++ SetAutoAccept: n.setAutoAccept,CodesStop: n.panelStopCodesTask,SendKey: transfer.key,SendTargets: transfer.targets,@@@ -268,6 +339,8 @@ func (n *Agent) panelState() panel.State {n.mu.Lock()out.Activity = append([]panel.Activity(nil), n.activity...)out.FirstRun = n.panelFirst == nil || !n.panelFirst.Answered++ out.Clipboard = n.panelClipboard++ out.AutoAccept = n.panelAutoAcceptn.mu.Unlock()return out}@@@ -400,6 +473,45 @@ func (n *Agent) setCapability(id string, on bool) error {return nil}++// setClipboard keeps the clipboard-sync switch. It starts off, and turning it on is this machine's consent: text++// copied with the window's Send clipboard action is sealed for one of the person's own machines, never sent in clear.++func (n *Agent) setClipboard(on bool) error {++ n.mu.Lock()++ err := savePanelSettings(n.opt.Paths.StateDir, on, n.panelAutoAccept)++ if err == nil {++ n.panelClipboard = on++ }++ n.mu.Unlock()++ if err != nil {++ return err++ }++ if on {++ n.addActivity("Turned on clipboard sharing with your other machines")++ } else {++ n.addActivity("Turned off clipboard sharing")++ }++ return nil++}++++// setAutoAccept keeps the per-machine choice to save incoming items from the person's own machines without asking.++func (n *Agent) setAutoAccept(on bool) error {++ n.mu.Lock()++ err := savePanelSettings(n.opt.Paths.StateDir, n.panelClipboard, on)++ if err == nil {++ n.panelAutoAccept = on++ }++ n.mu.Unlock()++ if err != nil {++ return err++ }++ if on {++ n.addActivity("Turned on saving what your own machines send")++ } else {++ n.addActivity("Turned off saving what your own machines send without asking")++ }++ return nil++}++// applyFirstRun records the answer to "What's this computer for?" and turns on what each use says: a browser for the// agents, or the AI plugin. Codes has no module switch on the Agent yet and earn only records the wish (Exchange// confirms it); Terminal and Desktop are never turned on here, only by their own switch in the window. A skip onlynnew file mode 100644@@@ -0,0 +1,46 @@++package agent++++import (++ "encoding/json"++ "os"++ "path/filepath"++ "runtime"++ "testing"++)++++// Clipboard sync and auto-accept are off on a machine that never turned them on, and a capability or first-run++// change never switches them on by accident.++func TestClipboardAndAutoAcceptStartOffAndPersist(t *testing.T) {++ dir := t.TempDir()++ if clipboard, autoAccept := loadPanelSettings(dir); clipboard || autoAccept {++ t.Fatalf("a fresh machine has clipboard=%v auto_accept=%v, want both off", clipboard, autoAccept)++ }++ if err := savePanelSettings(dir, true, true); err != nil {++ t.Fatal(err)++ }++ if clipboard, autoAccept := loadPanelSettings(dir); !clipboard || !autoAccept {++ t.Fatalf("the person's switches were not kept: clipboard=%v auto_accept=%v", clipboard, autoAccept)++ }++ // A capability or first-run save rewrites the same file and must keep the transfer switches.++ if err := savePanel(dir, map[string]bool{"terminal": true}, &panelFirstRun{Answered: true}); err != nil {++ t.Fatal(err)++ }++ if clipboard, autoAccept := loadPanelSettings(dir); !clipboard || !autoAccept {++ t.Fatalf("a capability save lost the transfer switches: clipboard=%v auto_accept=%v", clipboard, autoAccept)++ }++ info, err := os.Stat(filepath.Join(dir, panelFile))++ if err != nil {++ t.Fatal(err)++ }++ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {++ t.Fatalf("panel.json mode = %o, want 600", info.Mode().Perm())++ }++ raw, err := os.ReadFile(filepath.Join(dir, panelFile))++ if err != nil {++ t.Fatal(err)++ }++ var saved panelSaved++ if err := json.Unmarshal(raw, &saved); err != nil || !saved.Clipboard || !saved.AutoAccept {++ t.Fatalf("saved switches: %+v (%v)", saved, err)++ }++}@@@ -145,6 +145,10 @@ type State struct {Codes *Codes `json:"codes,omitempty"`// FirstRun is true until the person has answered "What's this computer for?" or skipped it.FirstRun bool `json:"first_run,omitempty"`++ // Clipboard is the person's switch for sharing copied text between their own machines. Off by default.++ Clipboard bool `json:"clipboard"`++ // AutoAccept saves incoming items from the person's own machines without asking. Off by default.++ AutoAccept bool `json:"auto_accept"`}// FirstRunAnswer is the body of POST /panel/api/first-run: the uses the person picked, or a skip that only stops the@@@ -188,6 +192,9 @@ type Options struct {// FirstRun records the answer to "What's this computer for?" and turns on what each use says. A skip only stops// the question.FirstRun func(uses []string, skip bool) error++ // SetClipboard and SetAutoAccept keep the two transfer switches. Both start off.++ SetClipboard func(on bool) error++ SetAutoAccept func(on bool) error// CodesStop stops the local Codes task with this id the way Codes' own stop does. It returns an error when the// task is not running on this computer.CodesStop func(id string) error@@@ -439,6 +446,40 @@ func (s *Server) api() http.Handler {return}w.WriteHeader(http.StatusNoContent)++ case path == "clipboard" && r.Method == http.MethodPost:++ 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.SetClipboard == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot change this switch"))++ return++ }++ if err := s.opt.SetClipboard(*body.On); err != nil {++ writeJSON(w, http.StatusConflict, errBody(err.Error()))++ return++ }++ writeJSON(w, http.StatusOK, map[string]bool{"on": *body.On})++ case path == "send/auto-accept" && r.Method == http.MethodPost:++ 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.SetAutoAccept == nil {++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot change this switch"))++ return++ }++ if err := s.opt.SetAutoAccept(*body.On); err != nil {++ writeJSON(w, http.StatusConflict, errBody(err.Error()))++ return++ }++ writeJSON(w, http.StatusOK, map[string]bool{"on": *body.On})case path == "first-run" && r.Method == http.MethodPost:var body FirstRunAnswerif !decode(w, r, &body) {@@@ -372,3 +372,36 @@ func TestAPIUnknownPathIs404(t *testing.T) {t.Fatalf("unknown: %d", w.Code)}}++++// Clipboard sharing and auto-accept are per-machine switches that start off; the window can turn each one on and++// the state it polls says which way it is.++func TestTransferSwitchesStartOffAndCanBeTurnedOn(t *testing.T) {++ clipboard, autoAccept := false, false++ srv := testServer(t, Options{++ State: func() State { return State{Clipboard: clipboard, AutoAccept: autoAccept} },++ SetClipboard: func(on bool) error { clipboard = on; return nil },++ SetAutoAccept: func(on bool) error { autoAccept = on; return nil },++ })++ state := func() State {++ w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, "", "")++ if w.Code != http.StatusOK {++ t.Fatalf("state: %d %s", w.Code, w.Body)++ }++ var st State++ if err := json.Unmarshal(w.Body.Bytes(), &st); err != nil {++ t.Fatal(err)++ }++ return st++ }++ if st := state(); st.Clipboard || st.AutoAccept {++ t.Fatalf("a fresh machine reports clipboard=%v auto_accept=%v, want both off", st.Clipboard, st.AutoAccept)++ }++ for _, call := range []struct{ path string }{{"clipboard"}, {"send/auto-accept"}} {++ if w := do(t, srv, http.MethodPost, "/panel/api/"+call.path, testToken, "", `{"on":true}`); w.Code != http.StatusOK {++ t.Fatalf("%s: %d %s", call.path, w.Code, w.Body)++ }++ }++ if st := state(); !st.Clipboard || !st.AutoAccept {++ t.Fatalf("the switches did not turn on: clipboard=%v auto_accept=%v", st.Clipboard, st.AutoAccept)++ }++}@@@ -29,6 +29,8 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev| `POST /panel/api/files/:id/decline` | — | Removes the incoming item. || `POST /panel/api/files/:id/complete` | — | Removes the accepted item after download starts. || `POST /panel/api/first-run` | `{ "uses": ["codes", "browser"], "skip": false }` | The first-run answer. Uses are `codes`, `browser`, `models`, `earn` and `vibe`. The daemon turns on what each says (`earn` stays off until it is confirmed on Exchange) and stops asking; `skip: true` only stops asking. |++| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only, sealed like every other item. |++| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default. |## `GET /panel/api/state`@@@ -47,7 +49,9 @@ Every key is optional. A section with nothing in it is left out; the window neve"agents": [{ "id": "claude-code", "name": "Claude Code", "state": "ready", "version": "2.1.3" }, { "id": "codex", "name": "Codex", "state": "signin", "hint": "codex login" }],"tasks": [{ "id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "title": "Make every page work at 320 px", "folder": "~/code/openvibe-site", "agent": "Claude Code", "state": "running", "started_at": "2026-11-07T09:52:00Z", "url": "https://openvibe.codes/tasks/tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF" }]},-- "activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }]++ "activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }],++ "clipboard": false,++ "auto_accept": false}```@@@ -68,6 +68,9 @@<textarea id="lk-send-note" data-lk-send-note rows="3" maxlength="10000" placeholder="Write a short note"></textarea><button type="button" data-lk-send-note-button>Send note</button><p class="lk-fine" data-lk-drop-say role="status" aria-live="polite"></p>++ <label class="lk-fine"><input type="checkbox" data-lk-clipboard> Share copied text with my other machines <small>(off by default, text only, encrypted the same way)</small></label>++ <p class="lk-fine"><button type="button" data-lk-clipboard-button>Send what I copied</button></p>++ <label class="lk-fine"><input type="checkbox" data-lk-auto-accept> Save what my own machines send without asking <small>(per machine)</small></label><h3>Waiting for you</h3><ul class="lk-send-inbox" data-lk-send-inbox></ul></section></div>@@@ -78,6 +78,10 @@root.setAttribute('data-needs', m.needs_you ? '1' : '0');root.style.setProperty('--fill', String(Math.max(10, 100 - Math.min(running.length, 4) * 22)));$('[data-lk-down]').hidden = true;++ settings.clipboard = !!s.clipboard;++ settings.auto_accept = !!s.auto_accept;++ if ($('[data-lk-clipboard]')) $('[data-lk-clipboard]').checked = settings.clipboard;++ if ($('[data-lk-auto-accept]')) $('[data-lk-auto-accept]').checked = settings.auto_accept;$('[data-lk-first]').hidden = !s.first_run;$('[data-lk-name]').textContent = m.name || 'This computer';drawCodes(s.codes, m);@@@ -175,6 +179,8 @@});var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');var sendKey = null, lockerModule = null;++ var settings = { clipboard: false, auto_accept: false };++ var seenInbox = {};function panelApi(path, method, body) {return fetch('/panel/api/' + path, { method: method || 'GET', headers: Object.assign({ 'X-Link-Panel': token }, body ? { 'Content-Type': 'application/json' } : {}),body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }).then(function (r) {@@@ -184,8 +190,10 @@}function base64(bytes) { var s = ''; for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s); }function unbase64(s) { var raw = atob(s), out = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; }-- function packed(name, mime, data) {-- var meta = new TextEncoder().encode(JSON.stringify({ name: name, mime: mime || 'application/octet-stream' }));++ function packed(name, mime, data, kind) {++ var info = { name: name, mime: mime || 'application/octet-stream' };++ if (kind) info.kind = kind;++ var meta = new TextEncoder().encode(JSON.stringify(info));var out = new Uint8Array(4 + meta.length + data.length); new DataView(out.buffer).setUint32(0, meta.length); out.set(meta, 4); out.set(data, 4 + meta.length); return out;}function moduleLocker() { return lockerModule || (lockerModule = import('https://openvibe.link/locker.mjs').then(function (m) { return m.locker; })); }@@@ -197,14 +205,14 @@if (targetSelect.options.length === 1) say('Open Link on another paired machine to turn on receiving.');}).catch(function (e) { if (targetSelect.options.length === 1) { targetSelect.replaceChildren(new Option('No receiving machine is ready', '')); say(e.message); } });}-- function sendPayload(name, mime, data) {++ function sendPayload(name, mime, data, kind) {var opt = targetSelect.options[targetSelect.selectedIndex];if (!opt || !opt.value) { say('Choose one of your machines first.'); return Promise.resolve(); }if (data.length > 20 * 1024 * 1024) { say('Choose a file smaller than 20 MB for now.'); return Promise.resolve(); }say('Encrypting on this computer…');return moduleLocker().then(function (locker) {var publicKey = JSON.parse(opt.dataset.publicKey || '{}');-- var content = packed(name, mime, data);++ var content = packed(name, mime, data, kind);return locker.seal(content, { lock: 'people', keys: [{ subject: { type: 'link', id: opt.value }, publicKey: publicKey }] }).then(function (sealed) {say('Sending encrypted data…');return panelApi('files', 'POST', { target_node: opt.value, envelope: base64(sealed.bytes) });@@@ -214,6 +222,12 @@}function sendFile(file) { return file.arrayBuffer().then(function (b) { return sendPayload(file.name, file.type, new Uint8Array(b)); }); }function sendNote() { var text = $('[data-lk-send-note]').value; if (!text.trim()) { say('Write a note first.'); return; } sendPayload('note.txt', 'text/plain;charset=utf-8', new TextEncoder().encode(text)).then(function () { $('[data-lk-send-note]').value = ''; }); }++ function saveFile(info, fileBytes, id) {++ var safeName = String(info.name || 'received-file').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').slice(0, 180) || 'received-file';++ var url = URL.createObjectURL(new Blob([fileBytes], { type: info.mime || 'application/octet-stream' }));++ var a = document.createElement('a'); a.href = url; a.download = safeName; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 30000);++ return panelApi('files/' + encodeURIComponent(id) + '/complete', 'POST', {}).then(function () { say('Accepted. Your browser saved ' + safeName + ' to Downloads.'); });++ }function downloadAccepted(item) {var path = 'files/' + encodeURIComponent(item.id);return Promise.all([moduleLocker(), panelApi('send/key'), panelApi(path + '/accept', 'POST', {})]).then(function (parts) {@@@ -224,10 +238,15 @@var view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), n = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0);if (n < 2 || n > view.length - 4) throw new Error('The encrypted item is incomplete.');var info = JSON.parse(new TextDecoder().decode(view.subarray(4, 4 + n))), fileBytes = view.subarray(4 + n);-- var safeName = String(info.name || 'received-file').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').slice(0, 180) || 'received-file';-- var url = URL.createObjectURL(new Blob([fileBytes], { type: info.mime || 'application/octet-stream' }));-- var a = document.createElement('a'); a.href = url; a.download = safeName; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 30000);-- return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () { say('Accepted. Your browser saved ' + safeName + ' to Downloads.'); });++ if (info.kind === 'clipboard' && navigator.clipboard && navigator.clipboard.writeText) {++ var text = new TextDecoder().decode(fileBytes);++ return navigator.clipboard.writeText(text).then(function () {++ return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () {++ say('Copied text from ' + (item.sender_name || 'your machine') + ' to this computer\'s clipboard.');++ });++ }, function () { return saveFile(info, fileBytes, item.id); });++ }++ return saveFile(info, fileBytes, item.id);});}function size(bytes) {@@@ -237,6 +256,11 @@function loadInbox() {panelApi('send/inbox').then(function (body) {var list = $('[data-lk-send-inbox]');++ (body.items || []).forEach(function (item) {++ if (!settings.auto_accept || seenInbox[item.id]) return;++ seenInbox[item.id] = true;++ downloadAccepted(item).catch(function (e) { say(e.message || 'An item from one of your machines could not be opened.'); });++ });list.replaceChildren.apply(list, (body.items || []).map(function (item) {var li = el('li', 'lk-send-item'), label = el('span', '', 'Encrypted item from ' + (item.sender_name || 'your machine') + ' · ' + size(item.bytes) + ' · ' + ago(item.created_at));var actions = el('div'), accept = el('button', '', 'Accept'), decline = el('button', '', 'Decline');@@@ -253,6 +277,16 @@dropTarget.addEventListener('drop', function (e) { e.preventDefault(); if (e.dataTransfer.files[0]) sendFile(e.dataTransfer.files[0]); });$('[data-lk-files]').addEventListener('change', function () { if (this.files[0]) sendFile(this.files[0]); this.value = ''; });$('[data-lk-send-note-button]').addEventListener('click', sendNote);++ if ($('[data-lk-clipboard]')) $('[data-lk-clipboard]').addEventListener('change', function () { send('clipboard', 'POST', { on: this.checked }); });++ if ($('[data-lk-auto-accept]')) $('[data-lk-auto-accept]').addEventListener('change', function () { send('send/auto-accept', 'POST', { on: this.checked }); });++ if ($('[data-lk-clipboard-button]')) $('[data-lk-clipboard-button]').addEventListener('click', function () {++ if (!settings.clipboard) { say('Turn on sharing copied text first.'); return; }++ if (!navigator.clipboard || !navigator.clipboard.readText) { say('This browser does not let Link read the clipboard.'); return; }++ navigator.clipboard.readText().then(function (text) {++ if (!text.trim()) { say('There is no text on this computer\'s clipboard.'); return; }++ return sendPayload('clipboard.txt', 'text/plain;charset=utf-8', new TextEncoder().encode(text), 'clipboard');++ }).catch(function () { say('Link could not read the clipboard. Click the page and try again.'); });++ });loadTargets(); loadInbox(); setInterval(function () { if (document.visibilityState === 'visible') { loadTargets(); loadInbox(); } }, 3000);function tick() { if (document.visibilityState === 'visible') load(); }load();@@@ -161,5 +161,7 @@"url": "https://openvibe.codes/tasks/tsk_01M6N2"}]-- }++ },++ "clipboard": false,++ "auto_accept": false}@@@ -20,19 +20,27 @@ type Tokens interface {}var itemID = regexp.MustCompile(`^snd_[a-f0-9]{32}$`)++var uploadID = regexp.MustCompile(`^upl_[a-f0-9]{32}$`)++++// uploadChunk is how much of the base64 envelope Link sends in one part. The relay accepts up to 2 MiB; 1 MiB keeps++// one request small enough to retry quickly on a home connection.++const uploadChunk = 1024 * 1024type Client struct {BaseURL stringTokens TokensHTTP *http.ClientAgent string++ // Self is this machine's principal (lnk_…), so a direct transfer can name its sender.++ Self string}type Machine struct {-- ID string `json:"id"`-- Name string `json:"name"`-- Online bool `json:"online"`-- PublicKey JWK `json:"public_key"`++ ID string `json:"id"`++ Name string `json:"name"`++ Online bool `json:"online"`++ PublicKey JWK `json:"public_key"`++ LAN []string `json:"lan"`}// Item is one waiting transfer as the inbox lists it: who sent it and how big it is. The ciphertext is not in the@@@ -46,8 +54,21 @@ type Item struct {ExpiresAt time.Time `json:"expires_at"`}--func (c *Client) Register(ctx context.Context, key JWK) error {-- return c.do(ctx, http.MethodPut, "/api/v1/link/self/send/key", map[string]any{"public_key": Public(key)}, nil)++func (c *Client) Register(ctx context.Context, key JWK, lan ...string) error {++ body := map[string]any{"public_key": Public(key)}++ if len(lan) > 0 {++ body["lan"] = lan++ }++ return c.do(ctx, http.MethodPut, "/api/v1/link/self/send/key", body, nil)++}++++// LANKey is the owner's shared secret for direct transfers; every one of the owner's machines reads the same one.++func (c *Client) LANKey(ctx context.Context) (string, error) {++ var out struct {++ Key string `json:"key"`++ }++ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/lan-key", nil, &out)++ return out.Key, err}func (c *Client) Targets(ctx context.Context) ([]Machine, error) {var out struct {@@@ -56,8 +77,140 @@ func (c *Client) Targets(ctx context.Context) ([]Machine, error) {err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/targets", nil, &out)return out.Machines, err}++++// Upload is the relay's view of a transfer that is arriving in parts: the part after the last one it stored, and how++// many bytes it holds. Resuming asks the relay for this instead of guessing from the failed request.++type Upload struct {++ ID string `json:"id"`++ Bytes int64 `json:"bytes"`++ Received int64 `json:"received"`++ Seq int `json:"seq"`++ ItemID string `json:"item_id"`++}++++// Send uploads the Locker envelope in parts and resumes from the relay's acknowledged part after a dropped++// connection. Finishing is idempotent on the relay, so a lost finish answer does not make a second item.func (c *Client) Send(ctx context.Context, target, envelope string) error {-- return c.do(ctx, http.MethodPost, "/api/v1/link/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil)++ if envelope == "" {++ return fmt.Errorf("the encrypted item is empty")++ }++ // Same network: hand it over directly, sealed as before. Any problem (off the LAN, a timeout, no shared secret)++ // falls through to the relay, which keeps the item until the receiver accepts it.++ if err := c.sendLAN(ctx, target, envelope); err == nil {++ return nil++ }++ upload, err := c.StartUpload(ctx, target, int64(len(envelope)))++ if err != nil {++ // A Link newer than the Network it talks to still works: the one-request form is the v1 relay.++ if single := c.do(ctx, http.MethodPost, "/api/v1/link/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil); single == nil {++ return nil++ }++ return err++ }++ failures := 0++ for {++ for upload.Seq*uploadChunk < len(envelope) {++ from := upload.Seq * uploadChunk++ to := from + uploadChunk++ if to > len(envelope) {++ to = len(envelope)++ }++ next, err := c.UploadPart(ctx, upload.ID, upload.Seq, envelope[from:to])++ if err == nil {++ upload = next++ failures = 0++ continue++ }++ if ctx.Err() != nil {++ return fmt.Errorf("sending was stopped")++ }++ failures++++ if failures > 5 {++ return fmt.Errorf("the connection kept dropping; the parts already sent are kept for an hour, try again")++ }++ time.Sleep(time.Duration(failures) * 250 * time.Millisecond)++ status, statusErr := c.UploadStatus(ctx, upload.ID)++ if statusErr != nil {++ continue++ }++ upload = status++ }++ item, err := c.FinishUpload(ctx, upload.ID)++ if err == nil {++ upload.ItemID = item++ return nil++ }++ if ctx.Err() != nil {++ return fmt.Errorf("sending was stopped")++ }++ failures++++ if failures > 5 {++ return fmt.Errorf("the relay did not confirm the item; the parts are kept for an hour, try again")++ }++ status, statusErr := c.UploadStatus(ctx, upload.ID)++ if statusErr == nil {++ if status.ItemID != "" {++ return nil++ }++ upload = status++ }++ time.Sleep(time.Duration(failures) * 250 * time.Millisecond)++ }++}++++func (c *Client) StartUpload(ctx context.Context, target string, bytes int64) (Upload, error) {++ var out Upload++ err := c.do(ctx, http.MethodPost, "/api/v1/link/self/send/upload", map[string]any{"target_node": target, "bytes": bytes}, &out)++ return out, err++}++++func (c *Client) UploadPart(ctx context.Context, id string, seq int, chunk string) (Upload, error) {++ if !uploadID.MatchString(id) {++ return Upload{}, fmt.Errorf("this transfer is no longer available")++ }++ var out Upload++ err := c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/link/self/send/upload/%s/%d", id, seq), map[string]string{"chunk": chunk}, &out)++ return out, err++}++++func (c *Client) UploadStatus(ctx context.Context, id string) (Upload, error) {++ if !uploadID.MatchString(id) {++ return Upload{}, fmt.Errorf("this transfer is no longer available")++ }++ var out Upload++ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/upload/"+id, nil, &out)++ return out, err++}++++func (c *Client) FinishUpload(ctx context.Context, id string) (string, error) {++ if !uploadID.MatchString(id) {++ return "", fmt.Errorf("this transfer is no longer available")++ }++ var out struct {++ ID string `json:"id"`++ }++ err := c.do(ctx, http.MethodPost, "/api/v1/link/self/send/upload/"+id+"/finish", map[string]string{}, &out)++ return out.ID, err++}++++// Outbox is the sender's list: what this machine sent and whether the receiving machine accepted or saved it.++type OutboxItem struct {++ ID string `json:"id"`++ Target string `json:"target_node"`++ TargetName string `json:"target_name"`++ Bytes int64 `json:"bytes"`++ State string `json:"state"`++ CreatedAt time.Time `json:"created_at"`++ AcceptedAt time.Time `json:"accepted_at"`++ ExpiresAt time.Time `json:"expires_at"`++}++++func (c *Client) Outbox(ctx context.Context) ([]OutboxItem, error) {++ var out struct {++ Items []OutboxItem `json:"items"`++ }++ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/outbox", nil, &out)++ return out.Items, err}func (c *Client) Inbox(ctx context.Context) ([]Item, error) {var out struct {nnew file mode 100644@@@ -0,0 +1,329 @@++package send++++// Direct delivery on the same network: a Link listens on a random local port and accepts a sealed Locker envelope++// only from another of the owner's machines. The sender proves it with an HMAC from the owner's LAN secret, which++// both machines read from Network with their own node tokens; no relay round trip is needed, and an unauthenticated++// neighbour cannot put anything in the inbox. The payload is always ciphertext: the listener never has a key.++++import (++ "context"++ "crypto/hmac"++ "crypto/rand"++ "crypto/sha256"++ "crypto/subtle"++ "encoding/base64"++ "encoding/hex"++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "net"++ "net/http"++ "strconv"++ "strings"++ "sync"++ "time"++)++++const (++ lanPath = "/api/link-send/v1"++ lanMaxBody = 40 << 20++ lanWindow = 5 * time.Minute++ lanMaxItems = 100++ lanMaxNonces = 4096++)++++// Incoming is one sealed item that arrived directly. It is held in memory until the person accepts, declines or Link++// exits; the relay copy is still there if they restart, because the sender only takes the direct path when accepted.++type Incoming struct {++ ID string++ Sender string++ SenderName string++ Envelope string++ Bytes int64++ CreatedAt time.Time++}++++// LAN is one Link's direct listener and its small queue of received items.++type LAN struct {++ target string++ mu sync.Mutex++ secret []byte++ items map[string]Incoming++ nonces map[string]time.Time++ ln net.Listener++ server *http.Server++}++++func NewLAN(target string) *LAN {++ return &LAN{target: target, items: map[string]Incoming{}, nonces: map[string]time.Time{}}++}++++// Listen opens a random port. It is loopback-only in tests; on a real machine it is reachable on the local network,++// which is the point, and every request still has to carry the owner's HMAC.++func (l *LAN) Listen(addr string) error {++ if addr == "" {++ addr = "0.0.0.0:0"++ }++ ln, err := net.Listen("tcp", addr)++ if err != nil {++ return err++ }++ l.ln = ln++ l.server = &http.Server{Handler: l.handler(), ReadHeaderTimeout: 5 * time.Second}++ go func() { _ = l.server.Serve(ln) }()++ return nil++}++++func (l *LAN) Close() error {++ if l.server != nil {++ return l.server.Close()++ }++ return nil++}++++// Port is the listening port, or 0 when Listen did not succeed.++func (l *LAN) Port() int {++ if l.ln == nil {++ return 0++ }++ if a, ok := l.ln.Addr().(*net.TCPAddr); ok {++ return a.Port++ }++ return 0++}++++// URLs are the addresses to advertise to Network: every non-loopback IPv4 address of this machine, plus loopback for++// two Links on one computer (a test, or two profiles).++func (l *LAN) URLs() []string {++ port := l.Port()++ if port == 0 {++ return nil++ }++ seen := map[string]bool{}++ var out []string++ add := func(host string) {++ if host == "" || seen[host] {++ return++ }++ seen[host] = true++ out = append(out, "http://"+net.JoinHostPort(host, strconv.Itoa(port)))++ }++ add("127.0.0.1")++ ifaces, err := net.Interfaces()++ if err != nil {++ return out++ }++ for _, iface := range ifaces {++ if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {++ continue++ }++ addrs, _ := iface.Addrs()++ for _, a := range addrs {++ ip, _, err := net.ParseCIDR(a.String())++ if err != nil || ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.To4() == nil {++ continue++ }++ add(ip.String())++ }++ }++ return out++}++++func (l *LAN) SetSecret(encoded string) error {++ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))++ if err != nil || len(raw) < 16 {++ return errors.New("the LAN sender secret is not valid")++ }++ l.mu.Lock()++ l.secret = append([]byte(nil), raw...)++ l.mu.Unlock()++ return nil++}++++func (l *LAN) Items() []Incoming {++ l.mu.Lock()++ defer l.mu.Unlock()++ out := make([]Incoming, 0, len(l.items))++ for _, item := range l.items {++ out = append(out, item)++ }++ return out++}++++func (l *LAN) Accept(id string) (string, error) {++ l.mu.Lock()++ defer l.mu.Unlock()++ item, ok := l.items[id]++ if !ok {++ return "", errors.New("that item is no longer waiting")++ }++ return item.Envelope, nil++}++++func (l *LAN) Decline(id string) error {++ l.mu.Lock()++ defer l.mu.Unlock()++ if _, ok := l.items[id]; !ok {++ return errors.New("that item is no longer waiting")++ }++ delete(l.items, id)++ return nil++}++++func (l *LAN) Complete(id string) error {++ l.mu.Lock()++ defer l.mu.Unlock()++ if _, ok := l.items[id]; !ok {++ return errors.New("that item is no longer waiting")++ }++ delete(l.items, id)++ return nil++}++++type lanRequest struct {++ Sender string `json:"sender_node"`++ Target string `json:"target_node"`++ Envelope string `json:"envelope"`++ At int64 `json:"at"`++ Nonce string `json:"nonce"`++ Mac string `json:"mac"`++}++++func (l *LAN) handler() http.Handler {++ mux := http.NewServeMux()++ mux.HandleFunc(lanPath, func(w http.ResponseWriter, r *http.Request) {++ if r.Method != http.MethodPost {++ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)++ return++ }++ var req lanRequest++ if err := json.NewDecoder(io.LimitReader(r.Body, lanMaxBody)).Decode(&req); err != nil {++ http.Error(w, "unreadable transfer", http.StatusBadRequest)++ return++ }++ l.mu.Lock()++ secret := append([]byte(nil), l.secret...)++ l.mu.Unlock()++ if len(secret) == 0 {++ http.Error(w, "this machine has not turned on receiving", http.StatusForbidden)++ return++ }++ if req.Target != l.target {++ http.Error(w, "that item is for another machine", http.StatusNotFound)++ return++ }++ if req.Sender == "" || req.Sender == l.target || req.Nonce == "" || len(req.Nonce) > 128 {++ http.Error(w, "the sender is not one of this machine's owner's machines", http.StatusForbidden)++ return++ }++ if len(req.Envelope) == 0 || len(req.Envelope) > 32*1024*1024 {++ http.Error(w, "the encrypted item is missing or too large", http.StatusBadRequest)++ return++ }++ now := time.Now()++ at := time.Unix(req.At, 0)++ if req.At == 0 || now.Sub(at) > lanWindow || at.Sub(now) > lanWindow {++ http.Error(w, "the transfer is too old", http.StatusForbidden)++ return++ }++ want := lanMAC(secret, req.Sender, req.Target, req.At, req.Nonce, req.Envelope)++ if subtle.ConstantTimeCompare([]byte(req.Mac), []byte(want)) != 1 {++ http.Error(w, "the sender is not one of this machine's owner's machines", http.StatusForbidden)++ return++ }++ l.mu.Lock()++ defer l.mu.Unlock()++ if _, seen := l.nonces[req.Nonce]; seen {++ http.Error(w, "that transfer was already received", http.StatusConflict)++ return++ }++ if len(l.nonces) > lanMaxNonces {++ for nonce, when := range l.nonces {++ if now.Sub(when) > lanWindow {++ delete(l.nonces, nonce)++ }++ }++ }++ if len(l.items) >= lanMaxItems {++ http.Error(w, "this machine's transfer list is full; accept or decline an item first", http.StatusTooManyRequests)++ return++ }++ l.nonces[req.Nonce] = now++ id := "lan_" + hex.EncodeToString(random(16))++ l.items[id] = Incoming{ID: id, Sender: req.Sender, Envelope: req.Envelope, Bytes: int64(len(req.Envelope)), CreatedAt: now}++ w.Header().Set("Content-Type", "application/json")++ w.WriteHeader(http.StatusCreated)++ _, _ = w.Write([]byte(`{"id":"` + id + `"}`))++ })++ return mux++}++++func lanMAC(secret []byte, sender, target string, at int64, nonce, envelope string) string {++ sum := sha256.Sum256([]byte(envelope))++ h := hmac.New(sha256.New, secret)++ fmt.Fprintf(h, "openvibe-link-send-lan-v1\n%s\n%s\n%d\n%s\n%s\n", sender, target, at, nonce, base64.RawURLEncoding.EncodeToString(sum[:]))++ return hex.EncodeToString(h.Sum(nil))++}++++func random(n int) []byte {++ b := make([]byte, n)++ if _, err := rand.Read(b); err != nil {++ panic(err)++ }++ return b++}++++// sendLAN tries the target's advertised local endpoints. Any failure (not on the network, a timeout, an old secret)++// falls back to the relay by returning an error.++func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {++ if c.Self == "" {++ return errors.New("this machine has no id for a direct transfer")++ }++ machines, err := c.Targets(ctx)++ if err != nil {++ return err++ }++ var endpoints []string++ for _, m := range machines {++ if m.ID == target {++ endpoints = m.LAN++ break++ }++ }++ if len(endpoints) == 0 {++ return errors.New("the receiving machine is not on this network")++ }++ encoded, err := c.LANKey(ctx)++ if err != nil {++ return err++ }++ secret, err := base64.RawURLEncoding.DecodeString(encoded)++ if err != nil || len(secret) < 16 {++ return errors.New("the LAN sender secret is not valid")++ }++ for _, base := range endpoints {++ at := time.Now().Unix()++ nonce := hex.EncodeToString(random(16))++ body, _ := json.Marshal(lanRequest{++ Sender: c.Self, Target: target, Envelope: envelope, At: at, Nonce: nonce,++ Mac: lanMAC(secret, c.Self, target, at, nonce, envelope),++ })++ req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+lanPath, strings.NewReader(string(body)))++ if err != nil {++ continue++ }++ req.Header.Set("Content-Type", "application/json")++ client := &http.Client{Timeout: 2 * time.Second}++ resp, err := client.Do(req)++ if err != nil {++ continue++ }++ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))++ resp.Body.Close()++ if resp.StatusCode == http.StatusCreated {++ return nil++ }++ }++ return errors.New("the direct transfer did not arrive")++}nnew file mode 100644@@@ -0,0 +1,113 @@++package send++++import (++ "bytes"++ "context"++ "encoding/base64"++ "encoding/hex"++ "encoding/json"++ "net/http"++ "net/http/httptest"++ "strings"++ "testing"++ "time"++)++++const lanTarget = "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ"++++func startTestLAN(t *testing.T) (*LAN, string) {++ t.Helper()++ lan := NewLAN(lanTarget)++ if err := lan.Listen("127.0.0.1:0"); err != nil {++ t.Fatal(err)++ }++ t.Cleanup(func() { _ = lan.Close() })++ secret := strings.Repeat("s", 32)++ if err := lan.SetSecret(base64.RawURLEncoding.EncodeToString([]byte(secret))); err != nil {++ t.Fatal(err)++ }++ return lan, secret++}++++func lanBody(sender, secret, envelope string) []byte {++ at := time.Now().Unix()++ nonce := hex.EncodeToString(random(16))++ b, _ := json.Marshal(lanRequest{Sender: sender, Target: lanTarget, Envelope: envelope, At: at, Nonce: nonce,++ Mac: lanMAC([]byte(secret), sender, lanTarget, at, nonce, envelope)})++ return b++}++++func postLAN(t *testing.T, url string, body []byte) int {++ t.Helper()++ resp, err := http.Post(url+lanPath, "application/json", bytes.NewReader(body))++ if err != nil {++ t.Fatal(err)++ }++ defer resp.Body.Close()++ return resp.StatusCode++}++++// A machine refuses a sender that does not hold the owner's LAN secret, and it refuses a replayed transfer even when++// it does. The accepted envelope is the same ciphertext the sender made: the listener never sees a key.++func TestLANRefusesANonOwnerAndReplayedTransfer(t *testing.T) {++ lan, secret := startTestLAN(t)++ url := "http://" + lan.ln.Addr().String()++ envelope := base64.RawStdEncoding.EncodeToString([]byte("sealed bytes"))++ if code := postLAN(t, url, lanBody("lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ", "not-the-owner-secret", envelope)); code != http.StatusForbidden {++ t.Fatalf("a sender without the owner secret got %d, want 403", code)++ }++ if got := len(lan.Items()); got != 0 {++ t.Fatalf("a refused sender put %d items in the inbox", got)++ }++ body := lanBody("lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ", secret, envelope)++ if code := postLAN(t, url, body); code != http.StatusCreated {++ t.Fatalf("the owner's machine got %d, want 201", code)++ }++ if code := postLAN(t, url, body); code != http.StatusConflict {++ t.Fatalf("a replayed transfer got %d, want 409", code)++ }++ items := lan.Items()++ if len(items) != 1 || items[0].Envelope != envelope {++ t.Fatalf("stored items = %+v", items)++ }++ if got, err := lan.Accept(items[0].ID); err != nil || got != envelope {++ t.Fatalf("accept = %q, %v", got, err)++ }++}++++// A sender on the same network hands the sealed item straight to the target: the relay is not called for the upload.++func TestSendUsesLANBeforeTheRelay(t *testing.T) {++ lan, secret := startTestLAN(t)++ relayCalls := 0++ network := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ switch r.URL.Path {++ case "/api/v1/link/self/send/targets":++ relayCalls++++ _ = json.NewEncoder(w).Encode(map[string]any{"machines": []map[string]any{{++ "id": lanTarget, "name": "Workshop", "online": true,++ "public_key": map[string]string{"kty": "EC", "crv": "P-256", "x": "x", "y": "y"},++ "lan": []string{"http://" + lan.ln.Addr().String()},++ }}})++ case "/api/v1/link/self/send/lan-key":++ relayCalls++++ _ = json.NewEncoder(w).Encode(map[string]string{"key": base64.RawURLEncoding.EncodeToString([]byte(secret))})++ default:++ relayCalls += 100++ t.Errorf("the relay was asked to upload over %s %s", r.Method, r.URL.Path)++ }++ }))++ defer network.Close()++ c := &Client{BaseURL: network.URL, Tokens: staticToken("node-token"), HTTP: network.Client(),++ Self: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ"}++ envelope := base64.RawStdEncoding.EncodeToString([]byte(strings.Repeat("sealed", 100)))++ if err := c.Send(context.Background(), lanTarget, envelope); err != nil {++ t.Fatal(err)++ }++ if relayCalls != 2 {++ t.Fatalf("relay calls = %d, want only the target list and the LAN secret", relayCalls)++ }++ items := lan.Items()++ if len(items) != 1 || items[0].Envelope != envelope {++ t.Fatalf("the direct transfer stored %+v", items)++ }++}nnew file mode 100644@@@ -0,0 +1,86 @@++package send++++import (++ "context"++ "encoding/json"++ "fmt"++ "net/http"++ "net/http/httptest"++ "strings"++ "sync"++ "testing"++)++++// A dropped part must not restart the transfer: Link asks the relay which part it holds and continues there. The++// finished item is the exact ciphertext the sender began with.++func TestSendResumesAfterDroppedPart(t *testing.T) {++ const id = "upl_0123456789abcdef0123456789abcdef"++ envelope := strings.Repeat("QUJD", 300000) // ~1.2 MiB: two parts++ var mu sync.Mutex++ parts := map[int]string{}++ next := 0++ dropped := false++ statusReads := 0++ var finished string++++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ mu.Lock()++ defer mu.Unlock()++ switch {++ case r.Method == http.MethodPost && r.URL.Path == "/api/v1/link/self/send/upload":++ w.Header().Set("Content-Type", "application/json")++ w.WriteHeader(http.StatusCreated)++ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 0, "seq": 0})++ case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/link/self/send/upload/"+id+"/"):++ var seq int++ if _, err := fmt.Sscanf(strings.TrimPrefix(r.URL.Path, "/api/v1/link/self/send/upload/"+id+"/"), "%d", &seq); err != nil {++ t.Fatalf("bad part path %s", r.URL.Path)++ }++ var body struct {++ Chunk string `json:"chunk"`++ }++ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {++ t.Fatal(err)++ }++ if seq == 1 && !dropped {++ dropped = true++ w.WriteHeader(http.StatusBadGateway)++ return++ }++ if seq != next {++ t.Fatalf("part %d arrived while the relay held %d", seq, next)++ }++ parts[seq] = body.Chunk++ next++++ w.Header().Set("Content-Type", "application/json")++ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 1, "seq": next})++ case r.Method == http.MethodGet && r.URL.Path == "/api/v1/link/self/send/upload/"+id:++ statusReads++++ w.Header().Set("Content-Type", "application/json")++ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 1, "seq": next})++ case r.Method == http.MethodPost && r.URL.Path == "/api/v1/link/self/send/upload/"+id+"/finish":++ var b strings.Builder++ for i := 0; i < next; i++ {++ b.WriteString(parts[i])++ }++ finished = b.String()++ w.Header().Set("Content-Type", "application/json")++ w.WriteHeader(http.StatusCreated)++ _ = json.NewEncoder(w).Encode(map[string]any{"id": "snd_0123456789abcdef0123456789abcdef"})++ default:++ t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)++ }++ }))++ defer server.Close()++++ c := &Client{BaseURL: server.URL, Tokens: staticToken("node-token"), HTTP: server.Client()}++ if err := c.Send(context.Background(), "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ", envelope); err != nil {++ t.Fatal(err)++ }++ if !dropped || statusReads == 0 {++ t.Fatalf("the test did not drop a part (dropped=%v statusReads=%d)", dropped, statusReads)++ }++ if finished != envelope {++ t.Fatalf("the relay assembled %d bytes, want %d", len(finished), len(envelope))++ }++}
@@ -113,8 +113,10 @@ turns on a feature you turned off here.
@@ -113,8 +113,10 @@ turns on a feature you turned off here.
**Link's own window:** the tray's **Open Link** (or `openvibe-link panel`) opens a small page on the machine itself
**Link's own window:** the tray's **Open Link** (or `openvibe-link 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, the coding-agent CLIs and the Codes
at `127.0.0.1` that shows what runs here, what this computer allows, your shares, the coding-agent CLIs and the Codes
tasks this machine ran today, and what happened, and lets you pause everything, stop one thing, turn a capability off,
tasks this machine ran today, and what happened, and lets you pause everything, stop one thing, turn a capability off,
-share a port, or send a file or a note to another of your machines (sealed on this computer with
-openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). It is served only on
+share a port, or send a file, a note or copied text to another of your machines (sealed on this computer with
+openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). A transfer is uploaded in
+parts, so a dropped connection continues where it stopped; on the same network it is handed over directly, and
+otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only. 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)
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.
for the security rules.
@@ -39,14 +39,21 @@ A restart makes a new port and a new token: an old link stops working.
@@ -39,14 +39,21 @@ A restart makes a new port and a new token: an old link stops working.
| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
| `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 |
| `DELETE /panel/api/shares/:port` | Ends that share | Real |
| `POST /panel/api/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |
| `POST /panel/api/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |
+| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json` |
+| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json` |
| `GET /panel/api/send/targets` | Your other active machines that have a receive key | Real for Network-paired machines |
| `GET /panel/api/send/targets` | Your other active machines that have a receive key | Real for Network-paired machines |
| `GET /panel/api/send/inbox` | Pending encrypted items | Real; the envelope remains ciphertext until you accept |
| `GET /panel/api/send/inbox` | Pending encrypted items | Real; the envelope remains ciphertext until you accept |
| `GET /panel/api/send/key` | This machine's receive key for opening an accepted envelope | Real; loopback panel token required |
| `GET /panel/api/send/key` | This machine's receive key for opening an accepted envelope | Real; loopback panel token required |
-| `POST /panel/api/files` `{ "target_node": "lnk_…", "envelope": "…" }` | Relays an already encrypted item to one of your other machines | Real for Network-paired machines; 20 MB file limit |
+| `POST /panel/api/files` `{ "target_node": "lnk_…", "envelope": "…" }` | Sends an already encrypted item to one of your other machines | Direct on the same network when it can, otherwise uploaded in parts through the relay; 20 MB file limit |
| `POST /panel/api/files/:id/accept` | Gets the pending envelope after you choose Accept | Real; the window opens it locally and downloads it |
| `POST /panel/api/files/:id/accept` | Gets the pending envelope after you choose Accept | Real; the window opens it locally and downloads it |
| `POST /panel/api/files/:id/decline` | Removes a pending item | Real |
| `POST /panel/api/files/:id/decline` | Removes a pending item | Real |
| `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |
| `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |
+`state.clipboard` and `state.auto_accept` are the two per-machine switches; both are `false` until the person turns
+them on, and `panel.json` keeps them with the capability switches. A direct transfer on the same network is queued in
+memory by this machine's listener and appears in `send/inbox` beside the relay's items; the listener accepts only an
+HMAC from the owner's LAN secret, so another machine on the network cannot put anything there.
+
`state` omits what the Link cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`state` omits what the Link cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Link's own record of what it saw (an approval
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Link's own record of what it saw (an approval
waiting or answered, a pause, a share, a capability), newest first, at most 30.
waiting or answered, a pause, a share, a capability), newest first, at most 30.
@@ -145,10 +145,12 @@ type Agent struct {
@@ -145,10 +145,12 @@ type Agent struct {
// panel is Link's own window (nil until Run started it). panelOff are the capability switches turned off in it and
// panel is Link's own window (nil until Run started it). panelOff are the capability switches turned off in it and
// panelFirst is its first-run answer, both kept in state/panel.json; activity is what "What happened here" lists,
// panelFirst is its first-run answer, both kept in state/panel.json; activity is what "What happened here" lists,
// newest first.
// newest first.
- panel *panel.Server
- panelOff map[string]bool
- panelFirst *panelFirstRun
- activity []panel.Activity
+ panel *panel.Server
+ panelOff map[string]bool
+ panelFirst *panelFirstRun
+ panelClipboard bool
+ panelAutoAccept bool
+ activity []panel.Activity
// codes is what the Codes worker ran here today; codesAgents caches the coding-agent CLIs the window shows.
// codes is what the Codes worker ran here today; codesAgents caches the coding-agent CLIs the window shows.
codes *codesTaskState
codes *codesTaskState
codesAgents codesAgentsCache
codesAgents codesAgentsCache
@@ -194,9 +196,10 @@ func New(opt Options) (*Agent, error) {
@@ -194,9 +196,10 @@ func New(opt Options) (*Agent, error) {
opt.Log.Error("latch", "err", err)
opt.Log.Error("latch", "err", err)
}
}
panelOff, panelFirst := loadPanel(opt.Paths.StateDir, opt.Creds != nil)
panelOff, panelFirst := loadPanel(opt.Paths.StateDir, opt.Creds != nil)
+ panelClipboard, panelAutoAccept := loadPanelSettings(opt.Paths.StateDir)
n := &Agent{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
n := &Agent{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: panelOff, panelFirst: panelFirst}
+ panelOff: panelOff, panelFirst: panelFirst, panelClipboard: panelClipboard, panelAutoAccept: panelAutoAccept}
home, _ := os.UserHomeDir()
home, _ := os.UserHomeDir()
n.codes = loadCodesTasks(opt.Paths.StateDir, home, opt.Log)
n.codes = loadCodesTasks(opt.Paths.StateDir, home, opt.Log)
n.codesAgents.fn = opt.CodesAgents
n.codesAgents.fn = opt.CodesAgents
@@ -93,12 +93,29 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
@@ -93,12 +93,29 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
}
}
client := &send.Client{BaseURL: n.opt.Creds.Network,
client := &send.Client{BaseURL: n.opt.Creds.Network,
Tokens: link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-link/"+n.opt.Version, "openvibe.network"),
Tokens: link.NewTokenSourceFor(n.opt.Creds, nil, "openvibe-link/"+n.opt.Version, "openvibe.network"),
- Agent: "openvibe-link/" + n.opt.Version}
+ Agent: "openvibe-link/" + n.opt.Version, Self: n.opt.Creds.Principal}
+ // A random local port accepts a sealed envelope from another of the owner's machines on this network. The relay
+ // stays the fallback, and nothing unauthenticated can reach the inbox.
+ lan := send.NewLAN(n.opt.Creds.Principal)
+ if err := lan.Listen(""); err != nil {
+ n.log.Warn("Link Send stays on the relay: no local listening port", "err", err)
+ } else {
+ go func() {
+ <-ctx.Done()
+ _ = lan.Close()
+ }()
+ }
go func() {
go func() {
t := time.NewTicker(time.Minute)
t := time.NewTicker(time.Minute)
defer t.Stop()
defer t.Stop()
for {
for {
- if err := client.Register(ctx, key); err == nil {
+ secretOK := false
+ if secret, err := client.LANKey(ctx); err == nil {
+ if err := lan.SetSecret(secret); err == nil {
+ secretOK = true
+ }
+ }
+ if err := client.Register(ctx, key, lan.URLs()...); err == nil && secretOK {
return
return
}
}
select {
select {
@@ -129,15 +146,33 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
@@ -129,15 +146,33 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
if err != nil {
if err != nil {
return nil, err
return nil, err
}
}
- out := make([]panel.IncomingSend, 0, len(items))
+ out := make([]panel.IncomingSend, 0, len(items)+len(lan.Items()))
for _, item := range items {
for _, item := range items {
out = append(out, panel.IncomingSend{ID: item.ID, Sender: item.Sender, SenderName: item.SenderName, Bytes: item.Bytes, CreatedAt: item.CreatedAt})
out = append(out, panel.IncomingSend{ID: item.ID, Sender: item.Sender, SenderName: item.SenderName, Bytes: item.Bytes, CreatedAt: item.CreatedAt})
}
}
+ for _, item := range lan.Items() {
+ out = append(out, panel.IncomingSend{ID: item.ID, Sender: item.Sender, SenderName: "one of your machines", Bytes: item.Bytes, CreatedAt: item.CreatedAt})
+ }
return out, nil
return out, nil
},
},
- accept: func(id string) (string, error) { return client.Accept(ctx, id) },
- decline: func(id string) error { return client.Decline(ctx, id) },
- complete: func(id string) error { return client.Complete(ctx, id) },
+ accept: func(id string) (string, error) {
+ if strings.HasPrefix(id, "lan_") {
+ return lan.Accept(id)
+ }
+ return client.Accept(ctx, id)
+ },
+ decline: func(id string) error {
+ if strings.HasPrefix(id, "lan_") {
+ return lan.Decline(id)
+ }
+ return client.Decline(ctx, id)
+ },
+ complete: func(id string) error {
+ if strings.HasPrefix(id, "lan_") {
+ return lan.Complete(id)
+ }
+ return client.Complete(ctx, id)
+ },
}
}
}
}
@@ -145,19 +180,31 @@ type panelSaved struct {
@@ -145,19 +180,31 @@ type panelSaved struct {
Off []string `json:"off"`
Off []string `json:"off"`
// FirstRun is absent in a file written before the question existed.
// FirstRun is absent in a file written before the question existed.
FirstRun *panelFirstRun `json:"first_run,omitempty"`
FirstRun *panelFirstRun `json:"first_run,omitempty"`
+ // Clipboard sync is off until the person turns it on, on this machine. It is text only.
+ Clipboard bool `json:"clipboard,omitempty"`
+ // AutoAccept saves incoming items from the person's own machines without asking, per machine.
+ AutoAccept bool `json:"auto_accept,omitempty"`
}
}
-// loadPanel reads the switches turned off and the first-run answer. A file written before the question existed, on a
-// machine that is paired, is a Agent that was set up before this change (paired, its capabilities already saved): it
-// is not asked. A machine that never answered and has no such file is asked once.
-func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {
- off := map[string]bool{}
+func readPanel(dir string) (panelSaved, bool) {
b, err := os.ReadFile(filepath.Join(dir, panelFile))
b, err := os.ReadFile(filepath.Join(dir, panelFile))
if err != nil {
if err != nil {
- return off, nil
+ return panelSaved{}, false
}
}
var s panelSaved
var s panelSaved
if json.Unmarshal(b, &s) != nil {
if json.Unmarshal(b, &s) != nil {
+ return panelSaved{}, false
+ }
+ return s, true
+}
+
+// loadPanel reads the switches turned off and the first-run answer. A file written before the question existed, on a
+// machine that is paired, is a Agent that was set up before this change (paired, its capabilities already saved): it
+// is not asked. A machine that never answered and has no such file is asked once.
+func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {
+ off := map[string]bool{}
+ s, ok := readPanel(dir)
+ if !ok {
return off, nil
return off, nil
}
}
for _, id := range s.Off {
for _, id := range s.Off {
@@ -172,11 +219,17 @@ func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {
@@ -172,11 +219,17 @@ func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {
return off, nil
return off, nil
}
}
-func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {
- s := panelSaved{Off: []string{}, FirstRun: first}
- for id := range off {
- s.Off = append(s.Off, id)
+// loadPanelSettings reads the two transfer switches. Both are false when the file has neither: clipboard sync and
+// auto-accept are off by default and only this machine's person turns them on.
+func loadPanelSettings(dir string) (clipboard, autoAccept bool) {
+ s, ok := readPanel(dir)
+ if !ok {
+ return false, false
}
}
+ return s.Clipboard, s.AutoAccept
+}
+
+func writePanel(dir string, s panelSaved) error {
b, err := json.MarshalIndent(s, "", " ")
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
if err != nil {
return err
return err
@@ -188,6 +241,22 @@ func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {
@@ -188,6 +241,22 @@ func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {
return os.Rename(tmp, filepath.Join(dir, panelFile))
return os.Rename(tmp, filepath.Join(dir, panelFile))
}
}
+func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {
+ // Keep the transfer switches, which this call does not name.
+ old, _ := readPanel(dir)
+ s := panelSaved{Off: []string{}, FirstRun: first, Clipboard: old.Clipboard, AutoAccept: old.AutoAccept}
+ for id := range off {
+ s.Off = append(s.Off, id)
+ }
+ return writePanel(dir, s)
+}
+
+func savePanelSettings(dir string, clipboard, autoAccept bool) error {
+ old, _ := readPanel(dir)
+ s := panelSaved{Off: old.Off, FirstRun: old.FirstRun, Clipboard: clipboard, AutoAccept: autoAccept}
+ return writePanel(dir, s)
+}
+
// startPanel serves Link's window until ctx ends. A failure leaves the Agent running without it.
// startPanel serves Link's window until ctx ends. A failure leaves the Agent running without it.
func (n *Agent) startPanel(ctx context.Context) {
func (n *Agent) startPanel(ctx context.Context) {
if !n.opt.NoShares {
if !n.opt.NoShares {
@@ -208,6 +277,8 @@ func (n *Agent) startPanel(ctx context.Context) {
@@ -208,6 +277,8 @@ func (n *Agent) startPanel(ctx context.Context) {
Share: n.shareAdd,
Share: n.shareAdd,
Unshare: n.shareRemove,
Unshare: n.shareRemove,
FirstRun: n.applyFirstRun,
FirstRun: n.applyFirstRun,
+ SetClipboard: n.setClipboard,
+ SetAutoAccept: n.setAutoAccept,
CodesStop: n.panelStopCodesTask,
CodesStop: n.panelStopCodesTask,
SendKey: transfer.key,
SendKey: transfer.key,
SendTargets: transfer.targets,
SendTargets: transfer.targets,
@@ -268,6 +339,8 @@ func (n *Agent) panelState() panel.State {
@@ -268,6 +339,8 @@ func (n *Agent) panelState() panel.State {
n.mu.Lock()
n.mu.Lock()
out.Activity = append([]panel.Activity(nil), n.activity...)
out.Activity = append([]panel.Activity(nil), n.activity...)
out.FirstRun = n.panelFirst == nil || !n.panelFirst.Answered
out.FirstRun = n.panelFirst == nil || !n.panelFirst.Answered
+ out.Clipboard = n.panelClipboard
+ out.AutoAccept = n.panelAutoAccept
n.mu.Unlock()
n.mu.Unlock()
return out
return out
}
}
@@ -400,6 +473,45 @@ func (n *Agent) setCapability(id string, on bool) error {
@@ -400,6 +473,45 @@ func (n *Agent) setCapability(id string, on bool) error {
return nil
return nil
}
}
+// setClipboard keeps the clipboard-sync switch. It starts off, and turning it on is this machine's consent: text
+// copied with the window's Send clipboard action is sealed for one of the person's own machines, never sent in clear.
+func (n *Agent) setClipboard(on bool) error {
+ n.mu.Lock()
+ err := savePanelSettings(n.opt.Paths.StateDir, on, n.panelAutoAccept)
+ if err == nil {
+ n.panelClipboard = on
+ }
+ n.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ if on {
+ n.addActivity("Turned on clipboard sharing with your other machines")
+ } else {
+ n.addActivity("Turned off clipboard sharing")
+ }
+ return nil
+}
+
+// setAutoAccept keeps the per-machine choice to save incoming items from the person's own machines without asking.
+func (n *Agent) setAutoAccept(on bool) error {
+ n.mu.Lock()
+ err := savePanelSettings(n.opt.Paths.StateDir, n.panelClipboard, on)
+ if err == nil {
+ n.panelAutoAccept = on
+ }
+ n.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ if on {
+ n.addActivity("Turned on saving what your own machines send")
+ } else {
+ n.addActivity("Turned off saving what your own machines send without asking")
+ }
+ return nil
+}
+
// applyFirstRun records the answer to "What's this computer for?" and turns on what each use says: a browser for the
// applyFirstRun records the answer to "What's this computer for?" and turns on what each use says: a browser for the
// agents, or the AI plugin. Codes has no module switch on the Agent yet and earn only records the wish (Exchange
// agents, or the AI plugin. Codes has no module switch on the Agent yet and earn only records the wish (Exchange
// confirms it); Terminal and Desktop are never turned on here, only by their own switch in the window. A skip only
// confirms it); Terminal and Desktop are never turned on here, only by their own switch in the window. A skip only
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+package agent
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+// Clipboard sync and auto-accept are off on a machine that never turned them on, and a capability or first-run
+// change never switches them on by accident.
+func TestClipboardAndAutoAcceptStartOffAndPersist(t *testing.T) {
+ dir := t.TempDir()
+ if clipboard, autoAccept := loadPanelSettings(dir); clipboard || autoAccept {
+ t.Fatalf("a fresh machine has clipboard=%v auto_accept=%v, want both off", clipboard, autoAccept)
+ }
+ if err := savePanelSettings(dir, true, true); err != nil {
+ t.Fatal(err)
+ }
+ if clipboard, autoAccept := loadPanelSettings(dir); !clipboard || !autoAccept {
+ t.Fatalf("the person's switches were not kept: clipboard=%v auto_accept=%v", clipboard, autoAccept)
+ }
+ // A capability or first-run save rewrites the same file and must keep the transfer switches.
+ if err := savePanel(dir, map[string]bool{"terminal": true}, &panelFirstRun{Answered: true}); err != nil {
+ t.Fatal(err)
+ }
+ if clipboard, autoAccept := loadPanelSettings(dir); !clipboard || !autoAccept {
+ t.Fatalf("a capability save lost the transfer switches: clipboard=%v auto_accept=%v", clipboard, autoAccept)
+ }
+ info, err := os.Stat(filepath.Join(dir, panelFile))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
+ t.Fatalf("panel.json mode = %o, want 600", info.Mode().Perm())
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, panelFile))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var saved panelSaved
+ if err := json.Unmarshal(raw, &saved); err != nil || !saved.Clipboard || !saved.AutoAccept {
+ t.Fatalf("saved switches: %+v (%v)", saved, err)
+ }
+}
@@ -145,6 +145,10 @@ type State struct {
@@ -145,6 +145,10 @@ type State struct {
Codes *Codes `json:"codes,omitempty"`
Codes *Codes `json:"codes,omitempty"`
// FirstRun is true until the person has answered "What's this computer for?" or skipped it.
// FirstRun is true until the person has answered "What's this computer for?" or skipped it.
FirstRun bool `json:"first_run,omitempty"`
FirstRun bool `json:"first_run,omitempty"`
+ // Clipboard is the person's switch for sharing copied text between their own machines. Off by default.
+ Clipboard bool `json:"clipboard"`
+ // AutoAccept saves incoming items from the person's own machines without asking. Off by default.
+ AutoAccept bool `json:"auto_accept"`
}
}
// FirstRunAnswer is the body of POST /panel/api/first-run: the uses the person picked, or a skip that only stops the
// FirstRunAnswer is the body of POST /panel/api/first-run: the uses the person picked, or a skip that only stops the
@@ -188,6 +192,9 @@ type Options struct {
@@ -188,6 +192,9 @@ type Options struct {
// FirstRun records the answer to "What's this computer for?" and turns on what each use says. A skip only stops
// FirstRun records the answer to "What's this computer for?" and turns on what each use says. A skip only stops
// the question.
// the question.
FirstRun func(uses []string, skip bool) error
FirstRun func(uses []string, skip bool) error
+ // SetClipboard and SetAutoAccept keep the two transfer switches. Both start off.
+ SetClipboard func(on bool) error
+ SetAutoAccept func(on bool) error
// CodesStop stops the local Codes task with this id the way Codes' own stop does. It returns an error when the
// CodesStop stops the local Codes task with this id the way Codes' own stop does. It returns an error when the
// task is not running on this computer.
// task is not running on this computer.
CodesStop func(id string) error
CodesStop func(id string) error
@@ -439,6 +446,40 @@ func (s *Server) api() http.Handler {
@@ -439,6 +446,40 @@ func (s *Server) api() http.Handler {
return
return
}
}
w.WriteHeader(http.StatusNoContent)
w.WriteHeader(http.StatusNoContent)
+ case path == "clipboard" && r.Method == http.MethodPost:
+ 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.SetClipboard == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot change this switch"))
+ return
+ }
+ if err := s.opt.SetClipboard(*body.On); err != nil {
+ writeJSON(w, http.StatusConflict, errBody(err.Error()))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]bool{"on": *body.On})
+ case path == "send/auto-accept" && r.Method == http.MethodPost:
+ 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.SetAutoAccept == nil {
+ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot change this switch"))
+ return
+ }
+ if err := s.opt.SetAutoAccept(*body.On); err != nil {
+ writeJSON(w, http.StatusConflict, errBody(err.Error()))
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]bool{"on": *body.On})
case path == "first-run" && r.Method == http.MethodPost:
case path == "first-run" && r.Method == http.MethodPost:
var body FirstRunAnswer
var body FirstRunAnswer
if !decode(w, r, &body) {
if !decode(w, r, &body) {
@@ -372,3 +372,36 @@ func TestAPIUnknownPathIs404(t *testing.T) {
@@ -372,3 +372,36 @@ func TestAPIUnknownPathIs404(t *testing.T) {
t.Fatalf("unknown: %d", w.Code)
t.Fatalf("unknown: %d", w.Code)
}
}
}
}
+
+// Clipboard sharing and auto-accept are per-machine switches that start off; the window can turn each one on and
+// the state it polls says which way it is.
+func TestTransferSwitchesStartOffAndCanBeTurnedOn(t *testing.T) {
+ clipboard, autoAccept := false, false
+ srv := testServer(t, Options{
+ State: func() State { return State{Clipboard: clipboard, AutoAccept: autoAccept} },
+ SetClipboard: func(on bool) error { clipboard = on; return nil },
+ SetAutoAccept: func(on bool) error { autoAccept = on; return nil },
+ })
+ state := func() State {
+ w := do(t, srv, http.MethodGet, "/panel/api/state", testToken, "", "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("state: %d %s", w.Code, w.Body)
+ }
+ var st State
+ if err := json.Unmarshal(w.Body.Bytes(), &st); err != nil {
+ t.Fatal(err)
+ }
+ return st
+ }
+ if st := state(); st.Clipboard || st.AutoAccept {
+ t.Fatalf("a fresh machine reports clipboard=%v auto_accept=%v, want both off", st.Clipboard, st.AutoAccept)
+ }
+ for _, call := range []struct{ path string }{{"clipboard"}, {"send/auto-accept"}} {
+ if w := do(t, srv, http.MethodPost, "/panel/api/"+call.path, testToken, "", `{"on":true}`); w.Code != http.StatusOK {
+ t.Fatalf("%s: %d %s", call.path, w.Code, w.Body)
+ }
+ }
+ if st := state(); !st.Clipboard || !st.AutoAccept {
+ t.Fatalf("the switches did not turn on: clipboard=%v auto_accept=%v", st.Clipboard, st.AutoAccept)
+ }
+}
@@ -29,6 +29,8 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
@@ -29,6 +29,8 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
| `POST /panel/api/files/:id/decline` | — | Removes the incoming item. |
| `POST /panel/api/files/:id/decline` | — | Removes the incoming item. |
| `POST /panel/api/files/:id/complete` | — | Removes the accepted item after download starts. |
| `POST /panel/api/files/:id/complete` | — | Removes the accepted item after download starts. |
| `POST /panel/api/first-run` | `{ "uses": ["codes", "browser"], "skip": false }` | The first-run answer. Uses are `codes`, `browser`, `models`, `earn` and `vibe`. The daemon turns on what each says (`earn` stays off until it is confirmed on Exchange) and stops asking; `skip: true` only stops asking. |
| `POST /panel/api/first-run` | `{ "uses": ["codes", "browser"], "skip": false }` | The first-run answer. Uses are `codes`, `browser`, `models`, `earn` and `vibe`. The daemon turns on what each says (`earn` stays off until it is confirmed on Exchange) and stops asking; `skip: true` only stops asking. |
+| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only, sealed like every other item. |
+| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default. |
## `GET /panel/api/state`
## `GET /panel/api/state`
@@ -47,7 +49,9 @@ Every key is optional. A section with nothing in it is left out; the window neve
@@ -47,7 +49,9 @@ Every key is optional. A section with nothing in it is left out; the window neve
"agents": [{ "id": "claude-code", "name": "Claude Code", "state": "ready", "version": "2.1.3" }, { "id": "codex", "name": "Codex", "state": "signin", "hint": "codex login" }],
"agents": [{ "id": "claude-code", "name": "Claude Code", "state": "ready", "version": "2.1.3" }, { "id": "codex", "name": "Codex", "state": "signin", "hint": "codex login" }],
"tasks": [{ "id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "title": "Make every page work at 320 px", "folder": "~/code/openvibe-site", "agent": "Claude Code", "state": "running", "started_at": "2026-11-07T09:52:00Z", "url": "https://openvibe.codes/tasks/tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF" }]
"tasks": [{ "id": "tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF", "title": "Make every page work at 320 px", "folder": "~/code/openvibe-site", "agent": "Claude Code", "state": "running", "started_at": "2026-11-07T09:52:00Z", "url": "https://openvibe.codes/tasks/tsk_01M6Q1Z8V4T7R2N5K3B9C6D0EF" }]
},
},
- "activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }]
+ "activity": [{ "at": "2026-11-07T10:02:00Z", "text": "Vibe opened a browser to compare hosting plans" }],
+ "clipboard": false,
+ "auto_accept": false
}
}
```
```
@@ -68,6 +68,9 @@
@@ -68,6 +68,9 @@
<textarea id="lk-send-note" data-lk-send-note rows="3" maxlength="10000" placeholder="Write a short note"></textarea>
<textarea id="lk-send-note" data-lk-send-note rows="3" maxlength="10000" placeholder="Write a short note"></textarea>
<button type="button" data-lk-send-note-button>Send note</button>
<button type="button" data-lk-send-note-button>Send note</button>
<p class="lk-fine" data-lk-drop-say role="status" aria-live="polite"></p>
<p class="lk-fine" data-lk-drop-say role="status" aria-live="polite"></p>
+ <label class="lk-fine"><input type="checkbox" data-lk-clipboard> Share copied text with my other machines <small>(off by default, text only, encrypted the same way)</small></label>
+ <p class="lk-fine"><button type="button" data-lk-clipboard-button>Send what I copied</button></p>
+ <label class="lk-fine"><input type="checkbox" data-lk-auto-accept> Save what my own machines send without asking <small>(per machine)</small></label>
<h3>Waiting for you</h3><ul class="lk-send-inbox" data-lk-send-inbox></ul>
<h3>Waiting for you</h3><ul class="lk-send-inbox" data-lk-send-inbox></ul>
</section>
</section>
</div>
</div>
@@ -78,6 +78,10 @@
@@ -78,6 +78,10 @@
root.setAttribute('data-needs', m.needs_you ? '1' : '0');
root.setAttribute('data-needs', m.needs_you ? '1' : '0');
root.style.setProperty('--fill', String(Math.max(10, 100 - Math.min(running.length, 4) * 22)));
root.style.setProperty('--fill', String(Math.max(10, 100 - Math.min(running.length, 4) * 22)));
$('[data-lk-down]').hidden = true;
$('[data-lk-down]').hidden = true;
+ settings.clipboard = !!s.clipboard;
+ settings.auto_accept = !!s.auto_accept;
+ if ($('[data-lk-clipboard]')) $('[data-lk-clipboard]').checked = settings.clipboard;
+ if ($('[data-lk-auto-accept]')) $('[data-lk-auto-accept]').checked = settings.auto_accept;
$('[data-lk-first]').hidden = !s.first_run;
$('[data-lk-first]').hidden = !s.first_run;
$('[data-lk-name]').textContent = m.name || 'This computer';
$('[data-lk-name]').textContent = m.name || 'This computer';
drawCodes(s.codes, m);
drawCodes(s.codes, m);
@@ -175,6 +179,8 @@
@@ -175,6 +179,8 @@
});
});
var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');
var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');
var sendKey = null, lockerModule = null;
var sendKey = null, lockerModule = null;
+ var settings = { clipboard: false, auto_accept: false };
+ var seenInbox = {};
function panelApi(path, method, body) {
function panelApi(path, method, body) {
return fetch('/panel/api/' + path, { method: method || 'GET', headers: Object.assign({ 'X-Link-Panel': token }, body ? { 'Content-Type': 'application/json' } : {}),
return fetch('/panel/api/' + path, { method: method || 'GET', headers: Object.assign({ 'X-Link-Panel': token }, body ? { 'Content-Type': 'application/json' } : {}),
body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }).then(function (r) {
body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }).then(function (r) {
@@ -184,8 +190,10 @@
@@ -184,8 +190,10 @@
}
}
function base64(bytes) { var s = ''; for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s); }
function base64(bytes) { var s = ''; for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s); }
function unbase64(s) { var raw = atob(s), out = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; }
function unbase64(s) { var raw = atob(s), out = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; }
- function packed(name, mime, data) {
- var meta = new TextEncoder().encode(JSON.stringify({ name: name, mime: mime || 'application/octet-stream' }));
+ function packed(name, mime, data, kind) {
+ var info = { name: name, mime: mime || 'application/octet-stream' };
+ if (kind) info.kind = kind;
+ var meta = new TextEncoder().encode(JSON.stringify(info));
var out = new Uint8Array(4 + meta.length + data.length); new DataView(out.buffer).setUint32(0, meta.length); out.set(meta, 4); out.set(data, 4 + meta.length); return out;
var out = new Uint8Array(4 + meta.length + data.length); new DataView(out.buffer).setUint32(0, meta.length); out.set(meta, 4); out.set(data, 4 + meta.length); return out;
}
}
function moduleLocker() { return lockerModule || (lockerModule = import('https://openvibe.link/locker.mjs').then(function (m) { return m.locker; })); }
function moduleLocker() { return lockerModule || (lockerModule = import('https://openvibe.link/locker.mjs').then(function (m) { return m.locker; })); }
@@ -197,14 +205,14 @@
@@ -197,14 +205,14 @@
if (targetSelect.options.length === 1) say('Open Link on another paired machine to turn on receiving.');
if (targetSelect.options.length === 1) say('Open Link on another paired machine to turn on receiving.');
}).catch(function (e) { if (targetSelect.options.length === 1) { targetSelect.replaceChildren(new Option('No receiving machine is ready', '')); say(e.message); } });
}).catch(function (e) { if (targetSelect.options.length === 1) { targetSelect.replaceChildren(new Option('No receiving machine is ready', '')); say(e.message); } });
}
}
- function sendPayload(name, mime, data) {
+ function sendPayload(name, mime, data, kind) {
var opt = targetSelect.options[targetSelect.selectedIndex];
var opt = targetSelect.options[targetSelect.selectedIndex];
if (!opt || !opt.value) { say('Choose one of your machines first.'); return Promise.resolve(); }
if (!opt || !opt.value) { say('Choose one of your machines first.'); return Promise.resolve(); }
if (data.length > 20 * 1024 * 1024) { say('Choose a file smaller than 20 MB for now.'); return Promise.resolve(); }
if (data.length > 20 * 1024 * 1024) { say('Choose a file smaller than 20 MB for now.'); return Promise.resolve(); }
say('Encrypting on this computer…');
say('Encrypting on this computer…');
return moduleLocker().then(function (locker) {
return moduleLocker().then(function (locker) {
var publicKey = JSON.parse(opt.dataset.publicKey || '{}');
var publicKey = JSON.parse(opt.dataset.publicKey || '{}');
- var content = packed(name, mime, data);
+ var content = packed(name, mime, data, kind);
return locker.seal(content, { lock: 'people', keys: [{ subject: { type: 'link', id: opt.value }, publicKey: publicKey }] }).then(function (sealed) {
return locker.seal(content, { lock: 'people', keys: [{ subject: { type: 'link', id: opt.value }, publicKey: publicKey }] }).then(function (sealed) {
say('Sending encrypted data…');
say('Sending encrypted data…');
return panelApi('files', 'POST', { target_node: opt.value, envelope: base64(sealed.bytes) });
return panelApi('files', 'POST', { target_node: opt.value, envelope: base64(sealed.bytes) });
@@ -214,6 +222,12 @@
@@ -214,6 +222,12 @@
}
}
function sendFile(file) { return file.arrayBuffer().then(function (b) { return sendPayload(file.name, file.type, new Uint8Array(b)); }); }
function sendFile(file) { return file.arrayBuffer().then(function (b) { return sendPayload(file.name, file.type, new Uint8Array(b)); }); }
function sendNote() { var text = $('[data-lk-send-note]').value; if (!text.trim()) { say('Write a note first.'); return; } sendPayload('note.txt', 'text/plain;charset=utf-8', new TextEncoder().encode(text)).then(function () { $('[data-lk-send-note]').value = ''; }); }
function sendNote() { var text = $('[data-lk-send-note]').value; if (!text.trim()) { say('Write a note first.'); return; } sendPayload('note.txt', 'text/plain;charset=utf-8', new TextEncoder().encode(text)).then(function () { $('[data-lk-send-note]').value = ''; }); }
+ function saveFile(info, fileBytes, id) {
+ var safeName = String(info.name || 'received-file').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').slice(0, 180) || 'received-file';
+ var url = URL.createObjectURL(new Blob([fileBytes], { type: info.mime || 'application/octet-stream' }));
+ var a = document.createElement('a'); a.href = url; a.download = safeName; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
+ return panelApi('files/' + encodeURIComponent(id) + '/complete', 'POST', {}).then(function () { say('Accepted. Your browser saved ' + safeName + ' to Downloads.'); });
+ }
function downloadAccepted(item) {
function downloadAccepted(item) {
var path = 'files/' + encodeURIComponent(item.id);
var path = 'files/' + encodeURIComponent(item.id);
return Promise.all([moduleLocker(), panelApi('send/key'), panelApi(path + '/accept', 'POST', {})]).then(function (parts) {
return Promise.all([moduleLocker(), panelApi('send/key'), panelApi(path + '/accept', 'POST', {})]).then(function (parts) {
@@ -224,10 +238,15 @@
@@ -224,10 +238,15 @@
var view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), n = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0);
var view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), n = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0);
if (n < 2 || n > view.length - 4) throw new Error('The encrypted item is incomplete.');
if (n < 2 || n > view.length - 4) throw new Error('The encrypted item is incomplete.');
var info = JSON.parse(new TextDecoder().decode(view.subarray(4, 4 + n))), fileBytes = view.subarray(4 + n);
var info = JSON.parse(new TextDecoder().decode(view.subarray(4, 4 + n))), fileBytes = view.subarray(4 + n);
- var safeName = String(info.name || 'received-file').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').slice(0, 180) || 'received-file';
- var url = URL.createObjectURL(new Blob([fileBytes], { type: info.mime || 'application/octet-stream' }));
- var a = document.createElement('a'); a.href = url; a.download = safeName; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
- return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () { say('Accepted. Your browser saved ' + safeName + ' to Downloads.'); });
+ if (info.kind === 'clipboard' && navigator.clipboard && navigator.clipboard.writeText) {
+ var text = new TextDecoder().decode(fileBytes);
+ return navigator.clipboard.writeText(text).then(function () {
+ return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () {
+ say('Copied text from ' + (item.sender_name || 'your machine') + ' to this computer\'s clipboard.');
+ });
+ }, function () { return saveFile(info, fileBytes, item.id); });
+ }
+ return saveFile(info, fileBytes, item.id);
});
});
}
}
function size(bytes) {
function size(bytes) {
@@ -237,6 +256,11 @@
@@ -237,6 +256,11 @@
function loadInbox() {
function loadInbox() {
panelApi('send/inbox').then(function (body) {
panelApi('send/inbox').then(function (body) {
var list = $('[data-lk-send-inbox]');
var list = $('[data-lk-send-inbox]');
+ (body.items || []).forEach(function (item) {
+ if (!settings.auto_accept || seenInbox[item.id]) return;
+ seenInbox[item.id] = true;
+ downloadAccepted(item).catch(function (e) { say(e.message || 'An item from one of your machines could not be opened.'); });
+ });
list.replaceChildren.apply(list, (body.items || []).map(function (item) {
list.replaceChildren.apply(list, (body.items || []).map(function (item) {
var li = el('li', 'lk-send-item'), label = el('span', '', 'Encrypted item from ' + (item.sender_name || 'your machine') + ' · ' + size(item.bytes) + ' · ' + ago(item.created_at));
var li = el('li', 'lk-send-item'), label = el('span', '', 'Encrypted item from ' + (item.sender_name || 'your machine') + ' · ' + size(item.bytes) + ' · ' + ago(item.created_at));
var actions = el('div'), accept = el('button', '', 'Accept'), decline = el('button', '', 'Decline');
var actions = el('div'), accept = el('button', '', 'Accept'), decline = el('button', '', 'Decline');
@@ -253,6 +277,16 @@
@@ -253,6 +277,16 @@
dropTarget.addEventListener('drop', function (e) { e.preventDefault(); if (e.dataTransfer.files[0]) sendFile(e.dataTransfer.files[0]); });
dropTarget.addEventListener('drop', function (e) { e.preventDefault(); if (e.dataTransfer.files[0]) sendFile(e.dataTransfer.files[0]); });
$('[data-lk-files]').addEventListener('change', function () { if (this.files[0]) sendFile(this.files[0]); this.value = ''; });
$('[data-lk-files]').addEventListener('change', function () { if (this.files[0]) sendFile(this.files[0]); this.value = ''; });
$('[data-lk-send-note-button]').addEventListener('click', sendNote);
$('[data-lk-send-note-button]').addEventListener('click', sendNote);
+ if ($('[data-lk-clipboard]')) $('[data-lk-clipboard]').addEventListener('change', function () { send('clipboard', 'POST', { on: this.checked }); });
+ if ($('[data-lk-auto-accept]')) $('[data-lk-auto-accept]').addEventListener('change', function () { send('send/auto-accept', 'POST', { on: this.checked }); });
+ if ($('[data-lk-clipboard-button]')) $('[data-lk-clipboard-button]').addEventListener('click', function () {
+ if (!settings.clipboard) { say('Turn on sharing copied text first.'); return; }
+ if (!navigator.clipboard || !navigator.clipboard.readText) { say('This browser does not let Link read the clipboard.'); return; }
+ navigator.clipboard.readText().then(function (text) {
+ if (!text.trim()) { say('There is no text on this computer\'s clipboard.'); return; }
+ return sendPayload('clipboard.txt', 'text/plain;charset=utf-8', new TextEncoder().encode(text), 'clipboard');
+ }).catch(function () { say('Link could not read the clipboard. Click the page and try again.'); });
+ });
loadTargets(); loadInbox(); setInterval(function () { if (document.visibilityState === 'visible') { loadTargets(); loadInbox(); } }, 3000);
loadTargets(); loadInbox(); setInterval(function () { if (document.visibilityState === 'visible') { loadTargets(); loadInbox(); } }, 3000);
function tick() { if (document.visibilityState === 'visible') load(); }
function tick() { if (document.visibilityState === 'visible') load(); }
load();
load();
@@ -161,5 +161,7 @@
@@ -161,5 +161,7 @@
"url": "https://openvibe.codes/tasks/tsk_01M6N2"
"url": "https://openvibe.codes/tasks/tsk_01M6N2"
}
}
]
]
- }
+ },
+ "clipboard": false,
+ "auto_accept": false
}
}
@@ -20,19 +20,27 @@ type Tokens interface {
@@ -20,19 +20,27 @@ type Tokens interface {
}
}
var itemID = regexp.MustCompile(`^snd_[a-f0-9]{32}$`)
var itemID = regexp.MustCompile(`^snd_[a-f0-9]{32}$`)
+var uploadID = regexp.MustCompile(`^upl_[a-f0-9]{32}$`)
+
+// uploadChunk is how much of the base64 envelope Link sends in one part. The relay accepts up to 2 MiB; 1 MiB keeps
+// one request small enough to retry quickly on a home connection.
+const uploadChunk = 1024 * 1024
type Client struct {
type Client struct {
BaseURL string
BaseURL string
Tokens Tokens
Tokens Tokens
HTTP *http.Client
HTTP *http.Client
Agent string
Agent string
+ // Self is this machine's principal (lnk_…), so a direct transfer can name its sender.
+ Self string
}
}
type Machine struct {
type Machine struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Online bool `json:"online"`
- PublicKey JWK `json:"public_key"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Online bool `json:"online"`
+ PublicKey JWK `json:"public_key"`
+ LAN []string `json:"lan"`
}
}
// Item is one waiting transfer as the inbox lists it: who sent it and how big it is. The ciphertext is not in the
// Item is one waiting transfer as the inbox lists it: who sent it and how big it is. The ciphertext is not in the
@@ -46,8 +54,21 @@ type Item struct {
@@ -46,8 +54,21 @@ type Item struct {
ExpiresAt time.Time `json:"expires_at"`
ExpiresAt time.Time `json:"expires_at"`
}
}
-func (c *Client) Register(ctx context.Context, key JWK) error {
- return c.do(ctx, http.MethodPut, "/api/v1/link/self/send/key", map[string]any{"public_key": Public(key)}, nil)
+func (c *Client) Register(ctx context.Context, key JWK, lan ...string) error {
+ body := map[string]any{"public_key": Public(key)}
+ if len(lan) > 0 {
+ body["lan"] = lan
+ }
+ return c.do(ctx, http.MethodPut, "/api/v1/link/self/send/key", body, nil)
+}
+
+// LANKey is the owner's shared secret for direct transfers; every one of the owner's machines reads the same one.
+func (c *Client) LANKey(ctx context.Context) (string, error) {
+ var out struct {
+ Key string `json:"key"`
+ }
+ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/lan-key", nil, &out)
+ return out.Key, err
}
}
func (c *Client) Targets(ctx context.Context) ([]Machine, error) {
func (c *Client) Targets(ctx context.Context) ([]Machine, error) {
var out struct {
var out struct {
@@ -56,8 +77,140 @@ func (c *Client) Targets(ctx context.Context) ([]Machine, error) {
@@ -56,8 +77,140 @@ func (c *Client) Targets(ctx context.Context) ([]Machine, error) {
err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/targets", nil, &out)
err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/targets", nil, &out)
return out.Machines, err
return out.Machines, err
}
}
+
+// Upload is the relay's view of a transfer that is arriving in parts: the part after the last one it stored, and how
+// many bytes it holds. Resuming asks the relay for this instead of guessing from the failed request.
+type Upload struct {
+ ID string `json:"id"`
+ Bytes int64 `json:"bytes"`
+ Received int64 `json:"received"`
+ Seq int `json:"seq"`
+ ItemID string `json:"item_id"`
+}
+
+// Send uploads the Locker envelope in parts and resumes from the relay's acknowledged part after a dropped
+// connection. Finishing is idempotent on the relay, so a lost finish answer does not make a second item.
func (c *Client) Send(ctx context.Context, target, envelope string) error {
func (c *Client) Send(ctx context.Context, target, envelope string) error {
- return c.do(ctx, http.MethodPost, "/api/v1/link/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil)
+ if envelope == "" {
+ return fmt.Errorf("the encrypted item is empty")
+ }
+ // Same network: hand it over directly, sealed as before. Any problem (off the LAN, a timeout, no shared secret)
+ // falls through to the relay, which keeps the item until the receiver accepts it.
+ if err := c.sendLAN(ctx, target, envelope); err == nil {
+ return nil
+ }
+ upload, err := c.StartUpload(ctx, target, int64(len(envelope)))
+ if err != nil {
+ // A Link newer than the Network it talks to still works: the one-request form is the v1 relay.
+ if single := c.do(ctx, http.MethodPost, "/api/v1/link/self/send", map[string]string{"target_node": target, "envelope": envelope}, nil); single == nil {
+ return nil
+ }
+ return err
+ }
+ failures := 0
+ for {
+ for upload.Seq*uploadChunk < len(envelope) {
+ from := upload.Seq * uploadChunk
+ to := from + uploadChunk
+ if to > len(envelope) {
+ to = len(envelope)
+ }
+ next, err := c.UploadPart(ctx, upload.ID, upload.Seq, envelope[from:to])
+ if err == nil {
+ upload = next
+ failures = 0
+ continue
+ }
+ if ctx.Err() != nil {
+ return fmt.Errorf("sending was stopped")
+ }
+ failures++
+ if failures > 5 {
+ return fmt.Errorf("the connection kept dropping; the parts already sent are kept for an hour, try again")
+ }
+ time.Sleep(time.Duration(failures) * 250 * time.Millisecond)
+ status, statusErr := c.UploadStatus(ctx, upload.ID)
+ if statusErr != nil {
+ continue
+ }
+ upload = status
+ }
+ item, err := c.FinishUpload(ctx, upload.ID)
+ if err == nil {
+ upload.ItemID = item
+ return nil
+ }
+ if ctx.Err() != nil {
+ return fmt.Errorf("sending was stopped")
+ }
+ failures++
+ if failures > 5 {
+ return fmt.Errorf("the relay did not confirm the item; the parts are kept for an hour, try again")
+ }
+ status, statusErr := c.UploadStatus(ctx, upload.ID)
+ if statusErr == nil {
+ if status.ItemID != "" {
+ return nil
+ }
+ upload = status
+ }
+ time.Sleep(time.Duration(failures) * 250 * time.Millisecond)
+ }
+}
+
+func (c *Client) StartUpload(ctx context.Context, target string, bytes int64) (Upload, error) {
+ var out Upload
+ err := c.do(ctx, http.MethodPost, "/api/v1/link/self/send/upload", map[string]any{"target_node": target, "bytes": bytes}, &out)
+ return out, err
+}
+
+func (c *Client) UploadPart(ctx context.Context, id string, seq int, chunk string) (Upload, error) {
+ if !uploadID.MatchString(id) {
+ return Upload{}, fmt.Errorf("this transfer is no longer available")
+ }
+ var out Upload
+ err := c.do(ctx, http.MethodPut, fmt.Sprintf("/api/v1/link/self/send/upload/%s/%d", id, seq), map[string]string{"chunk": chunk}, &out)
+ return out, err
+}
+
+func (c *Client) UploadStatus(ctx context.Context, id string) (Upload, error) {
+ if !uploadID.MatchString(id) {
+ return Upload{}, fmt.Errorf("this transfer is no longer available")
+ }
+ var out Upload
+ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/upload/"+id, nil, &out)
+ return out, err
+}
+
+func (c *Client) FinishUpload(ctx context.Context, id string) (string, error) {
+ if !uploadID.MatchString(id) {
+ return "", fmt.Errorf("this transfer is no longer available")
+ }
+ var out struct {
+ ID string `json:"id"`
+ }
+ err := c.do(ctx, http.MethodPost, "/api/v1/link/self/send/upload/"+id+"/finish", map[string]string{}, &out)
+ return out.ID, err
+}
+
+// Outbox is the sender's list: what this machine sent and whether the receiving machine accepted or saved it.
+type OutboxItem struct {
+ ID string `json:"id"`
+ Target string `json:"target_node"`
+ TargetName string `json:"target_name"`
+ Bytes int64 `json:"bytes"`
+ State string `json:"state"`
+ CreatedAt time.Time `json:"created_at"`
+ AcceptedAt time.Time `json:"accepted_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func (c *Client) Outbox(ctx context.Context) ([]OutboxItem, error) {
+ var out struct {
+ Items []OutboxItem `json:"items"`
+ }
+ err := c.do(ctx, http.MethodGet, "/api/v1/link/self/send/outbox", nil, &out)
+ return out.Items, err
}
}
func (c *Client) Inbox(ctx context.Context) ([]Item, error) {
func (c *Client) Inbox(ctx context.Context) ([]Item, error) {
var out struct {
var out struct {
new file mode 100644
new file mode 100644
@@ -0,0 +1,329 @@
@@ -0,0 +1,329 @@
+package send
+
+// Direct delivery on the same network: a Link listens on a random local port and accepts a sealed Locker envelope
+// only from another of the owner's machines. The sender proves it with an HMAC from the owner's LAN secret, which
+// both machines read from Network with their own node tokens; no relay round trip is needed, and an unauthenticated
+// neighbour cannot put anything in the inbox. The payload is always ciphertext: the listener never has a key.
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ lanPath = "/api/link-send/v1"
+ lanMaxBody = 40 << 20
+ lanWindow = 5 * time.Minute
+ lanMaxItems = 100
+ lanMaxNonces = 4096
+)
+
+// Incoming is one sealed item that arrived directly. It is held in memory until the person accepts, declines or Link
+// exits; the relay copy is still there if they restart, because the sender only takes the direct path when accepted.
+type Incoming struct {
+ ID string
+ Sender string
+ SenderName string
+ Envelope string
+ Bytes int64
+ CreatedAt time.Time
+}
+
+// LAN is one Link's direct listener and its small queue of received items.
+type LAN struct {
+ target string
+ mu sync.Mutex
+ secret []byte
+ items map[string]Incoming
+ nonces map[string]time.Time
+ ln net.Listener
+ server *http.Server
+}
+
+func NewLAN(target string) *LAN {
+ return &LAN{target: target, items: map[string]Incoming{}, nonces: map[string]time.Time{}}
+}
+
+// Listen opens a random port. It is loopback-only in tests; on a real machine it is reachable on the local network,
+// which is the point, and every request still has to carry the owner's HMAC.
+func (l *LAN) Listen(addr string) error {
+ if addr == "" {
+ addr = "0.0.0.0:0"
+ }
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ return err
+ }
+ l.ln = ln
+ l.server = &http.Server{Handler: l.handler(), ReadHeaderTimeout: 5 * time.Second}
+ go func() { _ = l.server.Serve(ln) }()
+ return nil
+}
+
+func (l *LAN) Close() error {
+ if l.server != nil {
+ return l.server.Close()
+ }
+ return nil
+}
+
+// Port is the listening port, or 0 when Listen did not succeed.
+func (l *LAN) Port() int {
+ if l.ln == nil {
+ return 0
+ }
+ if a, ok := l.ln.Addr().(*net.TCPAddr); ok {
+ return a.Port
+ }
+ return 0
+}
+
+// URLs are the addresses to advertise to Network: every non-loopback IPv4 address of this machine, plus loopback for
+// two Links on one computer (a test, or two profiles).
+func (l *LAN) URLs() []string {
+ port := l.Port()
+ if port == 0 {
+ return nil
+ }
+ seen := map[string]bool{}
+ var out []string
+ add := func(host string) {
+ if host == "" || seen[host] {
+ return
+ }
+ seen[host] = true
+ out = append(out, "http://"+net.JoinHostPort(host, strconv.Itoa(port)))
+ }
+ add("127.0.0.1")
+ ifaces, err := net.Interfaces()
+ if err != nil {
+ return out
+ }
+ for _, iface := range ifaces {
+ if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
+ continue
+ }
+ addrs, _ := iface.Addrs()
+ for _, a := range addrs {
+ ip, _, err := net.ParseCIDR(a.String())
+ if err != nil || ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.To4() == nil {
+ continue
+ }
+ add(ip.String())
+ }
+ }
+ return out
+}
+
+func (l *LAN) SetSecret(encoded string) error {
+ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
+ if err != nil || len(raw) < 16 {
+ return errors.New("the LAN sender secret is not valid")
+ }
+ l.mu.Lock()
+ l.secret = append([]byte(nil), raw...)
+ l.mu.Unlock()
+ return nil
+}
+
+func (l *LAN) Items() []Incoming {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ out := make([]Incoming, 0, len(l.items))
+ for _, item := range l.items {
+ out = append(out, item)
+ }
+ return out
+}
+
+func (l *LAN) Accept(id string) (string, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ item, ok := l.items[id]
+ if !ok {
+ return "", errors.New("that item is no longer waiting")
+ }
+ return item.Envelope, nil
+}
+
+func (l *LAN) Decline(id string) error {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if _, ok := l.items[id]; !ok {
+ return errors.New("that item is no longer waiting")
+ }
+ delete(l.items, id)
+ return nil
+}
+
+func (l *LAN) Complete(id string) error {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if _, ok := l.items[id]; !ok {
+ return errors.New("that item is no longer waiting")
+ }
+ delete(l.items, id)
+ return nil
+}
+
+type lanRequest struct {
+ Sender string `json:"sender_node"`
+ Target string `json:"target_node"`
+ Envelope string `json:"envelope"`
+ At int64 `json:"at"`
+ Nonce string `json:"nonce"`
+ Mac string `json:"mac"`
+}
+
+func (l *LAN) handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc(lanPath, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req lanRequest
+ if err := json.NewDecoder(io.LimitReader(r.Body, lanMaxBody)).Decode(&req); err != nil {
+ http.Error(w, "unreadable transfer", http.StatusBadRequest)
+ return
+ }
+ l.mu.Lock()
+ secret := append([]byte(nil), l.secret...)
+ l.mu.Unlock()
+ if len(secret) == 0 {
+ http.Error(w, "this machine has not turned on receiving", http.StatusForbidden)
+ return
+ }
+ if req.Target != l.target {
+ http.Error(w, "that item is for another machine", http.StatusNotFound)
+ return
+ }
+ if req.Sender == "" || req.Sender == l.target || req.Nonce == "" || len(req.Nonce) > 128 {
+ http.Error(w, "the sender is not one of this machine's owner's machines", http.StatusForbidden)
+ return
+ }
+ if len(req.Envelope) == 0 || len(req.Envelope) > 32*1024*1024 {
+ http.Error(w, "the encrypted item is missing or too large", http.StatusBadRequest)
+ return
+ }
+ now := time.Now()
+ at := time.Unix(req.At, 0)
+ if req.At == 0 || now.Sub(at) > lanWindow || at.Sub(now) > lanWindow {
+ http.Error(w, "the transfer is too old", http.StatusForbidden)
+ return
+ }
+ want := lanMAC(secret, req.Sender, req.Target, req.At, req.Nonce, req.Envelope)
+ if subtle.ConstantTimeCompare([]byte(req.Mac), []byte(want)) != 1 {
+ http.Error(w, "the sender is not one of this machine's owner's machines", http.StatusForbidden)
+ return
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if _, seen := l.nonces[req.Nonce]; seen {
+ http.Error(w, "that transfer was already received", http.StatusConflict)
+ return
+ }
+ if len(l.nonces) > lanMaxNonces {
+ for nonce, when := range l.nonces {
+ if now.Sub(when) > lanWindow {
+ delete(l.nonces, nonce)
+ }
+ }
+ }
+ if len(l.items) >= lanMaxItems {
+ http.Error(w, "this machine's transfer list is full; accept or decline an item first", http.StatusTooManyRequests)
+ return
+ }
+ l.nonces[req.Nonce] = now
+ id := "lan_" + hex.EncodeToString(random(16))
+ l.items[id] = Incoming{ID: id, Sender: req.Sender, Envelope: req.Envelope, Bytes: int64(len(req.Envelope)), CreatedAt: now}
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _, _ = w.Write([]byte(`{"id":"` + id + `"}`))
+ })
+ return mux
+}
+
+func lanMAC(secret []byte, sender, target string, at int64, nonce, envelope string) string {
+ sum := sha256.Sum256([]byte(envelope))
+ h := hmac.New(sha256.New, secret)
+ fmt.Fprintf(h, "openvibe-link-send-lan-v1\n%s\n%s\n%d\n%s\n%s\n", sender, target, at, nonce, base64.RawURLEncoding.EncodeToString(sum[:]))
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+func random(n int) []byte {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return b
+}
+
+// sendLAN tries the target's advertised local endpoints. Any failure (not on the network, a timeout, an old secret)
+// falls back to the relay by returning an error.
+func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {
+ if c.Self == "" {
+ return errors.New("this machine has no id for a direct transfer")
+ }
+ machines, err := c.Targets(ctx)
+ if err != nil {
+ return err
+ }
+ var endpoints []string
+ for _, m := range machines {
+ if m.ID == target {
+ endpoints = m.LAN
+ break
+ }
+ }
+ if len(endpoints) == 0 {
+ return errors.New("the receiving machine is not on this network")
+ }
+ encoded, err := c.LANKey(ctx)
+ if err != nil {
+ return err
+ }
+ secret, err := base64.RawURLEncoding.DecodeString(encoded)
+ if err != nil || len(secret) < 16 {
+ return errors.New("the LAN sender secret is not valid")
+ }
+ for _, base := range endpoints {
+ at := time.Now().Unix()
+ nonce := hex.EncodeToString(random(16))
+ body, _ := json.Marshal(lanRequest{
+ Sender: c.Self, Target: target, Envelope: envelope, At: at, Nonce: nonce,
+ Mac: lanMAC(secret, c.Self, target, at, nonce, envelope),
+ })
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+lanPath, strings.NewReader(string(body)))
+ if err != nil {
+ continue
+ }
+ req.Header.Set("Content-Type", "application/json")
+ client := &http.Client{Timeout: 2 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ continue
+ }
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+ resp.Body.Close()
+ if resp.StatusCode == http.StatusCreated {
+ return nil
+ }
+ }
+ return errors.New("the direct transfer did not arrive")
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+package send
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+const lanTarget = "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+
+func startTestLAN(t *testing.T) (*LAN, string) {
+ t.Helper()
+ lan := NewLAN(lanTarget)
+ if err := lan.Listen("127.0.0.1:0"); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = lan.Close() })
+ secret := strings.Repeat("s", 32)
+ if err := lan.SetSecret(base64.RawURLEncoding.EncodeToString([]byte(secret))); err != nil {
+ t.Fatal(err)
+ }
+ return lan, secret
+}
+
+func lanBody(sender, secret, envelope string) []byte {
+ at := time.Now().Unix()
+ nonce := hex.EncodeToString(random(16))
+ b, _ := json.Marshal(lanRequest{Sender: sender, Target: lanTarget, Envelope: envelope, At: at, Nonce: nonce,
+ Mac: lanMAC([]byte(secret), sender, lanTarget, at, nonce, envelope)})
+ return b
+}
+
+func postLAN(t *testing.T, url string, body []byte) int {
+ t.Helper()
+ resp, err := http.Post(url+lanPath, "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ return resp.StatusCode
+}
+
+// A machine refuses a sender that does not hold the owner's LAN secret, and it refuses a replayed transfer even when
+// it does. The accepted envelope is the same ciphertext the sender made: the listener never sees a key.
+func TestLANRefusesANonOwnerAndReplayedTransfer(t *testing.T) {
+ lan, secret := startTestLAN(t)
+ url := "http://" + lan.ln.Addr().String()
+ envelope := base64.RawStdEncoding.EncodeToString([]byte("sealed bytes"))
+ if code := postLAN(t, url, lanBody("lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ", "not-the-owner-secret", envelope)); code != http.StatusForbidden {
+ t.Fatalf("a sender without the owner secret got %d, want 403", code)
+ }
+ if got := len(lan.Items()); got != 0 {
+ t.Fatalf("a refused sender put %d items in the inbox", got)
+ }
+ body := lanBody("lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ", secret, envelope)
+ if code := postLAN(t, url, body); code != http.StatusCreated {
+ t.Fatalf("the owner's machine got %d, want 201", code)
+ }
+ if code := postLAN(t, url, body); code != http.StatusConflict {
+ t.Fatalf("a replayed transfer got %d, want 409", code)
+ }
+ items := lan.Items()
+ if len(items) != 1 || items[0].Envelope != envelope {
+ t.Fatalf("stored items = %+v", items)
+ }
+ if got, err := lan.Accept(items[0].ID); err != nil || got != envelope {
+ t.Fatalf("accept = %q, %v", got, err)
+ }
+}
+
+// A sender on the same network hands the sealed item straight to the target: the relay is not called for the upload.
+func TestSendUsesLANBeforeTheRelay(t *testing.T) {
+ lan, secret := startTestLAN(t)
+ relayCalls := 0
+ network := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/link/self/send/targets":
+ relayCalls++
+ _ = json.NewEncoder(w).Encode(map[string]any{"machines": []map[string]any{{
+ "id": lanTarget, "name": "Workshop", "online": true,
+ "public_key": map[string]string{"kty": "EC", "crv": "P-256", "x": "x", "y": "y"},
+ "lan": []string{"http://" + lan.ln.Addr().String()},
+ }}})
+ case "/api/v1/link/self/send/lan-key":
+ relayCalls++
+ _ = json.NewEncoder(w).Encode(map[string]string{"key": base64.RawURLEncoding.EncodeToString([]byte(secret))})
+ default:
+ relayCalls += 100
+ t.Errorf("the relay was asked to upload over %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer network.Close()
+ c := &Client{BaseURL: network.URL, Tokens: staticToken("node-token"), HTTP: network.Client(),
+ Self: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPZ"}
+ envelope := base64.RawStdEncoding.EncodeToString([]byte(strings.Repeat("sealed", 100)))
+ if err := c.Send(context.Background(), lanTarget, envelope); err != nil {
+ t.Fatal(err)
+ }
+ if relayCalls != 2 {
+ t.Fatalf("relay calls = %d, want only the target list and the LAN secret", relayCalls)
+ }
+ items := lan.Items()
+ if len(items) != 1 || items[0].Envelope != envelope {
+ t.Fatalf("the direct transfer stored %+v", items)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+package send
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// A dropped part must not restart the transfer: Link asks the relay which part it holds and continues there. The
+// finished item is the exact ciphertext the sender began with.
+func TestSendResumesAfterDroppedPart(t *testing.T) {
+ const id = "upl_0123456789abcdef0123456789abcdef"
+ envelope := strings.Repeat("QUJD", 300000) // ~1.2 MiB: two parts
+ var mu sync.Mutex
+ parts := map[int]string{}
+ next := 0
+ dropped := false
+ statusReads := 0
+ var finished string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/api/v1/link/self/send/upload":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 0, "seq": 0})
+ case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/link/self/send/upload/"+id+"/"):
+ var seq int
+ if _, err := fmt.Sscanf(strings.TrimPrefix(r.URL.Path, "/api/v1/link/self/send/upload/"+id+"/"), "%d", &seq); err != nil {
+ t.Fatalf("bad part path %s", r.URL.Path)
+ }
+ var body struct {
+ Chunk string `json:"chunk"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if seq == 1 && !dropped {
+ dropped = true
+ w.WriteHeader(http.StatusBadGateway)
+ return
+ }
+ if seq != next {
+ t.Fatalf("part %d arrived while the relay held %d", seq, next)
+ }
+ parts[seq] = body.Chunk
+ next++
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 1, "seq": next})
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v1/link/self/send/upload/"+id:
+ statusReads++
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "bytes": len(envelope), "received": 1, "seq": next})
+ case r.Method == http.MethodPost && r.URL.Path == "/api/v1/link/self/send/upload/"+id+"/finish":
+ var b strings.Builder
+ for i := 0; i < next; i++ {
+ b.WriteString(parts[i])
+ }
+ finished = b.String()
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "snd_0123456789abcdef0123456789abcdef"})
+ default:
+ t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+
+ c := &Client{BaseURL: server.URL, Tokens: staticToken("node-token"), HTTP: server.Client()}
+ if err := c.Send(context.Background(), "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ", envelope); err != nil {
+ t.Fatal(err)
+ }
+ if !dropped || statusReads == 0 {
+ t.Fatalf("the test did not drop a part (dropped=%v statusReads=%d)", dropped, statusReads)
+ }
+ if finished != envelope {
+ t.Fatalf("the relay assembled %d bytes, want %d", len(finished), len(envelope))
+ }
+}