openvibers/openvibe.link Commits
Add scoped machine access and agent messaging to Link (#157)
Changes
@@@ -43,6 +43,8 @@ serves the share this computer was picked for at `https://<name>--<you>.openvibe**Host a shared party computer** with `openvibe-link party host <party-id>` from [openvibe.party](https://openvibe.party). Link shows the running desktop and its Stop action in its window ([guide](docs/party-desktop.md)).++**Ask another machine for access:** `openvibe-link access request <machine> --scope screen,desk --for 1h`. The target owner approves on their machine; a signed, scoped grant controls each use. Turn on **Let my agents talk** to register local agent CLIs and give them Link's `openvibe-link mcp` tools. [Machine access and agent messages](docs/access-and-agents.md) explains scopes, approval, revocation, the inbox, and MCP setup.++**Sell a local model** on [OpenVibe.Exchange](https://openvibe.exchange):`openvibe-link exchange serve --model qwen3:32b --public-url https://gpu.example.com/v1`. It serves the model from thelocal OpenAI-compatible server (Ollama by default at `127.0.0.1:11434`), registers the public https address through thennew file mode 100644@@@ -0,0 +1,452 @@++package main++++import (++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"++)++++type accessArgs struct {++ Scope, For, Reason string++ JSON bool++}++++func accessDuration(raw string) (int64, error) {++ if raw == "" {++ raw = "1h"++ }++ if raw == "forever" {++ return 0, nil++ }++ if strings.HasSuffix(raw, "d") {++ var d int64++ if _, err := fmt.Sscanf(raw, "%dd", &d); err == nil && d >= 1 && d <= 30 {++ return d * 86400, nil++ }++ return 0, errors.New("--for must be 1m through 30d, or forever for same-owner agent/send")++ }++ d, err := time.ParseDuration(raw)++ if err != nil || d < time.Minute || d > 30*24*time.Hour {++ return 0, errors.New("--for must be 1m through 30d, or forever for same-owner agent/send")++ }++ return int64(d.Seconds()), nil++}++++func cmdAccess(g *globals, args []string, opt accessArgs, out io.Writer) error {++ if len(args) == 0 {++ return errors.New("usage: openvibe-link access request|list|approve|deny|revoke")++ }++ data := map[string]any{}++ action := args[0]++ switch action {++ case "request":++ if len(args) != 2 {++ return errors.New("usage: openvibe-link access request <machine> --scope agent,send [--for 1h] [--reason text]")++ }++ if opt.Scope == "" {++ return errors.New("--scope is required")++ }++ scopes := strings.Split(opt.Scope, ",")++ seen := map[string]bool{}++ for _, scope := range scopes {++ if !validScope(scope) || seen[scope] {++ return errors.New("--scope must list distinct agent,send,screen,desk,computer,worker")++ }++ seen[scope] = true++ }++ seconds, err := accessDuration(opt.For)++ if err != nil {++ return err++ }++ if seconds == 0 && (len(scopes) > 2 || !seen["agent"] && !seen["send"] || seen["screen"] || seen["desk"] || seen["computer"] || seen["worker"]) {++ return errors.New("forever is only for agent and send")++ }++ if len(opt.Reason) > 500 {++ return errors.New("reason is too long")++ }++ data = map[string]any{"machine": args[1], "scopes": scopes, "duration_seconds": seconds, "reason": opt.Reason}++ case "list":++ if len(args) != 1 {++ return errors.New("usage: openvibe-link access list")++ }++ case "approve", "deny", "revoke":++ if len(args) != 2 {++ return fmt.Errorf("usage: openvibe-link access %s <id>", action)++ }++ data["id"] = args[1]++ if action == "approve" {++ if opt.Scope != "" {++ scopes := strings.Split(opt.Scope, ",")++ for _, s := range scopes {++ if !validScope(s) {++ return errors.New("invalid scope")++ }++ }++ data["scopes"] = scopes++ }++ if opt.For != "" {++ seconds, err := accessDuration(opt.For)++ if err != nil {++ return err++ }++ if seconds == 0 {++ return errors.New("an answer may only shorten the requested time")++ }++ data["duration_seconds"] = seconds++ }++ }++ default:++ return errors.New("usage: openvibe-link access request|list|approve|deny|revoke")++ }++ raw, _ := json.Marshal(data)++ resp, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdAccess, ID: action, Data: raw}, 20*time.Second)++ if err != nil {++ return err++ }++ var value any++ if len(resp.Data) > 0 {++ if err := json.Unmarshal(resp.Data, &value); err != nil {++ return err++ }++ }++ if opt.JSON {++ b, _ := json.MarshalIndent(value, "", " ")++ _, err = fmt.Fprintln(out, string(b))++ return err++ }++ if action == "list" {++ return printAccessList(out, value)++ }++ if action == "request" {++ seconds, _ := accessDuration(opt.For)++ if _, err = fmt.Fprintf(out, "Asked %s for: %s (%s). Waiting for their owner…\n", args[1], scopeWords(strings.Split(opt.Scope, ",")), accessTime(seconds)); err != nil {++ return err++ }++ id := responseID(value)++ if id == "" {++ return errors.New("Network did not return the request id")++ }++ return waitAccessAnswer(g, id, strings.Split(opt.Scope, ","), out)++ }++ if action == "approve" {++ _, err = fmt.Fprintln(out, "Allowed.")++ return err++ }++ if action == "deny" {++ _, err = fmt.Fprintln(out, "Denied.")++ return err++ }++ if action == "revoke" {++ _, err = fmt.Fprintln(out, "Access ended.")++ return err++ }++ label := map[string]string{"request": "Access requested", "approve": "Access approved", "deny": "Access denied", "revoke": "Access revoked"}[action]++ if m, ok := value.(map[string]any); ok {++ for _, key := range []string{"id", "request_id", "grant_id"} {++ if id, _ := m[key].(string); id != "" {++ label += ": " + id++ break++ }++ }++ if status, _ := m["status"].(string); status != "" {++ label += " (" + status + ")"++ }++ }++ _, err = fmt.Fprintln(out, label)++ return err++}++++func scopeWords(scopes []string) string {++ words := map[string]string{"agent": "Message your agents", "send": "Send you files", "screen": "Watch the screen", "desk": "Use a private desk", "computer": "Control this computer", "worker": "Run jobs in the sandbox"}++ var parts []string++ for _, s := range scopes {++ if w := words[s]; w != "" {++ parts = append(parts, w)++ }++ }++ if len(parts) == 0 {++ return "No access"++ }++ if len(parts) == 1 {++ return parts[0]++ }++ for i := 1; i < len(parts); i++ {++ parts[i] = strings.ToLower(parts[i][:1]) + parts[i][1:]++ }++ return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]++}++++func accessTime(seconds int64) string {++ if seconds == 0 {++ return "until you take it back"++ }++ if seconds%86400 == 0 {++ if seconds == 86400 {++ return "1 day"++ }++ return fmt.Sprintf("%d days", seconds/86400)++ }++ if seconds%3600 == 0 {++ if seconds == 3600 {++ return "1 hour"++ }++ return fmt.Sprintf("%d hours", seconds/3600)++ }++ if seconds%60 == 0 {++ if seconds == 60 {++ return "1 minute"++ }++ return fmt.Sprintf("%d minutes", seconds/60)++ }++ return (time.Duration(seconds) * time.Second).String()++}++++func printAccessList(out io.Writer, value any) error {++ root, _ := value.(map[string]any)++ groups := map[string][]string{"Waiting": {}, "Allowed": {}, "Ended": {}}++ if reqs, _ := root["requests"].(map[string]any); reqs != nil {++ if rows, _ := reqs["requests"].([]any); rows != nil {++ for _, raw := range rows {++ row, _ := raw.(map[string]any)++ if row == nil {++ continue++ }++ status, _ := row["status"].(string)++ group := "Ended"++ if status == "pending" {++ group = "Waiting"++ } else if status == "approved" {++ continue++ }++ groups[group] = append(groups[group], formatAccessRow(row))++ }++ }++ }++ if grants, _ := root["grants"].(map[string]any); grants != nil {++ if rows, _ := grants["grants"].([]any); rows != nil {++ for _, raw := range rows {++ row, _ := raw.(map[string]any)++ if row == nil {++ continue++ }++ group := "Allowed"++ if row["revoked_at"] != nil {++ group = "Ended"++ } else if expiry, _ := row["expires_at"].(string); expiry != "" {++ at, err := time.Parse(time.RFC3339, expiry)++ if err != nil || !at.After(time.Now()) {++ group = "Ended"++ }++ }++ groups[group] = append(groups[group], formatAccessRow(row))++ }++ }++ }++ for _, group := range []string{"Waiting", "Allowed", "Ended"} {++ if _, err := fmt.Fprintln(out, group); err != nil {++ return err++ }++ if len(groups[group]) == 0 {++ if _, err := fmt.Fprintln(out, " None"); err != nil {++ return err++ }++ continue++ }++ for _, row := range groups[group] {++ if _, err := fmt.Fprintln(out, " "+row); err != nil {++ return err++ }++ }++ }++ return nil++}++++func formatAccessRow(row map[string]any) string {++ from, _ := row["from_principal"].(string)++ to, _ := row["to_principal"].(string)++ fromName, _ := row["from_machine_name"].(string)++ fromOwner, _ := row["from_owner_name"].(string)++ toName, _ := row["to_machine_name"].(string)++ if fromName != "" {++ from = fromName++ if fromOwner != "" {++ from = fromOwner + "'s " + fromName++ }++ }++ if toName != "" {++ to = toName++ }++ name := from + " → " + to++ var scopes []string++ if list, _ := row["scopes"].([]any); list != nil {++ for _, raw := range list {++ if s, _ := raw.(string); s != "" {++ scopes = append(scopes, s)++ }++ }++ }++ id, _ := row["id"].(string)++ when := ""++ if expiry, _ := row["expires_at"].(string); expiry != "" {++ when = " · " + accessLeft(expiry)++ } else if pending, _ := row["pending_until"].(string); pending != "" {++ when = " · waiting"++ } else if value, ok := row["expires_at"]; ok && value == nil {++ when = " · until you take it back"++ }++ return name + " · " + scopeWords(scopes) + when + " · " + id++}++++func responseID(value any) string {++ root, _ := value.(map[string]any)++ for _, key := range []string{"id", "request_id", "grant_id"} {++ if id, _ := root[key].(string); id != "" {++ return id++ }++ }++ for _, key := range []string{"request", "grant"} {++ if row, _ := root[key].(map[string]any); row != nil {++ for _, idKey := range []string{"id", "request_id", "grant_id"} {++ if id, _ := row[idKey].(string); id != "" {++ return id++ }++ }++ }++ }++ return ""++}++++type accessListRow struct {++ ID string `json:"id"`++ RequestID string `json:"request_id"`++ Status string `json:"status"`++ Scopes []string `json:"scopes"`++ ExpiresAt any `json:"expires_at"`++ PendingTill string `json:"pending_until"`++ RequestedAt string `json:"requested_at"`++ AskedAt string `json:"asked_at"`++}++++type accessListAnswer struct {++ Requests struct {++ Requests []accessListRow `json:"requests"`++ } `json:"requests"`++ Grants struct {++ Grants []accessListRow `json:"grants"`++ } `json:"grants"`++}++++// waitAccessAnswer follows one request until its owner answers it, as the++// design's `access request` output promises. A person may take up to ten++// minutes; a script can pass --json to return at once.++func waitAccessAnswer(g *globals, id string, scopes []string, out io.Writer) error {++ deadline := time.Now().Add(10 * time.Minute)++ for {++ raw, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdAccess, ID: "list"}, 20*time.Second)++ if err != nil {++ return err++ }++ var answer accessListAnswer++ if len(raw.Data) > 0 && json.Unmarshal(raw.Data, &answer) == nil {++ for _, request := range answer.Requests.Requests {++ if request.ID != id {++ continue++ }++ switch request.Status {++ case "pending":++ case "denied":++ _, err = fmt.Fprintln(out, "Denied.")++ return err++ case "expired":++ _, err = fmt.Fprintln(out, "No answer in 10 minutes; ask again later.")++ return err++ case "approved":++ // The grant below carries the answer's scopes and time.++ }++ }++ for _, grant := range answer.Grants.Grants {++ if grant.RequestID != id {++ continue++ }++ if grant.Scopes != nil && !sameScopes(grant.Scopes, scopes) {++ _, err = fmt.Fprintf(out, "Allowed, narrowed to: %s.\n", scopeWords(grant.Scopes))++ return err++ }++ switch expiry := grant.ExpiresAt.(type) {++ case nil:++ _, err = fmt.Fprintln(out, "Allowed until you take it back.")++ case string:++ at, parseErr := time.Parse(time.RFC3339, expiry)++ if parseErr != nil {++ _, err = fmt.Fprintln(out, "Allowed.")++ } else {++ _, err = fmt.Fprintf(out, "Allowed until %s.\n", at.Local().Format("15:04"))++ }++ default:++ _, err = fmt.Fprintln(out, "Allowed.")++ }++ return err++ }++ }++ if !time.Now().Before(deadline) {++ _, err = fmt.Fprintln(out, "No answer in 10 minutes; ask again later.")++ return err++ }++ time.Sleep(2 * time.Second)++ }++}++++func sameScopes(a, b []string) bool {++ if len(a) != len(b) {++ return false++ }++ seen := map[string]bool{}++ for _, s := range a {++ seen[s] = true++ }++ for _, s := range b {++ if !seen[s] {++ return false++ }++ }++ return true++}++++func accessLeft(expiry string) string {++ at, err := time.Parse(time.RFC3339, expiry)++ if err != nil {++ return "until " + expiry++ }++ left := time.Until(at)++ if left <= 0 {++ return "ended"++ }++ if left < time.Minute {++ left = time.Minute++ }++ left = left.Round(time.Minute)++ days := int(left / (24 * time.Hour))++ hours := int((left % (24 * time.Hour)) / time.Hour)++ minutes := int((left % time.Hour) / time.Minute)++ switch {++ case days > 0:++ return fmt.Sprintf("%dd %dh left", days, hours)++ case hours > 0:++ return fmt.Sprintf("%dh %dm left", hours, minutes)++ default:++ return fmt.Sprintf("%dm left", minutes)++ }++}++++func validScope(s string) bool {++ switch s {++ case "agent", "send", "screen", "desk", "computer", "worker":++ return true++ }++ return false++}nnew file mode 100644@@@ -0,0 +1,74 @@++package main++++import (++ "bytes"++ "encoding/json"++ "strings"++ "testing"++++ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"++)++++func TestAccessListShowsDesignStates(t *testing.T) {++ value := map[string]any{++ "requests": map[string]any{"requests": []any{map[string]any{"id": "req_123", "from_principal": "lnk_a", "to_principal": "lnk_b", "status": "pending", "scopes": []any{"screen", "desk"}}}},++ "grants": map[string]any{"grants": []any{map[string]any{"id": "gra_123", "from_principal": "lnk_a", "to_principal": "lnk_b", "scopes": []any{"send"}, "expires_at": nil}}},++ }++ var out bytes.Buffer++ if err := printAccessList(&out, value); err != nil {++ t.Fatal(err)++ }++ got := out.String()++ for _, part := range []string{"Waiting\n", "Watch the screen and use a private desk", "Allowed\n", "Send you files", "until you take it back", "Ended\n None"} {++ if !strings.Contains(got, part) {++ t.Fatalf("missing %q in %q", part, got)++ }++ }++ if accessTime(3600) != "1 hour" || accessTime(0) != "until you take it back" {++ t.Fatal("duration words")++ }++}++++func TestAccessRequestWaitsForTheAnswer(t *testing.T) {++ var polls int++ g := reviewSocket(t, func(r localctl.Request) localctl.Response {++ if r.Cmd != localctl.CmdAccess {++ return localctl.Response{Error: "unexpected command"}++ }++ switch r.ID {++ case "request":++ return localctl.Response{OK: true, Data: json.RawMessage(`{"request":{"id":"req_12345678","status":"pending"}}`)}++ case "list":++ polls++++ if polls == 1 {++ return localctl.Response{OK: true, Data: json.RawMessage(`{"requests":{"requests":[{"id":"req_12345678","status":"pending"}]},"grants":{"grants":[]}}`)}++ }++ return localctl.Response{OK: true, Data: json.RawMessage(`{"requests":{"requests":[{"id":"req_12345678","status":"approved"}]},"grants":{"grants":[{"id":"gra_12345678","request_id":"req_12345678","scopes":["screen"],"expires_at":"2030-01-01T12:40:00Z"}]}}`)}++ }++ return localctl.Response{Error: "unexpected action"}++ })++ var out bytes.Buffer++ if err := cmdAccess(g, []string{"request", "lnk_target"}, accessArgs{Scope: "screen,desk", For: "1h"}, &out); err != nil {++ t.Fatal(err)++ }++ if got := out.String(); !strings.Contains(got, "Asked lnk_target for: Watch the screen and use a private desk (1 hour).") ||++ !strings.Contains(got, "Allowed, narrowed to: Watch the screen.") {++ t.Fatalf("request output: %q", got)++ }++}++++func TestAccessRequestReportsDenied(t *testing.T) {++ g := reviewSocket(t, func(r localctl.Request) localctl.Response {++ if r.ID == "request" {++ return localctl.Response{OK: true, Data: json.RawMessage(`{"request":{"id":"req_12345678"}}`)}++ }++ return localctl.Response{OK: true, Data: json.RawMessage(`{"requests":{"requests":[{"id":"req_12345678","status":"denied"}]},"grants":{"grants":[]}}`)}++ })++ var out bytes.Buffer++ if err := cmdAccess(g, []string{"request", "lnk_target"}, accessArgs{Scope: "agent", For: "1h"}, &out); err != nil {++ t.Fatal(err)++ }++ if !strings.Contains(out.String(), "Waiting for their owner…") || !strings.Contains(out.String(), "Denied.") {++ t.Fatalf("denied output: %q", out.String())++ }++}@@@ -74,6 +74,11 @@ Usage:start drives as before (stop is the kill switch, and holds until resume)openvibe-link plugins [--json]openvibe-link approvals [--json] what the computer plugin waits to be approved (control level "ask")++ openvibe-link mcp serve agent and machine tools over MCP stdio++ openvibe-link mcp --print-config claude|codex|muse|opencode++ openvibe-link mcp install claude|codex|muse|opencode ask before adding to a CLI++ openvibe-link access request <machine> --scope agent,send [--for 1h] [--reason text]++ openvibe-link access list|approve <id>|deny <id>|revoke <id>openvibe-link approve <ID> let it runopenvibe-link deny <ID> refuse itopenvibe-link tray [--autostart on|off]@@@ -192,26 +197,31 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) intfs.StringVar(&g.home, "home", "", "config and state directory")fs.StringVar(&g.logLevel, "log-level", "info", "log level")var (-- server = fs.String("server", "", "OpenVibe.Bot origin (pair)")-- kind = fs.String("kind", "", "onboard or bridge (pair)")-- robot = fs.String("robot", "", "the rob_… id of the robot to pair with, from the installer command (pair)")-- name = fs.String("name", "", "a name for this device on openvibe.bot; default: the hostname (pair)")-- codeArg = fs.String("code", "", "the pairing code, instead of the argument (pair)")-- network = fs.String("network", "", "OpenVibe.Network origin: pair this machine there (pair)")-- pairing = fs.String("pairing", "", "the pair_… id from the installer command (pair, with --network)")-- force = fs.Bool("force", false, "pair again even if already paired")-- dryRun = fs.Bool("dry-run", false, "run only the dry-run plugin and the test pattern (run)")-- asJSON = fs.Bool("json", false, "print JSON (status, plugins)")-- svcUser = fs.String("user", "", "run the service as this account (install)")-- session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")-- keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")-- autoArg = fs.String("autostart", "", "on or off: start the tray when you sign in (tray)")-- shKey = fs.String("key", "", "the share's ovc_… connect key from connect.openvibe.link (share)")-- shPort = fs.Int("port", 0, "the local port to share (share)")-- shSrv = fs.String("connect-server", "", "OpenVibe Connect's tunnel URL; default wss://connect.openvibe.link/tunnel (share)")-- shID = fs.String("share", "", "a share this computer serves itself, as OpenVibe Link: its shr_… id, no key (share)")-- gitRoot = fs.String("root", "", "the folder Link may clone into or publish from (clone, git); default ~/OpenVibe")-- yes = fs.Bool("yes", false, "approve this clone without asking (clone; for a script, never for a person)")++ server = fs.String("server", "", "OpenVibe.Bot origin (pair)")++ kind = fs.String("kind", "", "onboard or bridge (pair)")++ robot = fs.String("robot", "", "the rob_… id of the robot to pair with, from the installer command (pair)")++ name = fs.String("name", "", "a name for this device on openvibe.bot; default: the hostname (pair)")++ codeArg = fs.String("code", "", "the pairing code, instead of the argument (pair)")++ network = fs.String("network", "", "OpenVibe.Network origin: pair this machine there (pair)")++ pairing = fs.String("pairing", "", "the pair_… id from the installer command (pair, with --network)")++ force = fs.Bool("force", false, "pair again even if already paired")++ dryRun = fs.Bool("dry-run", false, "run only the dry-run plugin and the test pattern (run)")++ asJSON = fs.Bool("json", false, "print JSON (status, plugins)")++ printConfig = fs.String("print-config", "", "print MCP server config for a CLI (mcp)")++ mcpAgent = fs.String("agent", "", "local CLI identity for MCP")++ accessScope = fs.String("scope", "", "comma-separated machine access scopes")++ accessFor = fs.String("for", "", "machine access duration")++ accessReason = fs.String("reason", "", "why this machine asks for access")++ svcUser = fs.String("user", "", "run the service as this account (install)")++ session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")++ keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")++ autoArg = fs.String("autostart", "", "on or off: start the tray when you sign in (tray)")++ shKey = fs.String("key", "", "the share's ovc_… connect key from connect.openvibe.link (share)")++ shPort = fs.Int("port", 0, "the local port to share (share)")++ shSrv = fs.String("connect-server", "", "OpenVibe Connect's tunnel URL; default wss://connect.openvibe.link/tunnel (share)")++ shID = fs.String("share", "", "a share this computer serves itself, as OpenVibe Link: its shr_… id, no key (share)")++ gitRoot = fs.String("root", "", "the folder Link may clone into or publish from (clone, git); default ~/OpenVibe")++ yes = fs.Bool("yes", false, "approve this clone without asking (clone; for a script, never for a person)"))// Accept flags anywhere: split positionals out.var positional []string@@@ -285,6 +295,14 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) interr = cmdQuit(g, stdout)case "plugins":err = cmdPlugins(g, *asJSON, stdout)++ case "mcp":++ if *printConfig != "" {++ err = mcpCommand(g, []string{"--print-config", *printConfig}, *mcpAgent, stdin, stdout)++ } else {++ err = mcpCommand(g, positional, *mcpAgent, stdin, stdout)++ }++ case "access":++ err = cmdAccess(g, positional, accessArgs{Scope: *accessScope, For: *accessFor, Reason: *accessReason, JSON: *asJSON}, stdout)case "approvals":err = cmdApprovals(g, *asJSON, stdout)case "approve", "deny":nnew file mode 100644@@@ -0,0 +1,187 @@++package main++++import (++ "bufio"++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "os"++ "os/exec"++ "path/filepath"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Link/internal/mcp"++)++++type socketMCP struct{ path, agent string }++++func (s socketMCP) Call(name string, args json.RawMessage) (json.RawMessage, error) {++ timeout := 10 * time.Second++ if name == "agent_ask" || name == "machine_use" {++ timeout = 125 * time.Second++ }++ r, err := localctl.Call(s.path, localctl.Request{Cmd: localctl.CmdMCP, ID: name, Agent: s.agent, Data: args}, timeout)++ if err != nil {++ return nil, err++ }++ if len(r.Data) == 0 {++ return json.RawMessage(`{}`), nil++ }++ return r.Data, nil++}++++func mcpCommand(g *globals, args []string, agent string, in io.Reader, out io.Writer) error {++ if len(args) == 0 {++ return mcp.Serve(in, out, socketMCP{path: g.paths.SocketPath(), agent: agent})++ }++ if len(args) == 2 && args[0] == "--print-config" {++ return printMCPConfig(g, args[1], out)++ }++ if len(args) == 2 && args[0] == "install" {++ return installMCP(g, args[1], in, out)++ }++ return errors.New("usage: openvibe-link mcp [--print-config claude|codex|muse|opencode | install claude|codex|muse|opencode]")++}++++func mcpArgs(g *globals, cli string) (string, []string, error) {++ exe, err := os.Executable()++ if err != nil {++ return "", nil, err++ }++ local := map[string]string{"claude": "claude-code", "codex": "codex", "muse": "muse", "opencode": "opencode"}[cli]++ args := []string{"mcp", "--agent", local}++ if g.home != "" {++ args = append(args, "--home", g.home)++ }++ return exe, args, nil++}++++func mcpSnippet(g *globals, cli string) (string, error) {++ exe, args, err := mcpArgs(g, cli)++ if err != nil {++ return "", err++ }++ quoted, _ := json.Marshal(exe)++ argv, _ := json.Marshal(args)++ switch cli {++ case "claude":++ return fmt.Sprintf(`{"mcpServers":{"openvibe-link":{"command":%s,"args":%s}}}`, quoted, argv), nil++ case "codex":++ var b strings.Builder++ fmt.Fprintf(&b, "[mcp_servers.openvibe-link]\ncommand = %s\nargs = %s\n", quoted, argv)++ return b.String(), nil++ case "muse":++ return fmt.Sprintf(`{"mcp_servers":{"openvibe-link":{"transport":"stdio","command":%s,"args":%s,"mode":"optional"}}}`, quoted, argv), nil++ case "opencode":++ command, _ := json.Marshal(append([]string{exe}, args...))++ return fmt.Sprintf(`{"mcp":{"servers":{"openvibe-link":{"type":"local","command":%s}}}}`, command), nil++ default:++ return "", fmt.Errorf("unknown CLI %q", cli)++ }++}++++func printMCPConfig(g *globals, cli string, out io.Writer) error {++ s, err := mcpSnippet(g, cli)++ if err != nil {++ return err++ }++ _, err = fmt.Fprintln(out, s)++ return err++}++++// installMCP is an explicit offer: the person must answer yes before a CLI++// config is touched. For three CLIs, their own `mcp add` does the edit. Muse++// has no add command, so its JSON settings are patched with a safe file swap.++func installMCP(g *globals, cli string, in io.Reader, out io.Writer) error {++ if _, err := mcpSnippet(g, cli); err != nil {++ return err++ }++ if _, err := fmt.Fprintf(out, "Add OpenVibe Link's MCP tools to %s for this account? [y/N] ", cli); err != nil {++ return err++ }++ line, err := bufio.NewReader(in).ReadString('\n')++ if err != nil && err != io.EOF {++ return err++ }++ if strings.ToLower(strings.TrimSpace(line)) != "y" {++ _, _ = fmt.Fprintln(out, "Nothing changed.")++ return nil++ }++ exe, args, err := mcpArgs(g, cli)++ if err != nil {++ return err++ }++ var command *exec.Cmd++ switch cli {++ case "claude":++ command = exec.Command("claude", append([]string{"mcp", "add", "--transport", "stdio", "--scope", "user", "openvibe-link", "--", exe}, args...)...)++ case "codex":++ command = exec.Command("codex", append([]string{"mcp", "add", "openvibe-link", "--", exe}, args...)...)++ case "opencode":++ command = exec.Command("opencode", append([]string{"mcp", "add", "openvibe-link", "--", exe}, args...)...)++ case "muse":++ return installMuseMCP(exe, args, out)++ }++ command.Stdout, command.Stderr = out, out++ if err := command.Run(); err != nil {++ return fmt.Errorf("%s did not add the MCP server: %w", cli, err)++ }++ return nil++}++++func installMuseMCP(exe string, args []string, out io.Writer) error {++ home, err := os.UserHomeDir()++ if err != nil {++ return err++ }++ path := filepath.Join(home, ".config", "muse", "settings.json")++ settings := map[string]json.RawMessage{}++ if raw, err := os.ReadFile(path); err == nil {++ if json.Unmarshal(raw, &settings) != nil || settings == nil {++ return errors.New("Muse settings are not a JSON object")++ }++ } else if !os.IsNotExist(err) {++ return err++ }++ servers := map[string]json.RawMessage{}++ if raw := settings["mcp_servers"]; len(raw) > 0 && (json.Unmarshal(raw, &servers) != nil || servers == nil) {++ return errors.New("Muse mcp_servers is not an object")++ }++ if _, exists := servers["openvibe-link"]; exists {++ return errors.New("Muse already has an openvibe-link MCP server")++ }++ entry, _ := json.Marshal(map[string]any{"transport": "stdio", "command": exe, "args": args, "mode": "optional"})++ servers["openvibe-link"] = entry++ settings["mcp_servers"], _ = json.Marshal(servers)++ updated, err := json.MarshalIndent(settings, "", " ")++ if err != nil {++ return err++ }++ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {++ return err++ }++ tmp, err := os.CreateTemp(filepath.Dir(path), ".settings-*")++ if err != nil {++ return err++ }++ defer os.Remove(tmp.Name())++ if err := tmp.Chmod(0600); err != nil {++ tmp.Close()++ return err++ }++ if _, err := tmp.Write(append(updated, '\n')); err != nil {++ tmp.Close()++ return err++ }++ if err := tmp.Close(); err != nil {++ return err++ }++ if err := os.Rename(tmp.Name(), path); err != nil {++ return err++ }++ _, err = fmt.Fprintln(out, "Added OpenVibe Link to Muse settings.")++ return err++}nnew file mode 100644@@@ -0,0 +1,58 @@++package main++++import (++ "bytes"++ "encoding/json"++ "strings"++ "testing"++++ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"++)++++func TestMCPInstallOffersAndPrintedConfigs(t *testing.T) {++ for _, cli := range []string{"claude", "codex", "muse", "opencode"} {++ t.Run(cli, func(t *testing.T) {++ var printed bytes.Buffer++ if err := printMCPConfig(&globals{}, cli, &printed); err != nil {++ t.Fatal(err)++ }++ if !strings.Contains(printed.String(), "openvibe-link") || !strings.Contains(printed.String(), "--agent") {++ t.Fatalf("config: %s", printed.String())++ }++ var offered bytes.Buffer++ if err := installMCP(&globals{}, cli, strings.NewReader("n\n"), &offered); err != nil {++ t.Fatal(err)++ }++ if !strings.Contains(offered.String(), "[y/N]") || !strings.Contains(offered.String(), "Nothing changed") {++ t.Fatalf("offer: %s", offered.String())++ }++ })++ }++}++++func TestMCPStdioUsesTheLocalSocket(t *testing.T) {++ g := reviewSocket(t, func(r localctl.Request) localctl.Response {++ if r.Cmd != localctl.CmdMCP || r.Agent != "codex" {++ return localctl.Response{Error: "wrong local command"}++ }++ if r.ID == "agents_list" {++ return localctl.Response{OK: true, Data: json.RawMessage(`{"agents":[{"id":"agt_01J8Z4M2Q0R7T9YV3K6N8P1W2X","name":"Codex","status":"online"}]}`)}++ }++ return localctl.Response{Error: "no active grant for this machine and scope"}++ })++ call := func(name, args string) string {++ t.Helper()++ input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"` + name + `","arguments":` + args + `}}` + "\n"++ var out bytes.Buffer++ if err := mcpCommand(g, nil, "codex", strings.NewReader(input), &out); err != nil {++ t.Fatal(err)++ }++ return out.String()++ }++ if got := call("agents_list", `{}`); !strings.Contains(got, `agt_01J8Z4M2Q0R7T9YV3K6N8P1W2X`) {++ t.Fatalf("agents_list: %s", got)++ }++ if got := call("machine_use", `{"machine":"lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2Z","scope":"screen","action":"screen","data":{}}`); !strings.Contains(got, `"isError":true`) || !strings.Contains(got, "no active grant") {++ t.Fatalf("refusal: %s", got)++ }++}@@@ -126,6 +126,9 @@ func (n *dbusNotifier) Ask(a agent.Approval, again string) {var actions []stringif n.actions {actions = []string{"approve", "Approve", "deny", "Deny"}++ if a.Plugin == "machine-access" {++ actions[1] = "Allow"++ }} else {body += "\nopenvibe-link approve " + safety.QuoteHint(a.ID) + " (or deny " + safety.QuoteHint(a.ID) + ")"}@@@ -138,7 +141,14 @@ func (n *dbusNotifier) Ask(a agent.Approval, again string) {}hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(2)), "desktop-entry": dbus.MakeVariant("openvibe-link-tray")}var nid uint32-- err := n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Link", replaces, "dialog-question", "OpenVibe: your agent asks",++ title := "OpenVibe: your agent asks"++ if a.Plugin == "machine-access" {++ title = a.Title++ if title == "" {++ title = "Machine asks for access"++ }++ }++ err := n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Link", replaces, "dialog-question", title,n.text(body), actions, hints, expire).Store(&nid)if err != nil {n.log.Warn("tray: notification failed", "err", err)@@@ -149,7 +149,11 @@ func (u *systrayUI) SetSlots(slots []traySlot) {u.bindings = append([]traySlot(nil), slots...)for _, slot := range slots {item := u.requests.AddSubMenuItem(slot.Label, "")-- approve := item.AddSubMenuItem("Approve", "Let it run")++ label := "Approve"++ if slot.Approval.Plugin == "machine-access" {++ label = "Allow"++ }++ approve := item.AddSubMenuItem(label, "Let it run")deny := item.AddSubMenuItem("Deny", "Refuse it")u.slots = append(u.slots, item)forwardTrayClicks(approve.ClickedCh, u.answers, slot.Approval.ID, true)nnew file mode 100644@@@ -0,0 +1,52 @@++# Machine access and agent messages++++Network-paired Links can ask to use another machine. The target owner answers on that machine through the same approval list and tray notification used by computer actions, or from a signed-in Network surface. An approval creates a time-limited grant for only the selected scopes. Revoking it ends further use.++++## Request and answer++++```sh++openvibe-link access request 'Alex desktop' --scope screen,desk --for 1h --reason 'Checking the build'++openvibe-link access list++openvibe-link access approve req_… --scope screen --for 30m++openvibe-link access deny req_…++openvibe-link access revoke gra_…++```++++`--for` defaults to 1 hour and accepts a Go duration through 30 days, or `1d` through `30d`. `forever` is accepted only for `agent` and `send`; Network also requires both machines to have the same owner. The target can narrow scopes and shorten duration when approving, never widen them. `access request` waits up to ten minutes and then prints `Allowed until 12:40.`, `Allowed, narrowed to: …`, `Denied.` or `No answer in 10 minutes; ask again later.`; `--json` returns at once with the request id for a script. `access list` groups waiting requests, allowed grants and ended access. `--json` returns the same data for scripts without bearer grant tokens.++++The scopes are `agent` (message local agents), `send` (offer Locker-sealed files), `screen` (capture or watch the screen), `desk` (use a private desk), `computer` (run only the target's enabled computer actions), and `worker` (run sandbox jobs). A computer grant never raises the target's own `observe`, `ask`, `trusted`, or `full` level. At `ask`, each acting command still needs the target owner's local answer. The target's capability switches and stop latch still apply.++++The tray shows a machine request with **Allow** and **Deny** actions. `openvibe-link approvals` lists it beside computer requests, and `openvibe-link approve <nonce>` or `deny <nonce>` uses the same local approval socket. Network settles an answer once, even if two signed-in surfaces present it at once.++++Each relayed use carries Network's signed grant JWT. Link opens a Bot relay with `relay.open`; Bot checks the sending device, target and scope and sends `relay.opened` to both machines. The target Link independently verifies Network's published signing key, signature, issuer, machine audience, sender, scope, expiration, and current Network grant state on the opening and again as the relay runs. `relay.closed` or `access.revoked` ends a still-unexpired grant at once. It records the use in local activity. The direct LAN send path requires the same signed grant and live check in addition to the owner's LAN HMAC. A stopped, expired, revoked, or unavailable grant fails closed.++++## Agent messages++++Set `agents_talk: true` in Link's config to turn on **Let my agents talk**. It is off by default. Once on, Link registers the installed agent CLIs found by its fleet probe with Network's directory. Incoming `agent.task` frames enter a local inbox only while the switch is on. Cross-machine tasks also require an `agent` grant. Tasks carry the sending agent, person and thread; unanswered threads expire after 24 hours. An agent's message supplies information, never authority to operate the machine.++++`openvibe-link mcp` serves the local MCP tools over stdio and the running Link's protected local control socket:++++| Tool | Example arguments | Answer |++| --- | --- | --- |++| `agents_list` | `{}` | Directory entries |++| `agent_ask` | `{"agent":"agt_…","text":"Can you check this?","wait_seconds":30}` | Completed result, or a pending task id after the wait |++| `agent_tell` | `{"agent":"agt_…","text":"Build passed"}` | Created task and thread |++| `agent_inbox` | `{}` | Tasks addressed to this local CLI |++| `agent_reply` | `{"task":"tsk_…","text":"Done"}` | Completed task and thread |++| `machines_list` | `{}` | Machines and held grants, without tokens |++| `machine_request_access` | `{"machine":"lnk_…","scopes":["send"],"for_seconds":3600}` | Pending request |++| `machine_use` | `{"machine":"lnk_…","scope":"send","action":"offer","data":{"envelope":"<sealed Locker envelope>"}}` | One operation's answer, or a refusal |++++`machine_use` accepts `task` for `agent`, `offer` for `send`, `screen` or `screen_stream` for `screen`, `desk` or `desk.stream` for `desk`, `exec`, `fs`, `input`, `browser` or `probe` for `computer`, and `run`, `status` or `cancel` for `worker`. Its `data` is the existing local feature's JSON value, except an `offer` wraps an `envelope`, `run` carries a platform job, and worker `status`/`cancel` take `{ "id": "job_…" }`. The target checks the specific scope before dispatch. Agent tasks are queued; they do not silently start an agent CLI.++++For a CLI that supports an MCP server, Link can print the configuration to add by hand, or offer to install it. Installing prompts before writing any tool configuration:++++```sh++openvibe-link mcp --print-config claude++openvibe-link mcp --print-config codex++openvibe-link mcp --print-config muse++openvibe-link mcp --print-config opencode++openvibe-link mcp install codex++```++++Use the corresponding CLI name with `install` for Claude Code, Muse or OpenCode. The server identifies the calling CLI; `agent_inbox` and `agent_reply` are restricted to that CLI's registered directory entry.@@@ -1,5 +1,13 @@# Link ↔ OpenVibe.Bot protocol (bot.device-message@1, device side)++## Machine access and agent relay++++A Network-paired Link asks Bot for a relay with `relay.open` (`to`, `grant_token`). Bot verifies Network's signed token, opens a session and sends `relay.opened` to both machines with the relay id, its scopes and the same token. Each operation or answer then travels as `relay.frame` (`relay`, `scope`, `body`); Bot checks the grant and scope on every frame, and both Links verify the token and check Network's current grant state themselves. `relay.close` ends the session and `relay.closed` tells both sides why (closed, expired or revoked). `access.revoked` with `grant_id` also ends a locally cached grant at once.++++The relay body is `{"id","action","data"}`, plus `level` for `computer`. The answer is `{"id","ok","data"}` or `{"id","ok":false,"error"}`. The scope maps to an existing local feature: `agent` queues a task, `send` places a Locker-sealed envelope in Link Send, `screen` and `desk` use their command kinds, `computer` uses the computer plugin's own owner level and per-action approval (Bot and the target both refuse a level above the target's own), and `worker` uses the sandbox job handler. The LAN handover for granted Send uses `/api/link-send/v2` with both the owner's HMAC and the same grant check.++++Network delivers an A2A task to a machine through Bot as `agent.task` (`task`), after checking the sender, the receiving agent and any machine grant. The machine answers with `agent.task_result` (`task_id`, `status`, `result`); Bot records it on Network and acknowledges with `agent.task_result_ack`. The local agent inbox is gated by the owner's `agents_talk` switch.++OpenVibe.Bot's own `docs/protocol.md` (section 1, the `/device` socket) and its `POST /api/v1/pair` handler are thesource of truth. Every field name below lives in one Go package, `internal/protocol/`, and the exact frames from Bot'sdoc are pinned verbatim in [`internal/protocol/testdata/bot`](../internal/protocol/testdata/bot) (its README names thennew file mode 100644@@@ -0,0 +1,201 @@++package access++++import (++ "bytes"++ "context"++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "net/http"++ "net/url"++ "regexp"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/credentials"++)++++var routeID = regexp.MustCompile(`^[a-z]{3}_[A-Za-z0-9_-]{8,80}$`)++++// Client uses this machine's Network node token for self access and agent++// routes. Network still decides who may request, answer, revoke and relay.++type Client struct {++ Origin string++ Tokens Tokens++ HTTP *http.Client++}++++type Tokens interface {++ Token(context.Context) (credentials.Secret, error)++ Invalidate()++}++++func (c *Client) do(ctx context.Context, method, path string, body any) (json.RawMessage, error) {++ var none json.RawMessage++ u, err := url.Parse(c.Origin)++ if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" ||++ (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1"))) {++ return none, errors.New("machine access requires a Network HTTPS origin")++ }++ if c.Tokens == nil {++ return none, errors.New("machine is not paired through Network")++ }++ var content io.Reader++ if body != nil {++ raw, err := json.Marshal(body)++ if err != nil {++ return none, err++ }++ content = bytes.NewReader(raw)++ }++ req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.Origin, "/")+path, content)++ if err != nil {++ return none, err++ }++ token, err := c.Tokens.Token(ctx)++ if err != nil {++ return none, err++ }++ req.Header.Set("Authorization", "Bearer "+token.Reveal())++ if body != nil {++ req.Header.Set("Content-Type", "application/json")++ }++ client := c.HTTP++ if client == nil {++ client = &http.Client{Timeout: 20 * time.Second}++ }++ resp, err := client.Do(req)++ if err != nil {++ return none, err++ }++ defer resp.Body.Close()++ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))++ if err != nil {++ return none, err++ }++ if resp.StatusCode < 200 || resp.StatusCode >= 300 {++ var problem struct {++ Code string `json:"code"`++ }++ _ = json.Unmarshal(raw, &problem)++ if len(problem.Code) > 100 || strings.ContainsAny(problem.Code, " \n\r\t") {++ problem.Code = ""++ }++ return none, fmt.Errorf("Network refused machine access: HTTP %d %s", resp.StatusCode, problem.Code)++ }++ if len(raw) == 0 {++ raw = []byte("{}")++ }++ if !json.Valid(raw) {++ return none, errors.New("Network returned invalid JSON")++ }++ return raw, nil++}++++func (c *Client) Request(ctx context.Context, machine string, scopes []string, seconds int64, reason, agent string) (json.RawMessage, error) {++ if machine == "" || len(machine) > 160 || len(scopes) == 0 || seconds < 0 || seconds > 30*86400 || len(reason) > 500 {++ return nil, errors.New("invalid access request")++ }++ for _, s := range scopes {++ if !ValidScope(s) {++ return nil, errors.New("invalid access scope")++ }++ if seconds == 0 && s != "agent" && s != "send" {++ return nil, errors.New("forever is only for agent and send")++ }++ }++ if !strings.HasPrefix(machine, "lnk_") && !strings.HasPrefix(machine, "@") {++ known, err := c.Machines(ctx)++ if err != nil {++ return nil, err++ }++ var list struct {++ Machines []struct{ ID, Name string } `json:"machines"`++ }++ if json.Unmarshal(known, &list) != nil {++ return nil, errors.New("Network returned an invalid machine list")++ }++ found := ""++ for _, row := range list.Machines {++ if strings.EqualFold(row.Name, machine) {++ if found != "" {++ return nil, errors.New("machine name is ambiguous; use its id")++ }++ found = row.ID++ }++ }++ if found == "" {++ return nil, errors.New("machine name not found")++ }++ machine = found++ }++ var duration any = seconds++ if seconds == 0 {++ duration = nil++ }++ return c.do(ctx, http.MethodPost, "/api/v1/link/self/access-requests", map[string]any{"target": machine, "scopes": scopes, "duration_seconds": duration, "reason": reason, "from_agent": agent})++}++++func ValidScope(s string) bool { return scopes[s] }++++func (c *Client) List(ctx context.Context) (json.RawMessage, error) {++ return c.do(ctx, http.MethodGet, "/api/v1/link/self/access-requests", nil)++}++func (c *Client) Grants(ctx context.Context) (json.RawMessage, error) {++ return c.do(ctx, http.MethodGet, "/api/v1/link/self/access-grants", nil)++}++func (c *Client) GrantToken(ctx context.Context, id string) (json.RawMessage, error) {++ if !routeID.MatchString(id) {++ return nil, errors.New("invalid grant id")++ }++ return c.do(ctx, http.MethodPost, "/api/v1/link/self/access-grants/"+id+"/token", map[string]any{})++}++func (c *Client) Machines(ctx context.Context) (json.RawMessage, error) {++ return c.do(ctx, http.MethodGet, "/api/v1/link/self/send/targets", nil)++}++func (c *Client) Answer(ctx context.Context, id string, approve bool, scopes []string, seconds int64) (json.RawMessage, error) {++ if !routeID.MatchString(id) {++ return nil, errors.New("invalid request id")++ }++ body := map[string]any{"approve": approve}++ if len(scopes) > 0 {++ for _, s := range scopes {++ if !ValidScope(s) {++ return nil, errors.New("invalid narrowed scope")++ }++ }++ body["scopes"] = scopes++ }++ if seconds > 0 {++ if seconds > 30*86400 {++ return nil, errors.New("invalid narrowed duration")++ }++ body["duration_seconds"] = seconds++ }++ return c.do(ctx, http.MethodPost, "/api/v1/link/self/access-requests/"+id+"/answer", body)++}++func (c *Client) Revoke(ctx context.Context, id string) (json.RawMessage, error) {++ if !routeID.MatchString(id) {++ return nil, errors.New("invalid grant id")++ }++ return c.do(ctx, http.MethodPost, "/api/v1/link/self/access-grants/"+id+"/revoke", map[string]any{})++}++func (c *Client) Agents(ctx context.Context) (json.RawMessage, error) {++ return c.do(ctx, http.MethodGet, "/api/v1/link/self/agents", nil)++}++func (c *Client) RegisterAgent(ctx context.Context, entry any) (json.RawMessage, error) {++ return c.do(ctx, http.MethodPost, "/api/v1/agents/register", entry)++}++func (c *Client) Task(ctx context.Context, id string, task any) (json.RawMessage, error) {++ if !routeID.MatchString(id) {++ return nil, errors.New("invalid agent id")++ }++ return c.do(ctx, http.MethodPost, "/api/v1/agents/"+id+"/tasks", task)++}++func (c *Client) TaskStatus(ctx context.Context, id string) (json.RawMessage, error) {++ if !routeID.MatchString(id) {++ return nil, errors.New("invalid task id")++ }++ return c.do(ctx, http.MethodGet, "/api/v1/agents/tasks/"+id, nil)++}nnew file mode 100644@@@ -0,0 +1,107 @@++package access++++import (++ "context"++ "encoding/json"++ "net/http"++ "net/http/httptest"++ "strings"++ "sync"++ "testing"++++ "github.com/OpenVibers/OpenVibe.Link/internal/credentials"++)++++type testTokens struct{}++++func (testTokens) Invalidate() {}++++func (testTokens) Token(context.Context) (credentials.Secret, error) {++ return credentials.NewSecret("test-node-token"), nil++}++++func TestAccessClientRoutesAndRefusals(t *testing.T) {++ var mu sync.Mutex++ var calls []string++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ if r.Header.Get("Authorization") != "Bearer test-node-token" {++ t.Errorf("missing node authorization")++ }++ mu.Lock()++ calls = append(calls, r.Method+" "+r.URL.Path)++ mu.Unlock()++ if r.Method == http.MethodPost && r.URL.Path == "/api/v1/link/self/access-requests" {++ var body map[string]any++ if json.NewDecoder(r.Body).Decode(&body) != nil || body["target"] != "lnk_other" || body["duration_seconds"] != float64(3600) {++ t.Errorf("wrong request shape")++ }++ }++ if strings.HasSuffix(r.URL.Path, "/answer") {++ var body map[string]any++ if json.NewDecoder(r.Body).Decode(&body) != nil || body["approve"] != true {++ t.Errorf("wrong answer shape")++ }++ }++ if strings.HasSuffix(r.URL.Path, "/token") {++ _ = json.NewEncoder(w).Encode(map[string]any{"grant_token": "signed-test-token"})++ return++ }++ if strings.Contains(r.URL.Path, "/revoke") {++ w.WriteHeader(403)++ _, _ = w.Write([]byte(`{"code":"access.not_allowed","detail":"test-node-token"}`))++ return++ }++ _ = json.NewEncoder(w).Encode(map[string]any{"id": "req_12345678", "status": "pending"})++ }))++ defer server.Close()++ c := &Client{Origin: server.URL, Tokens: testTokens{}}++ ctx := context.Background()++ if _, err := c.Request(ctx, "lnk_other", []string{"agent"}, 3600, "work", ""); err != nil {++ t.Fatal(err)++ }++ if _, err := c.List(ctx); err != nil {++ t.Fatal(err)++ }++ if _, err := c.Answer(ctx, "req_12345678", true, []string{"agent"}, 1800); err != nil {++ t.Fatal(err)++ }++ if _, err := c.GrantToken(ctx, "grt_12345678"); err != nil {++ t.Fatal(err)++ }++ if _, err := c.Revoke(ctx, "gra_12345678"); err == nil || strings.Contains(err.Error(), "test-node-token") {++ t.Fatalf("unsafe refusal: %v", err)++ }++ if _, err := c.Answer(ctx, "../../etc", true, nil, 0); err == nil {++ t.Fatal("unsafe id accepted")++ }++ if _, err := c.Request(ctx, "lnk_other", []string{"admin"}, 3600, "", ""); err == nil {++ t.Fatal("unknown scope accepted")++ }++ mu.Lock()++ defer mu.Unlock()++ if len(calls) != 5 {++ t.Fatalf("calls: %v", calls)++ }++ if calls[0] != "POST /api/v1/link/self/access-requests" || calls[2] != "POST /api/v1/link/self/access-requests/req_12345678/answer" {++ t.Fatalf("routes: %v", calls)++ }++}++++func TestRequestResolvesOwnMachineName(t *testing.T) {++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ if r.Method == http.MethodGet && r.URL.Path == "/api/v1/link/self/send/targets" {++ _, _ = w.Write([]byte(`{"machines":[{"id":"lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2Z","name":"Desktop"}]}`))++ return++ }++ var body map[string]any++ if json.NewDecoder(r.Body).Decode(&body) != nil || body["target"] != "lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2Z" || body["duration_seconds"] != nil {++ t.Errorf("request did not resolve name and forever: %v", body)++ }++ _, _ = w.Write([]byte(`{"request":{"id":"req_12345678"}}`))++ }))++ defer server.Close()++ c := &Client{Origin: server.URL, Tokens: testTokens{}}++ if _, err := c.Request(context.Background(), "desktop", []string{"send"}, 0, "", ""); err != nil {++ t.Fatal(err)++ }++}nnew file mode 100644@@@ -0,0 +1,213 @@++// Package access verifies machine grants independently of Bot's relay checks.++package access++++import (++ "context"++ "crypto"++ "crypto/rsa"++ "crypto/sha256"++ "encoding/base64"++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "math/big"++ "net/http"++ "net/url"++ "strings"++ "sync"++ "time"++)++++var ErrGrant = errors.New("machine access grant refused")++++var scopes = map[string]bool{"agent": true, "send": true, "screen": true, "desk": true, "computer": true, "worker": true}++++// Claims are the Network-signed link.access-grant-token@1 claims. A grant id is++// required so a revocation can end a still-unexpired grant immediately.++type Claims struct {++ ID string `json:"grant_id"`++ JTI string `json:"jti"`++ Issuer string `json:"iss"`++ From string `json:"sub"`++ To string `json:"aud"`++ Scopes []string `json:"scopes"`++ Expiry int64 `json:"exp"`++ Issued int64 `json:"iat"`++ Before int64 `json:"nbf,omitempty"`++}++++// Verifier pins Network's signing keys to the configured HTTPS origin and++// caches them briefly. Revocations are kept until the JWT would expire.++type Verifier struct {++ Network string++ Machine string++ Client *http.Client++ Now func() time.Time++++ mu sync.Mutex++ keys map[string]*rsa.PublicKey++ keysAt time.Time++ revoked map[string]time.Time++}++++func NewVerifier(network, machine string) *Verifier {++ return &Verifier{Network: strings.TrimRight(network, "/"), Machine: machine, Client: &http.Client{Timeout: 5 * time.Second}, Now: time.Now, revoked: map[string]time.Time{}}++}++++func (v *Verifier) Revoke(id string, until time.Time) {++ if id == "" {++ return++ }++ v.mu.Lock()++ defer v.mu.Unlock()++ if v.revoked == nil {++ v.revoked = map[string]time.Time{}++ }++ v.revoked[id] = until++}++++// Verify checks every use, including the requested scope and the sending++// machine. A malformed token or a key fetch failure never opens access.++func (v *Verifier) Verify(ctx context.Context, token, sender, scope string) (Claims, error) {++ if !scopes[scope] {++ return Claims{}, ErrGrant++ }++ return v.verify(ctx, token, sender, scope)++}++++// VerifyAny checks a grant token without selecting one scope: Bot's relay.opened++// names the grant's scopes, and each later relay.frame selects one.++func (v *Verifier) VerifyAny(ctx context.Context, token, sender string) (Claims, error) {++ return v.verify(ctx, token, sender, "")++}++++func (v *Verifier) verify(ctx context.Context, token, sender, scope string) (Claims, error) {++ var c Claims++ if token == "" || sender == "" || sender == v.Machine || v.Machine == "" {++ return c, ErrGrant++ }++ parts := strings.Split(token, ".")++ if len(parts) != 3 || len(token) > 16<<10 {++ return c, ErrGrant++ }++ header, err := base64.RawURLEncoding.DecodeString(parts[0])++ if err != nil || len(header) > 2048 {++ return c, ErrGrant++ }++ var h struct {++ Alg string `json:"alg"`++ Kid string `json:"kid"`++ Typ string `json:"typ"`++ }++ if json.Unmarshal(header, &h) != nil || h.Alg != "RS256" || h.Kid == "" {++ return c, ErrGrant++ }++ key, err := v.key(ctx, h.Kid)++ if err != nil {++ return c, fmt.Errorf("%w: signing key unavailable", ErrGrant)++ }++ sig, err := base64.RawURLEncoding.DecodeString(parts[2])++ if err != nil {++ return c, ErrGrant++ }++ hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))++ if rsa.VerifyPKCS1v15(key, crypto.SHA256, hash[:], sig) != nil {++ return c, ErrGrant++ }++ payload, err := base64.RawURLEncoding.DecodeString(parts[1])++ if err != nil || len(payload) > 8<<10 || json.Unmarshal(payload, &c) != nil {++ return Claims{}, ErrGrant++ }++ now := v.Now()++ if c.ID == "" || c.JTI == "" || c.From != sender || c.To != v.Machine || c.Expiry <= now.Unix() || c.Before > now.Unix() || c.Issued > now.Unix() ||++ (c.Issuer != v.Network && c.Issuer != strings.TrimPrefix(v.Network, "https://")) {++ return Claims{}, ErrGrant++ }++ found := scope == ""++ for _, s := range c.Scopes {++ if !scopes[s] {++ return Claims{}, ErrGrant++ }++ found = found || s == scope++ }++ if !found {++ return Claims{}, ErrGrant++ }++ v.mu.Lock()++ defer v.mu.Unlock()++ if until, ok := v.revoked[c.ID]; ok && (until.IsZero() || now.Before(until)) {++ return Claims{}, ErrGrant++ }++ return c, nil++}++++func (v *Verifier) key(ctx context.Context, kid string) (*rsa.PublicKey, error) {++ v.mu.Lock()++ if v.keys != nil && v.Now().Sub(v.keysAt) < 5*time.Minute {++ key := v.keys[kid]++ v.mu.Unlock()++ if key != nil {++ return key, nil++ }++ // A signing-key rotation can introduce a new kid before the cache TTL.++ }++ v.mu.Unlock()++ u, parseErr := url.Parse(v.Network)++ if parseErr != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") ||++ (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1"))) {++ return nil, ErrGrant++ }++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.Network+"/api/.well-known/jwks", nil)++ if err != nil {++ return nil, err++ }++ client := v.Client++ if client == nil {++ client = &http.Client{Timeout: 5 * time.Second}++ }++ resp, err := client.Do(req)++ if err != nil {++ return nil, err++ }++ defer resp.Body.Close()++ if resp.StatusCode != http.StatusOK {++ return nil, ErrGrant++ }++ var doc struct {++ Keys []struct {++ Kid string `json:"kid"`++ Kty string `json:"kty"`++ Alg string `json:"alg"`++ N string `json:"n"`++ E string `json:"e"`++ } `json:"keys"`++ }++ if json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&doc) != nil {++ return nil, ErrGrant++ }++ keys := map[string]*rsa.PublicKey{}++ for _, item := range doc.Keys {++ if item.Kid == "" || item.Kty != "RSA" || item.Alg != "RS256" {++ continue++ }++ n, ne := base64.RawURLEncoding.DecodeString(item.N)++ e, ee := base64.RawURLEncoding.DecodeString(item.E)++ if ne != nil || ee != nil || len(n) < 256 || len(n) > 512 || len(e) == 0 || len(e) > 4 {++ continue++ }++ exponent := new(big.Int).SetBytes(e).Int64()++ if exponent < 3 || exponent > 1<<31-1 || exponent%2 == 0 {++ continue++ }++ keys[item.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(exponent)}++ }++ v.mu.Lock()++ v.keys, v.keysAt = keys, v.Now()++ key := keys[kid]++ v.mu.Unlock()++ if key == nil {++ return nil, ErrGrant++ }++ return key, nil++}nnew file mode 100644@@@ -0,0 +1,72 @@++package access++++import (++ "context"++ "crypto"++ "crypto/rand"++ "crypto/rsa"++ "crypto/sha256"++ "encoding/base64"++ "encoding/json"++ "net/http"++ "net/http/httptest"++ "testing"++ "time"++)++++func TestGrantVerification(t *testing.T) {++ private, err := rsa.GenerateKey(rand.Reader, 2048)++ if err != nil {++ t.Fatal(err)++ }++ pub := private.PublicKey++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {++ if r.URL.Path != "/api/.well-known/jwks" {++ t.Errorf("path: %s", r.URL.Path)++ }++ _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{++ "kid": "network-1", "kty": "RSA", "alg": "RS256",++ "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),++ "e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),++ }}})++ }))++ defer server.Close()++ v := NewVerifier(server.URL, "lnk_target")++ now := time.Unix(2000000000, 0)++ v.Now = func() time.Time { return now }++ sign := func(c Claims) string {++ t.Helper()++ h, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "network-1"})++ p, _ := json.Marshal(c)++ body := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p)++ digest := sha256.Sum256([]byte(body))++ sig, err := rsa.SignPKCS1v15(rand.Reader, private, crypto.SHA256, digest[:])++ if err != nil {++ t.Fatal(err)++ }++ return body + "." + base64.RawURLEncoding.EncodeToString(sig)++ }++ c := Claims{ID: "gra_12345678", JTI: "jti_12345678", Issuer: server.URL, From: "lnk_sender", To: "lnk_target", Scopes: []string{"agent", "send"}, Issued: now.Unix(), Expiry: now.Add(time.Hour).Unix()}++ token := sign(c)++ if _, err := v.Verify(context.Background(), token, c.From, "send"); err != nil {++ t.Fatal(err)++ }++ for _, test := range []struct{ name, token, sender, scope string }{++ {"wrong sender", token, "lnk_other", "send"},++ {"scope", token, c.From, "computer"},++ {"unknown scope", token, c.From, "admin"},++ {"tamper", token + "x", c.From, "send"},++ {"expired", sign(Claims{ID: c.ID, JTI: c.JTI, Issuer: c.Issuer, From: c.From, To: c.To, Scopes: c.Scopes, Issued: c.Issued, Expiry: now.Unix() - 1}), c.From, "send"},++ {"other audience", sign(Claims{ID: c.ID, JTI: c.JTI, Issuer: c.Issuer, From: c.From, To: "lnk_other", Scopes: c.Scopes, Issued: c.Issued, Expiry: c.Expiry}), c.From, "send"},++ } {++ t.Run(test.name, func(t *testing.T) {++ if _, err := v.Verify(context.Background(), test.token, test.sender, test.scope); err == nil {++ t.Fatal("grant accepted")++ }++ })++ }++ v.Revoke(c.ID, now.Add(time.Hour))++ if _, err := v.Verify(context.Background(), token, c.From, "send"); err == nil {++ t.Fatal("revoked grant accepted")++ }++}nnew file mode 100644@@@ -0,0 +1,185 @@++package access++++import (++ "crypto/rand"++ "encoding/hex"++ "encoding/json"++ "errors"++ "os"++ "path/filepath"++ "sort"++ "sync"++ "time"++)++++// Task is the local projection of agent.task@1. Link keeps the original++// sender, person and thread visible to the receiving agent.++type Task struct {++ ID string `json:"id"`++ To string `json:"to"`++ From string `json:"from"`++ OnBehalfOf string `json:"on_behalf_of"`++ Thread string `json:"thread"`++ FromMachine string `json:"from_machine,omitempty"`++ Message struct {++ Parts []struct {++ Type string `json:"type"`++ Text string `json:"text"`++ } `json:"parts"`++ } `json:"message"`++ CreatedAt string `json:"created_at,omitempty"`++}++++type InboxEntry struct {++ Task Task `json:"task"`++ ReceivedAt time.Time `json:"received_at"`++ Status string `json:"status"`++ Reply string `json:"reply,omitempty"`++}++++type Inbox struct {++ path string++ now func() time.Time++ mu sync.Mutex++ entries []InboxEntry++}++++func OpenInbox(path string) (*Inbox, error) {++ i := &Inbox{path: path, now: time.Now}++ raw, err := os.ReadFile(path)++ if os.IsNotExist(err) {++ return i, nil++ }++ if err != nil {++ return nil, err++ }++ if len(raw) > 2<<20 || json.Unmarshal(raw, &i.entries) != nil {++ return nil, errors.New("agent inbox state is invalid")++ }++ i.prune()++ return i, nil++}++++func (i *Inbox) prune() {++ now := i.now()++ out := i.entries[:0]++ for _, entry := range i.entries {++ if now.Sub(entry.ReceivedAt) < 24*time.Hour && now.Sub(entry.ReceivedAt) > -time.Minute {++ out = append(out, entry)++ }++ }++ i.entries = out++}++++func (i *Inbox) save() error {++ raw, err := json.Marshal(i.entries)++ if err != nil {++ return err++ }++ if err := os.MkdirAll(filepath.Dir(i.path), 0700); err != nil {++ return err++ }++ tmp, err := os.CreateTemp(filepath.Dir(i.path), ".agent-inbox-*")++ if err != nil {++ return err++ }++ defer os.Remove(tmp.Name())++ if err := tmp.Chmod(0600); err != nil {++ tmp.Close()++ return err++ }++ if _, err := tmp.Write(raw); err != nil {++ tmp.Close()++ return err++ }++ if err := tmp.Close(); err != nil {++ return err++ }++ return os.Rename(tmp.Name(), i.path)++}++++func (i *Inbox) Add(t Task) error {++ if t.ID == "" || t.To == "" || t.From == "" || t.OnBehalfOf == "" || t.Thread == "" || len(t.Message.Parts) == 0 || len(t.Message.Parts) > 20 {++ return errors.New("invalid agent task")++ }++ for _, p := range t.Message.Parts {++ if p.Type != "text" || len(p.Text) > 8000 {++ return errors.New("invalid agent task text")++ }++ }++ i.mu.Lock()++ defer i.mu.Unlock()++ i.prune()++ for _, entry := range i.entries {++ if entry.Task.ID == t.ID {++ return nil++ }++ }++ if len(i.entries) >= 100 {++ return errors.New("agent inbox is full")++ }++ i.entries = append(i.entries, InboxEntry{Task: t, ReceivedAt: i.now(), Status: "pending"})++ return i.save()++}++++func (i *Inbox) List(agent string) []InboxEntry {++ i.mu.Lock()++ defer i.mu.Unlock()++ i.prune()++ out := make([]InboxEntry, 0, len(i.entries))++ for _, entry := range i.entries {++ if agent == "" || entry.Task.To == agent {++ out = append(out, entry)++ }++ }++ sort.Slice(out, func(a, b int) bool { return out[a].ReceivedAt.Before(out[b].ReceivedAt) })++ return out++}++++func (i *Inbox) Reply(id, text string) (InboxEntry, error) {++ if id == "" || len(text) > 8000 {++ return InboxEntry{}, errors.New("invalid agent reply")++ }++ i.mu.Lock()++ defer i.mu.Unlock()++ i.prune()++ for n := range i.entries {++ if i.entries[n].Task.ID != id {++ continue++ }++ if i.entries[n].Status != "pending" {++ return InboxEntry{}, errors.New("agent task already answered")++ }++ i.entries[n].Status = "completed"++ i.entries[n].Reply = text++ if err := i.save(); err != nil {++ return InboxEntry{}, err++ }++ return i.entries[n], nil++ }++ return InboxEntry{}, errors.New("agent task not in this inbox")++}++++func NewTaskID() string {++ b := make([]byte, 16)++ if _, err := rand.Read(b); err != nil {++ return ""++ }++ return "tsk_" + hex.EncodeToString(b)++}++++func NewRelayID() string {++ b := make([]byte, 16)++ if _, err := rand.Read(b); err != nil {++ return ""++ }++ return "mrl_" + hex.EncodeToString(b)++}++++func NewThreadID() string {++ b := make([]byte, 16)++ if _, err := rand.Read(b); err != nil {++ return ""++ }++ return "thr_" + hex.EncodeToString(b)++}nnew file mode 100644@@@ -0,0 +1,67 @@++package access++++import (++ "path/filepath"++ "strings"++ "testing"++ "time"++)++++func sampleTask() Task {++ t := Task{ID: "tsk_12345678", To: "agt_target", From: "agt_sender", OnBehalfOf: "usr_owner", Thread: "thr_12345678"}++ t.Message.Parts = append(t.Message.Parts, struct {++ Type string `json:"type"`++ Text string `json:"text"`++ }{Type: "text", Text: "hello"})++ return t++}++++func TestInboxPersistsAndExpires(t *testing.T) {++ path := filepath.Join(t.TempDir(), "agent-inbox.json")++ i, err := OpenInbox(path)++ if err != nil {++ t.Fatal(err)++ }++ task := sampleTask()++ if err := i.Add(task); err != nil {++ t.Fatal(err)++ }++ if err := i.Add(task); err != nil {++ t.Fatal(err)++ }++ if len(i.List("agt_target")) != 1 || len(i.List("agt_other")) != 0 {++ t.Fatal("wrong inbox filter or duplicate")++ }++ i, err = OpenInbox(path)++ if err != nil {++ t.Fatal(err)++ }++ entry, err := i.Reply(task.ID, "answer")++ if err != nil || entry.Status != "completed" {++ t.Fatalf("reply: %+v %v", entry, err)++ }++ if _, err := i.Reply(task.ID, "again"); err == nil {++ t.Fatal("duplicate reply accepted")++ }++ i.now = func() time.Time { return time.Now().Add(25 * time.Hour) }++ if len(i.List("")) != 0 {++ t.Fatal("stale thread remains")++ }++}++++func TestInboxRefusesInvalidTask(t *testing.T) {++ i, err := OpenInbox(filepath.Join(t.TempDir(), "inbox.json"))++ if err != nil {++ t.Fatal(err)++ }++ task := sampleTask()++ task.Message.Parts[0].Text = strings.Repeat("x", 8001)++ if err := i.Add(task); err == nil {++ t.Fatal("oversized text accepted")++ }++ task = sampleTask()++ task.OnBehalfOf = ""++ if err := i.Add(task); err == nil {++ t.Fatal("missing person accepted")++ }++}nnew file mode 100644@@@ -0,0 +1,1468 @@++package agent++++import (++ "context"++ "encoding/json"++ "errors"++ "fmt"++ "os"++ "path/filepath"++ "sort"++ "strconv"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/access"++ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Link/internal/mono"++ "github.com/OpenVibers/OpenVibe.Link/internal/protocol"++ "github.com/OpenVibers/OpenVibe.Link/internal/send"++)++++func (n *Agent) agentResultAck(taskID, failure string) {++ n.mu.Lock()++ ch := n.agentResults[taskID]++ n.mu.Unlock()++ if ch != nil {++ select {++ case ch <- failure:++ default:++ }++ }++}++++func (n *Agent) failAgentResults(detail string) {++ n.mu.Lock()++ waiters := make([]chan string, 0, len(n.agentResults))++ for id, ch := range n.agentResults {++ waiters = append(waiters, ch)++ delete(n.agentResults, id)++ }++ n.mu.Unlock()++ for _, ch := range waiters {++ select {++ case ch <- detail:++ default:++ }++ }++}++++func (n *Agent) receiveAgentTask(frame protocol.AgentTaskFrame) error {++ if !n.opt.Config.AgentsTalk {++ return errors.New("this machine has Let my agents talk turned off")++ }++ if n.accessInbox == nil || n.opt.Creds == nil || !n.opt.Creds.NetworkPaired() {++ return errors.New("agent inbox unavailable")++ }++ if len(frame.Task) == 0 || len(frame.Task) > 200<<10 {++ return errors.New("agent task is missing or too large")++ }++ var task access.Task++ if json.Unmarshal(frame.Task, &task) != nil {++ return errors.New("invalid agent task")++ }++ n.mu.Lock()++ registered := false++ for _, local := range n.localAgents {++ if local.ID == task.To {++ registered = true++ break++ }++ }++ n.mu.Unlock()++ if !registered {++ return errors.New("task is not addressed to an agent registered on this machine")++ }++ if err := n.accessInbox.Add(task); err != nil {++ return err++ }++ n.addActivity(fmt.Sprintf("Agent task %s from %s on behalf of %s arrived", task.ID, task.From, task.OnBehalfOf))++ return nil++}++++func (n *Agent) revokeAccess(frame protocol.AccessRevoked) {++ if n.grantVerifier != nil && frame.GrantID != "" {++ n.grantVerifier.Revoke(frame.GrantID, time.Now().Add(31*24*time.Hour))++ n.addActivity("Machine access grant " + frame.GrantID + " was revoked")++ }++ n.mu.Lock()++ sessions := make([]*machineRelay, 0)++ for _, session := range n.relaySessions {++ if session.Claims.ID == frame.GrantID {++ sessions = append(sessions, session)++ }++ }++ n.mu.Unlock()++ for _, session := range sessions {++ n.closeRelay(session, "revoked")++ }++}++++func machineGrant(raw json.RawMessage, self, machine, scope string) string {++ var list struct {++ Grants []struct {++ ID string `json:"id"`++ From string `json:"from_principal"`++ To string `json:"to_principal"`++ Scopes []string `json:"scopes"`++ Revoked *string `json:"revoked_at"`++ Expires *string `json:"expires_at"`++ } `json:"grants"`++ }++ if json.Unmarshal(raw, &list) != nil {++ return ""++ }++ for _, grant := range list.Grants {++ if grant.From != self || grant.To != machine || grant.ID == "" || grant.Revoked != nil {++ continue++ }++ if grant.Expires != nil {++ at, err := time.Parse(time.RFC3339, *grant.Expires)++ if err != nil || !at.After(time.Now()) {++ continue++ }++ }++ for _, s := range grant.Scopes {++ if s == scope {++ return grant.ID++ }++ }++ }++ return ""++}++++func responseJSON(value any) localctl.Response {++ b, err := json.Marshal(value)++ if err != nil {++ return localctl.Response{Error: "could not encode the answer"}++ }++ return localctl.Response{OK: true, Data: b}++}++++// A grant token is used only inside Link. Neither `access list --json` nor an++// MCP tool answer may return a bearer token to a model or a terminal log.++func publicJSON(raw json.RawMessage) any {++ var value any++ if json.Unmarshal(raw, &value) != nil {++ return map[string]any{}++ }++ return stripPrivate(value)++}++++func publicData(raw json.RawMessage) json.RawMessage {++ b, _ := json.Marshal(publicJSON(raw))++ return b++}++++func stripPrivate(value any) any {++ switch v := value.(type) {++ case map[string]any:++ out := make(map[string]any, len(v))++ for key, item := range v {++ lower := strings.ToLower(key)++ if strings.Contains(lower, "token") || strings.Contains(lower, "secret") || strings.Contains(lower, "credential") {++ continue++ }++ out[key] = stripPrivate(item)++ }++ return out++ case []any:++ out := make([]any, len(v))++ for i, item := range v {++ out[i] = stripPrivate(item)++ }++ return out++ default:++ return value++ }++}++++func (n *Agent) mcpControl(name, caller string, raw json.RawMessage) localctl.Response {++ if !n.opt.Config.AgentsTalk && (name == "agent_inbox" || name == "agent_reply") {++ return localctl.Response{Error: "Let my agents talk is off on this machine"}++ }++ switch name {++ case "agent_inbox":++ var args struct {++ Agent string `json:"agent"`++ }++ if json.Unmarshal(raw, &args) != nil {++ return localctl.Response{Error: "invalid inbox arguments"}++ }++ local, err := n.localAgentFor(caller)++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ if args.Agent != "" && args.Agent != local.ID {++ return localctl.Response{Error: "this CLI cannot read another agent's inbox"}++ }++ return responseJSON(map[string]any{"tasks": n.accessInbox.List(local.ID)})++ case "agent_reply":++ var args struct {++ Task string `json:"task"`++ Text string `json:"text"`++ }++ if json.Unmarshal(raw, &args) != nil {++ return localctl.Response{Error: "invalid reply arguments"}++ }++ local, err := n.localAgentFor(caller)++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ var pending *access.InboxEntry++ for _, entry := range n.accessInbox.List(local.ID) {++ if entry.Task.ID == args.Task {++ copy := entry++ pending = ©++ break++ }++ }++ if pending == nil || pending.Status != "pending" {++ return localctl.Response{Error: "task is not in this agent's inbox"}++ }++ if n.link == nil {++ return localctl.Response{Error: "the Link is not connected to OpenVibe.Bot"}++ }++ if strings.TrimSpace(args.Text) == "" || len(args.Text) > 8000 {++ return localctl.Response{Error: "a reply needs up to 8000 characters"}++ }++ result, _ := json.Marshal(map[string]any{"parts": []any{map[string]any{"type": "text", "text": args.Text}}})++ // Bot records the answer on Network; a node token may not post agent++ // task results directly, and Bot checks the task was delivered here.++ waiter := make(chan string, 1)++ n.mu.Lock()++ n.agentResults[args.Task] = waiter++ n.mu.Unlock()++ defer func() {++ n.mu.Lock()++ delete(n.agentResults, args.Task)++ n.mu.Unlock()++ }()++ if err := n.link.Send(protocol.AgentTaskResult{TaskID: args.Task, Status: "completed", Result: result}); err != nil {++ return localctl.Response{Error: err.Error()}++ }++ select {++ case failure := <-waiter:++ if failure != "" {++ return localctl.Response{Error: failure}++ }++ case <-time.After(10 * time.Second):++ return localctl.Response{Error: "Bot did not record the answer in time"}++ }++ entry, err := n.accessInbox.Reply(args.Task, args.Text)++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ return responseJSON(map[string]any{"id": entry.Task.ID, "thread": entry.Task.Thread, "status": entry.Status})++ case "agents_list":++ if n.accessClient == nil {++ return localctl.Response{Error: "pair this machine through Network first"}++ }++ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ defer cancel()++ data, err := n.accessClient.Agents(ctx)++ if err != nil {++ // Network's node-self directory route is newer than this Link. A++ // local machine still lists the CLIs it has registered.++ return responseJSON(map[string]any{"agents": n.localAgentEntries()})++ }++ return localctl.Response{OK: true, Data: publicData(data)}++ case "machines_list":++ if n.accessClient == nil {++ return localctl.Response{Error: "pair this machine through Network first"}++ }++ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ defer cancel()++ grants, err := n.accessClient.Grants(ctx)++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ machines, machineErr := n.accessClient.Machines(ctx)++ if machineErr != nil {++ machines = json.RawMessage(`{"machines":[]}`)++ }++ var machineList struct {++ Machines []any `json:"machines"`++ }++ var grantList struct {++ Grants []any `json:"grants"`++ }++ _ = json.Unmarshal(machines, &machineList)++ _ = json.Unmarshal(grants, &grantList)++ if machineList.Machines == nil {++ machineList.Machines = []any{}++ }++ if grantList.Grants == nil {++ grantList.Grants = []any{}++ }++ return responseJSON(map[string]any{"machines": stripPrivate(machineList.Machines), "grants": stripPrivate(grantList.Grants)})++ case "machine_request_access":++ if n.accessClient == nil {++ return localctl.Response{Error: "pair this machine through Network first"}++ }++ // The request names the agent when "Let my agents talk" has registered++ // this CLI. Without it the machine itself still asks, so a local agent++ // can request access before the owner turns agent messages on.++ agentID := ""++ if local, err := n.localAgentFor(caller); err == nil {++ agentID = local.ID++ }++ var args struct {++ Machine string `json:"machine"`++ Scopes []string `json:"scopes"`++ ForSeconds int64 `json:"for_seconds"`++ Reason string `json:"reason"`++ }++ if json.Unmarshal(raw, &args) != nil {++ return localctl.Response{Error: "invalid access request"}++ }++ if args.ForSeconds == 0 {++ args.ForSeconds = 3600++ }++ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ defer cancel()++ data, err := n.accessClient.Request(ctx, args.Machine, args.Scopes, args.ForSeconds, args.Reason, agentID)++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ return localctl.Response{OK: true, Data: publicData(data)}++ case "agent_tell", "agent_ask":++ return n.sendAgentTask(name, caller, raw)++ case "machine_use":++ return n.machineUse(raw)++ }++ return localctl.Response{Error: "unknown MCP tool"}++}++++type relayRequest struct {++ ID string `json:"id"`++ Action string `json:"action"`++ Level string `json:"level,omitempty"`++ Data json.RawMessage `json:"data,omitempty"`++}++++type relayReply struct {++ ID string `json:"id"`++ OK bool `json:"ok"`++ Error string `json:"error,omitempty"`++ Data json.RawMessage `json:"data,omitempty"`++}++++type relayOpened struct {++ Frame protocol.RelayOpened++ Err error++}++++// machineRelay is one relay.opened session Bot keeps open between two Links.++// Opener is true on the machine that asked; Peer is the other machine.++type machineRelay struct {++ ID string++ Peer string++ Token string++ Claims access.Claims++ Scopes map[string]bool++ Expires time.Time++ Opener bool++ CheckedAt time.Time++ WaitingID string++}++++type relaySource struct {++ to, scope, relay, bodyID string++}++++func validRelayID(id string) bool {++ if len(id) < 12 || len(id) > 84 || !strings.HasPrefix(id, "rly_") {++ return false++ }++ for _, c := range id[4:] {++ if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && c != '-' && c != '_' {++ return false++ }++ }++ return true++}++++func validOperationID(id string) bool {++ if len(id) != 36 || !strings.HasPrefix(id, "mrl_") {++ return false++ }++ for _, c := range id[4:] {++ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {++ return false++ }++ }++ return true++}++++func (n *Agent) machineUse(raw json.RawMessage) localctl.Response {++ if n.accessClient == nil || n.link == nil || n.opt.Creds == nil {++ return localctl.Response{Error: "machine relay unavailable"}++ }++ var args struct {++ Machine string `json:"machine"`++ Scope string `json:"scope"`++ Action string `json:"action"`++ Data json.RawMessage `json:"data"`++ }++ if json.Unmarshal(raw, &args) != nil || args.Machine == "" || args.Machine == n.opt.Creds.Principal || !access.ValidScope(args.Scope) || !validRelayAction(args.Scope, args.Action) || len(args.Data) > 1<<20 {++ return localctl.Response{Error: "invalid machine use"}++ }++ if !strings.HasPrefix(args.Machine, "lnk_") {++ return localctl.Response{Error: "invalid machine id"}++ }++ if len(args.Data) == 0 {++ args.Data = json.RawMessage(`{}`)++ }++ if args.Scope == "computer" {++ var object map[string]any++ if json.Unmarshal(args.Data, &object) != nil || object == nil {++ return localctl.Response{Error: "a computer action must be a JSON object"}++ }++ }++ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)++ grants, err := n.accessClient.Grants(ctx)++ cancel()++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ grantID := machineGrant(grants, n.opt.Creds.Principal, args.Machine, args.Scope)++ if grantID == "" {++ return localctl.Response{Error: "no active grant for this machine and scope"}++ }++ ctx, cancel = context.WithTimeout(context.Background(), 8*time.Second)++ tokenAnswer, err := n.accessClient.GrantToken(ctx, grantID)++ if err != nil {++ cancel()++ return localctl.Response{Error: err.Error()}++ }++ var tokenRow struct {++ GrantToken string `json:"grant_token"`++ }++ if json.Unmarshal(tokenAnswer, &tokenRow) != nil || tokenRow.GrantToken == "" {++ cancel()++ return localctl.Response{Error: "Network did not issue the grant token"}++ }++ token := tokenRow.GrantToken++ claims, err := access.NewVerifier(n.opt.Creds.Network, args.Machine).Verify(ctx, token, n.opt.Creds.Principal, args.Scope)++ cancel()++ if err != nil || claims.ID != grantID {++ return localctl.Response{Error: "Network issued an invalid grant token"}++ }++ if args.Scope == "send" && args.Action == "offer" {++ var offer struct {++ Envelope string `json:"envelope"`++ }++ if json.Unmarshal(args.Data, &offer) != nil || offer.Envelope == "" || len(offer.Envelope) > 900000 {++ return localctl.Response{Error: "invalid sealed item"}++ }++ client := &send.Client{BaseURL: n.opt.Creds.Network, Tokens: n.accessClient.Tokens, Self: n.opt.Creds.Principal}++ lanCtx, lanCancel := context.WithTimeout(context.Background(), 3*time.Second)++ id, lanErr := client.SendGrantedLAN(lanCtx, args.Machine, offer.Envelope, token)++ lanCancel()++ if lanErr == nil {++ n.addActivity("Sent a sealed item to machine " + args.Machine + " on the LAN")++ return responseJSON(map[string]any{"machine": args.Machine, "scope": "send", "result": map[string]any{"id": id, "transport": "lan"}})++ }++ }++ bodyID := access.NewRelayID()++ if bodyID == "" {++ return localctl.Response{Error: "could not create a relay id"}++ }++ if args.Scope == "worker" && args.Action == "run" {++ var job protocol.Job++ if json.Unmarshal(args.Data, &job) != nil || !protocol.ValidJobID(job.ID) {++ return localctl.Response{Error: "invalid worker job"}++ }++ }++ body := map[string]any{"id": bodyID, "action": args.Action, "data": args.Data}++ if args.Scope == "computer" {++ level := requestedComputerLevel(args.Data)++ body["level"] = level++ }++ bodyJSON, err := json.Marshal(body)++ if err != nil {++ return localctl.Response{Error: "could not encode the relay body"}++ }++ if len(bodyJSON) > 256<<10 {++ return localctl.Response{Error: "the relay body is larger than 256 KiB; send files on the LAN or use a smaller item"}++ }++ // Bot opens one relay per operation: relay.open, relay.opened to both++ // machines, then relay.frame each way. The receiving Link verifies the same++ // token and checks the scope again.++ opened := make(chan relayOpened, 1)++ n.mu.Lock()++ if n.relayOpening[args.Machine] != nil {++ n.mu.Unlock()++ return localctl.Response{Error: "another relay to this machine is opening"}++ }++ n.relayOpening[args.Machine] = opened++ n.mu.Unlock()++ defer func() {++ n.mu.Lock()++ if n.relayOpening[args.Machine] == opened {++ delete(n.relayOpening, args.Machine)++ }++ n.mu.Unlock()++ }()++ if err := n.link.Send(protocol.RelayOpen{To: args.Machine, GrantToken: token}); err != nil {++ return localctl.Response{Error: err.Error()}++ }++ var session *machineRelay++ select {++ case answer := <-opened:++ if answer.Err != nil {++ return localctl.Response{Error: answer.Err.Error()}++ }++ n.mu.Lock()++ session = n.relaySessions[answer.Frame.Relay]++ n.mu.Unlock()++ case <-time.After(15 * time.Second):++ return localctl.Response{Error: "the other machine did not open the relay in time"}++ }++ if session == nil {++ return localctl.Response{Error: "the relay opened without a verified grant"}++ }++ wait := make(chan relayReply, 1)++ n.mu.Lock()++ n.relayWaiting[bodyID] = wait++ if current := n.relaySessions[session.ID]; current != nil {++ current.WaitingID = bodyID++ }++ n.mu.Unlock()++ defer func() {++ n.mu.Lock()++ delete(n.relayWaiting, bodyID)++ n.mu.Unlock()++ }()++ if err := n.link.Send(protocol.RelayFrame{Relay: session.ID, Scope: args.Scope, Body: bodyJSON}); err != nil {++ n.closeRelay(session, "send failed")++ return localctl.Response{Error: err.Error()}++ }++ select {++ case reply := <-wait:++ n.closeRelay(session, "done")++ if !reply.OK {++ return localctl.Response{Error: reply.Error}++ }++ n.addActivity("Used " + args.Scope + " on machine " + args.Machine)++ return responseJSON(map[string]any{"id": bodyID, "machine": args.Machine, "scope": args.Scope, "result": publicJSON(reply.Data)})++ case <-time.After(120 * time.Second):++ n.closeRelay(session, "timeout")++ return localctl.Response{Error: "machine did not answer within 120 seconds"}++ }++}++++func requestedComputerLevel(raw json.RawMessage) string {++ var object struct {++ Level string `json:"level"`++ }++ _ = json.Unmarshal(raw, &object)++ switch object.Level {++ case "observe", "ask", "trusted", "full":++ return object.Level++ }++ return "ask"++}++++func computerRank(level string) int {++ switch level {++ case "observe":++ return 0++ case "ask":++ return 1++ case "trusted":++ return 2++ case "full":++ return 3++ }++ return -1++}++++func validRelayAction(scope, action string) bool {++ switch scope {++ case "agent":++ return action == "task"++ case "send":++ return action == "offer"++ case "screen":++ return action == protocol.KindScreen || action == protocol.KindScreenStream++ case "desk":++ return action == protocol.KindDesk || action == protocol.KindDeskStream++ case "computer":++ return action == protocol.KindExec || action == protocol.KindFS || action == protocol.KindInput || action == protocol.KindBrowser || action == protocol.KindProbe++ case "worker":++ return action == "run" || action == "cancel" || action == "status"++ }++ return false++}++++func (n *Agent) receiveRelayOpened(frame protocol.RelayOpened) {++ self := ""++ if n.opt.Creds != nil {++ self = n.opt.Creds.Principal++ }++ refuse := func(reason string) {++ if n.link != nil {++ _ = n.link.Send(protocol.RelayClose{Relay: frame.Relay})++ }++ n.mu.Lock()++ if ch := n.relayOpening[frame.To]; ch != nil && frame.From == self {++ select {++ case ch <- relayOpened{Err: errors.New(reason)}:++ default:++ }++ }++ n.mu.Unlock()++ }++ if self == "" || n.accessClient == nil || !validRelayID(frame.Relay) || frame.GrantToken == "" || len(frame.Scopes) == 0 ||++ frame.From == "" || frame.To == "" || frame.From == frame.To || (frame.From != self && frame.To != self) {++ refuse("invalid relay opening")++ return++ }++ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)++ claims, err := access.NewVerifier(n.opt.Creds.Network, frame.To).Verify(ctx, frame.GrantToken, frame.From, frame.Scopes[0])++ cancel()++ if err != nil || claims.ID == "" || claims.From != frame.From || claims.To != frame.To || claims.Expiry <= time.Now().Unix() {++ refuse("the relay grant was not valid")++ return++ }++ scopes := map[string]bool{}++ for _, scope := range claims.Scopes {++ if !access.ValidScope(scope) {++ refuse("the relay grant names an unknown scope")++ return++ }++ scopes[scope] = true++ }++ peer := frame.From++ if frame.From == self {++ peer = frame.To++ }++ session := &machineRelay{ID: frame.Relay, Peer: peer, Token: frame.GrantToken, Claims: claims,++ Scopes: scopes, Expires: time.Unix(claims.Expiry, 0), Opener: frame.From == self}++ n.mu.Lock()++ if n.relaySessions[frame.Relay] != nil {++ n.mu.Unlock()++ refuse("the relay is already open")++ return++ }++ n.relaySessions[frame.Relay] = session++ var ch chan relayOpened++ if session.Opener {++ ch = n.relayOpening[frame.To]++ }++ n.mu.Unlock()++ if ch != nil {++ select {++ case ch <- relayOpened{Frame: frame}:++ default:++ }++ }++}++++func (n *Agent) receiveRelayFrame(frame protocol.RelayFrame) {++ n.mu.Lock()++ session := n.relaySessions[frame.Relay]++ n.mu.Unlock()++ if session == nil || frame.From != session.Peer || !session.Scopes[frame.Scope] || len(frame.Body) == 0 || len(frame.Body) > 500<<10 {++ if session != nil {++ n.closeRelay(session, "invalid frame")++ }++ return++ }++ if time.Now().After(session.Expires) {++ n.closeRelay(session, "expired")++ return++ }++ if time.Since(session.CheckedAt) > 5*time.Second {++ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)++ grants, err := n.accessClient.Grants(ctx)++ cancel()++ if err != nil || !grantStillActive(grants, session.Claims.ID, session.Claims.From, session.Claims.To, frame.Scope) {++ n.closeRelay(session, "revoked")++ return++ }++ session.CheckedAt = time.Now()++ }++ if session.Opener {++ var reply relayReply++ if json.Unmarshal(frame.Body, &reply) != nil || reply.ID == "" {++ return++ }++ n.mu.Lock()++ ch := n.relayWaiting[reply.ID]++ n.mu.Unlock()++ if ch != nil {++ select {++ case ch <- reply:++ default:++ }++ }++ return++ }++ var request relayRequest++ if json.Unmarshal(frame.Body, &request) != nil || !validOperationID(request.ID) || !validRelayAction(frame.Scope, request.Action) {++ n.sendRelayReply(session, frame.Scope, request.ID, nil, errors.New("invalid machine operation"))++ return++ }++ if frame.Scope == "computer" {++ own := n.computerLevel()++ if request.Level != "" && (computerRank(request.Level) < 0 || computerRank(request.Level) > computerRank(own)) {++ n.sendRelayReply(session, frame.Scope, request.ID, nil, errors.New("this computer does not allow that level"))++ return++ }++ }++ immediate, data, err := n.dispatchRelay(session, frame.Scope, request)++ if immediate {++ n.sendRelayReply(session, frame.Scope, request.ID, data, err)++ } else if err != nil {++ n.sendRelayReply(session, frame.Scope, request.ID, nil, err)++ }++}++++// dispatchRelay runs one operation through the local feature that already++// serves this machine. immediate is false when the feature answers later++// through relayResult (a command ack, a job exit).++func (n *Agent) dispatchRelay(session *machineRelay, scope string, request relayRequest) (immediate bool, data any, err error) {++ if n.capabilityOff(relayCapability(scope, request.Action)) {++ return true, nil, errors.New("this capability is off on the target machine")++ }++ n.addActivity("Machine " + session.Peer + " used " + scope + " (grant " + session.Claims.ID + ")")++ switch scope {++ case "agent":++ if !n.opt.Config.AgentsTalk {++ return true, nil, errors.New("Let my agents talk is off on this machine")++ }++ err = n.receiveAgentTask(protocol.AgentTaskFrame{Task: request.Data})++ return true, map[string]any{"queued": err == nil}, err++ case "send":++ var offer struct {++ Envelope string `json:"envelope"`++ }++ if json.Unmarshal(request.Data, &offer) != nil || len(offer.Envelope) > 900000 {++ return true, nil, errors.New("invalid sealed item")++ }++ n.mu.Lock()++ lan := n.grantedSend++ n.mu.Unlock()++ if lan == nil {++ return true, nil, errors.New("Link Send is off on this machine")++ }++ id, err := lan.ReceiveGranted(session.Peer, offer.Envelope)++ return true, map[string]any{"id": id}, err++ case "worker":++ if request.Action == "cancel" || request.Action == "status" {++ var body struct {++ ID string `json:"id"`++ }++ if json.Unmarshal(request.Data, &body) != nil || !protocol.ValidJobID(body.ID) {++ return true, nil, errors.New("invalid job id")++ }++ n.mu.Lock()++ owner := n.remoteJobs[body.ID]++ n.mu.Unlock()++ if owner != session.Peer {++ return true, nil, errors.New("job is not owned by this machine")++ }++ if request.Action == "status" {++ result, ok := n.jobs.answer(body.ID)++ if !ok {++ return true, nil, errors.New("job not found")++ }++ return true, result, nil++ }++ n.jobCancel(protocol.JobCancel{ID: body.ID})++ return true, map[string]any{"cancelled": true}, nil++ }++ var job protocol.Job++ if json.Unmarshal(request.Data, &job) != nil || !protocol.ValidJobID(job.ID) {++ return true, nil, errors.New("invalid job")++ }++ source := relaySource{to: session.Peer, scope: scope, relay: session.ID, bodyID: request.ID}++ if err := n.jobRelayed(protocol.JobRequest{Job: job}, source); err != nil {++ return true, nil, err++ }++ return false, nil, nil++ default:++ if !json.Valid(request.Data) {++ return true, nil, errors.New("invalid command data")++ }++ n.mu.Lock()++ n.relaySources[request.ID] = relaySource{to: session.Peer, scope: scope, relay: session.ID, bodyID: request.ID}++ n.mu.Unlock()++ n.command(protocol.Command{ID: request.ID, Kind: request.Action, Value: request.Data, Operator: &protocol.Operator{Subject: session.Peer, Role: "machine"}}, time.Now().UnixMilli(), 0, mono.Now())++ return false, nil, nil++ }++}++++func (n *Agent) sendRelayReply(session *machineRelay, scope, bodyID string, data any, err error) {++ if n.link == nil || session == nil {++ return++ }++ reply := relayReply{ID: bodyID, OK: err == nil}++ if err != nil {++ reply.Error = err.Error()++ } else if data != nil {++ reply.Data, _ = json.Marshal(data)++ }++ body, marshalErr := json.Marshal(reply)++ if marshalErr != nil {++ return++ }++ _ = n.link.Send(protocol.RelayFrame{Relay: session.ID, Scope: scope, Body: body})++}++++func (n *Agent) closeRelay(session *machineRelay, reason string) {++ if session == nil {++ return++ }++ n.mu.Lock()++ delete(n.relaySessions, session.ID)++ bodyID := session.WaitingID++ n.mu.Unlock()++ if bodyID != "" {++ n.mu.Lock()++ ch := n.relayWaiting[bodyID]++ n.mu.Unlock()++ if ch != nil {++ select {++ case ch <- relayReply{ID: bodyID, Error: "the relay closed: " + reason}:++ default:++ }++ }++ }++ if n.link != nil {++ _ = n.link.Send(protocol.RelayClose{Relay: session.ID})++ }++}++++func (n *Agent) receiveRelayClosed(frame protocol.RelayClosed) {++ n.mu.Lock()++ session := n.relaySessions[frame.Relay]++ delete(n.relaySessions, frame.Relay)++ n.mu.Unlock()++ if session == nil {++ return++ }++ reason := frame.Reason++ if reason == "" {++ reason = "closed"++ }++ if session.WaitingID != "" {++ n.mu.Lock()++ ch := n.relayWaiting[session.WaitingID]++ n.mu.Unlock()++ if ch != nil {++ select {++ case ch <- relayReply{ID: session.WaitingID, Error: "the relay closed: " + reason}:++ default:++ }++ }++ }++ if session.Opener {++ n.mu.Lock()++ ch := n.relayOpening[session.Peer]++ n.mu.Unlock()++ if ch != nil {++ select {++ case ch <- relayOpened{Err: errors.New("the relay closed: " + reason)}:++ default:++ }++ }++ }++}++++func (n *Agent) failRelayWaiters(detail string) {++ n.mu.Lock()++ openings := make([]chan relayOpened, 0, len(n.relayOpening))++ for key, ch := range n.relayOpening {++ openings = append(openings, ch)++ delete(n.relayOpening, key)++ }++ waiters := map[string]chan relayReply{}++ for id, ch := range n.relayWaiting {++ waiters[id] = ch++ }++ n.mu.Unlock()++ for _, ch := range openings {++ select {++ case ch <- relayOpened{Err: errors.New(detail)}:++ default:++ }++ }++ for id, ch := range waiters {++ select {++ case ch <- relayReply{ID: id, Error: detail}:++ default:++ }++ }++}++++func grantStillActive(raw json.RawMessage, id, from, to, scope string) bool {++ var list struct {++ Grants []struct {++ ID string `json:"id"`++ From string `json:"from_principal"`++ To string `json:"to_principal"`++ Scopes []string `json:"scopes"`++ Revoked *string `json:"revoked_at"`++ Expires *string `json:"expires_at"`++ } `json:"grants"`++ }++ if json.Unmarshal(raw, &list) != nil {++ return false++ }++ for _, g := range list.Grants {++ if g.ID != id || g.From != from || g.To != to || g.Revoked != nil {++ continue++ }++ if g.Expires != nil {++ at, err := time.Parse(time.RFC3339, *g.Expires)++ if err != nil || !at.After(time.Now()) {++ continue++ }++ }++ for _, s := range g.Scopes {++ if s == scope {++ return true++ }++ }++ }++ return false++}++++func relayCapability(scope, action string) string {++ switch scope {++ case "screen":++ return protocol.KindScreen++ case "desk":++ return protocol.KindDesk++ case "computer":++ return action++ }++ return ""++}++++func (n *Agent) relayResult(m protocol.Message) bool {++ var id string++ switch v := m.(type) {++ case protocol.Ack:++ id = v.ID++ case protocol.AckData:++ id = v.ID++ case protocol.Nack:++ id = v.ID++ case protocol.JobExit:++ id = v.ID++ default:++ return false++ }++ n.mu.Lock()++ source := n.relaySources[id]++ if source.to != "" {++ delete(n.relaySources, id)++ if _, refused := m.(protocol.Nack); refused {++ delete(n.remoteJobs, id)++ }++ }++ n.mu.Unlock()++ if source.to == "" {++ // A relayed worker's ack already closed its relay. Its later job_exit++ // must not go to Bot as a job frame for a job Bot never started.++ if _, exited := m.(protocol.JobExit); exited {++ n.mu.Lock()++ _, remote := n.remoteJobs[id]++ delete(n.remoteJobs, id)++ n.mu.Unlock()++ if remote {++ return true++ }++ }++ return false++ }++ reply := relayReply{ID: source.bodyID, OK: true}++ if nk, ok := m.(protocol.Nack); ok {++ reply.OK = false++ reply.Error = nk.Message++ } else {++ reply.Data, _ = json.Marshal(m)++ }++ body, err := json.Marshal(reply)++ if err != nil {++ return true++ }++ if n.link != nil {++ _ = n.link.Send(protocol.RelayFrame{Relay: source.relay, Scope: source.scope, Body: body})++ }++ return true++}++++type localAgent struct{ ID, Owner, Name string }++++func loadLocalAgents(dir string) map[string]localAgent {++ entries := map[string]localAgent{}++ raw, err := os.ReadFile(filepath.Join(dir, "agent-directory.json"))++ if err != nil || len(raw) > 64<<10 || json.Unmarshal(raw, &entries) != nil || entries == nil {++ return map[string]localAgent{}++ }++ for cli, entry := range entries {++ if cli == "" || !strings.HasPrefix(entry.ID, "agt_") || !strings.HasPrefix(entry.Owner, "usr_") {++ delete(entries, cli)++ }++ }++ return entries++}++++func saveLocalAgents(dir string, entries map[string]localAgent) error {++ path := filepath.Join(dir, "agent-directory.json")++ if err := os.MkdirAll(dir, 0700); err != nil {++ return err++ }++ raw, err := json.Marshal(entries)++ if err != nil {++ return err++ }++ tmp, err := os.CreateTemp(dir, ".agent-directory-*")++ if err != nil {++ return err++ }++ defer os.Remove(tmp.Name())++ if err := tmp.Chmod(0600); err != nil {++ tmp.Close()++ return err++ }++ if _, err := tmp.Write(raw); err != nil {++ tmp.Close()++ return err++ }++ if err := tmp.Close(); err != nil {++ return err++ }++ return os.Rename(tmp.Name(), path)++}++++func (n *Agent) localAgentEntries() []map[string]any {++ n.mu.Lock()++ defer n.mu.Unlock()++ list := make([]map[string]any, 0, len(n.localAgents))++ machine := ""++ if n.opt.Creds != nil {++ machine = n.opt.Creds.Principal++ }++ for cli, entry := range n.localAgents {++ name := entry.Name++ if name == "" {++ name = cli++ }++ list = append(list, map[string]any{"id": entry.ID, "owner": entry.Owner, "kind": "link-local",++ "name": name, "machine_id": machine, "status": "online", "allow_others": false})++ }++ sort.Slice(list, func(i, j int) bool { return fmt.Sprint(list[i]["name"]) < fmt.Sprint(list[j]["name"]) })++ return list++}++++func (n *Agent) localAgentFor(cli string) (localAgent, error) {++ if !n.opt.Config.AgentsTalk {++ return localAgent{}, errors.New("Let my agents talk is off on this machine")++ }++ n.mu.Lock()++ entry, ok := n.localAgents[cli]++ n.mu.Unlock()++ if cli == "" || !ok || entry.ID == "" || entry.Owner == "" {++ return localAgent{}, errors.New("this CLI is not registered in the agent directory yet")++ }++ return entry, nil++}++++func (n *Agent) registerLocalAgents(ctx context.Context) {++ ticker := time.NewTicker(time.Minute)++ defer ticker.Stop()++ for {++ n.registerLocalAgentsOnce(ctx)++ select {++ case <-ctx.Done():++ return++ case <-ticker.C:++ }++ }++}++++func (n *Agent) registerLocalAgentsOnce(ctx context.Context) {++ if n.accessClient == nil || n.opt.Creds == nil || !n.opt.Config.AgentsTalk {++ return++ }++ for _, cli := range n.codesAgents.latest(n.probeCodesAgents) {++ if cli.State == "missing" {++ continue++ }++ status := "offline"++ if cli.State == "ready" {++ status = "online"++ }++ entry := map[string]any{"kind": "link-local", "local_id": cli.ID, "name": cli.Name, "machine_id": n.opt.Creds.Principal, "skills": []string{}, "status": status, "allow_others": false}++ n.mu.Lock()++ known := n.localAgents[cli.ID]++ n.mu.Unlock()++ if known.ID != "" {++ entry["id"] = known.ID++ }++ callCtx, cancel := context.WithTimeout(ctx, 10*time.Second)++ raw, err := n.accessClient.RegisterAgent(callCtx, entry)++ cancel()++ if err != nil {++ n.log.Debug("agent registration unavailable", "cli", cli.ID, "err", err)++ continue++ }++ var result struct {++ ID string `json:"id"`++ Owner string `json:"owner"`++ Agent struct {++ ID string `json:"id"`++ Owner string `json:"owner"`++ } `json:"agent"`++ }++ if json.Unmarshal(raw, &result) != nil {++ continue++ }++ if result.ID == "" {++ result.ID = result.Agent.ID++ }++ if result.Owner == "" {++ result.Owner = result.Agent.Owner++ }++ if !strings.HasPrefix(result.ID, "agt_") || result.Owner == "" {++ continue++ }++ n.mu.Lock()++ n.localAgents[cli.ID] = localAgent{ID: result.ID, Owner: result.Owner, Name: cli.Name}++ copy := make(map[string]localAgent, len(n.localAgents))++ for key, value := range n.localAgents {++ copy[key] = value++ }++ n.mu.Unlock()++ if err := saveLocalAgents(n.opt.Paths.StateDir, copy); err != nil {++ n.log.Warn("agent directory id not saved", "err", err)++ }++ }++}++++func (n *Agent) sendAgentTask(name, caller string, raw json.RawMessage) localctl.Response {++ if n.accessClient == nil {++ return localctl.Response{Error: "pair this machine through Network first"}++ }++ local, err := n.localAgentFor(caller)++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ var args struct {++ Agent string `json:"agent"`++ Text string `json:"text"`++ Thread string `json:"thread"`++ WaitSeconds int `json:"wait_seconds"`++ }++ if json.Unmarshal(raw, &args) != nil || len(args.Text) > 8000 || args.Agent == "" || args.Text == "" {++ return localctl.Response{Error: "invalid agent message"}++ }++ if name == "agent_ask" {++ if args.WaitSeconds == 0 {++ args.WaitSeconds = 30++ }++ if args.WaitSeconds < 1 || args.WaitSeconds > 120 {++ return localctl.Response{Error: "wait_seconds must be 1 to 120"}++ }++ }++ if args.Thread == "" {++ args.Thread = access.NewThreadID()++ }++ if args.Thread == "" {++ return localctl.Response{Error: "could not make a thread id"}++ }++ task := map[string]any{"to": args.Agent, "from": local.ID, "on_behalf_of": local.Owner, "thread": args.Thread, "message": map[string]any{"role": "user", "parts": []any{map[string]any{"type": "text", "text": args.Text}}}}++ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ data, err := n.accessClient.Task(ctx, args.Agent, task)++ cancel()++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ if name == "agent_tell" {++ return localctl.Response{OK: true, Data: data}++ }++ var created struct {++ ID string `json:"id"`++ Status string `json:"status"`++ Task struct {++ ID string `json:"id"`++ Status string `json:"status"`++ } `json:"task"`++ }++ if json.Unmarshal(data, &created) != nil {++ return localctl.Response{Error: "Network did not return a task id"}++ }++ if created.ID == "" {++ created.ID = created.Task.ID++ created.Status = created.Task.Status++ }++ if created.ID == "" {++ return localctl.Response{Error: "Network did not return a task id"}++ }++ if created.Status == "completed" || created.Status == "failed" || created.Status == "cancelled" {++ return localctl.Response{OK: true, Data: data}++ }++ deadline := time.Now().Add(time.Duration(args.WaitSeconds) * time.Second)++ for time.Now().Before(deadline) {++ time.Sleep(500 * time.Millisecond)++ pollCtx, pollCancel := context.WithTimeout(context.Background(), 5*time.Second)++ data, err = n.accessClient.TaskStatus(pollCtx, created.ID)++ pollCancel()++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ var result struct {++ Status string `json:"status"`++ Task struct {++ Status string `json:"status"`++ } `json:"task"`++ }++ if json.Unmarshal(data, &result) != nil {++ return localctl.Response{Error: "Network returned an invalid task"}++ }++ if result.Status == "" {++ result.Status = result.Task.Status++ }++ if result.Status == "completed" || result.Status == "failed" || result.Status == "cancelled" {++ return localctl.Response{OK: true, Data: data}++ }++ }++ return responseJSON(map[string]any{"id": created.ID, "status": "pending", "thread": args.Thread})++}++++func (n *Agent) accessControl(action string, raw json.RawMessage) localctl.Response {++ if n.accessClient == nil {++ return localctl.Response{Error: "pair this machine through OpenVibe.Network first"}++ }++ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ defer cancel()++ var args struct {++ Machine string `json:"machine"`++ Scopes []string `json:"scopes"`++ Duration int64 `json:"duration_seconds"`++ Reason string `json:"reason"`++ ID string `json:"id"`++ }++ if len(raw) > 0 && json.Unmarshal(raw, &args) != nil {++ return localctl.Response{Error: "invalid access arguments"}++ }++ var data json.RawMessage++ var err error++ switch action {++ case "request":++ data, err = n.accessClient.Request(ctx, args.Machine, args.Scopes, args.Duration, args.Reason, "")++ case "list":++ var requests, grants json.RawMessage++ requests, err = n.accessClient.List(ctx)++ if err == nil {++ grants, err = n.accessClient.Grants(ctx)++ }++ if err == nil {++ return responseJSON(map[string]any{"requests": publicJSON(requests), "grants": publicJSON(grants)})++ }++ case "approve", "deny":++ data, err = n.accessClient.Answer(ctx, args.ID, action == "approve", args.Scopes, args.Duration)++ case "revoke":++ data, err = n.accessClient.Revoke(ctx, args.ID)++ default:++ return localctl.Response{Error: "unknown access action"}++ }++ if err != nil {++ return localctl.Response{Error: err.Error()}++ }++ if action != "list" {++ n.addActivity("Machine access " + action + " completed")++ }++ return localctl.Response{OK: true, Data: publicData(data)}++}++++type incomingAccessRequest struct {++ ID string `json:"id"`++ FromPrincipal string `json:"from_principal"`++ ToPrincipal string `json:"to_principal"`++ FromOwner string `json:"from_owner"`++ FromAgent string `json:"from_agent"`++ FromMachineName string `json:"from_machine_name"`++ FromOwnerName string `json:"from_owner_name"`++ FromAgentName string `json:"from_agent_name"`++ Scopes []string `json:"scopes"`++ Reason string `json:"reason"`++ Status string `json:"status"`++ // Network currently returns asked_at/pending_until; link.access-request@1++ // names them requested_at/expires_at. Accept either so Link follows the++ // contract and the first Network build alike.++ AskedAt string `json:"asked_at"`++ RequestedAt string `json:"requested_at"`++ PendingUntil string `json:"pending_until"`++ ExpiresAt string `json:"expires_at"`++ DurationSeconds int64 `json:"duration_seconds"`++}++++func (r incomingAccessRequest) askedAt() string {++ if r.AskedAt != "" {++ return r.AskedAt++ }++ return r.RequestedAt++}++++func (r incomingAccessRequest) expiresAt() string {++ if r.PendingUntil != "" {++ return r.PendingUntil++ }++ return r.ExpiresAt++}++++func accessRequestWords(request incomingAccessRequest) (string, string) {++ actor := request.FromPrincipal++ if request.FromOwnerName != "" && request.FromMachineName != "" {++ actor = request.FromOwnerName + "'s " + request.FromMachineName++ } else if request.FromMachineName != "" {++ actor = request.FromMachineName++ }++ if request.FromAgentName != "" {++ actor = request.FromAgentName + " on " + actor++ }++ words := map[string]string{"agent": "message your agents", "send": "send you files", "screen": "watch the screen", "desk": "use a private desk", "computer": "control this computer", "worker": "run jobs in the sandbox"}++ var parts []string++ for _, scope := range request.Scopes {++ if word := words[scope]; word != "" {++ parts = append(parts, word)++ }++ }++ if len(parts) == 0 {++ parts = []string{"request access"}++ }++ phrase := parts[0]++ if len(parts) > 1 {++ phrase = strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]++ }++ phrase = strings.ToUpper(phrase[:1]) + phrase[1:]++ duration := "until you take it back"++ if request.DurationSeconds > 0 {++ duration = (time.Duration(request.DurationSeconds) * time.Second).String()++ if request.DurationSeconds%86400 == 0 {++ days := request.DurationSeconds / 86400++ duration = fmt.Sprintf("%d days", days)++ if days == 1 {++ duration = "1 day"++ }++ } else if request.DurationSeconds%3600 == 0 {++ hours := request.DurationSeconds / 3600++ duration = fmt.Sprintf("%d hours", hours)++ if hours == 1 {++ duration = "1 hour"++ }++ }++ }++ body := phrase + ", for " + duration + "."++ if request.DurationSeconds == 0 {++ body = phrase + ", until you take it back."++ }++ if request.Reason != "" {++ body += " " + strconv.Quote(request.Reason)++ }++ return actor + " asks for access", body++}++++func (n *Agent) pollAccessRequests(ctx context.Context) {++ ticker := time.NewTicker(5 * time.Second)++ defer ticker.Stop()++ for {++ n.refreshAccessRequests(ctx)++ select {++ case <-ctx.Done():++ return++ case <-ticker.C:++ }++ }++}++++func (n *Agent) refreshAccessRequests(ctx context.Context) {++ if n.accessClient == nil || n.opt.Creds == nil {++ return++ }++ callCtx, cancel := context.WithTimeout(ctx, 4*time.Second)++ defer cancel()++ raw, err := n.accessClient.List(callCtx)++ if err != nil {++ n.log.Debug("machine access requests unavailable", "err", err)++ return++ }++ var result struct {++ Requests []incomingAccessRequest `json:"requests"`++ }++ if json.Unmarshal(raw, &result) != nil {++ return++ }++ want := map[string]incomingAccessRequest{}++ for _, request := range result.Requests {++ if request.Status == "pending" && request.ToPrincipal == n.opt.Creds.Principal {++ want[request.ID] = request++ }++ }++ n.mu.Lock()++ defer n.mu.Unlock()++ for id, a := range n.approvals {++ if a.Plugin == "machine-access" {++ if _, ok := want[id]; !ok {++ delete(n.approvals, id)++ }++ }++ }++ for id, request := range want {++ if _, ok := n.approvals[id]; ok || len(n.approvals) >= 64 {++ continue++ }++ nonce := access.NewTaskID()++ if nonce == "" {++ continue++ }++ title, summary := accessRequestWords(request)++ at, _ := time.Parse(time.RFC3339, request.askedAt())++ if at.IsZero() {++ at = time.Now()++ }++ n.approvals[id] = Approval{ID: nonce, CommandID: id, Plugin: "machine-access", Kind: "Machine access", Title: title, Summary: summary, At: at.UnixMilli()}++ n.appendActivityLocked("Machine access request from " + request.FromPrincipal + " is waiting")++ }++}++++func (n *Agent) answerAccessApproval(a Approval, approved bool) error {++ if n.accessClient == nil {++ return errors.New("machine access unavailable")++ }++ n.mu.Lock()++ current, ok := n.approvals[a.CommandID]++ if !ok || current.ID != a.ID {++ n.mu.Unlock()++ return errors.New("that machine access request no longer waits")++ }++ if approved && n.drivingLocked() {++ n.mu.Unlock()++ return errors.New("the agent is using the mouse or keyboard; answer again in a moment")++ }++ n.mu.Unlock()++ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)++ defer cancel()++ if _, err := n.accessClient.Answer(ctx, a.CommandID, approved, nil, 0); err != nil {++ return err++ }++ n.mu.Lock()++ if current, ok := n.approvals[a.CommandID]; ok && current.ID == a.ID {++ delete(n.approvals, a.CommandID)++ }++ n.mu.Unlock()++ verb := "denied"++ if approved {++ verb = "approved"++ }++ n.addActivity("You " + verb + " machine access request " + a.CommandID)++ return nil++}nnew file mode 100644@@@ -0,0 +1,126 @@++package agent++++import (++ "encoding/json"++ "os"++ "path/filepath"++ "runtime"++ "strings"++ "testing"++++ "github.com/OpenVibers/OpenVibe.Link/internal/access"++ "github.com/OpenVibers/OpenVibe.Link/internal/config"++ "github.com/OpenVibers/OpenVibe.Link/internal/credentials"++ "github.com/OpenVibers/OpenVibe.Link/internal/protocol"++)++++func TestAgentTaskQueueRequiresOwnerSwitch(t *testing.T) {++ inbox, err := access.OpenInbox(filepath.Join(t.TempDir(), "inbox.json"))++ if err != nil {++ t.Fatal(err)++ }++ cfg := config.Default()++ n := &Agent{opt: Options{Config: cfg, Creds: &credentials.Credentials{Principal: "lnk_target", Network: "https://openvibe.network"}}, accessInbox: inbox, localAgents: map[string]localAgent{"codex": {ID: "agt_target", Owner: "usr_owner"}}}++ task := access.Task{ID: "tsk_12345678", To: "agt_target", From: "agt_sender", OnBehalfOf: "usr_owner", Thread: "thr_12345678"}++ task.Message.Parts = append(task.Message.Parts, struct {++ Type string `json:"type"`++ Text string `json:"text"`++ }{Type: "text", Text: "hello"})++ raw, _ := json.Marshal(task)++ frame := protocol.AgentTaskFrame{Task: raw}++ if err := n.receiveAgentTask(frame); err == nil {++ t.Fatal("off switch accepted task")++ }++ cfg.AgentsTalk = true++ if err := n.receiveAgentTask(frame); err != nil {++ t.Fatal(err)++ }++ if len(inbox.List("agt_target")) != 1 {++ t.Fatal("task did not enter inbox")++ }++ frame.Task = json.RawMessage(`{"id":"tsk_12345678","to":"agt_elsewhere","from":"agt_sender","on_behalf_of":"usr_owner","thread":"thr_12345678","message":{"parts":[{"type":"text","text":"hello"}]}}`)++ if err := n.receiveAgentTask(frame); err == nil {++ t.Fatal("task for an unregistered agent accepted")++ }++}++++func TestMachineAccessAnswersKeepGrantTokenPrivate(t *testing.T) {++ raw := json.RawMessage(`{"grant":{"id":"gra_12345678","grant_token":"fake-private-value"},"requests":[{"credential":"fake-private-value","status":"pending"}]}`)++ public := string(publicData(raw))++ if strings.Contains(public, "fake-private-value") || !strings.Contains(public, "gra_12345678") {++ t.Fatalf("unsafe access answer: %s", public)++ }++}++++func TestMachineRelayIDIsSeparateFromBotCommandIDs(t *testing.T) {++ if !validOperationID(access.NewRelayID()) || !validRelayID("rly_01J8Z4M2Q0R7T9YV3K6N8P1W2Z") ||++ validOperationID("cmd_12345678") || validRelayID("mrl_12345678901234567890123456789012") ||++ validRelayID("rly_cmd.12345678") || validOperationID("mrl_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz") {++ t.Fatal("relay id namespace accepted a command or malformed id")++ }++}++++func TestLocalAgentRegistrationIDsSurviveRestart(t *testing.T) {++ dir := t.TempDir()++ items := map[string]localAgent{"codex": {ID: "agt_12345678", Owner: "usr_12345678"}}++ if err := saveLocalAgents(dir, items); err != nil {++ t.Fatal(err)++ }++ info, err := os.Stat(filepath.Join(dir, "agent-directory.json"))++ if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0600) {++ t.Fatalf("directory file permissions: %v %v", info, err)++ }++ if got := loadLocalAgents(dir)["codex"]; got != items["codex"] {++ t.Fatalf("registration id changed: %+v", got)++ }++}++++func TestIncomingAccessRequestReadsBothContractAndFirstNetworkTimes(t *testing.T) {++ first := incomingAccessRequest{FromMachineName: "laptop", Scopes: []string{"screen", "desk"}, DurationSeconds: 3600}++ if err := json.Unmarshal([]byte(`{"asked_at":"2026-11-11T20:00:00Z","pending_until":"2026-11-12T20:00:00Z"}`), &first); err != nil {++ t.Fatal(err)++ }++ if first.askedAt() != "2026-11-11T20:00:00Z" || first.expiresAt() != "2026-11-12T20:00:00Z" {++ t.Fatalf("first Network fields: %+v", first)++ }++ contract := incomingAccessRequest{FromMachineName: "laptop", Scopes: []string{"screen", "desk"}, DurationSeconds: 3600}++ if err := json.Unmarshal([]byte(`{"requested_at":"2026-11-11T20:00:00Z","expires_at":"2026-11-12T20:00:00Z"}`), &contract); err != nil {++ t.Fatal(err)++ }++ if contract.askedAt() != "2026-11-11T20:00:00Z" || contract.expiresAt() != "2026-11-12T20:00:00Z" {++ t.Fatalf("contract fields: %+v", contract)++ }++ title, body := accessRequestWords(contract)++ if title != "laptop asks for access" || body != "Watch the screen and use a private desk, for 1 hour." {++ t.Fatalf("tray words: %q / %q", title, body)++ }++}++++func TestEveryRelayScopeMapsToItsLocalFeature(t *testing.T) {++ valid := map[string][]string{++ "agent": {"task"},++ "send": {"offer"},++ "screen": {protocol.KindScreen, protocol.KindScreenStream},++ "desk": {protocol.KindDesk, protocol.KindDeskStream},++ "computer": {protocol.KindExec, protocol.KindFS, protocol.KindInput, protocol.KindBrowser, protocol.KindProbe},++ "worker": {"run", "status", "cancel"},++ }++ for scope, actions := range valid {++ for _, action := range actions {++ if !validRelayAction(scope, action) {++ t.Errorf("%s/%s refused", scope, action)++ }++ }++ }++ for _, bad := range [][2]string{{"agent", "run"}, {"send", "task"}, {"screen", "exec"}, {"worker", "browse"}, {"admin", "run"}} {++ if validRelayAction(bad[0], bad[1]) {++ t.Errorf("%s/%s accepted", bad[0], bad[1])++ }++ }++ if relayCapability("screen", protocol.KindScreen) != protocol.KindScreen ||++ relayCapability("desk", protocol.KindDesk) != protocol.KindDesk ||++ relayCapability("computer", protocol.KindExec) != protocol.KindExec ||++ relayCapability("worker", "run") != "" {++ t.Fatal("capability mapping")++ }++}@@@ -25,6 +25,7 @@ import ("sync/atomic""time"++ "github.com/OpenVibers/OpenVibe.Link/internal/access""github.com/OpenVibers/OpenVibe.Link/internal/config""github.com/OpenVibers/OpenVibe.Link/internal/connect""github.com/OpenVibers/OpenVibe.Link/internal/credentials"@@@ -37,6 +38,7 @@ import ("github.com/OpenVibers/OpenVibe.Link/internal/plugins""github.com/OpenVibers/OpenVibe.Link/internal/protocol""github.com/OpenVibers/OpenVibe.Link/internal/safety"++ "github.com/OpenVibers/OpenVibe.Link/internal/send""github.com/OpenVibers/OpenVibe.Link/internal/stream""github.com/OpenVibers/OpenVibe.Link/internal/video""github.com/OpenVibers/OpenVibe.Link/internal/worker"@@@ -159,6 +161,18 @@ type Agent struct {codesAgents codesAgentsCache// shares keeps OpenVibe Connect shares up and lets the window add or end one.shares *shareSet++ // accessInbox keeps received agent tasks until answered or 24 hours old.++ accessInbox *access.Inbox++ grantVerifier *access.Verifier++ accessClient *access.Client++ localAgents map[string]localAgent++ relayWaiting map[string]chan relayReply++ relayOpening map[string]chan relayOpened++ relaySessions map[string]*machineRelay++ relaySources map[string]relaySource++ agentResults map[string]chan string++ remoteJobs map[string]string++ grantedSend *send.LAN}// ErrQuit is what Run returns after `openvibe-link quit`: every actuator stopped, nothing latched, the process may exit.@@@ -192,6 +206,7 @@ type Approval struct {binding *plugins.ApprovalRequestPlugin string `json:"plugin"`Kind string `json:"kind,omitempty"`++ Title string `json:"title,omitempty"`Summary string `json:"summary,omitempty"`At int64 `json:"at"`ExpiresS int64 `json:"expires_s,omitempty"`@@@ -213,10 +228,18 @@ func New(opt Options) (*Agent, error) {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, panelClipboard: panelClipboard, panelAutoAccept: panelAutoAccept}++ panelOff: panelOff, panelFirst: panelFirst, panelClipboard: panelClipboard, panelAutoAccept: panelAutoAccept, localAgents: loadLocalAgents(opt.Paths.StateDir), relayWaiting: map[string]chan relayReply{}, relayOpening: map[string]chan relayOpened{}, relaySessions: map[string]*machineRelay{}, relaySources: map[string]relaySource{}, agentResults: map[string]chan string{}, remoteJobs: map[string]string{}}home, _ := os.UserHomeDir()n.codes = loadCodesTasks(opt.Paths.StateDir, home, opt.Log)n.codesAgents.fn = opt.CodesAgents++ n.accessInbox, err = access.OpenInbox(opt.Paths.StateDir + string(os.PathSeparator) + "agent-inbox.json")++ if err != nil {++ return nil, err++ }++ if opt.Creds != nil && opt.Creds.NetworkPaired() {++ n.grantVerifier = access.NewVerifier(opt.Creds.Network, opt.Creds.Principal)++ n.accessClient = &access.Client{Origin: opt.Creds.Network, Tokens: link.NewTokenSourceFor(opt.Creds, nil, "openvibe-link/"+opt.Version, "openvibe.network")}++ }n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)var specs []plugins.Spec@@@ -356,6 +379,14 @@ func (n *Agent) Run(parent context.Context) error {wg.Add(1)go func() { defer wg.Done(); n.fleet.Run(ctx) }()}++ if n.accessClient != nil {++ wg.Add(1)++ go func() { defer wg.Done(); n.pollAccessRequests(ctx) }()++ if n.opt.Config.AgentsTalk {++ wg.Add(1)++ go func() { defer wg.Done(); n.registerLocalAgents(ctx) }()++ }++ }// OpenVibe's own computer pool (docs/party-desktop.md): a machine the owner registered asks Chat what to host.if n.opt.Config.PartyPool.Enabled {wg.Add(1)@@@ -448,6 +479,29 @@ func (n *Agent) Frame(f protocol.Frame) {} else {n.log.Debug("job_exit_ack ignored: no worker", "id", m.ID)}++ case protocol.AgentTaskFrame:++ if err := n.receiveAgentTask(m); err != nil {++ n.log.Warn("agent task refused", "err", err)++ }++ case protocol.AgentTaskResultAck:++ n.agentResultAck(m.TaskID, "")++ case protocol.AccessRevoked:++ n.revokeAccess(m)++ case protocol.RelayOpened:++ // Store the session before the next frame is read: relay.frame may follow++ // on the same socket, and its goroutine must find the session.++ n.receiveRelayOpened(m)++ case protocol.RelayFrame:++ go n.receiveRelayFrame(m)++ case protocol.RelayClosed:++ n.receiveRelayClosed(m)++ case protocol.Error:++ if strings.HasPrefix(m.Code, "bot.relay_") || strings.HasPrefix(m.Code, "bot.agent_task_") {++ n.failRelayWaiters(m.Code + ": " + m.Detail)++ }++ if strings.HasPrefix(m.Code, "bot.agent_task_") {++ n.failAgentResults(m.Code + ": " + m.Detail)++ }}}@@@ -1093,6 +1147,9 @@ func (n *Agent) serverNow(sentMS int64, received mono.Instant) int64 {}func (n *Agent) send(m protocol.Message) {++ if n.relayResult(m) {++ return++ }if n.link == nil {return}@@@ -1617,6 +1674,10 @@ func (n *Agent) Approvals() []Approval {defer n.mu.Unlock()out := make([]Approval, 0, len(n.approvals))for id, a := range n.approvals {++ if a.Plugin == "machine-access" {++ out = append(out, a)++ continue++ }p := n.mgr.Get(a.Plugin)if p == nil || !p.ApprovalValid(a.binding) {delete(n.approvals, id)@@@ -1672,6 +1733,10 @@ func (n *Agent) control(r localctl.Request) localctl.Response {return localctl.Response{Error: err.Error()}}return localctl.Response{OK: true, Data: b}++ case localctl.CmdMCP:++ return n.mcpControl(r.ID, r.Agent, r.Data)++ case localctl.CmdAccess:++ return n.accessControl(r.ID, r.Data)case localctl.CmdApprove, localctl.CmdDeny:if err := n.answerApproval(r.ID, r.Cmd == localctl.CmdApprove); err != nil {return localctl.Response{Error: err.Error()}@@@ -1,6 +1,7 @@package agentimport (++ "errors""sync""github.com/OpenVibers/OpenVibe.Link/internal/protocol"@@@ -78,6 +79,13 @@ func (j *jobs) accepted(id string) bool {return ok}++func (j *jobs) answer(id string) (protocol.Message, bool) {++ j.mu.Lock()++ defer j.mu.Unlock()++ m, ok := j.answers[id]++ return m, ok++}++// job answers a job frame with ack or nack, keyed by the job id, and runs an accepted job. A job the worker holds is// answered from it (an ack while it runs, its job_exit once it ended) and never started twice.func (n *Agent) job(r protocol.JobRequest) {@@@ -104,6 +112,43 @@ func (n *Agent) job(r protocol.JobRequest) {}}++// jobRelayed accepts only a fresh worker job. Its owner is recorded before the++// acknowledgement leaves Link, so status and cancel cannot reach Bot jobs or++// jobs started by another machine.++func (n *Agent) jobRelayed(r protocol.JobRequest, source relaySource) error {++ id := r.Job.ID++ n.mu.Lock()++ _, claimed := n.remoteJobs[id]++ n.mu.Unlock()++ if claimed {++ return errors.New("job id is already in use")++ }++ if n.worker != nil {++ if _, known := n.worker.Known(id); known {++ return errors.New("job id is already in use")++ }++ }++ answer, accepted := n.jobs.request(r)++ if !accepted {++ if nk, ok := answer.(protocol.Nack); ok {++ return errors.New(nk.Message)++ }++ return errors.New("job id is already in use")++ }++ n.mu.Lock()++ n.remoteJobs[id] = source.to++ n.relaySources[id] = source++ n.mu.Unlock()++ n.send(answer)++ if n.codes != nil {++ n.codes.accepted(r.Job)++ }++ if n.worker != nil {++ n.worker.Launch(id)++ }++ return nil++}++// sendJobFrame sends a worker frame; a job_exit also becomes the answer to a resent job frame.func (n *Agent) sendJobFrame(m protocol.Message) {if e, ok := m.(protocol.JobExit); ok {@@@ -8,6 +8,7 @@ import ("testing""time"++ "github.com/OpenVibers/OpenVibe.Link/internal/credentials""github.com/OpenVibers/OpenVibe.Link/internal/protocol")@@@ -18,6 +19,31 @@ func testJob(id string) protocol.Job {Args: json.RawMessage(`{"width":320}`), TTLMS: 60000, Limits: protocol.JobLimits{WallMS: 30000, CPUMS: 30000, MemBytes: 268435456}}}++func TestRelayedWorkerJobBelongsOnlyToRequestingMachine(t *testing.T) {++ n := &Agent{opt: Options{Creds: &credentials.Credentials{Principal: "lnk_receiver"}}, jobs: newJobs([]string{protocol.ClassFunction}),++ remoteJobs: map[string]string{}, relaySources: map[string]relaySource{}}++ first := testJob(jobID(950))++ if err := n.jobRelayed(protocol.JobRequest{Job: first}, relaySource{to: "lnk_sender", scope: "worker", relay: "rly_one", bodyID: "mrl_one"}); err != nil {++ t.Fatal(err)++ }++ if n.remoteJobs[first.ID] != "lnk_sender" {++ t.Fatal("accepted job has no machine owner")++ }++ if err := n.jobRelayed(protocol.JobRequest{Job: first}, relaySource{to: "lnk_other", scope: "worker", relay: "rly_two", bodyID: "mrl_two"}); err == nil {++ t.Fatal("another machine reused the job id")++ }++ botJob := testJob(jobID(951))++ if _, accepted := n.jobs.request(protocol.JobRequest{Job: botJob}); !accepted {++ t.Fatal("could not seed Bot job")++ }++ if err := n.jobRelayed(protocol.JobRequest{Job: botJob}, relaySource{to: "lnk_sender", scope: "worker", relay: "rly_three", bodyID: "mrl_three"}); err == nil {++ t.Fatal("remote machine took a Bot job id")++ }++ if n.remoteJobs[botJob.ID] != "" {++ t.Fatal("Bot job was assigned a machine owner")++ }++}++func (e *env) jobReply(j protocol.Job) protocol.Message {e.t.Helper()if err := e.conn.Job(j); err != nil {nnew file mode 100644@@@ -0,0 +1,249 @@++package agent++++import (++ "context"++ "encoding/json"++ "errors"++ "io"++ "log/slog"++ "os"++ "strings"++ "testing"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/config"++ "github.com/OpenVibers/OpenVibe.Link/internal/fakebot"++ "github.com/OpenVibers/OpenVibe.Link/internal/fakenetwork"++ "github.com/OpenVibers/OpenVibe.Link/internal/link"++ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Link/internal/panel"++ "github.com/OpenVibers/OpenVibe.Link/internal/protocol"++)++++func TestTwoLinksRequestApproveUseRevoke(t *testing.T) {++ bot := fakebot.New()++ t.Cleanup(bot.Close)++ network := fakenetwork.New(bot)++ t.Cleanup(network.Close)++ network.AddCode("ACCESS11")++ const other = "lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2Z"++ network.AddMachine("ACCESS22", other, "dev_net02")++ start := func(code string) (*Agent, config.Paths, context.CancelFunc) {++ t.Helper()++ creds, err := link.PairNetwork(context.Background(), nil, network.URL(), protocol.LinkPairingRequest{Code: code}, "test")++ if err != nil {++ t.Fatal(err)++ }++ creds.Server = bot.URL()++ creds, err = link.Bind(context.Background(), nil, creds, link.NewTokenSource(creds, nil, "test"), "test")++ if err != nil {++ t.Fatal(err)++ }++ home, err := os.MkdirTemp("", "ova")++ if err != nil {++ t.Fatal(err)++ }++ t.Cleanup(func() { _ = os.RemoveAll(home) })++ paths := config.HomePaths(home)++ cfg := config.Default()++ cfg.Plugins = nil++ cfg.AgentsTalk = true++ cfg.Video.Source = "off"++ n, err := New(Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", NoShares: true, HeartbeatInterval: 100 * time.Millisecond, LinkBackoffMin: 50 * time.Millisecond,++ Log: slog.New(slog.NewTextHandler(io.Discard, nil)), CodesAgents: func() []panel.CodesAgent { return []panel.CodesAgent{{ID: "codex", Name: "Codex", State: "ready"}} }})++ if err != nil {++ t.Fatal(err)++ }++ ctx, cancel := context.WithCancel(context.Background())++ go func() { _ = n.Run(ctx) }()++ t.Cleanup(cancel)++ if _, err := bot.NextConn(5 * time.Second); err != nil {++ t.Fatal(err)++ }++ return n, paths, cancel++ }++ a, ap, _ := start("ACCESS11")++ b, bp, _ := start("ACCESS22")++ t.Logf("two Links, two state directories: A=%s B=%s", ap.StateDir, bp.StateDir)++ a.registerLocalAgentsOnce(context.Background())++ b.registerLocalAgentsOnce(context.Background())++ call := func(paths config.Paths, req localctl.Request) (json.RawMessage, error) {++ t.Helper()++ r, err := localctl.Call(paths.SocketPath(), req, 10*time.Second)++ return r.Data, err++ }++ request, err := call(ap, localctl.Request{Cmd: localctl.CmdAccess, ID: "request", Data: json.RawMessage(`{"machine":"` + other + `","scopes":["send"],"duration_seconds":3600,"reason":"test two links"}`)})++ if err != nil {++ t.Fatal(err)++ }++ var requested struct {++ Request struct {++ ID string `json:"id"`++ } `json:"request"`++ }++ if json.Unmarshal(request, &requested) != nil || requested.Request.ID == "" {++ t.Fatalf("request: %s", request)++ }++ t.Logf("A asked B: %s", request)++ b.refreshAccessRequests(context.Background())++ var approval Approval++ for _, item := range b.Approvals() {++ if item.Plugin == "machine-access" && item.CommandID == requested.Request.ID {++ approval = item++ break++ }++ }++ if approval.ID == "" {++ t.Fatal("target Link did not show tray approval")++ }++ if approval.Title != "Alex's laptop asks for access" || approval.Summary != `Send you files, for 1 hour. "test two links"` {++ t.Fatalf("tray words: %q / %q", approval.Title, approval.Summary)++ }++ t.Logf("B's tray: %q / %q (approval %s)", approval.Title, approval.Summary, approval.ID)++ if _, err = call(bp, localctl.Request{Cmd: localctl.CmdApprove, ID: approval.ID}); err != nil {++ t.Fatal(err)++ }++ t.Log("B's owner clicked Allow")++ use := json.RawMessage(`{"machine":"` + other + `","scope":"send","action":"offer","data":{"envelope":"sealed-test-item"}}`)++ result, err := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "machine_use", Data: use})++ if err != nil {++ t.Fatal(err)++ }++ if !strings.Contains(string(result), `"scope":"send"`) {++ t.Fatalf("machine_use: %s", result)++ }++ t.Logf("A used send: %s", result)++ b.mu.Lock()++ lan := b.grantedSend++ b.mu.Unlock()++ if lan == nil || len(lan.Items()) != 1 || lan.Items()[0].Sender != fakenetwork.Principal {++ t.Fatal("sealed item did not reach the target inbox")++ }++ grants, err := a.accessClient.Grants(context.Background())++ if err != nil {++ t.Fatal(err)++ }++ var list struct {++ Grants []struct {++ ID string `json:"id"`++ } `json:"grants"`++ }++ if json.Unmarshal(grants, &list) != nil || len(list.Grants) != 1 {++ t.Fatal("grant missing")++ }++ t.Logf("A holds grant %s", list.Grants[0].ID)++ if _, err = call(bp, localctl.Request{Cmd: localctl.CmdAccess, ID: "revoke", Data: json.RawMessage(`{"id":"` + list.Grants[0].ID + `"}`)}); err != nil {++ t.Fatal(err)++ }++ t.Log("B revoked the grant")++ if _, err = call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "machine_use", Data: use}); err == nil || !strings.Contains(err.Error(), "no active grant") {++ t.Fatalf("revoke did not stop use: %v", err)++ }++ t.Logf("A's next use was refused: %v", err)++ if listed, listErr := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "agents_list", Agent: "codex", Data: json.RawMessage(`{}`)}); listErr != nil || !strings.Contains(string(listed), "Codex") {++ t.Fatalf("agents_list: %s %v", listed, listErr)++ } else {++ t.Logf("agents_list: %s", listed)++ }++ if listed, listErr := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "machines_list", Agent: "codex", Data: json.RawMessage(`{}`)}); listErr != nil || !strings.Contains(string(listed), other) {++ t.Fatalf("machines_list: %s %v", listed, listErr)++ } else {++ t.Logf("machines_list: %s", listed)++ }++ b.mu.Lock()++ remoteAgent := b.localAgents["codex"].ID++ b.mu.Unlock()++ if remoteAgent == "" {++ t.Fatal("local CLI did not register")++ }++ if told, tellErr := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_tell", Agent: "codex", Data: json.RawMessage(`{"agent":"` + remoteAgent + `","text":"hello"}`)}); tellErr != nil {++ t.Fatal(tellErr)++ } else {++ t.Logf("agent_tell: %s", told)++ }++ var inbox json.RawMessage++ for until := time.Now().Add(2 * time.Second); time.Now().Before(until); time.Sleep(20 * time.Millisecond) {++ inbox, err = call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_inbox", Agent: "codex", Data: json.RawMessage(`{}`)})++ if err == nil && strings.Contains(string(inbox), "hello") {++ break++ }++ }++ if !strings.Contains(string(inbox), "hello") {++ t.Fatalf("agent task not in inbox: %s %v", inbox, err)++ }++ t.Logf("agent_inbox: %s", inbox)++ var found struct {++ Tasks []struct {++ Task struct {++ ID string `json:"id"`++ } `json:"task"`++ } `json:"tasks"`++ }++ if json.Unmarshal(inbox, &found) != nil || len(found.Tasks) != 1 {++ t.Fatalf("inbox: %s", inbox)++ }++ if replied, replyErr := call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_reply", Agent: "codex", Data: json.RawMessage(`{"task":"` + found.Tasks[0].Task.ID + `","text":"received"}`)}); replyErr != nil {++ t.Fatal(replyErr)++ } else {++ t.Logf("agent_reply: %s", replied)++ }++ if _, err = call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_inbox", Agent: "unregistered", Data: json.RawMessage(`{}`)}); err == nil {++ t.Fatal("unregistered CLI read inbox")++ }++ type askResult struct {++ answer json.RawMessage++ err error++ }++ askDone := make(chan askResult, 1)++ go func() {++ answer, askErr := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_ask", Agent: "codex", Data: json.RawMessage(`{"agent":"` + remoteAgent + `","text":"status?","wait_seconds":4}`)})++ if askErr == nil && !strings.Contains(string(answer), "all clear") {++ askErr = errors.New("agent_ask did not return the reply")++ }++ askDone <- askResult{answer, askErr}++ }()++ var nextID string++ for until := time.Now().Add(3 * time.Second); time.Now().Before(until); time.Sleep(20 * time.Millisecond) {++ raw, readErr := call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_inbox", Agent: "codex", Data: json.RawMessage(`{}`)})++ if readErr != nil {++ t.Fatal(readErr)++ }++ var entries struct {++ Tasks []struct {++ Status string `json:"status"`++ Task struct {++ ID string `json:"id"`++ } `json:"task"`++ } `json:"tasks"`++ }++ if json.Unmarshal(raw, &entries) != nil {++ t.Fatal("invalid inbox")++ }++ for _, entry := range entries.Tasks {++ if entry.Status == "pending" {++ nextID = entry.Task.ID++ }++ }++ if nextID != "" {++ break++ }++ }++ if nextID == "" {++ t.Fatal("agent_ask task did not arrive")++ }++ if _, err = call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_reply", Agent: "codex", Data: json.RawMessage(`{"task":"` + nextID + `","text":"all clear"}`)}); err != nil {++ t.Fatal(err)++ }++ if asked := <-askDone; asked.err != nil {++ t.Fatal(asked.err)++ } else {++ t.Logf("agent_ask: %s", asked.answer)++ }++ requestedByAgent, err := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "machine_request_access", Agent: "codex", Data: json.RawMessage(`{"machine":"` + other + `","scopes":["worker"],"for_seconds":3600}`)})++ if err != nil || !strings.Contains(string(requestedByAgent), `"from_agent"`) {++ t.Fatalf("machine_request_access: %s %v", requestedByAgent, err)++ }++ t.Logf("machine_request_access: %s", requestedByAgent)++ t.Log("request -> target tray approval -> granted send use -> revoke -> use refused")++ t.Log("agents_list -> agent_tell -> agent_inbox -> agent_reply -> agent_ask; machine_request_access; unregistered inbox refused")++}@@@ -17,6 +17,7 @@ import ("strings""time"++ "github.com/OpenVibers/OpenVibe.Link/internal/access""github.com/OpenVibers/OpenVibe.Link/internal/firstrun""github.com/OpenVibers/OpenVibe.Link/internal/link""github.com/OpenVibers/OpenVibe.Link/internal/panel"@@@ -97,6 +98,23 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {// 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)++ lan.SetGrantCheck(func(ctx context.Context, token, sender string) error {++ if n.grantVerifier == nil || n.accessClient == nil {++ return access.ErrGrant++ }++ claims, err := n.grantVerifier.Verify(ctx, token, sender, "send")++ if err != nil {++ return access.ErrGrant++ }++ grants, err := n.accessClient.Grants(ctx)++ if err != nil || !grantStillActive(grants, claims.ID, sender, n.opt.Creds.Principal, "send") {++ return access.ErrGrant++ }++ return nil++ })++ n.mu.Lock()++ n.grantedSend = lan++ n.mu.Unlock()if err := lan.Listen(""); err != nil {n.log.Warn("Link Send stays on the relay: no local listening port", "err", err)} else {@@@ -702,6 +720,14 @@ func (n *Agent) panelStop(id string) error {// answerApproval lets one waiting request run or refuses it; the tray, the CLI and the window all end here.func (n *Agent) answerApproval(nonce string, approved bool) error {++ n.mu.Lock()++ for _, a := range n.approvals {++ if a.ID == nonce && a.Plugin == "machine-access" {++ n.mu.Unlock()++ return n.answerAccessApproval(a, approved)++ }++ }++ n.mu.Unlock()n.mu.Lock()var a Approvalok := false@@@ -123,6 +123,9 @@ type Config struct {// PartyPool offers this machine to OpenVibe's own computer pool for openvibe.party (docs/party-desktop.md).// Off unless enabled. The machine still has to be registered by the owner on openvibe.party/pool.PartyPool PartyPoolConfig `json:"party_pool,omitzero"`++ // AgentsTalk lets this machine register its local CLIs and receive agent tasks.++ // The owner must turn it on explicitly; the default is false.++ AgentsTalk bool `json:"agents_talk,omitempty"`}// PartyPoolConfig is this machine's part in OpenVibe's own computer pool: the Link asks Chat, as this machine, what@@@ -13,6 +13,7 @@package fakebotimport (++ "context""crypto/rand""encoding/hex""encoding/json"@@@ -26,6 +27,7 @@ import ("github.com/gorilla/websocket"++ "github.com/OpenVibers/OpenVibe.Link/internal/access""github.com/OpenVibers/OpenVibe.Link/internal/protocol")@@@ -46,17 +48,23 @@ type Server struct {// no measured round trip, so no bound on its clock estimate.HoldHeartbeatAcks bool-- mu sync.Mutex-- codes map[string]string // code → robot id-- creds map[string]string // credential → device id-- robots map[string]string // device id → robot id-- live map[string]*Conn // device id → its current connection-- estop map[string]bool // robot id → the robot's e-stop latch, as Bot keeps it-- pairRequests []protocol.PairRequest-- pairRefusal *refusal-- bindRefusals []refusal-- credInURL bool-- binds atomic.Int64++ mu sync.Mutex++ codes map[string]string // code → robot id++ creds map[string]string // credential → device id++ robots map[string]string // device id → robot id++ live map[string]*Conn // device id → its current connection++ machineDevices map[string]string // node principal → device id++ deviceMachines map[string]string // device id → node principal++ relays map[string]*fakeRelay++ relayOrigin string++ relayAllowed func(string, string, string, string) bool++ agentResult func(string, string, json.RawMessage)++ estop map[string]bool // robot id → the robot's e-stop latch, as Bot keeps it++ pairRequests []protocol.PairRequest++ pairRefusal *refusal++ bindRefusals []refusal++ credInURL bool++ binds atomic.Int64conns chan *Conncount atomic.Int64@@@ -69,6 +77,13 @@ type refusal struct {code, detail string}++type fakeRelay struct {++ ID, GrantID, From, To, Token string++ Scopes map[string]bool++ Expires time.Time++ FromConn, ToConn *Conn++}++// RobotID is the robot AddCode and AddCredential attach devices to.const RobotID = "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"@@@ -77,7 +92,7 @@ func (s *Server) SetHealth(status int) { s.health.Store(int64(status)) }// New starts the fake server.func New() *Server {-- s := &Server{codes: map[string]string{}, creds: map[string]string{}, robots: map[string]string{}, live: map[string]*Conn{}}++ s := &Server{codes: map[string]string{}, creds: map[string]string{}, robots: map[string]string{}, live: map[string]*Conn{}, machineDevices: map[string]string{}, deviceMachines: map[string]string{}, relays: map[string]*fakeRelay{}}s.health.Store(http.StatusOK)s.estop = map[string]bool{}s.conns = make(chan *Conn, 16)@@@ -134,6 +149,157 @@ func (s *Server) AddCredential(cred, deviceID string) {s.mu.Unlock()}++// SetRelayAuthority gives the fake Bot Network's JWKS and live grant lookup.++func (s *Server) SetRelayAuthority(origin string, allowed func(string, string, string, string) bool) {++ s.mu.Lock()++ s.relayOrigin, s.relayAllowed = origin, allowed++ s.mu.Unlock()++}++++func (s *Server) BindMachine(principal, deviceID string) {++ s.mu.Lock()++ s.machineDevices[principal] = deviceID++ s.deviceMachines[deviceID] = principal++ s.mu.Unlock()++}++++func (s *Server) RevokeGrant(id, to string) {++ s.mu.Lock()++ closed := make([]*fakeRelay, 0)++ for _, relay := range s.relays {++ if relay.GrantID == id {++ delete(s.relays, relay.ID)++ closed = append(closed, relay)++ }++ }++ s.mu.Unlock()++ for _, relay := range closed {++ for _, c := range []*Conn{relay.FromConn, relay.ToConn} {++ if c != nil {++ _ = c.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "revoked"})++ }++ }++ }++ if c := s.live[s.machineDevices[to]]; c != nil {++ _ = c.Send(protocol.AccessRevoked{GrantID: id})++ }++}++++// SetAgentResult receives agent.task_result from the machine an agent.task was delivered to.++func (s *Server) SetAgentResult(fn func(string, string, json.RawMessage)) {++ s.mu.Lock()++ s.agentResult = fn++ s.mu.Unlock()++}++++func (s *Server) DeliverAgentTask(to string, task protocol.AgentTaskFrame) bool {++ s.mu.Lock()++ c := s.live[s.machineDevices[to]]++ s.mu.Unlock()++ return c != nil && c.Send(protocol.AgentTaskFrame{Task: task.Task}) == nil++}++++func relayError(c *Conn, code, detail string) {++ _ = c.Send(protocol.Error{Code: code, Detail: detail})++}++++// relayFrame is Bot's machine-to-machine relay and agent task result path++// (server/realtime.js): relay.open checks the grant token, relay.frame carries++// one operation or answer, relay.close ends the session.++func (s *Server) relayFrame(c *Conn, m protocol.Message) {++ switch frame := m.(type) {++ case protocol.RelayOpen:++ s.mu.Lock()++ from := s.deviceMachines[c.DeviceID]++ target := s.live[s.machineDevices[frame.To]]++ origin, allowed := s.relayOrigin, s.relayAllowed++ s.mu.Unlock()++ if from == "" || target == nil || origin == "" || allowed == nil || !strings.HasPrefix(frame.To, "lnk_") || frame.To == from {++ relayError(c, "bot.relay_target_offline", "the receiving machine is not connected")++ return++ }++ claims, err := access.NewVerifier(origin, frame.To).VerifyAny(context.Background(), frame.GrantToken, from)++ if err != nil || claims.From != from || claims.To != frame.To {++ relayError(c, "bot.relay_grant_denied", "no live Network grant lets this machine relay to that one")++ return++ }++ scopes := map[string]bool{}++ for _, scope := range claims.Scopes {++ if !access.ValidScope(scope) || !allowed(claims.ID, from, frame.To, scope) {++ relayError(c, "bot.relay_grant_denied", "no live Network grant lets this machine relay to that one")++ return++ }++ scopes[scope] = true++ }++ id := "rly_" + token()[:26]++ relay := &fakeRelay{ID: id, GrantID: claims.ID, From: from, To: frame.To, Token: frame.GrantToken,++ Scopes: scopes, Expires: time.Unix(claims.Expiry, 0), FromConn: c, ToConn: target}++ s.mu.Lock()++ s.relays[id] = relay++ s.mu.Unlock()++ opened := protocol.RelayOpened{Relay: id, To: frame.To, GrantToken: frame.GrantToken, Scopes: claims.Scopes,++ ExpiresAt: time.Unix(claims.Expiry, 0).UTC().Format(time.RFC3339), From: from}++ _ = c.Send(opened)++ _ = target.Send(opened)++ case protocol.RelayFrame:++ s.mu.Lock()++ relay := s.relays[frame.Relay]++ var other *Conn++ if relay != nil {++ switch c {++ case relay.FromConn:++ other = relay.ToConn++ case relay.ToConn:++ other = relay.FromConn++ default:++ relay = nil++ }++ }++ s.mu.Unlock()++ if relay == nil || !relay.Scopes[frame.Scope] || len(frame.Body) == 0 || len(frame.Body) > 250<<10 {++ relayError(c, "bot.relay_scope_denied", "the relay is closed or the grant does not include that scope")++ return++ }++ if time.Now().After(relay.Expires) {++ s.mu.Lock()++ delete(s.relays, relay.ID)++ s.mu.Unlock()++ _ = c.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "expired"})++ if other != nil {++ _ = other.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "expired"})++ }++ relayError(c, "bot.relay_expired", "the grant token expired")++ return++ }++ from := relay.From++ if c == relay.ToConn {++ from = relay.To++ }++ _ = other.Send(protocol.RelayFrame{Relay: relay.ID, From: from, Scope: frame.Scope, Body: frame.Body})++ case protocol.RelayClose:++ s.mu.Lock()++ relay := s.relays[frame.Relay]++ if relay != nil {++ delete(s.relays, frame.Relay)++ }++ s.mu.Unlock()++ if relay == nil {++ relayError(c, "bot.relay_unknown", "the relay is already closed")++ return++ }++ _ = relay.FromConn.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "closed"})++ _ = relay.ToConn.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "closed"})++ case protocol.AgentTaskResult:++ s.mu.Lock()++ fn := s.agentResult++ s.mu.Unlock()++ if fn != nil {++ fn(frame.TaskID, frame.Status, frame.Result)++ }++ _ = c.Send(protocol.AgentTaskResultAck{TaskID: frame.TaskID, Status: frame.Status})++ }++}++// Revoke invalidates a credential and closes the device's live connection with 4003, as Bot's revoke does.func (s *Server) Revoke(cred string) {s.mu.Lock()@@@ -540,6 +706,9 @@ func (c *Conn) read() {}_ = c.Send(ack)}++ if c.srv != nil {++ c.srv.relayFrame(c, f.Msg)++ }c.fmu.Lock()c.frames = append(c.frames, f)c.fmu.Unlock()nnew file mode 100644@@@ -0,0 +1,347 @@++package fakenetwork++++import (++ "crypto"++ "crypto/rand"++ "crypto/rsa"++ "crypto/sha256"++ "encoding/base64"++ "encoding/json"++ "net/http"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/access"++)++++const fakeOwner = "usr_01J8Z4M2Q0R7T9YV3K6N8P1W2X"++++type fakeAccessRequest struct {++ ID string `json:"id"`++ FromPrincipal string `json:"from_principal"`++ ToPrincipal string `json:"to_principal"`++ FromOwner string `json:"from_owner"`++ ToOwner string `json:"to_owner"`++ FromAgent string `json:"from_agent,omitempty"`++ FromMachineName string `json:"from_machine_name,omitempty"`++ FromOwnerName string `json:"from_owner_name,omitempty"`++ FromAgentName string `json:"from_agent_name,omitempty"`++ Scopes []string `json:"scopes"`++ Reason string `json:"reason,omitempty"`++ Status string `json:"status"`++ RequestedAt string `json:"asked_at"`++ ExpiresAt string `json:"pending_until"`++ DurationSeconds int64 `json:"duration_seconds"`++}++++type fakeAccessGrant struct {++ ID string `json:"id"`++ RequestID string `json:"request_id"`++ FromPrincipal string `json:"from_principal"`++ ToPrincipal string `json:"to_principal"`++ Scopes []string `json:"scopes"`++ ApprovedBy string `json:"approved_by"`++ CreatedAt string `json:"created_at"`++ ExpiresAt *string `json:"expires_at"`++ RevokedAt *string `json:"revoked_at"`++}++++// GrantActive is fake Bot's live Network authorization on each relayed frame.++func (s *Server) GrantActive(id, from, to, scope string) bool {++ s.mu.Lock()++ defer s.mu.Unlock()++ g := s.grants[id]++ if g == nil || g.FromPrincipal != from || g.ToPrincipal != to || g.RevokedAt != nil {++ return false++ }++ if g.ExpiresAt != nil {++ at, err := time.Parse(time.RFC3339, *g.ExpiresAt)++ if err != nil || !at.After(time.Now()) {++ return false++ }++ }++ for _, v := range g.Scopes {++ if v == scope {++ return true++ }++ }++ return false++}++++func (s *Server) registerAccessRoutes(mux *http.ServeMux) {++ mux.HandleFunc("/api/.well-known/jwks", s.jwks)++ mux.HandleFunc("/api/v1/link/self/access-requests", s.accessRequests)++ mux.HandleFunc("/api/v1/link/self/access-requests/", s.accessRequestAction)++ mux.HandleFunc("/api/v1/link/self/access-grants", s.accessGrants)++ mux.HandleFunc("/api/v1/link/self/access-grants/", s.accessGrantAction)++ mux.HandleFunc("/api/v1/link/self/send/targets", s.accessMachines)++}++++func (s *Server) accessPrincipal(r *http.Request) string {++ token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")++ s.mu.Lock()++ defer s.mu.Unlock()++ if s.tokenAudience[token] != "openvibe.network" {++ return ""++ }++ return s.tokenOwners[token]++}++++func (s *Server) jwks(w http.ResponseWriter, r *http.Request) {++ if r.Method != http.MethodGet {++ w.WriteHeader(405)++ return++ }++ if s.signing == nil {++ w.WriteHeader(503)++ return++ }++ pub := s.signing.PublicKey++ w.Header().Set("Content-Type", "application/json")++ _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{++ "kid": "fake-network-1", "kty": "RSA", "alg": "RS256", "use": "sig",++ "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),++ "e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),++ }}})++}++++func (s *Server) accessRequests(w http.ResponseWriter, r *http.Request) {++ principal := s.accessPrincipal(r)++ if principal == "" {++ problem(w, 401, "access.node_required")++ return++ }++ switch r.Method {++ case http.MethodGet:++ s.mu.Lock()++ list := []fakeAccessRequest{}++ for _, v := range s.requests {++ if v.FromPrincipal == principal || v.ToPrincipal == principal {++ list = append(list, *v)++ }++ }++ s.mu.Unlock()++ _ = json.NewEncoder(w).Encode(map[string]any{"requests": list})++ case http.MethodPost:++ var body struct {++ Target string `json:"target"`++ Scopes []string `json:"scopes"`++ Duration int64 `json:"duration_seconds"`++ Reason string `json:"reason"`++ FromAgent string `json:"from_agent"`++ }++ if json.NewDecoder(r.Body).Decode(&body) != nil || body.Target == principal || body.Duration < 0 || body.Duration > 30*86400 || len(body.Scopes) == 0 {++ problem(w, 400, "access.invalid_request")++ return++ }++ for _, scope := range body.Scopes {++ if !access.ValidScope(scope) {++ problem(w, 400, "access.invalid_scope")++ return++ }++ if body.Duration == 0 && scope != "agent" && scope != "send" {++ problem(w, 400, "access.forever_scope")++ return++ }++ }++ s.mu.Lock()++ _, ok := s.machines[body.Target]++ if !ok {++ s.mu.Unlock()++ problem(w, 404, "access.machine_not_found")++ return++ }++ now := time.Now().UTC()++ req := &fakeAccessRequest{ID: "req_" + random()[:16], FromPrincipal: principal, ToPrincipal: body.Target,++ FromOwner: fakeOwner, ToOwner: fakeOwner, FromAgent: body.FromAgent, Scopes: body.Scopes, Reason: body.Reason, Status: "pending",++ FromMachineName: "laptop", FromOwnerName: "Alex",++ RequestedAt: now.Format(time.RFC3339), ExpiresAt: now.Add(24 * time.Hour).Format(time.RFC3339), DurationSeconds: body.Duration}++ s.requests[req.ID] = req++ s.mu.Unlock()++ w.WriteHeader(201)++ _ = json.NewEncoder(w).Encode(map[string]any{"request": req})++ default:++ w.WriteHeader(405)++ }++}++++func (s *Server) accessRequestAction(w http.ResponseWriter, r *http.Request) {++ principal := s.accessPrincipal(r)++ if principal == "" {++ problem(w, 401, "access.node_required")++ return++ }++ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/link/self/access-requests/"), "/")++ if r.Method != http.MethodPost || len(parts) != 2 || parts[1] != "answer" {++ w.WriteHeader(404)++ return++ }++ var body struct {++ Approved bool `json:"approve"`++ Scopes []string `json:"scopes"`++ Duration int64 `json:"duration_seconds"`++ }++ if json.NewDecoder(r.Body).Decode(&body) != nil {++ problem(w, 400, "access.invalid_answer")++ return++ }++ s.mu.Lock()++ defer s.mu.Unlock()++ req := s.requests[parts[0]]++ if req == nil || req.ToPrincipal != principal || req.Status != "pending" {++ problem(w, 403, "access.not_pending")++ return++ }++ if !body.Approved {++ req.Status = "denied"++ _ = json.NewEncoder(w).Encode(req)++ return++ }++ if len(body.Scopes) == 0 {++ body.Scopes = req.Scopes++ }++ allowed := map[string]bool{}++ for _, scope := range req.Scopes {++ allowed[scope] = true++ }++ for _, scope := range body.Scopes {++ if !allowed[scope] {++ problem(w, 400, "access.cannot_widen")++ return++ }++ }++ if body.Duration == 0 {++ body.Duration = req.DurationSeconds++ }++ if body.Duration > 30*86400 || (req.DurationSeconds > 0 && body.Duration > req.DurationSeconds) {++ problem(w, 400, "access.cannot_extend")++ return++ }++ req.Status = "approved"++ now := time.Now().UTC()++ var expiry *string++ if body.Duration > 0 {++ value := now.Add(time.Duration(body.Duration) * time.Second).Format(time.RFC3339)++ expiry = &value++ }++ grant := &fakeAccessGrant{ID: "grt_" + random()[:16], RequestID: req.ID, FromPrincipal: req.FromPrincipal, ToPrincipal: req.ToPrincipal,++ Scopes: body.Scopes, ApprovedBy: fakeOwner, CreatedAt: now.Format(time.RFC3339), ExpiresAt: expiry}++ s.grants[grant.ID] = grant++ _ = json.NewEncoder(w).Encode(map[string]any{"request": req, "grant": grant})++}++++func (s *Server) accessGrants(w http.ResponseWriter, r *http.Request) {++ principal := s.accessPrincipal(r)++ if principal == "" {++ problem(w, 401, "access.node_required")++ return++ }++ if r.Method != http.MethodGet {++ w.WriteHeader(405)++ return++ }++ s.mu.Lock()++ list := []map[string]any{}++ for _, g := range s.grants {++ if g.FromPrincipal != principal && g.ToPrincipal != principal {++ continue++ }++ raw, _ := json.Marshal(g)++ var row map[string]any++ _ = json.Unmarshal(raw, &row)++ list = append(list, row)++ }++ s.mu.Unlock()++ _ = json.NewEncoder(w).Encode(map[string]any{"grants": list})++}++++func (s *Server) grantToken(g *fakeAccessGrant) string {++ if s.signing == nil {++ return ""++ }++ until := time.Now().Add(time.Hour).Unix()++ if g.ExpiresAt != nil {++ if t, err := time.Parse(time.RFC3339, *g.ExpiresAt); err == nil {++ until = t.Unix()++ }++ }++ c := access.Claims{ID: g.ID, JTI: "jti_" + random()[:16], Issuer: s.URL(), From: g.FromPrincipal, To: g.ToPrincipal, Scopes: g.Scopes, Issued: time.Now().Unix(), Expiry: until}++ h, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "fake-network-1", "typ": "JWT"})++ p, _ := json.Marshal(c)++ message := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p)++ hash := sha256.Sum256([]byte(message))++ sig, err := rsa.SignPKCS1v15(rand.Reader, s.signing, crypto.SHA256, hash[:])++ if err != nil {++ return ""++ }++ return message + "." + base64.RawURLEncoding.EncodeToString(sig)++}++++func (s *Server) accessGrantAction(w http.ResponseWriter, r *http.Request) {++ principal := s.accessPrincipal(r)++ if principal == "" {++ problem(w, 401, "access.node_required")++ return++ }++ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/link/self/access-grants/"), "/")++ if r.Method != http.MethodPost || len(parts) != 2 || (parts[1] != "revoke" && parts[1] != "token") {++ w.WriteHeader(404)++ return++ }++ if parts[1] == "token" {++ s.mu.Lock()++ g := s.grants[parts[0]]++ if g == nil || g.FromPrincipal != principal || !s.GrantActiveLocked(g) {++ s.mu.Unlock()++ problem(w, 403, "access.grant_not_found")++ return++ }++ token := s.grantToken(g)++ copy := *g++ s.mu.Unlock()++ _ = json.NewEncoder(w).Encode(map[string]any{"grant": copy, "grant_token": token})++ return++ }++ s.mu.Lock()++ g := s.grants[parts[0]]++ if g == nil || (g.FromPrincipal != principal && g.ToPrincipal != principal) {++ s.mu.Unlock()++ problem(w, 403, "access.not_allowed")++ return++ }++ now := time.Now().UTC().Format(time.RFC3339)++ g.RevokedAt = &now++ id, to := g.ID, g.ToPrincipal++ s.mu.Unlock()++ if s.bot != nil {++ s.bot.RevokeGrant(id, to)++ }++ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "status": "revoked"})++}++++func (s *Server) GrantActiveLocked(g *fakeAccessGrant) bool {++ if g.RevokedAt != nil {++ return false++ }++ if g.ExpiresAt != nil {++ at, err := time.Parse(time.RFC3339, *g.ExpiresAt)++ if err != nil || !at.After(time.Now()) {++ return false++ }++ }++ return true++}++++func (s *Server) accessMachines(w http.ResponseWriter, r *http.Request) {++ if s.accessPrincipal(r) == "" {++ problem(w, 401, "access.node_required")++ return++ }++ s.mu.Lock()++ list := []map[string]any{}++ for _, m := range s.machines {++ list = append(list, map[string]any{"id": m.Principal, "name": m.Principal})++ }++ s.mu.Unlock()++ _ = json.NewEncoder(w).Encode(map[string]any{"machines": list})++}nnew file mode 100644@@@ -0,0 +1,171 @@++package fakenetwork++++import (++ "encoding/json"++ "net/http"++ "strings"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/access"++ "github.com/OpenVibers/OpenVibe.Link/internal/protocol"++)++++type fakeAgent struct {++ ID string `json:"id"`++ Kind string `json:"kind"`++ LocalID string `json:"local_id"`++ Name string `json:"name"`++ MachineID string `json:"machine_id"`++ Owner string `json:"owner"`++ Status string `json:"status"`++}++++type fakeAgentTask struct {++ ID string `json:"id"`++ To string `json:"to"`++ From string `json:"from"`++ OnBehalfOf string `json:"on_behalf_of"`++ Thread string `json:"thread"`++ Message json.RawMessage `json:"message"`++ CreatedAt string `json:"created_at"`++ Status string `json:"status"`++ Result json.RawMessage `json:"result,omitempty"`++ fromMachine string++ toMachine string++}++++func (s *Server) registerAgentRoutes(mux *http.ServeMux) {++ mux.HandleFunc("/api/v1/agents/register", s.agentRegister)++ mux.HandleFunc("/api/v1/link/self/agents", s.agentList)++ mux.HandleFunc("/api/v1/agents/", s.agentRoute)++}++++// AgentResult is Bot's report of an agent.task_result from the receiving machine.++func (s *Server) AgentResult(taskID, status string, result json.RawMessage) {++ s.mu.Lock()++ defer s.mu.Unlock()++ task := s.agentTasks[taskID]++ if task == nil {++ return++ }++ task.Status = status++ if len(result) > 0 {++ task.Result = result++ }++}++++func (s *Server) agentRegister(w http.ResponseWriter, r *http.Request) {++ principal := s.accessPrincipal(r)++ if principal == "" || r.Method != http.MethodPost {++ problem(w, 403, "agents.node_required")++ return++ }++ var body fakeAgent++ if json.NewDecoder(r.Body).Decode(&body) != nil || body.Kind != "link-local" || body.LocalID == "" || body.Name == "" || body.MachineID != principal {++ problem(w, 400, "agents.invalid_registration")++ return++ }++ s.mu.Lock()++ for _, a := range s.agents {++ if a.MachineID == principal && a.LocalID == body.LocalID {++ a.Status = body.Status++ result := *a++ s.mu.Unlock()++ _ = json.NewEncoder(w).Encode(result)++ return++ }++ }++ created := body++ created.ID = "agt_" + random()[:16]++ created.Owner = fakeOwner++ s.agents[created.ID] = &created++ s.mu.Unlock()++ w.WriteHeader(201)++ _ = json.NewEncoder(w).Encode(created)++}++++func (s *Server) agentList(w http.ResponseWriter, r *http.Request) {++ if s.accessPrincipal(r) == "" || r.Method != http.MethodGet {++ problem(w, 403, "agents.node_required")++ return++ }++ s.mu.Lock()++ list := make([]fakeAgent, 0, len(s.agents))++ for _, a := range s.agents {++ list = append(list, *a)++ }++ s.mu.Unlock()++ _ = json.NewEncoder(w).Encode(map[string]any{"agents": list})++}++++func (s *Server) agentRoute(w http.ResponseWriter, r *http.Request) {++ principal := s.accessPrincipal(r)++ if principal == "" {++ problem(w, 403, "agents.node_required")++ return++ }++ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/agents/"), "/")++ if len(parts) == 2 && parts[1] == "tasks" && r.Method == http.MethodPost {++ var body struct {++ From, OnBehalfOf, Thread string++ Message json.RawMessage++ }++ if json.NewDecoder(r.Body).Decode(&body) != nil || body.From == "" || !strings.HasPrefix(body.Thread, "thr_") || len(body.Message) == 0 {++ problem(w, 400, "agents.invalid_task")++ return++ }++ s.mu.Lock()++ dest := s.agents[parts[0]]++ sender := s.agents[body.From]++ if dest == nil || sender == nil || sender.MachineID != principal || dest.Status == "offline" {++ s.mu.Unlock()++ problem(w, 403, "agents.not_allowed")++ return++ }++ task := &fakeAgentTask{ID: access.NewTaskID(), To: dest.ID, From: sender.ID, OnBehalfOf: fakeOwner, Thread: body.Thread, Message: body.Message, CreatedAt: time.Now().UTC().Format(time.RFC3339), Status: "pending", fromMachine: principal, toMachine: dest.MachineID}++ s.agentTasks[task.ID] = task++ s.mu.Unlock()++ payload, _ := json.Marshal(task)++ if s.bot == nil || !s.bot.DeliverAgentTask(dest.MachineID, protocol.AgentTaskFrame{Task: payload}) {++ problem(w, 503, "agents.target_offline")++ return++ }++ w.WriteHeader(201)++ _ = json.NewEncoder(w).Encode(map[string]any{"task": task, "delivery": map[string]any{"delivered": true}})++ return++ }++ if len(parts) >= 2 && parts[0] == "tasks" {++ s.mu.Lock()++ task := s.agentTasks[parts[1]]++ if task == nil || principal != task.fromMachine && principal != task.toMachine {++ s.mu.Unlock()++ problem(w, 404, "agents.task_missing")++ return++ }++ if len(parts) == 2 && r.Method == http.MethodGet {++ copy := *task++ s.mu.Unlock()++ _ = json.NewEncoder(w).Encode(map[string]any{"task": copy})++ return++ }++ if len(parts) == 3 && parts[2] == "result" && r.Method == http.MethodPost && principal == task.toMachine {++ var body struct {++ Status string `json:"status"`++ Result json.RawMessage `json:"result"`++ }++ if json.NewDecoder(r.Body).Decode(&body) != nil || body.Status != "completed" || len(body.Result) == 0 {++ s.mu.Unlock()++ problem(w, 400, "agents.invalid_result")++ return++ }++ task.Status = "completed"++ task.Result = body.Result++ copy := *task++ s.mu.Unlock()++ _ = json.NewEncoder(w).Encode(map[string]any{"task": copy})++ return++ }++ s.mu.Unlock()++ }++ w.WriteHeader(404)++}@@@ -7,6 +7,7 @@ package fakenetworkimport ("crypto/rand"++ "crypto/rsa""encoding/hex""encoding/json""net/http"@@@ -33,22 +34,38 @@ type Server struct {bot *fakebot.Server-- mu sync.Mutex-- health int // GET /api/health status; 200 unless a test sets it-- codes map[string]bool // normalized code → live-- credential string // the node credential, once paired-- revoked bool-- tokens []string-- pairings []protocol.LinkPairingRequest-- tokenForms []map[string]string++ mu sync.Mutex++ health int // GET /api/health status; 200 unless a test sets it++ codes map[string]bool // normalized code → live++ credential string // the node credential, once paired++ revoked bool++ tokens []string++ pairings []protocol.LinkPairingRequest++ tokenForms []map[string]string++ machines map[string]fakeMachine++ codeMachines map[string]fakeMachine++ tokenOwners map[string]string++ tokenAudience map[string]string++ requests map[string]*fakeAccessRequest++ grants map[string]*fakeAccessGrant++ agents map[string]*fakeAgent++ agentTasks map[string]*fakeAgentTask++ signing *rsa.PrivateKey}++type fakeMachine struct{ Principal, DeviceID, Credential string }++// New starts the fake Network; bot (may be nil) learns every token it mints.func New(bot *fakebot.Server) *Server {-- s := &Server{TTL: 300, bot: bot, codes: map[string]bool{}, health: http.StatusOK}++ s := &Server{TTL: 300, bot: bot, codes: map[string]bool{}, health: http.StatusOK,++ machines: map[string]fakeMachine{}, codeMachines: map[string]fakeMachine{}, tokenOwners: map[string]string{}, tokenAudience: map[string]string{},++ requests: map[string]*fakeAccessRequest{}, grants: map[string]*fakeAccessGrant{}, agents: map[string]*fakeAgent{}, agentTasks: map[string]*fakeAgentTask{}}++ s.signing, _ = rsa.GenerateKey(rand.Reader, 2048)mux := http.NewServeMux()mux.HandleFunc(protocol.LinkPairingPath, s.pair)mux.HandleFunc(protocol.TokenPath, s.token)++ s.registerAccessRoutes(mux)++ s.registerAgentRoutes(mux)mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {s.mu.Lock()status := s.health@@@ -58,6 +75,10 @@ func New(bot *fakebot.Server) *Server {_, _ = w.Write([]byte(`{"ok":true}`))})s.HTTP = httptest.NewServer(mux)++ if bot != nil {++ bot.SetRelayAuthority(s.URL(), s.GrantActive)++ bot.SetAgentResult(s.AgentResult)++ }return s}@@@ -78,6 +99,17 @@ func (s *Server) AddCode(code string) {s.mu.Unlock()}++// AddMachine pairs a second fake Link with the same Network and Bot.++func (s *Server) AddMachine(code, principal, deviceID string) {++ s.mu.Lock()++ defer s.mu.Unlock()++ s.codes[code] = true++ s.codeMachines[code] = fakeMachine{Principal: principal, DeviceID: deviceID}++ if s.bot != nil {++ s.bot.BindMachine(principal, deviceID)++ }++}++// Revoke makes /oauth/token refuse the node credential (401 invalid_client), as a revoked principal is.func (s *Server) Revoke() {s.mu.Lock()@@@ -135,9 +167,15 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {s.mu.Lock()s.pairings = append(s.pairings, req)ok := s.codes[code] && (req.Pairing == "" || req.Pairing == PairingID)++ machine, defined := s.codeMachines[code]++ if !defined {++ machine = fakeMachine{Principal: Principal, DeviceID: DeviceID}++ }if ok {delete(s.codes, code)s.credential, s.revoked = random(), false++ machine.Credential = s.credential++ s.machines[machine.Principal] = machine}cred := s.credentials.mu.Unlock()@@@ -145,10 +183,13 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {problem(w, http.StatusForbidden, "registry.pairing_code_invalid")return}++ if s.bot != nil {++ s.bot.BindMachine(machine.Principal, machine.DeviceID)++ }w.Header().Set("Content-Type", "application/json")w.Header().Set("Cache-Control", "no-store")w.WriteHeader(http.StatusCreated)-- _ = json.NewEncoder(w).Encode(protocol.LinkPairingResponse{Principal: Principal, LinkID: "n-01j8z4m2q0r7t9yv3k6n8p1w2x",++ _ = json.NewEncoder(w).Encode(protocol.LinkPairingResponse{Principal: machine.Principal, LinkID: "n-01j8z4m2q0r7t9yv3k6n8p1w2x",HomeCell: "eu-1", Credential: cred, TokenEndpoint: s.HTTP.URL + protocol.TokenPath,PairedFor: &protocol.PairedFor{Service: "bot", Ref: fakebot.RobotID}})}@@@ -164,8 +205,9 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {}s.mu.Lock()s.tokenForms = append(s.tokenForms, form)-- good := !s.revoked && s.credential != "" && form["grant_type"] == "client_credentials" &&-- form["client_id"] == Principal && form["client_secret"] == s.credential++ machine, exists := s.machines[form["client_id"]]++ good := !s.revoked && exists && machine.Credential != "" && form["grant_type"] == "client_credentials" &&++ form["client_secret"] == machine.Credentials.mu.Unlock()w.Header().Set("Content-Type", "application/json")if !good {@@@ -173,7 +215,7 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {_ = json.NewEncoder(w).Encode(protocol.OAuthError{Error: "invalid_client", Description: "Invalid client credentials"})return}-- if form["audience"] != "openvibe.bot" {++ if form["audience"] != "openvibe.bot" && form["audience"] != "openvibe.network" {w.WriteHeader(http.StatusBadRequest)_ = json.NewEncoder(w).Encode(protocol.OAuthError{Error: "invalid_scope", Description: "a node token is for openvibe.network or openvibe.bot"})return@@@ -181,9 +223,11 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {tok := "eyJ" + random()s.mu.Lock()s.tokens = append(s.tokens, tok)++ s.tokenOwners[tok] = machine.Principal++ s.tokenAudience[tok] = form["audience"]s.mu.Unlock()-- if s.bot != nil {-- s.bot.AddCredential(tok, DeviceID)++ if s.bot != nil && form["audience"] == "openvibe.bot" {++ s.bot.AddCredential(tok, machine.DeviceID)}_ = json.NewEncoder(w).Encode(protocol.TokenResponse{AccessToken: tok, TokenType: "Bearer", ExpiresIn: s.TTL})}@@@ -37,13 +37,17 @@ const (CmdPanel = "panel"CmdPartyHost = "party_host"CmdPartyStop = "party_stop"++ // CmdMCP runs one named MCP tool through the same local authorization path as the CLI.++ CmdMCP = "mcp"++ CmdAccess = "access")type Request struct {-- Cmd string `json:"cmd"`-- ID string `json:"id,omitempty"` // approve and deny: the locally minted approval nonce (never the cloud command ID)-- On bool `json:"on,omitempty"` // tray: the tray is running-- Data json.RawMessage `json:"data,omitempty"` // first_run: the telemetry events to report++ Cmd string `json:"cmd"`++ ID string `json:"id,omitempty"` // approve and deny: the locally minted approval nonce (never the cloud command ID)++ Agent string `json:"agent,omitempty"` // MCP: which local CLI launched the server++ On bool `json:"on,omitempty"` // tray: the tray is running++ Data json.RawMessage `json:"data,omitempty"` // first_run: the telemetry events to report}type Response struct {@@@ -94,6 +98,11 @@ func Serve(ctx context.Context, path string, h Handler) error {_ = json.NewEncoder(c).Encode(Response{Error: "bad request"})return}++ if req.Cmd == CmdMCP {++ // agent_ask may wait for a remote agent's reply. The socket is local and++ // the MCP server still caps that wait at 120 seconds.++ _ = c.SetDeadline(time.Now().Add(123 * time.Second))++ }_ = json.NewEncoder(c).Encode(h(req))}(c)}nnew file mode 100644@@@ -0,0 +1,214 @@++// Package mcp serves Link's local machine and agent tools over MCP stdio.++package mcp++++import (++ "bufio"++ "encoding/json"++ "errors"++ "fmt"++ "io"++ "strings"++)++++const Version = "2025-11-25"++++type Caller interface {++ Call(name string, arguments json.RawMessage) (json.RawMessage, error)++}++++type Tool struct {++ Name string `json:"name"`++ Description string `json:"description"`++ InputSchema map[string]any `json:"inputSchema"`++}++++func schema(properties map[string]any, required ...string) map[string]any {++ if required == nil {++ required = []string{}++ }++ return map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}++}++++var textField = map[string]any{"type": "string", "minLength": 1, "maxLength": 8000}++var idField = map[string]any{"type": "string", "minLength": 1, "maxLength": 128}++var scopeField = map[string]any{"type": "string", "enum": []string{"agent", "send", "screen", "desk", "computer", "worker"}}++++var Tools = []Tool{++ {"agents_list", "List agents available to this person and their online status.", schema(map[string]any{})},++ {"agent_ask", "Ask an agent and wait for its answer for up to 120 seconds.", schema(map[string]any{"agent": idField, "text": textField, "wait_seconds": map[string]any{"type": "integer", "minimum": 1, "maximum": 120}}, "agent", "text")},++ {"agent_tell", "Send a message to an agent without waiting for an answer.", schema(map[string]any{"agent": idField, "text": textField, "thread": idField}, "agent", "text")},++ {"agent_inbox", "Read tasks addressed to this local agent.", schema(map[string]any{"agent": idField})},++ {"agent_reply", "Reply to a task in the local agent inbox.", schema(map[string]any{"task": idField, "text": textField}, "task", "text")},++ {"machines_list", "List machines and grants available to this machine.", schema(map[string]any{})},++ {"machine_request_access", "Ask another machine's owner for limited access.", schema(map[string]any{"machine": idField, "scopes": map[string]any{"type": "array", "minItems": 1, "items": scopeField}, "for_seconds": map[string]any{"type": "integer", "minimum": 60, "maximum": 2592000}, "reason": map[string]any{"type": "string", "maxLength": 500}}, "machine", "scopes")},++ {"machine_use", "Use a held grant for one operation on another machine.", schema(map[string]any{"machine": idField, "scope": scopeField, "action": idField, "data": map[string]any{"type": "object"}}, "machine", "scope", "action")},++}++++type request struct {++ JSONRPC string `json:"jsonrpc"`++ ID json.RawMessage `json:"id"`++ Method string `json:"method"`++ Params json.RawMessage `json:"params"`++}++++func answer(id json.RawMessage, value any, errCode int, errText string) any {++ if errCode != 0 {++ return map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": errCode, "message": errText}}++ }++ return map[string]any{"jsonrpc": "2.0", "id": id, "result": value}++}++++// Serve reads one JSON-RPC message per line. Stdout is only the protocol.++func Serve(in io.Reader, out io.Writer, caller Caller) error {++ scan := bufio.NewScanner(in)++ scan.Buffer(make([]byte, 4096), 1<<20)++ enc := json.NewEncoder(out)++ for scan.Scan() {++ var req request++ if json.Unmarshal(scan.Bytes(), &req) != nil || req.JSONRPC != "2.0" {++ if err := enc.Encode(answer(json.RawMessage("null"), nil, -32700, "invalid JSON-RPC message")); err != nil {++ return err++ }++ continue++ }++ if len(req.ID) == 0 {++ continue++ } // notifications need no response++ var result any++ var code int++ var msg string++ switch req.Method {++ case "initialize":++ result = map[string]any{"protocolVersion": Version, "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "openvibe-link", "version": "1"}}++ case "ping":++ result = map[string]any{}++ case "tools/list":++ result = map[string]any{"tools": Tools}++ case "tools/call":++ var p struct {++ Name string `json:"name"`++ Arguments json.RawMessage `json:"arguments"`++ }++ if json.Unmarshal(req.Params, &p) != nil {++ code, msg = -32602, "invalid tool parameters"++ break++ }++ if err := validate(p.Name, p.Arguments); err != nil {++ code, msg = -32602, err.Error()++ break++ }++ if caller == nil {++ code, msg = -32603, "local Link unavailable"++ break++ }++ data, err := caller.Call(p.Name, p.Arguments)++ if err != nil {++ result = map[string]any{"content": []any{map[string]any{"type": "text", "text": err.Error()}}, "isError": true}++ } else {++ result = map[string]any{"content": []any{map[string]any{"type": "text", "text": string(data)}}, "structuredContent": map[string]any{"data": json.RawMessage(data)}}++ }++ default:++ code, msg = -32601, "method not found"++ }++ if err := enc.Encode(answer(req.ID, result, code, msg)); err != nil {++ return err++ }++ }++ return scan.Err()++}++++func validate(name string, raw json.RawMessage) error {++ var tool *Tool++ for i := range Tools {++ if Tools[i].Name == name {++ tool = &Tools[i]++ break++ }++ }++ if tool == nil {++ return errors.New("unknown tool")++ }++ if len(raw) == 0 {++ raw = []byte("{}")++ }++ var args map[string]json.RawMessage++ if json.Unmarshal(raw, &args) != nil || args == nil {++ return errors.New("arguments must be an object")++ }++ properties := tool.InputSchema["properties"].(map[string]any)++ for _, required := range tool.InputSchema["required"].([]string) {++ if _, ok := args[required]; !ok {++ return fmt.Errorf("missing %s", required)++ }++ }++ for key, value := range args {++ property, ok := properties[key]++ if !ok {++ return fmt.Errorf("unknown argument %s", key)++ }++ definition := property.(map[string]any)++ switch definition["type"] {++ case "string":++ var s string++ if json.Unmarshal(value, &s) != nil {++ return fmt.Errorf("%s must be text", key)++ }++ if min, ok := definition["minLength"].(int); ok && len(strings.TrimSpace(s)) < min {++ return fmt.Errorf("%s is empty", key)++ }++ if max, ok := definition["maxLength"].(int); ok && len(s) > max {++ return fmt.Errorf("%s is too long", key)++ }++ if allowed, ok := definition["enum"].([]string); ok {++ found := false++ for _, v := range allowed {++ found = found || s == v++ }++ if !found {++ return fmt.Errorf("invalid %s", key)++ }++ }++ case "integer":++ var n int64++ if json.Unmarshal(value, &n) != nil {++ return fmt.Errorf("%s must be an integer", key)++ }++ if min, ok := definition["minimum"].(int); ok && n < int64(min) {++ return fmt.Errorf("%s is too small", key)++ }++ if max, ok := definition["maximum"].(int); ok && n > int64(max) {++ return fmt.Errorf("%s is too large", key)++ }++ case "array":++ var a []string++ if json.Unmarshal(value, &a) != nil {++ return fmt.Errorf("%s must be a list", key)++ }++ if len(a) == 0 {++ return fmt.Errorf("%s is empty", key)++ }++ seen := map[string]bool{}++ for _, s := range a {++ if !scoped(s) || seen[s] {++ return fmt.Errorf("invalid %s", key)++ }++ seen[s] = true++ }++ case "object":++ var object map[string]any++ if json.Unmarshal(value, &object) != nil || object == nil {++ return fmt.Errorf("%s must be an object", key)++ }++ }++ }++ return nil++}++++func scoped(s string) bool {++ for _, v := range []string{"agent", "send", "screen", "desk", "computer", "worker"} {++ if s == v {++ return true++ }++ }++ return false++}nnew file mode 100644@@@ -0,0 +1,92 @@++package mcp++++import (++ "bytes"++ "encoding/json"++ "errors"++ "strings"++ "testing"++)++++type fakeCaller struct {++ name string++ args json.RawMessage++ refuse bool++}++++func (f *fakeCaller) Call(name string, args json.RawMessage) (json.RawMessage, error) {++ f.name, f.args = name, args++ if f.refuse {++ return nil, errors.New("grant refused")++ }++ return json.RawMessage(`{"ok":true}`), nil++}++++func TestEveryToolAndRefusals(t *testing.T) {++ cases := []struct{ name, args string }{++ {"agents_list", `{}`}, {"agent_ask", `{"agent":"agt_1","text":"question","wait_seconds":2}`},++ {"agent_tell", `{"agent":"agt_1","text":"hello"}`}, {"agent_inbox", `{}`},++ {"agent_reply", `{"task":"tsk_1","text":"answer"}`}, {"machines_list", `{}`},++ {"machine_request_access", `{"machine":"lnk_1","scopes":["agent","send"],"for_seconds":3600}`},++ {"machine_use", `{"machine":"lnk_1","scope":"screen","action":"status","data":{}}`},++ }++ for _, tc := range cases {++ t.Run(tc.name, func(t *testing.T) {++ f := &fakeCaller{}++ input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"` + tc.name + `","arguments":` + tc.args + `}}` + "\n"++ var output bytes.Buffer++ if err := Serve(strings.NewReader(input), &output, f); err != nil {++ t.Fatal(err)++ }++ if f.name != tc.name || string(f.args) != tc.args {++ t.Fatalf("forwarded %q %s", f.name, f.args)++ }++ if !strings.Contains(output.String(), `"structuredContent":{"data":{"ok":true}}`) {++ t.Fatalf("missing result: %s", output.String())++ }++ f.refuse = true++ output.Reset()++ if err := Serve(strings.NewReader(input), &output, f); err != nil {++ t.Fatal(err)++ }++ if !strings.Contains(output.String(), `"isError":true`) {++ t.Fatalf("refusal missing: %s", output.String())++ }++ })++ }++ for _, tc := range []struct{ name, args string }{++ {"agent_ask", `{"agent":"agt_1","text":""}`},++ {"agent_ask", `{"agent":"agt_1","text":"x","wait_seconds":121}`},++ {"machine_request_access", `{"machine":"lnk_1","scopes":["admin"]}`},++ {"machine_use", `{"machine":"lnk_1","scope":"computer","action":""}`},++ {"agent_reply", `{"task":"tsk_1","text":"ok","grant":"fake"}`},++ } {++ t.Run("invalid-"+tc.name, func(t *testing.T) {++ if validate(tc.name, json.RawMessage(tc.args)) == nil {++ t.Fatal("accepted")++ }++ })++ }++}++++func TestHandshakeAndToolsList(t *testing.T) {++ input := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}` + "\n" +++ `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" +++ `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` + "\n"++ var out bytes.Buffer++ if err := Serve(strings.NewReader(input), &out, &fakeCaller{}); err != nil {++ t.Fatal(err)++ }++ lines := strings.Split(strings.TrimSpace(out.String()), "\n")++ if len(lines) != 2 {++ t.Fatalf("responses: %d", len(lines))++ }++ if !strings.Contains(lines[0], Version) {++ t.Fatal(lines[0])++ }++ for _, tool := range Tools {++ if !strings.Contains(lines[1], `"name":"`+tool.Name+`"`) {++ t.Fatalf("missing %s", tool.Name)++ }++ }++}nnew file mode 100644@@@ -0,0 +1,101 @@++package protocol++++import "encoding/json"++++// Machine-to-machine relay frames, as OpenVibe.Bot speaks them++// (server/realtime.js: relay.open → relay.opened, relay.frame both ways,++// relay.close → relay.closed). Bot verifies the grant token on open and again++// on every frame; the receiving Link verifies it too and caps each scope at its++// own local level.++const (++ TypeRelayOpen = "relay.open"++ TypeRelayOpened = "relay.opened"++ TypeRelayFrame = "relay.frame"++ TypeRelayClose = "relay.close"++ TypeRelayClosed = "relay.closed"++++ TypeAgentTask = "agent.task"++ TypeAgentTaskResult = "agent.task_result"++ TypeAgentTaskResultAck = "agent.task_result_ack"++ TypeAccessRevoked = "access.revoked"++)++++// RelayOpen asks Bot for a relay to To under a Network-signed grant token.++type RelayOpen struct {++ To string `json:"to"`++ GrantToken string `json:"grant_token"`++}++++func (RelayOpen) MessageType() string { return TypeRelayOpen }++++// RelayOpened is Bot's answer to both machines: the relay id, the grant's++// scopes and the same token, so each Link checks Network's signature itself.++type RelayOpened struct {++ Relay string `json:"relay"`++ To string `json:"to"`++ GrantToken string `json:"grant_token"`++ Scopes []string `json:"scopes"`++ ExpiresAt string `json:"expires_at,omitempty"`++ From string `json:"from"`++}++++func (RelayOpened) MessageType() string { return TypeRelayOpened }++++// RelayFrame carries one operation or its answer. From is filled in by Bot.++type RelayFrame struct {++ Relay string `json:"relay"`++ From string `json:"from,omitempty"`++ Scope string `json:"scope"`++ Body json.RawMessage `json:"body"`++}++++func (RelayFrame) MessageType() string { return TypeRelayFrame }++++// RelayClose asks Bot to end a relay when either side is done with it.++type RelayClose struct {++ Relay string `json:"relay"`++}++++func (RelayClose) MessageType() string { return TypeRelayClose }++++// RelayClosed tells both machines a relay ended, with Bot's reason (closed,++// expired or revoked).++type RelayClosed struct {++ Relay string `json:"relay"`++ Reason string `json:"reason,omitempty"`++}++++func (RelayClosed) MessageType() string { return TypeRelayClosed }++++// AgentTaskFrame is Network's A2A task delivered by Bot. Network has already++// checked the sender, the receiving agent and any machine grant.++type AgentTaskFrame struct {++ Task json.RawMessage `json:"task"`++}++++func (AgentTaskFrame) MessageType() string { return TypeAgentTask }++++// AgentTaskResult answers a delivered task; Bot records it on Network, so only++// the machine the task was delivered to may send one.++type AgentTaskResult struct {++ TaskID string `json:"task_id"`++ Status string `json:"status"`++ Result json.RawMessage `json:"result,omitempty"`++}++++func (AgentTaskResult) MessageType() string { return TypeAgentTaskResult }++++type AgentTaskResultAck struct {++ TaskID string `json:"task_id"`++ Status string `json:"status"`++}++++func (AgentTaskResultAck) MessageType() string { return TypeAgentTaskResultAck }++++// AccessRevoked is kept for a Network/Bot build that closes a grant with this++// frame instead of relay.closed; it ends locally cached access at once.++type AccessRevoked struct {++ GrantID string `json:"grant_id"`++}++++func (AccessRevoked) MessageType() string { return TypeAccessRevoked }@@@ -760,28 +760,37 @@ func (JobUsage) MessageType() string { return TypeJobUsage }func (JobExit) MessageType() string { return TypeJobExit }var registry = map[string]func() Message{-- TypeHello: func() Message { return &Hello{} },-- TypeConfig: func() Message { return &Config{} },-- TypeCommand: func() Message { return &Command{} },-- TypeEstop: func() Message { return &Estop{} },-- TypeHeartbeatAck: func() Message { return &HeartbeatAck{} },-- TypeError: func() Message { return &Error{} },-- TypeStatus: func() Message { return &Status{} },-- TypeTelemetry: func() Message { return &Telemetry{} },-- TypeAck: func() Message { return &AckData{} }, // an Ack unless it carries data-- TypeNack: func() Message { return &Nack{} },-- TypeHeartbeat: func() Message { return &Heartbeat{} },-- TypeReauth: func() Message { return &Reauth{} },-- TypeEstopState: func() Message { return &EstopState{} },-- TypeJob: func() Message { return &JobRequest{} },-- TypeJobCancel: func() Message { return &JobCancel{} },-- TypeJobExitAck: func() Message { return &JobExitAck{} },-- TypeJobStarted: func() Message { return &JobStarted{} },-- TypeJobStdout: func() Message { return &JobStdout{} },-- TypeJobUsage: func() Message { return &JobUsage{} },-- TypeJobExit: func() Message { return &JobExit{} },-- TypeVoiceAsk: func() Message { return &VoiceAsk{} },-- TypeVoiceAnswer: func() Message { return &VoiceAnswer{} },++ TypeHello: func() Message { return &Hello{} },++ TypeConfig: func() Message { return &Config{} },++ TypeCommand: func() Message { return &Command{} },++ TypeEstop: func() Message { return &Estop{} },++ TypeHeartbeatAck: func() Message { return &HeartbeatAck{} },++ TypeError: func() Message { return &Error{} },++ TypeStatus: func() Message { return &Status{} },++ TypeTelemetry: func() Message { return &Telemetry{} },++ TypeAck: func() Message { return &AckData{} }, // an Ack unless it carries data++ TypeNack: func() Message { return &Nack{} },++ TypeHeartbeat: func() Message { return &Heartbeat{} },++ TypeReauth: func() Message { return &Reauth{} },++ TypeEstopState: func() Message { return &EstopState{} },++ TypeJob: func() Message { return &JobRequest{} },++ TypeJobCancel: func() Message { return &JobCancel{} },++ TypeJobExitAck: func() Message { return &JobExitAck{} },++ TypeJobStarted: func() Message { return &JobStarted{} },++ TypeJobStdout: func() Message { return &JobStdout{} },++ TypeJobUsage: func() Message { return &JobUsage{} },++ TypeJobExit: func() Message { return &JobExit{} },++ TypeVoiceAsk: func() Message { return &VoiceAsk{} },++ TypeVoiceAnswer: func() Message { return &VoiceAnswer{} },++ TypeRelayOpen: func() Message { return &RelayOpen{} },++ TypeRelayOpened: func() Message { return &RelayOpened{} },++ TypeRelayFrame: func() Message { return &RelayFrame{} },++ TypeRelayClose: func() Message { return &RelayClose{} },++ TypeRelayClosed: func() Message { return &RelayClosed{} },++ TypeAgentTask: func() Message { return &AgentTaskFrame{} },++ TypeAgentTaskResult: func() Message { return &AgentTaskResult{} },++ TypeAgentTaskResultAck: func() Message { return &AgentTaskResultAck{} },++ TypeAccessRevoked: func() Message { return &AccessRevoked{} },}// ErrUnknownType is returned (wrapped) by Decode for a frame whose type is not in this version's message set.@@@ -885,6 +894,24 @@ func derefMessage(m Message) Message {return *vcase *VoiceAnswer:return *v++ case *AgentTaskFrame:++ return *v++ case *AccessRevoked:++ return *v++ case *RelayOpen:++ return *v++ case *RelayOpened:++ return *v++ case *RelayFrame:++ return *v++ case *RelayClose:++ return *v++ case *RelayClosed:++ return *v++ case *AgentTaskResult:++ return *v++ case *AgentTaskResultAck:++ return *v}return m}@@@ -27,6 +27,7 @@ import (const (lanPath = "/api/link-send/v1"++ lanGrantPath = "/api/link-send/v2"lanMaxBody = 40 << 20lanWindow = 5 * time.MinutelanMaxItems = 100@@@ -46,19 +47,28 @@ type Incoming struct {// 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++ target string++ mu sync.Mutex++ secret []byte++ items map[string]Incoming++ nonces map[string]time.Time++ ln net.Listener++ server *http.Server++ grantCheck func(context.Context, string, string) error}func NewLAN(target string) *LAN {return &LAN{target: target, items: map[string]Incoming{}, nonces: map[string]time.Time{}}}++// SetGrantCheck makes the direct granted path use the same signed grant and++// live revocation check as Bot-relayed transfers.++func (l *LAN) SetGrantCheck(check func(context.Context, string, string) error) {++ l.mu.Lock()++ l.grantCheck = check++ l.mu.Unlock()++}++// 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 {@@@ -151,6 +161,23 @@ func (l *LAN) Items() []Incoming {return out}++// ReceiveGranted queues a sealed item after the Link agent has verified a++// Network-signed send grant. The envelope stays encrypted until its owner opens++// it through the existing Link Send inbox.++func (l *LAN) ReceiveGranted(sender, envelope string) (string, error) {++ if sender == "" || sender == l.target || len(envelope) == 0 || len(envelope) > 32*1024*1024 {++ return "", errors.New("invalid granted transfer")++ }++ l.mu.Lock()++ defer l.mu.Unlock()++ if len(l.items) >= lanMaxItems {++ return "", errors.New("this machine's transfer list is full")++ }++ id := "lan_" + hex.EncodeToString(random(16))++ l.items[id] = Incoming{ID: id, Sender: sender, Envelope: envelope, Bytes: int64(len(envelope)), CreatedAt: time.Now()}++ return id, nil++}++func (l *LAN) Accept(id string) (string, error) {l.mu.Lock()defer l.mu.Unlock()@@@ -182,80 +209,94 @@ func (l *LAN) Complete(id string) error {}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"`++ 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"`++ GrantToken string `json:"grant_token,omitempty"`}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)++ serve := func(granted bool) http.HandlerFunc {++ return 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++ }++ if granted {++ l.mu.Lock()++ check := l.grantCheck++ l.mu.Unlock()++ if check == nil || req.GrantToken == "" || check(r.Context(), req.GrantToken, req.Sender) != nil {++ http.Error(w, "machine access grant refused", 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 + `"}`))}-- 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 + `"}`))-- })++ }++ mux.HandleFunc(lanPath, serve(false))++ mux.HandleFunc(lanGrantPath, serve(true))return mux}@@@ -277,12 +318,26 @@ func random(n int) []byte {// 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 {++ _, err := c.sendLANWithGrant(ctx, target, envelope, "")++ return err++}++++// SendGrantedLAN attempts the same-owner LAN hand-over with a signed send++// grant. A caller may fall back to Bot's authenticated relay on error.++func (c *Client) SendGrantedLAN(ctx context.Context, target, envelope, grantToken string) (string, error) {++ if grantToken == "" {++ return "", errors.New("send grant missing")++ }++ return c.sendLANWithGrant(ctx, target, envelope, grantToken)++}++++func (c *Client) sendLANWithGrant(ctx context.Context, target, envelope, grantToken string) (string, error) {if c.Self == "" {-- return errors.New("this machine has no id for a direct transfer")++ return "", errors.New("this machine has no id for a direct transfer")}machines, err := c.Targets(ctx)if err != nil {-- return err++ return "", err}var endpoints []stringfor _, m := range machines {@@@ -292,24 +347,28 @@ func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {}}if len(endpoints) == 0 {-- return errors.New("the receiving machine is not on this network")++ return "", errors.New("the receiving machine is not on this network")}encoded, err := c.LANKey(ctx)if err != nil {-- return err++ return "", err}secret, err := base64.RawURLEncoding.DecodeString(encoded)if err != nil || len(secret) < 16 {-- return errors.New("the LAN sender secret is not valid")++ 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),++ Mac: lanMAC(secret, c.Self, target, at, nonce, envelope), GrantToken: grantToken,})-- req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+lanPath, strings.NewReader(string(body)))++ path := lanPath++ if grantToken != "" {++ path = lanGrantPath++ }++ req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+path, strings.NewReader(string(body)))if err != nil {continue}@@@ -319,11 +378,14 @@ func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {if err != nil {continue}-- _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))++ var reply struct {++ ID string `json:"id"`++ }++ _ = json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&reply)resp.Body.Close()if resp.StatusCode == http.StatusCreated {-- return nil++ return reply.ID, nil}}-- return errors.New("the direct transfer did not arrive")++ return "", errors.New("the direct transfer did not arrive")}nnew file mode 100644@@@ -0,0 +1,62 @@++package send++++import (++ "bytes"++ "context"++ "encoding/base64"++ "encoding/json"++ "net/http"++ "net/http/httptest"++ "sync/atomic"++ "testing"++ "time"++)++++func TestGrantedLANRequiresLiveSendGrant(t *testing.T) {++ lan := NewLAN("lnk_target")++ secret := []byte("same-owner-lan-secret-123456")++ if err := lan.SetSecret(base64.RawURLEncoding.EncodeToString(secret)); err != nil {++ t.Fatal(err)++ }++ var allowed atomic.Bool++ allowed.Store(true)++ lan.SetGrantCheck(func(_ context.Context, token, sender string) error {++ if allowed.Load() && token == "signed-grant" && sender == "lnk_sender" {++ return nil++ }++ return ErrGrantTest{}++ })++ server := httptest.NewServer(lan.handler())++ defer server.Close()++ post := func(token string) int {++ t.Helper()++ at := time.Now().Unix()++ nonce := base64.RawURLEncoding.EncodeToString(random(16))++ envelope := "sealed-file"++ body, _ := json.Marshal(lanRequest{Sender: "lnk_sender", Target: "lnk_target", Envelope: envelope, At: at, Nonce: nonce,++ Mac: lanMAC(secret, "lnk_sender", "lnk_target", at, nonce, envelope), GrantToken: token})++ resp, err := http.Post(server.URL+lanGrantPath, "application/json", bytes.NewReader(body))++ if err != nil {++ t.Fatal(err)++ }++ defer resp.Body.Close()++ return resp.StatusCode++ }++ if got := post(""); got != http.StatusForbidden {++ t.Fatalf("missing grant: %d", got)++ }++ if got := post("signed-grant"); got != http.StatusCreated {++ t.Fatalf("live grant: %d", got)++ }++ allowed.Store(false)++ if got := post("signed-grant"); got != http.StatusForbidden {++ t.Fatalf("revoked grant: %d", got)++ }++ if len(lan.Items()) != 1 {++ t.Fatal("refused transfer reached the inbox")++ }++}++++type ErrGrantTest struct{}++++func (ErrGrantTest) Error() string { return "grant refused" }
@@ -43,6 +43,8 @@ serves the share this computer was picked for at `https://<name>--<you>.openvibe
@@ -43,6 +43,8 @@ serves the share this computer was picked for at `https://<name>--<you>.openvibe
**Host a shared party computer** with `openvibe-link party host <party-id>` from [openvibe.party](https://openvibe.party). Link shows the running desktop and its Stop action in its window ([guide](docs/party-desktop.md)).
**Host a shared party computer** with `openvibe-link party host <party-id>` from [openvibe.party](https://openvibe.party). Link shows the running desktop and its Stop action in its window ([guide](docs/party-desktop.md)).
+**Ask another machine for access:** `openvibe-link access request <machine> --scope screen,desk --for 1h`. The target owner approves on their machine; a signed, scoped grant controls each use. Turn on **Let my agents talk** to register local agent CLIs and give them Link's `openvibe-link mcp` tools. [Machine access and agent messages](docs/access-and-agents.md) explains scopes, approval, revocation, the inbox, and MCP setup.
+
**Sell a local model** on [OpenVibe.Exchange](https://openvibe.exchange):
**Sell a local model** on [OpenVibe.Exchange](https://openvibe.exchange):
`openvibe-link exchange serve --model qwen3:32b --public-url https://gpu.example.com/v1`. It serves the model from the
`openvibe-link exchange serve --model qwen3:32b --public-url https://gpu.example.com/v1`. It serves the model from the
local OpenAI-compatible server (Ollama by default at `127.0.0.1:11434`), registers the public https address through the
local OpenAI-compatible server (Ollama by default at `127.0.0.1:11434`), registers the public https address through the
new file mode 100644
new file mode 100644
@@ -0,0 +1,452 @@
@@ -0,0 +1,452 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"
+)
+
+type accessArgs struct {
+ Scope, For, Reason string
+ JSON bool
+}
+
+func accessDuration(raw string) (int64, error) {
+ if raw == "" {
+ raw = "1h"
+ }
+ if raw == "forever" {
+ return 0, nil
+ }
+ if strings.HasSuffix(raw, "d") {
+ var d int64
+ if _, err := fmt.Sscanf(raw, "%dd", &d); err == nil && d >= 1 && d <= 30 {
+ return d * 86400, nil
+ }
+ return 0, errors.New("--for must be 1m through 30d, or forever for same-owner agent/send")
+ }
+ d, err := time.ParseDuration(raw)
+ if err != nil || d < time.Minute || d > 30*24*time.Hour {
+ return 0, errors.New("--for must be 1m through 30d, or forever for same-owner agent/send")
+ }
+ return int64(d.Seconds()), nil
+}
+
+func cmdAccess(g *globals, args []string, opt accessArgs, out io.Writer) error {
+ if len(args) == 0 {
+ return errors.New("usage: openvibe-link access request|list|approve|deny|revoke")
+ }
+ data := map[string]any{}
+ action := args[0]
+ switch action {
+ case "request":
+ if len(args) != 2 {
+ return errors.New("usage: openvibe-link access request <machine> --scope agent,send [--for 1h] [--reason text]")
+ }
+ if opt.Scope == "" {
+ return errors.New("--scope is required")
+ }
+ scopes := strings.Split(opt.Scope, ",")
+ seen := map[string]bool{}
+ for _, scope := range scopes {
+ if !validScope(scope) || seen[scope] {
+ return errors.New("--scope must list distinct agent,send,screen,desk,computer,worker")
+ }
+ seen[scope] = true
+ }
+ seconds, err := accessDuration(opt.For)
+ if err != nil {
+ return err
+ }
+ if seconds == 0 && (len(scopes) > 2 || !seen["agent"] && !seen["send"] || seen["screen"] || seen["desk"] || seen["computer"] || seen["worker"]) {
+ return errors.New("forever is only for agent and send")
+ }
+ if len(opt.Reason) > 500 {
+ return errors.New("reason is too long")
+ }
+ data = map[string]any{"machine": args[1], "scopes": scopes, "duration_seconds": seconds, "reason": opt.Reason}
+ case "list":
+ if len(args) != 1 {
+ return errors.New("usage: openvibe-link access list")
+ }
+ case "approve", "deny", "revoke":
+ if len(args) != 2 {
+ return fmt.Errorf("usage: openvibe-link access %s <id>", action)
+ }
+ data["id"] = args[1]
+ if action == "approve" {
+ if opt.Scope != "" {
+ scopes := strings.Split(opt.Scope, ",")
+ for _, s := range scopes {
+ if !validScope(s) {
+ return errors.New("invalid scope")
+ }
+ }
+ data["scopes"] = scopes
+ }
+ if opt.For != "" {
+ seconds, err := accessDuration(opt.For)
+ if err != nil {
+ return err
+ }
+ if seconds == 0 {
+ return errors.New("an answer may only shorten the requested time")
+ }
+ data["duration_seconds"] = seconds
+ }
+ }
+ default:
+ return errors.New("usage: openvibe-link access request|list|approve|deny|revoke")
+ }
+ raw, _ := json.Marshal(data)
+ resp, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdAccess, ID: action, Data: raw}, 20*time.Second)
+ if err != nil {
+ return err
+ }
+ var value any
+ if len(resp.Data) > 0 {
+ if err := json.Unmarshal(resp.Data, &value); err != nil {
+ return err
+ }
+ }
+ if opt.JSON {
+ b, _ := json.MarshalIndent(value, "", " ")
+ _, err = fmt.Fprintln(out, string(b))
+ return err
+ }
+ if action == "list" {
+ return printAccessList(out, value)
+ }
+ if action == "request" {
+ seconds, _ := accessDuration(opt.For)
+ if _, err = fmt.Fprintf(out, "Asked %s for: %s (%s). Waiting for their owner…\n", args[1], scopeWords(strings.Split(opt.Scope, ",")), accessTime(seconds)); err != nil {
+ return err
+ }
+ id := responseID(value)
+ if id == "" {
+ return errors.New("Network did not return the request id")
+ }
+ return waitAccessAnswer(g, id, strings.Split(opt.Scope, ","), out)
+ }
+ if action == "approve" {
+ _, err = fmt.Fprintln(out, "Allowed.")
+ return err
+ }
+ if action == "deny" {
+ _, err = fmt.Fprintln(out, "Denied.")
+ return err
+ }
+ if action == "revoke" {
+ _, err = fmt.Fprintln(out, "Access ended.")
+ return err
+ }
+ label := map[string]string{"request": "Access requested", "approve": "Access approved", "deny": "Access denied", "revoke": "Access revoked"}[action]
+ if m, ok := value.(map[string]any); ok {
+ for _, key := range []string{"id", "request_id", "grant_id"} {
+ if id, _ := m[key].(string); id != "" {
+ label += ": " + id
+ break
+ }
+ }
+ if status, _ := m["status"].(string); status != "" {
+ label += " (" + status + ")"
+ }
+ }
+ _, err = fmt.Fprintln(out, label)
+ return err
+}
+
+func scopeWords(scopes []string) string {
+ words := map[string]string{"agent": "Message your agents", "send": "Send you files", "screen": "Watch the screen", "desk": "Use a private desk", "computer": "Control this computer", "worker": "Run jobs in the sandbox"}
+ var parts []string
+ for _, s := range scopes {
+ if w := words[s]; w != "" {
+ parts = append(parts, w)
+ }
+ }
+ if len(parts) == 0 {
+ return "No access"
+ }
+ if len(parts) == 1 {
+ return parts[0]
+ }
+ for i := 1; i < len(parts); i++ {
+ parts[i] = strings.ToLower(parts[i][:1]) + parts[i][1:]
+ }
+ return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
+}
+
+func accessTime(seconds int64) string {
+ if seconds == 0 {
+ return "until you take it back"
+ }
+ if seconds%86400 == 0 {
+ if seconds == 86400 {
+ return "1 day"
+ }
+ return fmt.Sprintf("%d days", seconds/86400)
+ }
+ if seconds%3600 == 0 {
+ if seconds == 3600 {
+ return "1 hour"
+ }
+ return fmt.Sprintf("%d hours", seconds/3600)
+ }
+ if seconds%60 == 0 {
+ if seconds == 60 {
+ return "1 minute"
+ }
+ return fmt.Sprintf("%d minutes", seconds/60)
+ }
+ return (time.Duration(seconds) * time.Second).String()
+}
+
+func printAccessList(out io.Writer, value any) error {
+ root, _ := value.(map[string]any)
+ groups := map[string][]string{"Waiting": {}, "Allowed": {}, "Ended": {}}
+ if reqs, _ := root["requests"].(map[string]any); reqs != nil {
+ if rows, _ := reqs["requests"].([]any); rows != nil {
+ for _, raw := range rows {
+ row, _ := raw.(map[string]any)
+ if row == nil {
+ continue
+ }
+ status, _ := row["status"].(string)
+ group := "Ended"
+ if status == "pending" {
+ group = "Waiting"
+ } else if status == "approved" {
+ continue
+ }
+ groups[group] = append(groups[group], formatAccessRow(row))
+ }
+ }
+ }
+ if grants, _ := root["grants"].(map[string]any); grants != nil {
+ if rows, _ := grants["grants"].([]any); rows != nil {
+ for _, raw := range rows {
+ row, _ := raw.(map[string]any)
+ if row == nil {
+ continue
+ }
+ group := "Allowed"
+ if row["revoked_at"] != nil {
+ group = "Ended"
+ } else if expiry, _ := row["expires_at"].(string); expiry != "" {
+ at, err := time.Parse(time.RFC3339, expiry)
+ if err != nil || !at.After(time.Now()) {
+ group = "Ended"
+ }
+ }
+ groups[group] = append(groups[group], formatAccessRow(row))
+ }
+ }
+ }
+ for _, group := range []string{"Waiting", "Allowed", "Ended"} {
+ if _, err := fmt.Fprintln(out, group); err != nil {
+ return err
+ }
+ if len(groups[group]) == 0 {
+ if _, err := fmt.Fprintln(out, " None"); err != nil {
+ return err
+ }
+ continue
+ }
+ for _, row := range groups[group] {
+ if _, err := fmt.Fprintln(out, " "+row); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func formatAccessRow(row map[string]any) string {
+ from, _ := row["from_principal"].(string)
+ to, _ := row["to_principal"].(string)
+ fromName, _ := row["from_machine_name"].(string)
+ fromOwner, _ := row["from_owner_name"].(string)
+ toName, _ := row["to_machine_name"].(string)
+ if fromName != "" {
+ from = fromName
+ if fromOwner != "" {
+ from = fromOwner + "'s " + fromName
+ }
+ }
+ if toName != "" {
+ to = toName
+ }
+ name := from + " → " + to
+ var scopes []string
+ if list, _ := row["scopes"].([]any); list != nil {
+ for _, raw := range list {
+ if s, _ := raw.(string); s != "" {
+ scopes = append(scopes, s)
+ }
+ }
+ }
+ id, _ := row["id"].(string)
+ when := ""
+ if expiry, _ := row["expires_at"].(string); expiry != "" {
+ when = " · " + accessLeft(expiry)
+ } else if pending, _ := row["pending_until"].(string); pending != "" {
+ when = " · waiting"
+ } else if value, ok := row["expires_at"]; ok && value == nil {
+ when = " · until you take it back"
+ }
+ return name + " · " + scopeWords(scopes) + when + " · " + id
+}
+
+func responseID(value any) string {
+ root, _ := value.(map[string]any)
+ for _, key := range []string{"id", "request_id", "grant_id"} {
+ if id, _ := root[key].(string); id != "" {
+ return id
+ }
+ }
+ for _, key := range []string{"request", "grant"} {
+ if row, _ := root[key].(map[string]any); row != nil {
+ for _, idKey := range []string{"id", "request_id", "grant_id"} {
+ if id, _ := row[idKey].(string); id != "" {
+ return id
+ }
+ }
+ }
+ }
+ return ""
+}
+
+type accessListRow struct {
+ ID string `json:"id"`
+ RequestID string `json:"request_id"`
+ Status string `json:"status"`
+ Scopes []string `json:"scopes"`
+ ExpiresAt any `json:"expires_at"`
+ PendingTill string `json:"pending_until"`
+ RequestedAt string `json:"requested_at"`
+ AskedAt string `json:"asked_at"`
+}
+
+type accessListAnswer struct {
+ Requests struct {
+ Requests []accessListRow `json:"requests"`
+ } `json:"requests"`
+ Grants struct {
+ Grants []accessListRow `json:"grants"`
+ } `json:"grants"`
+}
+
+// waitAccessAnswer follows one request until its owner answers it, as the
+// design's `access request` output promises. A person may take up to ten
+// minutes; a script can pass --json to return at once.
+func waitAccessAnswer(g *globals, id string, scopes []string, out io.Writer) error {
+ deadline := time.Now().Add(10 * time.Minute)
+ for {
+ raw, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdAccess, ID: "list"}, 20*time.Second)
+ if err != nil {
+ return err
+ }
+ var answer accessListAnswer
+ if len(raw.Data) > 0 && json.Unmarshal(raw.Data, &answer) == nil {
+ for _, request := range answer.Requests.Requests {
+ if request.ID != id {
+ continue
+ }
+ switch request.Status {
+ case "pending":
+ case "denied":
+ _, err = fmt.Fprintln(out, "Denied.")
+ return err
+ case "expired":
+ _, err = fmt.Fprintln(out, "No answer in 10 minutes; ask again later.")
+ return err
+ case "approved":
+ // The grant below carries the answer's scopes and time.
+ }
+ }
+ for _, grant := range answer.Grants.Grants {
+ if grant.RequestID != id {
+ continue
+ }
+ if grant.Scopes != nil && !sameScopes(grant.Scopes, scopes) {
+ _, err = fmt.Fprintf(out, "Allowed, narrowed to: %s.\n", scopeWords(grant.Scopes))
+ return err
+ }
+ switch expiry := grant.ExpiresAt.(type) {
+ case nil:
+ _, err = fmt.Fprintln(out, "Allowed until you take it back.")
+ case string:
+ at, parseErr := time.Parse(time.RFC3339, expiry)
+ if parseErr != nil {
+ _, err = fmt.Fprintln(out, "Allowed.")
+ } else {
+ _, err = fmt.Fprintf(out, "Allowed until %s.\n", at.Local().Format("15:04"))
+ }
+ default:
+ _, err = fmt.Fprintln(out, "Allowed.")
+ }
+ return err
+ }
+ }
+ if !time.Now().Before(deadline) {
+ _, err = fmt.Fprintln(out, "No answer in 10 minutes; ask again later.")
+ return err
+ }
+ time.Sleep(2 * time.Second)
+ }
+}
+
+func sameScopes(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ seen := map[string]bool{}
+ for _, s := range a {
+ seen[s] = true
+ }
+ for _, s := range b {
+ if !seen[s] {
+ return false
+ }
+ }
+ return true
+}
+
+func accessLeft(expiry string) string {
+ at, err := time.Parse(time.RFC3339, expiry)
+ if err != nil {
+ return "until " + expiry
+ }
+ left := time.Until(at)
+ if left <= 0 {
+ return "ended"
+ }
+ if left < time.Minute {
+ left = time.Minute
+ }
+ left = left.Round(time.Minute)
+ days := int(left / (24 * time.Hour))
+ hours := int((left % (24 * time.Hour)) / time.Hour)
+ minutes := int((left % time.Hour) / time.Minute)
+ switch {
+ case days > 0:
+ return fmt.Sprintf("%dd %dh left", days, hours)
+ case hours > 0:
+ return fmt.Sprintf("%dh %dm left", hours, minutes)
+ default:
+ return fmt.Sprintf("%dm left", minutes)
+ }
+}
+
+func validScope(s string) bool {
+ switch s {
+ case "agent", "send", "screen", "desk", "computer", "worker":
+ return true
+ }
+ return false
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"
+)
+
+func TestAccessListShowsDesignStates(t *testing.T) {
+ value := map[string]any{
+ "requests": map[string]any{"requests": []any{map[string]any{"id": "req_123", "from_principal": "lnk_a", "to_principal": "lnk_b", "status": "pending", "scopes": []any{"screen", "desk"}}}},
+ "grants": map[string]any{"grants": []any{map[string]any{"id": "gra_123", "from_principal": "lnk_a", "to_principal": "lnk_b", "scopes": []any{"send"}, "expires_at": nil}}},
+ }
+ var out bytes.Buffer
+ if err := printAccessList(&out, value); err != nil {
+ t.Fatal(err)
+ }
+ got := out.String()
+ for _, part := range []string{"Waiting\n", "Watch the screen and use a private desk", "Allowed\n", "Send you files", "until you take it back", "Ended\n None"} {
+ if !strings.Contains(got, part) {
+ t.Fatalf("missing %q in %q", part, got)
+ }
+ }
+ if accessTime(3600) != "1 hour" || accessTime(0) != "until you take it back" {
+ t.Fatal("duration words")
+ }
+}
+
+func TestAccessRequestWaitsForTheAnswer(t *testing.T) {
+ var polls int
+ g := reviewSocket(t, func(r localctl.Request) localctl.Response {
+ if r.Cmd != localctl.CmdAccess {
+ return localctl.Response{Error: "unexpected command"}
+ }
+ switch r.ID {
+ case "request":
+ return localctl.Response{OK: true, Data: json.RawMessage(`{"request":{"id":"req_12345678","status":"pending"}}`)}
+ case "list":
+ polls++
+ if polls == 1 {
+ return localctl.Response{OK: true, Data: json.RawMessage(`{"requests":{"requests":[{"id":"req_12345678","status":"pending"}]},"grants":{"grants":[]}}`)}
+ }
+ return localctl.Response{OK: true, Data: json.RawMessage(`{"requests":{"requests":[{"id":"req_12345678","status":"approved"}]},"grants":{"grants":[{"id":"gra_12345678","request_id":"req_12345678","scopes":["screen"],"expires_at":"2030-01-01T12:40:00Z"}]}}`)}
+ }
+ return localctl.Response{Error: "unexpected action"}
+ })
+ var out bytes.Buffer
+ if err := cmdAccess(g, []string{"request", "lnk_target"}, accessArgs{Scope: "screen,desk", For: "1h"}, &out); err != nil {
+ t.Fatal(err)
+ }
+ if got := out.String(); !strings.Contains(got, "Asked lnk_target for: Watch the screen and use a private desk (1 hour).") ||
+ !strings.Contains(got, "Allowed, narrowed to: Watch the screen.") {
+ t.Fatalf("request output: %q", got)
+ }
+}
+
+func TestAccessRequestReportsDenied(t *testing.T) {
+ g := reviewSocket(t, func(r localctl.Request) localctl.Response {
+ if r.ID == "request" {
+ return localctl.Response{OK: true, Data: json.RawMessage(`{"request":{"id":"req_12345678"}}`)}
+ }
+ return localctl.Response{OK: true, Data: json.RawMessage(`{"requests":{"requests":[{"id":"req_12345678","status":"denied"}]},"grants":{"grants":[]}}`)}
+ })
+ var out bytes.Buffer
+ if err := cmdAccess(g, []string{"request", "lnk_target"}, accessArgs{Scope: "agent", For: "1h"}, &out); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "Waiting for their owner…") || !strings.Contains(out.String(), "Denied.") {
+ t.Fatalf("denied output: %q", out.String())
+ }
+}
@@ -74,6 +74,11 @@ Usage:
@@ -74,6 +74,11 @@ Usage:
start drives as before (stop is the kill switch, and holds until resume)
start drives as before (stop is the kill switch, and holds until resume)
openvibe-link plugins [--json]
openvibe-link plugins [--json]
openvibe-link approvals [--json] what the computer plugin waits to be approved (control level "ask")
openvibe-link approvals [--json] what the computer plugin waits to be approved (control level "ask")
+ openvibe-link mcp serve agent and machine tools over MCP stdio
+ openvibe-link mcp --print-config claude|codex|muse|opencode
+ openvibe-link mcp install claude|codex|muse|opencode ask before adding to a CLI
+ openvibe-link access request <machine> --scope agent,send [--for 1h] [--reason text]
+ openvibe-link access list|approve <id>|deny <id>|revoke <id>
openvibe-link approve <ID> let it run
openvibe-link approve <ID> let it run
openvibe-link deny <ID> refuse it
openvibe-link deny <ID> refuse it
openvibe-link tray [--autostart on|off]
openvibe-link tray [--autostart on|off]
@@ -192,26 +197,31 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -192,26 +197,31 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
fs.StringVar(&g.home, "home", "", "config and state directory")
fs.StringVar(&g.home, "home", "", "config and state directory")
fs.StringVar(&g.logLevel, "log-level", "info", "log level")
fs.StringVar(&g.logLevel, "log-level", "info", "log level")
var (
var (
- server = fs.String("server", "", "OpenVibe.Bot origin (pair)")
- kind = fs.String("kind", "", "onboard or bridge (pair)")
- robot = fs.String("robot", "", "the rob_… id of the robot to pair with, from the installer command (pair)")
- name = fs.String("name", "", "a name for this device on openvibe.bot; default: the hostname (pair)")
- codeArg = fs.String("code", "", "the pairing code, instead of the argument (pair)")
- network = fs.String("network", "", "OpenVibe.Network origin: pair this machine there (pair)")
- pairing = fs.String("pairing", "", "the pair_… id from the installer command (pair, with --network)")
- force = fs.Bool("force", false, "pair again even if already paired")
- dryRun = fs.Bool("dry-run", false, "run only the dry-run plugin and the test pattern (run)")
- asJSON = fs.Bool("json", false, "print JSON (status, plugins)")
- svcUser = fs.String("user", "", "run the service as this account (install)")
- session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")
- keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")
- autoArg = fs.String("autostart", "", "on or off: start the tray when you sign in (tray)")
- shKey = fs.String("key", "", "the share's ovc_… connect key from connect.openvibe.link (share)")
- shPort = fs.Int("port", 0, "the local port to share (share)")
- shSrv = fs.String("connect-server", "", "OpenVibe Connect's tunnel URL; default wss://connect.openvibe.link/tunnel (share)")
- shID = fs.String("share", "", "a share this computer serves itself, as OpenVibe Link: its shr_… id, no key (share)")
- gitRoot = fs.String("root", "", "the folder Link may clone into or publish from (clone, git); default ~/OpenVibe")
- yes = fs.Bool("yes", false, "approve this clone without asking (clone; for a script, never for a person)")
+ server = fs.String("server", "", "OpenVibe.Bot origin (pair)")
+ kind = fs.String("kind", "", "onboard or bridge (pair)")
+ robot = fs.String("robot", "", "the rob_… id of the robot to pair with, from the installer command (pair)")
+ name = fs.String("name", "", "a name for this device on openvibe.bot; default: the hostname (pair)")
+ codeArg = fs.String("code", "", "the pairing code, instead of the argument (pair)")
+ network = fs.String("network", "", "OpenVibe.Network origin: pair this machine there (pair)")
+ pairing = fs.String("pairing", "", "the pair_… id from the installer command (pair, with --network)")
+ force = fs.Bool("force", false, "pair again even if already paired")
+ dryRun = fs.Bool("dry-run", false, "run only the dry-run plugin and the test pattern (run)")
+ asJSON = fs.Bool("json", false, "print JSON (status, plugins)")
+ printConfig = fs.String("print-config", "", "print MCP server config for a CLI (mcp)")
+ mcpAgent = fs.String("agent", "", "local CLI identity for MCP")
+ accessScope = fs.String("scope", "", "comma-separated machine access scopes")
+ accessFor = fs.String("for", "", "machine access duration")
+ accessReason = fs.String("reason", "", "why this machine asks for access")
+ svcUser = fs.String("user", "", "run the service as this account (install)")
+ session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")
+ keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")
+ autoArg = fs.String("autostart", "", "on or off: start the tray when you sign in (tray)")
+ shKey = fs.String("key", "", "the share's ovc_… connect key from connect.openvibe.link (share)")
+ shPort = fs.Int("port", 0, "the local port to share (share)")
+ shSrv = fs.String("connect-server", "", "OpenVibe Connect's tunnel URL; default wss://connect.openvibe.link/tunnel (share)")
+ shID = fs.String("share", "", "a share this computer serves itself, as OpenVibe Link: its shr_… id, no key (share)")
+ gitRoot = fs.String("root", "", "the folder Link may clone into or publish from (clone, git); default ~/OpenVibe")
+ yes = fs.Bool("yes", false, "approve this clone without asking (clone; for a script, never for a person)")
)
)
// Accept flags anywhere: split positionals out.
// Accept flags anywhere: split positionals out.
var positional []string
var positional []string
@@ -285,6 +295,14 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -285,6 +295,14 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
err = cmdQuit(g, stdout)
err = cmdQuit(g, stdout)
case "plugins":
case "plugins":
err = cmdPlugins(g, *asJSON, stdout)
err = cmdPlugins(g, *asJSON, stdout)
+ case "mcp":
+ if *printConfig != "" {
+ err = mcpCommand(g, []string{"--print-config", *printConfig}, *mcpAgent, stdin, stdout)
+ } else {
+ err = mcpCommand(g, positional, *mcpAgent, stdin, stdout)
+ }
+ case "access":
+ err = cmdAccess(g, positional, accessArgs{Scope: *accessScope, For: *accessFor, Reason: *accessReason, JSON: *asJSON}, stdout)
case "approvals":
case "approvals":
err = cmdApprovals(g, *asJSON, stdout)
err = cmdApprovals(g, *asJSON, stdout)
case "approve", "deny":
case "approve", "deny":
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Link/internal/mcp"
+)
+
+type socketMCP struct{ path, agent string }
+
+func (s socketMCP) Call(name string, args json.RawMessage) (json.RawMessage, error) {
+ timeout := 10 * time.Second
+ if name == "agent_ask" || name == "machine_use" {
+ timeout = 125 * time.Second
+ }
+ r, err := localctl.Call(s.path, localctl.Request{Cmd: localctl.CmdMCP, ID: name, Agent: s.agent, Data: args}, timeout)
+ if err != nil {
+ return nil, err
+ }
+ if len(r.Data) == 0 {
+ return json.RawMessage(`{}`), nil
+ }
+ return r.Data, nil
+}
+
+func mcpCommand(g *globals, args []string, agent string, in io.Reader, out io.Writer) error {
+ if len(args) == 0 {
+ return mcp.Serve(in, out, socketMCP{path: g.paths.SocketPath(), agent: agent})
+ }
+ if len(args) == 2 && args[0] == "--print-config" {
+ return printMCPConfig(g, args[1], out)
+ }
+ if len(args) == 2 && args[0] == "install" {
+ return installMCP(g, args[1], in, out)
+ }
+ return errors.New("usage: openvibe-link mcp [--print-config claude|codex|muse|opencode | install claude|codex|muse|opencode]")
+}
+
+func mcpArgs(g *globals, cli string) (string, []string, error) {
+ exe, err := os.Executable()
+ if err != nil {
+ return "", nil, err
+ }
+ local := map[string]string{"claude": "claude-code", "codex": "codex", "muse": "muse", "opencode": "opencode"}[cli]
+ args := []string{"mcp", "--agent", local}
+ if g.home != "" {
+ args = append(args, "--home", g.home)
+ }
+ return exe, args, nil
+}
+
+func mcpSnippet(g *globals, cli string) (string, error) {
+ exe, args, err := mcpArgs(g, cli)
+ if err != nil {
+ return "", err
+ }
+ quoted, _ := json.Marshal(exe)
+ argv, _ := json.Marshal(args)
+ switch cli {
+ case "claude":
+ return fmt.Sprintf(`{"mcpServers":{"openvibe-link":{"command":%s,"args":%s}}}`, quoted, argv), nil
+ case "codex":
+ var b strings.Builder
+ fmt.Fprintf(&b, "[mcp_servers.openvibe-link]\ncommand = %s\nargs = %s\n", quoted, argv)
+ return b.String(), nil
+ case "muse":
+ return fmt.Sprintf(`{"mcp_servers":{"openvibe-link":{"transport":"stdio","command":%s,"args":%s,"mode":"optional"}}}`, quoted, argv), nil
+ case "opencode":
+ command, _ := json.Marshal(append([]string{exe}, args...))
+ return fmt.Sprintf(`{"mcp":{"servers":{"openvibe-link":{"type":"local","command":%s}}}}`, command), nil
+ default:
+ return "", fmt.Errorf("unknown CLI %q", cli)
+ }
+}
+
+func printMCPConfig(g *globals, cli string, out io.Writer) error {
+ s, err := mcpSnippet(g, cli)
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintln(out, s)
+ return err
+}
+
+// installMCP is an explicit offer: the person must answer yes before a CLI
+// config is touched. For three CLIs, their own `mcp add` does the edit. Muse
+// has no add command, so its JSON settings are patched with a safe file swap.
+func installMCP(g *globals, cli string, in io.Reader, out io.Writer) error {
+ if _, err := mcpSnippet(g, cli); err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintf(out, "Add OpenVibe Link's MCP tools to %s for this account? [y/N] ", cli); err != nil {
+ return err
+ }
+ line, err := bufio.NewReader(in).ReadString('\n')
+ if err != nil && err != io.EOF {
+ return err
+ }
+ if strings.ToLower(strings.TrimSpace(line)) != "y" {
+ _, _ = fmt.Fprintln(out, "Nothing changed.")
+ return nil
+ }
+ exe, args, err := mcpArgs(g, cli)
+ if err != nil {
+ return err
+ }
+ var command *exec.Cmd
+ switch cli {
+ case "claude":
+ command = exec.Command("claude", append([]string{"mcp", "add", "--transport", "stdio", "--scope", "user", "openvibe-link", "--", exe}, args...)...)
+ case "codex":
+ command = exec.Command("codex", append([]string{"mcp", "add", "openvibe-link", "--", exe}, args...)...)
+ case "opencode":
+ command = exec.Command("opencode", append([]string{"mcp", "add", "openvibe-link", "--", exe}, args...)...)
+ case "muse":
+ return installMuseMCP(exe, args, out)
+ }
+ command.Stdout, command.Stderr = out, out
+ if err := command.Run(); err != nil {
+ return fmt.Errorf("%s did not add the MCP server: %w", cli, err)
+ }
+ return nil
+}
+
+func installMuseMCP(exe string, args []string, out io.Writer) error {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return err
+ }
+ path := filepath.Join(home, ".config", "muse", "settings.json")
+ settings := map[string]json.RawMessage{}
+ if raw, err := os.ReadFile(path); err == nil {
+ if json.Unmarshal(raw, &settings) != nil || settings == nil {
+ return errors.New("Muse settings are not a JSON object")
+ }
+ } else if !os.IsNotExist(err) {
+ return err
+ }
+ servers := map[string]json.RawMessage{}
+ if raw := settings["mcp_servers"]; len(raw) > 0 && (json.Unmarshal(raw, &servers) != nil || servers == nil) {
+ return errors.New("Muse mcp_servers is not an object")
+ }
+ if _, exists := servers["openvibe-link"]; exists {
+ return errors.New("Muse already has an openvibe-link MCP server")
+ }
+ entry, _ := json.Marshal(map[string]any{"transport": "stdio", "command": exe, "args": args, "mode": "optional"})
+ servers["openvibe-link"] = entry
+ settings["mcp_servers"], _ = json.Marshal(servers)
+ updated, err := json.MarshalIndent(settings, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".settings-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err := tmp.Chmod(0600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(append(updated, '\n')); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmp.Name(), path); err != nil {
+ return err
+ }
+ _, err = fmt.Fprintln(out, "Added OpenVibe Link to Muse settings.")
+ return err
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"
+)
+
+func TestMCPInstallOffersAndPrintedConfigs(t *testing.T) {
+ for _, cli := range []string{"claude", "codex", "muse", "opencode"} {
+ t.Run(cli, func(t *testing.T) {
+ var printed bytes.Buffer
+ if err := printMCPConfig(&globals{}, cli, &printed); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(printed.String(), "openvibe-link") || !strings.Contains(printed.String(), "--agent") {
+ t.Fatalf("config: %s", printed.String())
+ }
+ var offered bytes.Buffer
+ if err := installMCP(&globals{}, cli, strings.NewReader("n\n"), &offered); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(offered.String(), "[y/N]") || !strings.Contains(offered.String(), "Nothing changed") {
+ t.Fatalf("offer: %s", offered.String())
+ }
+ })
+ }
+}
+
+func TestMCPStdioUsesTheLocalSocket(t *testing.T) {
+ g := reviewSocket(t, func(r localctl.Request) localctl.Response {
+ if r.Cmd != localctl.CmdMCP || r.Agent != "codex" {
+ return localctl.Response{Error: "wrong local command"}
+ }
+ if r.ID == "agents_list" {
+ return localctl.Response{OK: true, Data: json.RawMessage(`{"agents":[{"id":"agt_01J8Z4M2Q0R7T9YV3K6N8P1W2X","name":"Codex","status":"online"}]}`)}
+ }
+ return localctl.Response{Error: "no active grant for this machine and scope"}
+ })
+ call := func(name, args string) string {
+ t.Helper()
+ input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"` + name + `","arguments":` + args + `}}` + "\n"
+ var out bytes.Buffer
+ if err := mcpCommand(g, nil, "codex", strings.NewReader(input), &out); err != nil {
+ t.Fatal(err)
+ }
+ return out.String()
+ }
+ if got := call("agents_list", `{}`); !strings.Contains(got, `agt_01J8Z4M2Q0R7T9YV3K6N8P1W2X`) {
+ t.Fatalf("agents_list: %s", got)
+ }
+ if got := call("machine_use", `{"machine":"lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2Z","scope":"screen","action":"screen","data":{}}`); !strings.Contains(got, `"isError":true`) || !strings.Contains(got, "no active grant") {
+ t.Fatalf("refusal: %s", got)
+ }
+}
@@ -126,6 +126,9 @@ func (n *dbusNotifier) Ask(a agent.Approval, again string) {
@@ -126,6 +126,9 @@ func (n *dbusNotifier) Ask(a agent.Approval, again string) {
var actions []string
var actions []string
if n.actions {
if n.actions {
actions = []string{"approve", "Approve", "deny", "Deny"}
actions = []string{"approve", "Approve", "deny", "Deny"}
+ if a.Plugin == "machine-access" {
+ actions[1] = "Allow"
+ }
} else {
} else {
body += "\nopenvibe-link approve " + safety.QuoteHint(a.ID) + " (or deny " + safety.QuoteHint(a.ID) + ")"
body += "\nopenvibe-link approve " + safety.QuoteHint(a.ID) + " (or deny " + safety.QuoteHint(a.ID) + ")"
}
}
@@ -138,7 +141,14 @@ func (n *dbusNotifier) Ask(a agent.Approval, again string) {
@@ -138,7 +141,14 @@ func (n *dbusNotifier) Ask(a agent.Approval, again string) {
}
}
hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(2)), "desktop-entry": dbus.MakeVariant("openvibe-link-tray")}
hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(2)), "desktop-entry": dbus.MakeVariant("openvibe-link-tray")}
var nid uint32
var nid uint32
- err := n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Link", replaces, "dialog-question", "OpenVibe: your agent asks",
+ title := "OpenVibe: your agent asks"
+ if a.Plugin == "machine-access" {
+ title = a.Title
+ if title == "" {
+ title = "Machine asks for access"
+ }
+ }
+ err := n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Link", replaces, "dialog-question", title,
n.text(body), actions, hints, expire).Store(&nid)
n.text(body), actions, hints, expire).Store(&nid)
if err != nil {
if err != nil {
n.log.Warn("tray: notification failed", "err", err)
n.log.Warn("tray: notification failed", "err", err)
@@ -149,7 +149,11 @@ func (u *systrayUI) SetSlots(slots []traySlot) {
@@ -149,7 +149,11 @@ func (u *systrayUI) SetSlots(slots []traySlot) {
u.bindings = append([]traySlot(nil), slots...)
u.bindings = append([]traySlot(nil), slots...)
for _, slot := range slots {
for _, slot := range slots {
item := u.requests.AddSubMenuItem(slot.Label, "")
item := u.requests.AddSubMenuItem(slot.Label, "")
- approve := item.AddSubMenuItem("Approve", "Let it run")
+ label := "Approve"
+ if slot.Approval.Plugin == "machine-access" {
+ label = "Allow"
+ }
+ approve := item.AddSubMenuItem(label, "Let it run")
deny := item.AddSubMenuItem("Deny", "Refuse it")
deny := item.AddSubMenuItem("Deny", "Refuse it")
u.slots = append(u.slots, item)
u.slots = append(u.slots, item)
forwardTrayClicks(approve.ClickedCh, u.answers, slot.Approval.ID, true)
forwardTrayClicks(approve.ClickedCh, u.answers, slot.Approval.ID, true)
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+# Machine access and agent messages
+
+Network-paired Links can ask to use another machine. The target owner answers on that machine through the same approval list and tray notification used by computer actions, or from a signed-in Network surface. An approval creates a time-limited grant for only the selected scopes. Revoking it ends further use.
+
+## Request and answer
+
+```sh
+openvibe-link access request 'Alex desktop' --scope screen,desk --for 1h --reason 'Checking the build'
+openvibe-link access list
+openvibe-link access approve req_… --scope screen --for 30m
+openvibe-link access deny req_…
+openvibe-link access revoke gra_…
+```
+
+`--for` defaults to 1 hour and accepts a Go duration through 30 days, or `1d` through `30d`. `forever` is accepted only for `agent` and `send`; Network also requires both machines to have the same owner. The target can narrow scopes and shorten duration when approving, never widen them. `access request` waits up to ten minutes and then prints `Allowed until 12:40.`, `Allowed, narrowed to: …`, `Denied.` or `No answer in 10 minutes; ask again later.`; `--json` returns at once with the request id for a script. `access list` groups waiting requests, allowed grants and ended access. `--json` returns the same data for scripts without bearer grant tokens.
+
+The scopes are `agent` (message local agents), `send` (offer Locker-sealed files), `screen` (capture or watch the screen), `desk` (use a private desk), `computer` (run only the target's enabled computer actions), and `worker` (run sandbox jobs). A computer grant never raises the target's own `observe`, `ask`, `trusted`, or `full` level. At `ask`, each acting command still needs the target owner's local answer. The target's capability switches and stop latch still apply.
+
+The tray shows a machine request with **Allow** and **Deny** actions. `openvibe-link approvals` lists it beside computer requests, and `openvibe-link approve <nonce>` or `deny <nonce>` uses the same local approval socket. Network settles an answer once, even if two signed-in surfaces present it at once.
+
+Each relayed use carries Network's signed grant JWT. Link opens a Bot relay with `relay.open`; Bot checks the sending device, target and scope and sends `relay.opened` to both machines. The target Link independently verifies Network's published signing key, signature, issuer, machine audience, sender, scope, expiration, and current Network grant state on the opening and again as the relay runs. `relay.closed` or `access.revoked` ends a still-unexpired grant at once. It records the use in local activity. The direct LAN send path requires the same signed grant and live check in addition to the owner's LAN HMAC. A stopped, expired, revoked, or unavailable grant fails closed.
+
+## Agent messages
+
+Set `agents_talk: true` in Link's config to turn on **Let my agents talk**. It is off by default. Once on, Link registers the installed agent CLIs found by its fleet probe with Network's directory. Incoming `agent.task` frames enter a local inbox only while the switch is on. Cross-machine tasks also require an `agent` grant. Tasks carry the sending agent, person and thread; unanswered threads expire after 24 hours. An agent's message supplies information, never authority to operate the machine.
+
+`openvibe-link mcp` serves the local MCP tools over stdio and the running Link's protected local control socket:
+
+| Tool | Example arguments | Answer |
+| --- | --- | --- |
+| `agents_list` | `{}` | Directory entries |
+| `agent_ask` | `{"agent":"agt_…","text":"Can you check this?","wait_seconds":30}` | Completed result, or a pending task id after the wait |
+| `agent_tell` | `{"agent":"agt_…","text":"Build passed"}` | Created task and thread |
+| `agent_inbox` | `{}` | Tasks addressed to this local CLI |
+| `agent_reply` | `{"task":"tsk_…","text":"Done"}` | Completed task and thread |
+| `machines_list` | `{}` | Machines and held grants, without tokens |
+| `machine_request_access` | `{"machine":"lnk_…","scopes":["send"],"for_seconds":3600}` | Pending request |
+| `machine_use` | `{"machine":"lnk_…","scope":"send","action":"offer","data":{"envelope":"<sealed Locker envelope>"}}` | One operation's answer, or a refusal |
+
+`machine_use` accepts `task` for `agent`, `offer` for `send`, `screen` or `screen_stream` for `screen`, `desk` or `desk.stream` for `desk`, `exec`, `fs`, `input`, `browser` or `probe` for `computer`, and `run`, `status` or `cancel` for `worker`. Its `data` is the existing local feature's JSON value, except an `offer` wraps an `envelope`, `run` carries a platform job, and worker `status`/`cancel` take `{ "id": "job_…" }`. The target checks the specific scope before dispatch. Agent tasks are queued; they do not silently start an agent CLI.
+
+For a CLI that supports an MCP server, Link can print the configuration to add by hand, or offer to install it. Installing prompts before writing any tool configuration:
+
+```sh
+openvibe-link mcp --print-config claude
+openvibe-link mcp --print-config codex
+openvibe-link mcp --print-config muse
+openvibe-link mcp --print-config opencode
+openvibe-link mcp install codex
+```
+
+Use the corresponding CLI name with `install` for Claude Code, Muse or OpenCode. The server identifies the calling CLI; `agent_inbox` and `agent_reply` are restricted to that CLI's registered directory entry.
@@ -1,5 +1,13 @@
@@ -1,5 +1,13 @@
# Link ↔ OpenVibe.Bot protocol (bot.device-message@1, device side)
# Link ↔ OpenVibe.Bot protocol (bot.device-message@1, device side)
+## Machine access and agent relay
+
+A Network-paired Link asks Bot for a relay with `relay.open` (`to`, `grant_token`). Bot verifies Network's signed token, opens a session and sends `relay.opened` to both machines with the relay id, its scopes and the same token. Each operation or answer then travels as `relay.frame` (`relay`, `scope`, `body`); Bot checks the grant and scope on every frame, and both Links verify the token and check Network's current grant state themselves. `relay.close` ends the session and `relay.closed` tells both sides why (closed, expired or revoked). `access.revoked` with `grant_id` also ends a locally cached grant at once.
+
+The relay body is `{"id","action","data"}`, plus `level` for `computer`. The answer is `{"id","ok","data"}` or `{"id","ok":false,"error"}`. The scope maps to an existing local feature: `agent` queues a task, `send` places a Locker-sealed envelope in Link Send, `screen` and `desk` use their command kinds, `computer` uses the computer plugin's own owner level and per-action approval (Bot and the target both refuse a level above the target's own), and `worker` uses the sandbox job handler. The LAN handover for granted Send uses `/api/link-send/v2` with both the owner's HMAC and the same grant check.
+
+Network delivers an A2A task to a machine through Bot as `agent.task` (`task`), after checking the sender, the receiving agent and any machine grant. The machine answers with `agent.task_result` (`task_id`, `status`, `result`); Bot records it on Network and acknowledges with `agent.task_result_ack`. The local agent inbox is gated by the owner's `agents_talk` switch.
+
OpenVibe.Bot's own `docs/protocol.md` (section 1, the `/device` socket) and its `POST /api/v1/pair` handler are the
OpenVibe.Bot's own `docs/protocol.md` (section 1, the `/device` socket) and its `POST /api/v1/pair` handler are the
source of truth. Every field name below lives in one Go package, `internal/protocol/`, and the exact frames from Bot's
source of truth. Every field name below lives in one Go package, `internal/protocol/`, and the exact frames from Bot's
doc are pinned verbatim in [`internal/protocol/testdata/bot`](../internal/protocol/testdata/bot) (its README names the
doc are pinned verbatim in [`internal/protocol/testdata/bot`](../internal/protocol/testdata/bot) (its README names the
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
+package access
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/credentials"
+)
+
+var routeID = regexp.MustCompile(`^[a-z]{3}_[A-Za-z0-9_-]{8,80}$`)
+
+// Client uses this machine's Network node token for self access and agent
+// routes. Network still decides who may request, answer, revoke and relay.
+type Client struct {
+ Origin string
+ Tokens Tokens
+ HTTP *http.Client
+}
+
+type Tokens interface {
+ Token(context.Context) (credentials.Secret, error)
+ Invalidate()
+}
+
+func (c *Client) do(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
+ var none json.RawMessage
+ u, err := url.Parse(c.Origin)
+ if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" ||
+ (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1"))) {
+ return none, errors.New("machine access requires a Network HTTPS origin")
+ }
+ if c.Tokens == nil {
+ return none, errors.New("machine is not paired through Network")
+ }
+ var content io.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return none, err
+ }
+ content = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.Origin, "/")+path, content)
+ if err != nil {
+ return none, err
+ }
+ token, err := c.Tokens.Token(ctx)
+ if err != nil {
+ return none, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token.Reveal())
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ client := c.HTTP
+ if client == nil {
+ client = &http.Client{Timeout: 20 * time.Second}
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return none, err
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return none, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ var problem struct {
+ Code string `json:"code"`
+ }
+ _ = json.Unmarshal(raw, &problem)
+ if len(problem.Code) > 100 || strings.ContainsAny(problem.Code, " \n\r\t") {
+ problem.Code = ""
+ }
+ return none, fmt.Errorf("Network refused machine access: HTTP %d %s", resp.StatusCode, problem.Code)
+ }
+ if len(raw) == 0 {
+ raw = []byte("{}")
+ }
+ if !json.Valid(raw) {
+ return none, errors.New("Network returned invalid JSON")
+ }
+ return raw, nil
+}
+
+func (c *Client) Request(ctx context.Context, machine string, scopes []string, seconds int64, reason, agent string) (json.RawMessage, error) {
+ if machine == "" || len(machine) > 160 || len(scopes) == 0 || seconds < 0 || seconds > 30*86400 || len(reason) > 500 {
+ return nil, errors.New("invalid access request")
+ }
+ for _, s := range scopes {
+ if !ValidScope(s) {
+ return nil, errors.New("invalid access scope")
+ }
+ if seconds == 0 && s != "agent" && s != "send" {
+ return nil, errors.New("forever is only for agent and send")
+ }
+ }
+ if !strings.HasPrefix(machine, "lnk_") && !strings.HasPrefix(machine, "@") {
+ known, err := c.Machines(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var list struct {
+ Machines []struct{ ID, Name string } `json:"machines"`
+ }
+ if json.Unmarshal(known, &list) != nil {
+ return nil, errors.New("Network returned an invalid machine list")
+ }
+ found := ""
+ for _, row := range list.Machines {
+ if strings.EqualFold(row.Name, machine) {
+ if found != "" {
+ return nil, errors.New("machine name is ambiguous; use its id")
+ }
+ found = row.ID
+ }
+ }
+ if found == "" {
+ return nil, errors.New("machine name not found")
+ }
+ machine = found
+ }
+ var duration any = seconds
+ if seconds == 0 {
+ duration = nil
+ }
+ return c.do(ctx, http.MethodPost, "/api/v1/link/self/access-requests", map[string]any{"target": machine, "scopes": scopes, "duration_seconds": duration, "reason": reason, "from_agent": agent})
+}
+
+func ValidScope(s string) bool { return scopes[s] }
+
+func (c *Client) List(ctx context.Context) (json.RawMessage, error) {
+ return c.do(ctx, http.MethodGet, "/api/v1/link/self/access-requests", nil)
+}
+func (c *Client) Grants(ctx context.Context) (json.RawMessage, error) {
+ return c.do(ctx, http.MethodGet, "/api/v1/link/self/access-grants", nil)
+}
+func (c *Client) GrantToken(ctx context.Context, id string) (json.RawMessage, error) {
+ if !routeID.MatchString(id) {
+ return nil, errors.New("invalid grant id")
+ }
+ return c.do(ctx, http.MethodPost, "/api/v1/link/self/access-grants/"+id+"/token", map[string]any{})
+}
+func (c *Client) Machines(ctx context.Context) (json.RawMessage, error) {
+ return c.do(ctx, http.MethodGet, "/api/v1/link/self/send/targets", nil)
+}
+func (c *Client) Answer(ctx context.Context, id string, approve bool, scopes []string, seconds int64) (json.RawMessage, error) {
+ if !routeID.MatchString(id) {
+ return nil, errors.New("invalid request id")
+ }
+ body := map[string]any{"approve": approve}
+ if len(scopes) > 0 {
+ for _, s := range scopes {
+ if !ValidScope(s) {
+ return nil, errors.New("invalid narrowed scope")
+ }
+ }
+ body["scopes"] = scopes
+ }
+ if seconds > 0 {
+ if seconds > 30*86400 {
+ return nil, errors.New("invalid narrowed duration")
+ }
+ body["duration_seconds"] = seconds
+ }
+ return c.do(ctx, http.MethodPost, "/api/v1/link/self/access-requests/"+id+"/answer", body)
+}
+func (c *Client) Revoke(ctx context.Context, id string) (json.RawMessage, error) {
+ if !routeID.MatchString(id) {
+ return nil, errors.New("invalid grant id")
+ }
+ return c.do(ctx, http.MethodPost, "/api/v1/link/self/access-grants/"+id+"/revoke", map[string]any{})
+}
+func (c *Client) Agents(ctx context.Context) (json.RawMessage, error) {
+ return c.do(ctx, http.MethodGet, "/api/v1/link/self/agents", nil)
+}
+func (c *Client) RegisterAgent(ctx context.Context, entry any) (json.RawMessage, error) {
+ return c.do(ctx, http.MethodPost, "/api/v1/agents/register", entry)
+}
+func (c *Client) Task(ctx context.Context, id string, task any) (json.RawMessage, error) {
+ if !routeID.MatchString(id) {
+ return nil, errors.New("invalid agent id")
+ }
+ return c.do(ctx, http.MethodPost, "/api/v1/agents/"+id+"/tasks", task)
+}
+func (c *Client) TaskStatus(ctx context.Context, id string) (json.RawMessage, error) {
+ if !routeID.MatchString(id) {
+ return nil, errors.New("invalid task id")
+ }
+ return c.do(ctx, http.MethodGet, "/api/v1/agents/tasks/"+id, nil)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+package access
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/credentials"
+)
+
+type testTokens struct{}
+
+func (testTokens) Invalidate() {}
+
+func (testTokens) Token(context.Context) (credentials.Secret, error) {
+ return credentials.NewSecret("test-node-token"), nil
+}
+
+func TestAccessClientRoutesAndRefusals(t *testing.T) {
+ var mu sync.Mutex
+ var calls []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer test-node-token" {
+ t.Errorf("missing node authorization")
+ }
+ mu.Lock()
+ calls = append(calls, r.Method+" "+r.URL.Path)
+ mu.Unlock()
+ if r.Method == http.MethodPost && r.URL.Path == "/api/v1/link/self/access-requests" {
+ var body map[string]any
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body["target"] != "lnk_other" || body["duration_seconds"] != float64(3600) {
+ t.Errorf("wrong request shape")
+ }
+ }
+ if strings.HasSuffix(r.URL.Path, "/answer") {
+ var body map[string]any
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body["approve"] != true {
+ t.Errorf("wrong answer shape")
+ }
+ }
+ if strings.HasSuffix(r.URL.Path, "/token") {
+ _ = json.NewEncoder(w).Encode(map[string]any{"grant_token": "signed-test-token"})
+ return
+ }
+ if strings.Contains(r.URL.Path, "/revoke") {
+ w.WriteHeader(403)
+ _, _ = w.Write([]byte(`{"code":"access.not_allowed","detail":"test-node-token"}`))
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "req_12345678", "status": "pending"})
+ }))
+ defer server.Close()
+ c := &Client{Origin: server.URL, Tokens: testTokens{}}
+ ctx := context.Background()
+ if _, err := c.Request(ctx, "lnk_other", []string{"agent"}, 3600, "work", ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.List(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.Answer(ctx, "req_12345678", true, []string{"agent"}, 1800); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.GrantToken(ctx, "grt_12345678"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.Revoke(ctx, "gra_12345678"); err == nil || strings.Contains(err.Error(), "test-node-token") {
+ t.Fatalf("unsafe refusal: %v", err)
+ }
+ if _, err := c.Answer(ctx, "../../etc", true, nil, 0); err == nil {
+ t.Fatal("unsafe id accepted")
+ }
+ if _, err := c.Request(ctx, "lnk_other", []string{"admin"}, 3600, "", ""); err == nil {
+ t.Fatal("unknown scope accepted")
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if len(calls) != 5 {
+ t.Fatalf("calls: %v", calls)
+ }
+ if calls[0] != "POST /api/v1/link/self/access-requests" || calls[2] != "POST /api/v1/link/self/access-requests/req_12345678/answer" {
+ t.Fatalf("routes: %v", calls)
+ }
+}
+
+func TestRequestResolvesOwnMachineName(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && r.URL.Path == "/api/v1/link/self/send/targets" {
+ _, _ = w.Write([]byte(`{"machines":[{"id":"lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2Z","name":"Desktop"}]}`))
+ return
+ }
+ var body map[string]any
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body["target"] != "lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2Z" || body["duration_seconds"] != nil {
+ t.Errorf("request did not resolve name and forever: %v", body)
+ }
+ _, _ = w.Write([]byte(`{"request":{"id":"req_12345678"}}`))
+ }))
+ defer server.Close()
+ c := &Client{Origin: server.URL, Tokens: testTokens{}}
+ if _, err := c.Request(context.Background(), "desktop", []string{"send"}, 0, "", ""); err != nil {
+ t.Fatal(err)
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,213 @@
@@ -0,0 +1,213 @@
+// Package access verifies machine grants independently of Bot's relay checks.
+package access
+
+import (
+ "context"
+ "crypto"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math/big"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+)
+
+var ErrGrant = errors.New("machine access grant refused")
+
+var scopes = map[string]bool{"agent": true, "send": true, "screen": true, "desk": true, "computer": true, "worker": true}
+
+// Claims are the Network-signed link.access-grant-token@1 claims. A grant id is
+// required so a revocation can end a still-unexpired grant immediately.
+type Claims struct {
+ ID string `json:"grant_id"`
+ JTI string `json:"jti"`
+ Issuer string `json:"iss"`
+ From string `json:"sub"`
+ To string `json:"aud"`
+ Scopes []string `json:"scopes"`
+ Expiry int64 `json:"exp"`
+ Issued int64 `json:"iat"`
+ Before int64 `json:"nbf,omitempty"`
+}
+
+// Verifier pins Network's signing keys to the configured HTTPS origin and
+// caches them briefly. Revocations are kept until the JWT would expire.
+type Verifier struct {
+ Network string
+ Machine string
+ Client *http.Client
+ Now func() time.Time
+
+ mu sync.Mutex
+ keys map[string]*rsa.PublicKey
+ keysAt time.Time
+ revoked map[string]time.Time
+}
+
+func NewVerifier(network, machine string) *Verifier {
+ return &Verifier{Network: strings.TrimRight(network, "/"), Machine: machine, Client: &http.Client{Timeout: 5 * time.Second}, Now: time.Now, revoked: map[string]time.Time{}}
+}
+
+func (v *Verifier) Revoke(id string, until time.Time) {
+ if id == "" {
+ return
+ }
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ if v.revoked == nil {
+ v.revoked = map[string]time.Time{}
+ }
+ v.revoked[id] = until
+}
+
+// Verify checks every use, including the requested scope and the sending
+// machine. A malformed token or a key fetch failure never opens access.
+func (v *Verifier) Verify(ctx context.Context, token, sender, scope string) (Claims, error) {
+ if !scopes[scope] {
+ return Claims{}, ErrGrant
+ }
+ return v.verify(ctx, token, sender, scope)
+}
+
+// VerifyAny checks a grant token without selecting one scope: Bot's relay.opened
+// names the grant's scopes, and each later relay.frame selects one.
+func (v *Verifier) VerifyAny(ctx context.Context, token, sender string) (Claims, error) {
+ return v.verify(ctx, token, sender, "")
+}
+
+func (v *Verifier) verify(ctx context.Context, token, sender, scope string) (Claims, error) {
+ var c Claims
+ if token == "" || sender == "" || sender == v.Machine || v.Machine == "" {
+ return c, ErrGrant
+ }
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 || len(token) > 16<<10 {
+ return c, ErrGrant
+ }
+ header, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil || len(header) > 2048 {
+ return c, ErrGrant
+ }
+ var h struct {
+ Alg string `json:"alg"`
+ Kid string `json:"kid"`
+ Typ string `json:"typ"`
+ }
+ if json.Unmarshal(header, &h) != nil || h.Alg != "RS256" || h.Kid == "" {
+ return c, ErrGrant
+ }
+ key, err := v.key(ctx, h.Kid)
+ if err != nil {
+ return c, fmt.Errorf("%w: signing key unavailable", ErrGrant)
+ }
+ sig, err := base64.RawURLEncoding.DecodeString(parts[2])
+ if err != nil {
+ return c, ErrGrant
+ }
+ hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
+ if rsa.VerifyPKCS1v15(key, crypto.SHA256, hash[:], sig) != nil {
+ return c, ErrGrant
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil || len(payload) > 8<<10 || json.Unmarshal(payload, &c) != nil {
+ return Claims{}, ErrGrant
+ }
+ now := v.Now()
+ if c.ID == "" || c.JTI == "" || c.From != sender || c.To != v.Machine || c.Expiry <= now.Unix() || c.Before > now.Unix() || c.Issued > now.Unix() ||
+ (c.Issuer != v.Network && c.Issuer != strings.TrimPrefix(v.Network, "https://")) {
+ return Claims{}, ErrGrant
+ }
+ found := scope == ""
+ for _, s := range c.Scopes {
+ if !scopes[s] {
+ return Claims{}, ErrGrant
+ }
+ found = found || s == scope
+ }
+ if !found {
+ return Claims{}, ErrGrant
+ }
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ if until, ok := v.revoked[c.ID]; ok && (until.IsZero() || now.Before(until)) {
+ return Claims{}, ErrGrant
+ }
+ return c, nil
+}
+
+func (v *Verifier) key(ctx context.Context, kid string) (*rsa.PublicKey, error) {
+ v.mu.Lock()
+ if v.keys != nil && v.Now().Sub(v.keysAt) < 5*time.Minute {
+ key := v.keys[kid]
+ v.mu.Unlock()
+ if key != nil {
+ return key, nil
+ }
+ // A signing-key rotation can introduce a new kid before the cache TTL.
+ }
+ v.mu.Unlock()
+ u, parseErr := url.Parse(v.Network)
+ if parseErr != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") ||
+ (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "127.0.0.1" || u.Hostname() == "localhost" || u.Hostname() == "::1"))) {
+ return nil, ErrGrant
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.Network+"/api/.well-known/jwks", nil)
+ if err != nil {
+ return nil, err
+ }
+ client := v.Client
+ if client == nil {
+ client = &http.Client{Timeout: 5 * time.Second}
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, ErrGrant
+ }
+ var doc struct {
+ Keys []struct {
+ Kid string `json:"kid"`
+ Kty string `json:"kty"`
+ Alg string `json:"alg"`
+ N string `json:"n"`
+ E string `json:"e"`
+ } `json:"keys"`
+ }
+ if json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&doc) != nil {
+ return nil, ErrGrant
+ }
+ keys := map[string]*rsa.PublicKey{}
+ for _, item := range doc.Keys {
+ if item.Kid == "" || item.Kty != "RSA" || item.Alg != "RS256" {
+ continue
+ }
+ n, ne := base64.RawURLEncoding.DecodeString(item.N)
+ e, ee := base64.RawURLEncoding.DecodeString(item.E)
+ if ne != nil || ee != nil || len(n) < 256 || len(n) > 512 || len(e) == 0 || len(e) > 4 {
+ continue
+ }
+ exponent := new(big.Int).SetBytes(e).Int64()
+ if exponent < 3 || exponent > 1<<31-1 || exponent%2 == 0 {
+ continue
+ }
+ keys[item.Kid] = &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(exponent)}
+ }
+ v.mu.Lock()
+ v.keys, v.keysAt = keys, v.Now()
+ key := keys[kid]
+ v.mu.Unlock()
+ if key == nil {
+ return nil, ErrGrant
+ }
+ return key, nil
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+package access
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestGrantVerification(t *testing.T) {
+ private, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub := private.PublicKey
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/.well-known/jwks" {
+ t.Errorf("path: %s", r.URL.Path)
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{
+ "kid": "network-1", "kty": "RSA", "alg": "RS256",
+ "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
+ }}})
+ }))
+ defer server.Close()
+ v := NewVerifier(server.URL, "lnk_target")
+ now := time.Unix(2000000000, 0)
+ v.Now = func() time.Time { return now }
+ sign := func(c Claims) string {
+ t.Helper()
+ h, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "network-1"})
+ p, _ := json.Marshal(c)
+ body := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p)
+ digest := sha256.Sum256([]byte(body))
+ sig, err := rsa.SignPKCS1v15(rand.Reader, private, crypto.SHA256, digest[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return body + "." + base64.RawURLEncoding.EncodeToString(sig)
+ }
+ c := Claims{ID: "gra_12345678", JTI: "jti_12345678", Issuer: server.URL, From: "lnk_sender", To: "lnk_target", Scopes: []string{"agent", "send"}, Issued: now.Unix(), Expiry: now.Add(time.Hour).Unix()}
+ token := sign(c)
+ if _, err := v.Verify(context.Background(), token, c.From, "send"); err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range []struct{ name, token, sender, scope string }{
+ {"wrong sender", token, "lnk_other", "send"},
+ {"scope", token, c.From, "computer"},
+ {"unknown scope", token, c.From, "admin"},
+ {"tamper", token + "x", c.From, "send"},
+ {"expired", sign(Claims{ID: c.ID, JTI: c.JTI, Issuer: c.Issuer, From: c.From, To: c.To, Scopes: c.Scopes, Issued: c.Issued, Expiry: now.Unix() - 1}), c.From, "send"},
+ {"other audience", sign(Claims{ID: c.ID, JTI: c.JTI, Issuer: c.Issuer, From: c.From, To: "lnk_other", Scopes: c.Scopes, Issued: c.Issued, Expiry: c.Expiry}), c.From, "send"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if _, err := v.Verify(context.Background(), test.token, test.sender, test.scope); err == nil {
+ t.Fatal("grant accepted")
+ }
+ })
+ }
+ v.Revoke(c.ID, now.Add(time.Hour))
+ if _, err := v.Verify(context.Background(), token, c.From, "send"); err == nil {
+ t.Fatal("revoked grant accepted")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+package access
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+)
+
+// Task is the local projection of agent.task@1. Link keeps the original
+// sender, person and thread visible to the receiving agent.
+type Task struct {
+ ID string `json:"id"`
+ To string `json:"to"`
+ From string `json:"from"`
+ OnBehalfOf string `json:"on_behalf_of"`
+ Thread string `json:"thread"`
+ FromMachine string `json:"from_machine,omitempty"`
+ Message struct {
+ Parts []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"parts"`
+ } `json:"message"`
+ CreatedAt string `json:"created_at,omitempty"`
+}
+
+type InboxEntry struct {
+ Task Task `json:"task"`
+ ReceivedAt time.Time `json:"received_at"`
+ Status string `json:"status"`
+ Reply string `json:"reply,omitempty"`
+}
+
+type Inbox struct {
+ path string
+ now func() time.Time
+ mu sync.Mutex
+ entries []InboxEntry
+}
+
+func OpenInbox(path string) (*Inbox, error) {
+ i := &Inbox{path: path, now: time.Now}
+ raw, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return i, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(raw) > 2<<20 || json.Unmarshal(raw, &i.entries) != nil {
+ return nil, errors.New("agent inbox state is invalid")
+ }
+ i.prune()
+ return i, nil
+}
+
+func (i *Inbox) prune() {
+ now := i.now()
+ out := i.entries[:0]
+ for _, entry := range i.entries {
+ if now.Sub(entry.ReceivedAt) < 24*time.Hour && now.Sub(entry.ReceivedAt) > -time.Minute {
+ out = append(out, entry)
+ }
+ }
+ i.entries = out
+}
+
+func (i *Inbox) save() error {
+ raw, err := json.Marshal(i.entries)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(i.path), 0700); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(i.path), ".agent-inbox-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err := tmp.Chmod(0600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(raw); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), i.path)
+}
+
+func (i *Inbox) Add(t Task) error {
+ if t.ID == "" || t.To == "" || t.From == "" || t.OnBehalfOf == "" || t.Thread == "" || len(t.Message.Parts) == 0 || len(t.Message.Parts) > 20 {
+ return errors.New("invalid agent task")
+ }
+ for _, p := range t.Message.Parts {
+ if p.Type != "text" || len(p.Text) > 8000 {
+ return errors.New("invalid agent task text")
+ }
+ }
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ i.prune()
+ for _, entry := range i.entries {
+ if entry.Task.ID == t.ID {
+ return nil
+ }
+ }
+ if len(i.entries) >= 100 {
+ return errors.New("agent inbox is full")
+ }
+ i.entries = append(i.entries, InboxEntry{Task: t, ReceivedAt: i.now(), Status: "pending"})
+ return i.save()
+}
+
+func (i *Inbox) List(agent string) []InboxEntry {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ i.prune()
+ out := make([]InboxEntry, 0, len(i.entries))
+ for _, entry := range i.entries {
+ if agent == "" || entry.Task.To == agent {
+ out = append(out, entry)
+ }
+ }
+ sort.Slice(out, func(a, b int) bool { return out[a].ReceivedAt.Before(out[b].ReceivedAt) })
+ return out
+}
+
+func (i *Inbox) Reply(id, text string) (InboxEntry, error) {
+ if id == "" || len(text) > 8000 {
+ return InboxEntry{}, errors.New("invalid agent reply")
+ }
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ i.prune()
+ for n := range i.entries {
+ if i.entries[n].Task.ID != id {
+ continue
+ }
+ if i.entries[n].Status != "pending" {
+ return InboxEntry{}, errors.New("agent task already answered")
+ }
+ i.entries[n].Status = "completed"
+ i.entries[n].Reply = text
+ if err := i.save(); err != nil {
+ return InboxEntry{}, err
+ }
+ return i.entries[n], nil
+ }
+ return InboxEntry{}, errors.New("agent task not in this inbox")
+}
+
+func NewTaskID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return ""
+ }
+ return "tsk_" + hex.EncodeToString(b)
+}
+
+func NewRelayID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return ""
+ }
+ return "mrl_" + hex.EncodeToString(b)
+}
+
+func NewThreadID() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return ""
+ }
+ return "thr_" + hex.EncodeToString(b)
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+package access
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func sampleTask() Task {
+ t := Task{ID: "tsk_12345678", To: "agt_target", From: "agt_sender", OnBehalfOf: "usr_owner", Thread: "thr_12345678"}
+ t.Message.Parts = append(t.Message.Parts, struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ }{Type: "text", Text: "hello"})
+ return t
+}
+
+func TestInboxPersistsAndExpires(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "agent-inbox.json")
+ i, err := OpenInbox(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ task := sampleTask()
+ if err := i.Add(task); err != nil {
+ t.Fatal(err)
+ }
+ if err := i.Add(task); err != nil {
+ t.Fatal(err)
+ }
+ if len(i.List("agt_target")) != 1 || len(i.List("agt_other")) != 0 {
+ t.Fatal("wrong inbox filter or duplicate")
+ }
+ i, err = OpenInbox(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entry, err := i.Reply(task.ID, "answer")
+ if err != nil || entry.Status != "completed" {
+ t.Fatalf("reply: %+v %v", entry, err)
+ }
+ if _, err := i.Reply(task.ID, "again"); err == nil {
+ t.Fatal("duplicate reply accepted")
+ }
+ i.now = func() time.Time { return time.Now().Add(25 * time.Hour) }
+ if len(i.List("")) != 0 {
+ t.Fatal("stale thread remains")
+ }
+}
+
+func TestInboxRefusesInvalidTask(t *testing.T) {
+ i, err := OpenInbox(filepath.Join(t.TempDir(), "inbox.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ task := sampleTask()
+ task.Message.Parts[0].Text = strings.Repeat("x", 8001)
+ if err := i.Add(task); err == nil {
+ t.Fatal("oversized text accepted")
+ }
+ task = sampleTask()
+ task.OnBehalfOf = ""
+ if err := i.Add(task); err == nil {
+ t.Fatal("missing person accepted")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,1468 @@
@@ -0,0 +1,1468 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/access"
+ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Link/internal/mono"
+ "github.com/OpenVibers/OpenVibe.Link/internal/protocol"
+ "github.com/OpenVibers/OpenVibe.Link/internal/send"
+)
+
+func (n *Agent) agentResultAck(taskID, failure string) {
+ n.mu.Lock()
+ ch := n.agentResults[taskID]
+ n.mu.Unlock()
+ if ch != nil {
+ select {
+ case ch <- failure:
+ default:
+ }
+ }
+}
+
+func (n *Agent) failAgentResults(detail string) {
+ n.mu.Lock()
+ waiters := make([]chan string, 0, len(n.agentResults))
+ for id, ch := range n.agentResults {
+ waiters = append(waiters, ch)
+ delete(n.agentResults, id)
+ }
+ n.mu.Unlock()
+ for _, ch := range waiters {
+ select {
+ case ch <- detail:
+ default:
+ }
+ }
+}
+
+func (n *Agent) receiveAgentTask(frame protocol.AgentTaskFrame) error {
+ if !n.opt.Config.AgentsTalk {
+ return errors.New("this machine has Let my agents talk turned off")
+ }
+ if n.accessInbox == nil || n.opt.Creds == nil || !n.opt.Creds.NetworkPaired() {
+ return errors.New("agent inbox unavailable")
+ }
+ if len(frame.Task) == 0 || len(frame.Task) > 200<<10 {
+ return errors.New("agent task is missing or too large")
+ }
+ var task access.Task
+ if json.Unmarshal(frame.Task, &task) != nil {
+ return errors.New("invalid agent task")
+ }
+ n.mu.Lock()
+ registered := false
+ for _, local := range n.localAgents {
+ if local.ID == task.To {
+ registered = true
+ break
+ }
+ }
+ n.mu.Unlock()
+ if !registered {
+ return errors.New("task is not addressed to an agent registered on this machine")
+ }
+ if err := n.accessInbox.Add(task); err != nil {
+ return err
+ }
+ n.addActivity(fmt.Sprintf("Agent task %s from %s on behalf of %s arrived", task.ID, task.From, task.OnBehalfOf))
+ return nil
+}
+
+func (n *Agent) revokeAccess(frame protocol.AccessRevoked) {
+ if n.grantVerifier != nil && frame.GrantID != "" {
+ n.grantVerifier.Revoke(frame.GrantID, time.Now().Add(31*24*time.Hour))
+ n.addActivity("Machine access grant " + frame.GrantID + " was revoked")
+ }
+ n.mu.Lock()
+ sessions := make([]*machineRelay, 0)
+ for _, session := range n.relaySessions {
+ if session.Claims.ID == frame.GrantID {
+ sessions = append(sessions, session)
+ }
+ }
+ n.mu.Unlock()
+ for _, session := range sessions {
+ n.closeRelay(session, "revoked")
+ }
+}
+
+func machineGrant(raw json.RawMessage, self, machine, scope string) string {
+ var list struct {
+ Grants []struct {
+ ID string `json:"id"`
+ From string `json:"from_principal"`
+ To string `json:"to_principal"`
+ Scopes []string `json:"scopes"`
+ Revoked *string `json:"revoked_at"`
+ Expires *string `json:"expires_at"`
+ } `json:"grants"`
+ }
+ if json.Unmarshal(raw, &list) != nil {
+ return ""
+ }
+ for _, grant := range list.Grants {
+ if grant.From != self || grant.To != machine || grant.ID == "" || grant.Revoked != nil {
+ continue
+ }
+ if grant.Expires != nil {
+ at, err := time.Parse(time.RFC3339, *grant.Expires)
+ if err != nil || !at.After(time.Now()) {
+ continue
+ }
+ }
+ for _, s := range grant.Scopes {
+ if s == scope {
+ return grant.ID
+ }
+ }
+ }
+ return ""
+}
+
+func responseJSON(value any) localctl.Response {
+ b, err := json.Marshal(value)
+ if err != nil {
+ return localctl.Response{Error: "could not encode the answer"}
+ }
+ return localctl.Response{OK: true, Data: b}
+}
+
+// A grant token is used only inside Link. Neither `access list --json` nor an
+// MCP tool answer may return a bearer token to a model or a terminal log.
+func publicJSON(raw json.RawMessage) any {
+ var value any
+ if json.Unmarshal(raw, &value) != nil {
+ return map[string]any{}
+ }
+ return stripPrivate(value)
+}
+
+func publicData(raw json.RawMessage) json.RawMessage {
+ b, _ := json.Marshal(publicJSON(raw))
+ return b
+}
+
+func stripPrivate(value any) any {
+ switch v := value.(type) {
+ case map[string]any:
+ out := make(map[string]any, len(v))
+ for key, item := range v {
+ lower := strings.ToLower(key)
+ if strings.Contains(lower, "token") || strings.Contains(lower, "secret") || strings.Contains(lower, "credential") {
+ continue
+ }
+ out[key] = stripPrivate(item)
+ }
+ return out
+ case []any:
+ out := make([]any, len(v))
+ for i, item := range v {
+ out[i] = stripPrivate(item)
+ }
+ return out
+ default:
+ return value
+ }
+}
+
+func (n *Agent) mcpControl(name, caller string, raw json.RawMessage) localctl.Response {
+ if !n.opt.Config.AgentsTalk && (name == "agent_inbox" || name == "agent_reply") {
+ return localctl.Response{Error: "Let my agents talk is off on this machine"}
+ }
+ switch name {
+ case "agent_inbox":
+ var args struct {
+ Agent string `json:"agent"`
+ }
+ if json.Unmarshal(raw, &args) != nil {
+ return localctl.Response{Error: "invalid inbox arguments"}
+ }
+ local, err := n.localAgentFor(caller)
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ if args.Agent != "" && args.Agent != local.ID {
+ return localctl.Response{Error: "this CLI cannot read another agent's inbox"}
+ }
+ return responseJSON(map[string]any{"tasks": n.accessInbox.List(local.ID)})
+ case "agent_reply":
+ var args struct {
+ Task string `json:"task"`
+ Text string `json:"text"`
+ }
+ if json.Unmarshal(raw, &args) != nil {
+ return localctl.Response{Error: "invalid reply arguments"}
+ }
+ local, err := n.localAgentFor(caller)
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ var pending *access.InboxEntry
+ for _, entry := range n.accessInbox.List(local.ID) {
+ if entry.Task.ID == args.Task {
+ copy := entry
+ pending = ©
+ break
+ }
+ }
+ if pending == nil || pending.Status != "pending" {
+ return localctl.Response{Error: "task is not in this agent's inbox"}
+ }
+ if n.link == nil {
+ return localctl.Response{Error: "the Link is not connected to OpenVibe.Bot"}
+ }
+ if strings.TrimSpace(args.Text) == "" || len(args.Text) > 8000 {
+ return localctl.Response{Error: "a reply needs up to 8000 characters"}
+ }
+ result, _ := json.Marshal(map[string]any{"parts": []any{map[string]any{"type": "text", "text": args.Text}}})
+ // Bot records the answer on Network; a node token may not post agent
+ // task results directly, and Bot checks the task was delivered here.
+ waiter := make(chan string, 1)
+ n.mu.Lock()
+ n.agentResults[args.Task] = waiter
+ n.mu.Unlock()
+ defer func() {
+ n.mu.Lock()
+ delete(n.agentResults, args.Task)
+ n.mu.Unlock()
+ }()
+ if err := n.link.Send(protocol.AgentTaskResult{TaskID: args.Task, Status: "completed", Result: result}); err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ select {
+ case failure := <-waiter:
+ if failure != "" {
+ return localctl.Response{Error: failure}
+ }
+ case <-time.After(10 * time.Second):
+ return localctl.Response{Error: "Bot did not record the answer in time"}
+ }
+ entry, err := n.accessInbox.Reply(args.Task, args.Text)
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ return responseJSON(map[string]any{"id": entry.Task.ID, "thread": entry.Task.Thread, "status": entry.Status})
+ case "agents_list":
+ if n.accessClient == nil {
+ return localctl.Response{Error: "pair this machine through Network first"}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ data, err := n.accessClient.Agents(ctx)
+ if err != nil {
+ // Network's node-self directory route is newer than this Link. A
+ // local machine still lists the CLIs it has registered.
+ return responseJSON(map[string]any{"agents": n.localAgentEntries()})
+ }
+ return localctl.Response{OK: true, Data: publicData(data)}
+ case "machines_list":
+ if n.accessClient == nil {
+ return localctl.Response{Error: "pair this machine through Network first"}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ grants, err := n.accessClient.Grants(ctx)
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ machines, machineErr := n.accessClient.Machines(ctx)
+ if machineErr != nil {
+ machines = json.RawMessage(`{"machines":[]}`)
+ }
+ var machineList struct {
+ Machines []any `json:"machines"`
+ }
+ var grantList struct {
+ Grants []any `json:"grants"`
+ }
+ _ = json.Unmarshal(machines, &machineList)
+ _ = json.Unmarshal(grants, &grantList)
+ if machineList.Machines == nil {
+ machineList.Machines = []any{}
+ }
+ if grantList.Grants == nil {
+ grantList.Grants = []any{}
+ }
+ return responseJSON(map[string]any{"machines": stripPrivate(machineList.Machines), "grants": stripPrivate(grantList.Grants)})
+ case "machine_request_access":
+ if n.accessClient == nil {
+ return localctl.Response{Error: "pair this machine through Network first"}
+ }
+ // The request names the agent when "Let my agents talk" has registered
+ // this CLI. Without it the machine itself still asks, so a local agent
+ // can request access before the owner turns agent messages on.
+ agentID := ""
+ if local, err := n.localAgentFor(caller); err == nil {
+ agentID = local.ID
+ }
+ var args struct {
+ Machine string `json:"machine"`
+ Scopes []string `json:"scopes"`
+ ForSeconds int64 `json:"for_seconds"`
+ Reason string `json:"reason"`
+ }
+ if json.Unmarshal(raw, &args) != nil {
+ return localctl.Response{Error: "invalid access request"}
+ }
+ if args.ForSeconds == 0 {
+ args.ForSeconds = 3600
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ data, err := n.accessClient.Request(ctx, args.Machine, args.Scopes, args.ForSeconds, args.Reason, agentID)
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ return localctl.Response{OK: true, Data: publicData(data)}
+ case "agent_tell", "agent_ask":
+ return n.sendAgentTask(name, caller, raw)
+ case "machine_use":
+ return n.machineUse(raw)
+ }
+ return localctl.Response{Error: "unknown MCP tool"}
+}
+
+type relayRequest struct {
+ ID string `json:"id"`
+ Action string `json:"action"`
+ Level string `json:"level,omitempty"`
+ Data json.RawMessage `json:"data,omitempty"`
+}
+
+type relayReply struct {
+ ID string `json:"id"`
+ OK bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+ Data json.RawMessage `json:"data,omitempty"`
+}
+
+type relayOpened struct {
+ Frame protocol.RelayOpened
+ Err error
+}
+
+// machineRelay is one relay.opened session Bot keeps open between two Links.
+// Opener is true on the machine that asked; Peer is the other machine.
+type machineRelay struct {
+ ID string
+ Peer string
+ Token string
+ Claims access.Claims
+ Scopes map[string]bool
+ Expires time.Time
+ Opener bool
+ CheckedAt time.Time
+ WaitingID string
+}
+
+type relaySource struct {
+ to, scope, relay, bodyID string
+}
+
+func validRelayID(id string) bool {
+ if len(id) < 12 || len(id) > 84 || !strings.HasPrefix(id, "rly_") {
+ return false
+ }
+ for _, c := range id[4:] {
+ if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && c != '-' && c != '_' {
+ return false
+ }
+ }
+ return true
+}
+
+func validOperationID(id string) bool {
+ if len(id) != 36 || !strings.HasPrefix(id, "mrl_") {
+ return false
+ }
+ for _, c := range id[4:] {
+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
+ return false
+ }
+ }
+ return true
+}
+
+func (n *Agent) machineUse(raw json.RawMessage) localctl.Response {
+ if n.accessClient == nil || n.link == nil || n.opt.Creds == nil {
+ return localctl.Response{Error: "machine relay unavailable"}
+ }
+ var args struct {
+ Machine string `json:"machine"`
+ Scope string `json:"scope"`
+ Action string `json:"action"`
+ Data json.RawMessage `json:"data"`
+ }
+ if json.Unmarshal(raw, &args) != nil || args.Machine == "" || args.Machine == n.opt.Creds.Principal || !access.ValidScope(args.Scope) || !validRelayAction(args.Scope, args.Action) || len(args.Data) > 1<<20 {
+ return localctl.Response{Error: "invalid machine use"}
+ }
+ if !strings.HasPrefix(args.Machine, "lnk_") {
+ return localctl.Response{Error: "invalid machine id"}
+ }
+ if len(args.Data) == 0 {
+ args.Data = json.RawMessage(`{}`)
+ }
+ if args.Scope == "computer" {
+ var object map[string]any
+ if json.Unmarshal(args.Data, &object) != nil || object == nil {
+ return localctl.Response{Error: "a computer action must be a JSON object"}
+ }
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ grants, err := n.accessClient.Grants(ctx)
+ cancel()
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ grantID := machineGrant(grants, n.opt.Creds.Principal, args.Machine, args.Scope)
+ if grantID == "" {
+ return localctl.Response{Error: "no active grant for this machine and scope"}
+ }
+ ctx, cancel = context.WithTimeout(context.Background(), 8*time.Second)
+ tokenAnswer, err := n.accessClient.GrantToken(ctx, grantID)
+ if err != nil {
+ cancel()
+ return localctl.Response{Error: err.Error()}
+ }
+ var tokenRow struct {
+ GrantToken string `json:"grant_token"`
+ }
+ if json.Unmarshal(tokenAnswer, &tokenRow) != nil || tokenRow.GrantToken == "" {
+ cancel()
+ return localctl.Response{Error: "Network did not issue the grant token"}
+ }
+ token := tokenRow.GrantToken
+ claims, err := access.NewVerifier(n.opt.Creds.Network, args.Machine).Verify(ctx, token, n.opt.Creds.Principal, args.Scope)
+ cancel()
+ if err != nil || claims.ID != grantID {
+ return localctl.Response{Error: "Network issued an invalid grant token"}
+ }
+ if args.Scope == "send" && args.Action == "offer" {
+ var offer struct {
+ Envelope string `json:"envelope"`
+ }
+ if json.Unmarshal(args.Data, &offer) != nil || offer.Envelope == "" || len(offer.Envelope) > 900000 {
+ return localctl.Response{Error: "invalid sealed item"}
+ }
+ client := &send.Client{BaseURL: n.opt.Creds.Network, Tokens: n.accessClient.Tokens, Self: n.opt.Creds.Principal}
+ lanCtx, lanCancel := context.WithTimeout(context.Background(), 3*time.Second)
+ id, lanErr := client.SendGrantedLAN(lanCtx, args.Machine, offer.Envelope, token)
+ lanCancel()
+ if lanErr == nil {
+ n.addActivity("Sent a sealed item to machine " + args.Machine + " on the LAN")
+ return responseJSON(map[string]any{"machine": args.Machine, "scope": "send", "result": map[string]any{"id": id, "transport": "lan"}})
+ }
+ }
+ bodyID := access.NewRelayID()
+ if bodyID == "" {
+ return localctl.Response{Error: "could not create a relay id"}
+ }
+ if args.Scope == "worker" && args.Action == "run" {
+ var job protocol.Job
+ if json.Unmarshal(args.Data, &job) != nil || !protocol.ValidJobID(job.ID) {
+ return localctl.Response{Error: "invalid worker job"}
+ }
+ }
+ body := map[string]any{"id": bodyID, "action": args.Action, "data": args.Data}
+ if args.Scope == "computer" {
+ level := requestedComputerLevel(args.Data)
+ body["level"] = level
+ }
+ bodyJSON, err := json.Marshal(body)
+ if err != nil {
+ return localctl.Response{Error: "could not encode the relay body"}
+ }
+ if len(bodyJSON) > 256<<10 {
+ return localctl.Response{Error: "the relay body is larger than 256 KiB; send files on the LAN or use a smaller item"}
+ }
+ // Bot opens one relay per operation: relay.open, relay.opened to both
+ // machines, then relay.frame each way. The receiving Link verifies the same
+ // token and checks the scope again.
+ opened := make(chan relayOpened, 1)
+ n.mu.Lock()
+ if n.relayOpening[args.Machine] != nil {
+ n.mu.Unlock()
+ return localctl.Response{Error: "another relay to this machine is opening"}
+ }
+ n.relayOpening[args.Machine] = opened
+ n.mu.Unlock()
+ defer func() {
+ n.mu.Lock()
+ if n.relayOpening[args.Machine] == opened {
+ delete(n.relayOpening, args.Machine)
+ }
+ n.mu.Unlock()
+ }()
+ if err := n.link.Send(protocol.RelayOpen{To: args.Machine, GrantToken: token}); err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ var session *machineRelay
+ select {
+ case answer := <-opened:
+ if answer.Err != nil {
+ return localctl.Response{Error: answer.Err.Error()}
+ }
+ n.mu.Lock()
+ session = n.relaySessions[answer.Frame.Relay]
+ n.mu.Unlock()
+ case <-time.After(15 * time.Second):
+ return localctl.Response{Error: "the other machine did not open the relay in time"}
+ }
+ if session == nil {
+ return localctl.Response{Error: "the relay opened without a verified grant"}
+ }
+ wait := make(chan relayReply, 1)
+ n.mu.Lock()
+ n.relayWaiting[bodyID] = wait
+ if current := n.relaySessions[session.ID]; current != nil {
+ current.WaitingID = bodyID
+ }
+ n.mu.Unlock()
+ defer func() {
+ n.mu.Lock()
+ delete(n.relayWaiting, bodyID)
+ n.mu.Unlock()
+ }()
+ if err := n.link.Send(protocol.RelayFrame{Relay: session.ID, Scope: args.Scope, Body: bodyJSON}); err != nil {
+ n.closeRelay(session, "send failed")
+ return localctl.Response{Error: err.Error()}
+ }
+ select {
+ case reply := <-wait:
+ n.closeRelay(session, "done")
+ if !reply.OK {
+ return localctl.Response{Error: reply.Error}
+ }
+ n.addActivity("Used " + args.Scope + " on machine " + args.Machine)
+ return responseJSON(map[string]any{"id": bodyID, "machine": args.Machine, "scope": args.Scope, "result": publicJSON(reply.Data)})
+ case <-time.After(120 * time.Second):
+ n.closeRelay(session, "timeout")
+ return localctl.Response{Error: "machine did not answer within 120 seconds"}
+ }
+}
+
+func requestedComputerLevel(raw json.RawMessage) string {
+ var object struct {
+ Level string `json:"level"`
+ }
+ _ = json.Unmarshal(raw, &object)
+ switch object.Level {
+ case "observe", "ask", "trusted", "full":
+ return object.Level
+ }
+ return "ask"
+}
+
+func computerRank(level string) int {
+ switch level {
+ case "observe":
+ return 0
+ case "ask":
+ return 1
+ case "trusted":
+ return 2
+ case "full":
+ return 3
+ }
+ return -1
+}
+
+func validRelayAction(scope, action string) bool {
+ switch scope {
+ case "agent":
+ return action == "task"
+ case "send":
+ return action == "offer"
+ case "screen":
+ return action == protocol.KindScreen || action == protocol.KindScreenStream
+ case "desk":
+ return action == protocol.KindDesk || action == protocol.KindDeskStream
+ case "computer":
+ return action == protocol.KindExec || action == protocol.KindFS || action == protocol.KindInput || action == protocol.KindBrowser || action == protocol.KindProbe
+ case "worker":
+ return action == "run" || action == "cancel" || action == "status"
+ }
+ return false
+}
+
+func (n *Agent) receiveRelayOpened(frame protocol.RelayOpened) {
+ self := ""
+ if n.opt.Creds != nil {
+ self = n.opt.Creds.Principal
+ }
+ refuse := func(reason string) {
+ if n.link != nil {
+ _ = n.link.Send(protocol.RelayClose{Relay: frame.Relay})
+ }
+ n.mu.Lock()
+ if ch := n.relayOpening[frame.To]; ch != nil && frame.From == self {
+ select {
+ case ch <- relayOpened{Err: errors.New(reason)}:
+ default:
+ }
+ }
+ n.mu.Unlock()
+ }
+ if self == "" || n.accessClient == nil || !validRelayID(frame.Relay) || frame.GrantToken == "" || len(frame.Scopes) == 0 ||
+ frame.From == "" || frame.To == "" || frame.From == frame.To || (frame.From != self && frame.To != self) {
+ refuse("invalid relay opening")
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ claims, err := access.NewVerifier(n.opt.Creds.Network, frame.To).Verify(ctx, frame.GrantToken, frame.From, frame.Scopes[0])
+ cancel()
+ if err != nil || claims.ID == "" || claims.From != frame.From || claims.To != frame.To || claims.Expiry <= time.Now().Unix() {
+ refuse("the relay grant was not valid")
+ return
+ }
+ scopes := map[string]bool{}
+ for _, scope := range claims.Scopes {
+ if !access.ValidScope(scope) {
+ refuse("the relay grant names an unknown scope")
+ return
+ }
+ scopes[scope] = true
+ }
+ peer := frame.From
+ if frame.From == self {
+ peer = frame.To
+ }
+ session := &machineRelay{ID: frame.Relay, Peer: peer, Token: frame.GrantToken, Claims: claims,
+ Scopes: scopes, Expires: time.Unix(claims.Expiry, 0), Opener: frame.From == self}
+ n.mu.Lock()
+ if n.relaySessions[frame.Relay] != nil {
+ n.mu.Unlock()
+ refuse("the relay is already open")
+ return
+ }
+ n.relaySessions[frame.Relay] = session
+ var ch chan relayOpened
+ if session.Opener {
+ ch = n.relayOpening[frame.To]
+ }
+ n.mu.Unlock()
+ if ch != nil {
+ select {
+ case ch <- relayOpened{Frame: frame}:
+ default:
+ }
+ }
+}
+
+func (n *Agent) receiveRelayFrame(frame protocol.RelayFrame) {
+ n.mu.Lock()
+ session := n.relaySessions[frame.Relay]
+ n.mu.Unlock()
+ if session == nil || frame.From != session.Peer || !session.Scopes[frame.Scope] || len(frame.Body) == 0 || len(frame.Body) > 500<<10 {
+ if session != nil {
+ n.closeRelay(session, "invalid frame")
+ }
+ return
+ }
+ if time.Now().After(session.Expires) {
+ n.closeRelay(session, "expired")
+ return
+ }
+ if time.Since(session.CheckedAt) > 5*time.Second {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ grants, err := n.accessClient.Grants(ctx)
+ cancel()
+ if err != nil || !grantStillActive(grants, session.Claims.ID, session.Claims.From, session.Claims.To, frame.Scope) {
+ n.closeRelay(session, "revoked")
+ return
+ }
+ session.CheckedAt = time.Now()
+ }
+ if session.Opener {
+ var reply relayReply
+ if json.Unmarshal(frame.Body, &reply) != nil || reply.ID == "" {
+ return
+ }
+ n.mu.Lock()
+ ch := n.relayWaiting[reply.ID]
+ n.mu.Unlock()
+ if ch != nil {
+ select {
+ case ch <- reply:
+ default:
+ }
+ }
+ return
+ }
+ var request relayRequest
+ if json.Unmarshal(frame.Body, &request) != nil || !validOperationID(request.ID) || !validRelayAction(frame.Scope, request.Action) {
+ n.sendRelayReply(session, frame.Scope, request.ID, nil, errors.New("invalid machine operation"))
+ return
+ }
+ if frame.Scope == "computer" {
+ own := n.computerLevel()
+ if request.Level != "" && (computerRank(request.Level) < 0 || computerRank(request.Level) > computerRank(own)) {
+ n.sendRelayReply(session, frame.Scope, request.ID, nil, errors.New("this computer does not allow that level"))
+ return
+ }
+ }
+ immediate, data, err := n.dispatchRelay(session, frame.Scope, request)
+ if immediate {
+ n.sendRelayReply(session, frame.Scope, request.ID, data, err)
+ } else if err != nil {
+ n.sendRelayReply(session, frame.Scope, request.ID, nil, err)
+ }
+}
+
+// dispatchRelay runs one operation through the local feature that already
+// serves this machine. immediate is false when the feature answers later
+// through relayResult (a command ack, a job exit).
+func (n *Agent) dispatchRelay(session *machineRelay, scope string, request relayRequest) (immediate bool, data any, err error) {
+ if n.capabilityOff(relayCapability(scope, request.Action)) {
+ return true, nil, errors.New("this capability is off on the target machine")
+ }
+ n.addActivity("Machine " + session.Peer + " used " + scope + " (grant " + session.Claims.ID + ")")
+ switch scope {
+ case "agent":
+ if !n.opt.Config.AgentsTalk {
+ return true, nil, errors.New("Let my agents talk is off on this machine")
+ }
+ err = n.receiveAgentTask(protocol.AgentTaskFrame{Task: request.Data})
+ return true, map[string]any{"queued": err == nil}, err
+ case "send":
+ var offer struct {
+ Envelope string `json:"envelope"`
+ }
+ if json.Unmarshal(request.Data, &offer) != nil || len(offer.Envelope) > 900000 {
+ return true, nil, errors.New("invalid sealed item")
+ }
+ n.mu.Lock()
+ lan := n.grantedSend
+ n.mu.Unlock()
+ if lan == nil {
+ return true, nil, errors.New("Link Send is off on this machine")
+ }
+ id, err := lan.ReceiveGranted(session.Peer, offer.Envelope)
+ return true, map[string]any{"id": id}, err
+ case "worker":
+ if request.Action == "cancel" || request.Action == "status" {
+ var body struct {
+ ID string `json:"id"`
+ }
+ if json.Unmarshal(request.Data, &body) != nil || !protocol.ValidJobID(body.ID) {
+ return true, nil, errors.New("invalid job id")
+ }
+ n.mu.Lock()
+ owner := n.remoteJobs[body.ID]
+ n.mu.Unlock()
+ if owner != session.Peer {
+ return true, nil, errors.New("job is not owned by this machine")
+ }
+ if request.Action == "status" {
+ result, ok := n.jobs.answer(body.ID)
+ if !ok {
+ return true, nil, errors.New("job not found")
+ }
+ return true, result, nil
+ }
+ n.jobCancel(protocol.JobCancel{ID: body.ID})
+ return true, map[string]any{"cancelled": true}, nil
+ }
+ var job protocol.Job
+ if json.Unmarshal(request.Data, &job) != nil || !protocol.ValidJobID(job.ID) {
+ return true, nil, errors.New("invalid job")
+ }
+ source := relaySource{to: session.Peer, scope: scope, relay: session.ID, bodyID: request.ID}
+ if err := n.jobRelayed(protocol.JobRequest{Job: job}, source); err != nil {
+ return true, nil, err
+ }
+ return false, nil, nil
+ default:
+ if !json.Valid(request.Data) {
+ return true, nil, errors.New("invalid command data")
+ }
+ n.mu.Lock()
+ n.relaySources[request.ID] = relaySource{to: session.Peer, scope: scope, relay: session.ID, bodyID: request.ID}
+ n.mu.Unlock()
+ n.command(protocol.Command{ID: request.ID, Kind: request.Action, Value: request.Data, Operator: &protocol.Operator{Subject: session.Peer, Role: "machine"}}, time.Now().UnixMilli(), 0, mono.Now())
+ return false, nil, nil
+ }
+}
+
+func (n *Agent) sendRelayReply(session *machineRelay, scope, bodyID string, data any, err error) {
+ if n.link == nil || session == nil {
+ return
+ }
+ reply := relayReply{ID: bodyID, OK: err == nil}
+ if err != nil {
+ reply.Error = err.Error()
+ } else if data != nil {
+ reply.Data, _ = json.Marshal(data)
+ }
+ body, marshalErr := json.Marshal(reply)
+ if marshalErr != nil {
+ return
+ }
+ _ = n.link.Send(protocol.RelayFrame{Relay: session.ID, Scope: scope, Body: body})
+}
+
+func (n *Agent) closeRelay(session *machineRelay, reason string) {
+ if session == nil {
+ return
+ }
+ n.mu.Lock()
+ delete(n.relaySessions, session.ID)
+ bodyID := session.WaitingID
+ n.mu.Unlock()
+ if bodyID != "" {
+ n.mu.Lock()
+ ch := n.relayWaiting[bodyID]
+ n.mu.Unlock()
+ if ch != nil {
+ select {
+ case ch <- relayReply{ID: bodyID, Error: "the relay closed: " + reason}:
+ default:
+ }
+ }
+ }
+ if n.link != nil {
+ _ = n.link.Send(protocol.RelayClose{Relay: session.ID})
+ }
+}
+
+func (n *Agent) receiveRelayClosed(frame protocol.RelayClosed) {
+ n.mu.Lock()
+ session := n.relaySessions[frame.Relay]
+ delete(n.relaySessions, frame.Relay)
+ n.mu.Unlock()
+ if session == nil {
+ return
+ }
+ reason := frame.Reason
+ if reason == "" {
+ reason = "closed"
+ }
+ if session.WaitingID != "" {
+ n.mu.Lock()
+ ch := n.relayWaiting[session.WaitingID]
+ n.mu.Unlock()
+ if ch != nil {
+ select {
+ case ch <- relayReply{ID: session.WaitingID, Error: "the relay closed: " + reason}:
+ default:
+ }
+ }
+ }
+ if session.Opener {
+ n.mu.Lock()
+ ch := n.relayOpening[session.Peer]
+ n.mu.Unlock()
+ if ch != nil {
+ select {
+ case ch <- relayOpened{Err: errors.New("the relay closed: " + reason)}:
+ default:
+ }
+ }
+ }
+}
+
+func (n *Agent) failRelayWaiters(detail string) {
+ n.mu.Lock()
+ openings := make([]chan relayOpened, 0, len(n.relayOpening))
+ for key, ch := range n.relayOpening {
+ openings = append(openings, ch)
+ delete(n.relayOpening, key)
+ }
+ waiters := map[string]chan relayReply{}
+ for id, ch := range n.relayWaiting {
+ waiters[id] = ch
+ }
+ n.mu.Unlock()
+ for _, ch := range openings {
+ select {
+ case ch <- relayOpened{Err: errors.New(detail)}:
+ default:
+ }
+ }
+ for id, ch := range waiters {
+ select {
+ case ch <- relayReply{ID: id, Error: detail}:
+ default:
+ }
+ }
+}
+
+func grantStillActive(raw json.RawMessage, id, from, to, scope string) bool {
+ var list struct {
+ Grants []struct {
+ ID string `json:"id"`
+ From string `json:"from_principal"`
+ To string `json:"to_principal"`
+ Scopes []string `json:"scopes"`
+ Revoked *string `json:"revoked_at"`
+ Expires *string `json:"expires_at"`
+ } `json:"grants"`
+ }
+ if json.Unmarshal(raw, &list) != nil {
+ return false
+ }
+ for _, g := range list.Grants {
+ if g.ID != id || g.From != from || g.To != to || g.Revoked != nil {
+ continue
+ }
+ if g.Expires != nil {
+ at, err := time.Parse(time.RFC3339, *g.Expires)
+ if err != nil || !at.After(time.Now()) {
+ continue
+ }
+ }
+ for _, s := range g.Scopes {
+ if s == scope {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func relayCapability(scope, action string) string {
+ switch scope {
+ case "screen":
+ return protocol.KindScreen
+ case "desk":
+ return protocol.KindDesk
+ case "computer":
+ return action
+ }
+ return ""
+}
+
+func (n *Agent) relayResult(m protocol.Message) bool {
+ var id string
+ switch v := m.(type) {
+ case protocol.Ack:
+ id = v.ID
+ case protocol.AckData:
+ id = v.ID
+ case protocol.Nack:
+ id = v.ID
+ case protocol.JobExit:
+ id = v.ID
+ default:
+ return false
+ }
+ n.mu.Lock()
+ source := n.relaySources[id]
+ if source.to != "" {
+ delete(n.relaySources, id)
+ if _, refused := m.(protocol.Nack); refused {
+ delete(n.remoteJobs, id)
+ }
+ }
+ n.mu.Unlock()
+ if source.to == "" {
+ // A relayed worker's ack already closed its relay. Its later job_exit
+ // must not go to Bot as a job frame for a job Bot never started.
+ if _, exited := m.(protocol.JobExit); exited {
+ n.mu.Lock()
+ _, remote := n.remoteJobs[id]
+ delete(n.remoteJobs, id)
+ n.mu.Unlock()
+ if remote {
+ return true
+ }
+ }
+ return false
+ }
+ reply := relayReply{ID: source.bodyID, OK: true}
+ if nk, ok := m.(protocol.Nack); ok {
+ reply.OK = false
+ reply.Error = nk.Message
+ } else {
+ reply.Data, _ = json.Marshal(m)
+ }
+ body, err := json.Marshal(reply)
+ if err != nil {
+ return true
+ }
+ if n.link != nil {
+ _ = n.link.Send(protocol.RelayFrame{Relay: source.relay, Scope: source.scope, Body: body})
+ }
+ return true
+}
+
+type localAgent struct{ ID, Owner, Name string }
+
+func loadLocalAgents(dir string) map[string]localAgent {
+ entries := map[string]localAgent{}
+ raw, err := os.ReadFile(filepath.Join(dir, "agent-directory.json"))
+ if err != nil || len(raw) > 64<<10 || json.Unmarshal(raw, &entries) != nil || entries == nil {
+ return map[string]localAgent{}
+ }
+ for cli, entry := range entries {
+ if cli == "" || !strings.HasPrefix(entry.ID, "agt_") || !strings.HasPrefix(entry.Owner, "usr_") {
+ delete(entries, cli)
+ }
+ }
+ return entries
+}
+
+func saveLocalAgents(dir string, entries map[string]localAgent) error {
+ path := filepath.Join(dir, "agent-directory.json")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return err
+ }
+ raw, err := json.Marshal(entries)
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(dir, ".agent-directory-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if err := tmp.Chmod(0600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(raw); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), path)
+}
+
+func (n *Agent) localAgentEntries() []map[string]any {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ list := make([]map[string]any, 0, len(n.localAgents))
+ machine := ""
+ if n.opt.Creds != nil {
+ machine = n.opt.Creds.Principal
+ }
+ for cli, entry := range n.localAgents {
+ name := entry.Name
+ if name == "" {
+ name = cli
+ }
+ list = append(list, map[string]any{"id": entry.ID, "owner": entry.Owner, "kind": "link-local",
+ "name": name, "machine_id": machine, "status": "online", "allow_others": false})
+ }
+ sort.Slice(list, func(i, j int) bool { return fmt.Sprint(list[i]["name"]) < fmt.Sprint(list[j]["name"]) })
+ return list
+}
+
+func (n *Agent) localAgentFor(cli string) (localAgent, error) {
+ if !n.opt.Config.AgentsTalk {
+ return localAgent{}, errors.New("Let my agents talk is off on this machine")
+ }
+ n.mu.Lock()
+ entry, ok := n.localAgents[cli]
+ n.mu.Unlock()
+ if cli == "" || !ok || entry.ID == "" || entry.Owner == "" {
+ return localAgent{}, errors.New("this CLI is not registered in the agent directory yet")
+ }
+ return entry, nil
+}
+
+func (n *Agent) registerLocalAgents(ctx context.Context) {
+ ticker := time.NewTicker(time.Minute)
+ defer ticker.Stop()
+ for {
+ n.registerLocalAgentsOnce(ctx)
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ }
+}
+
+func (n *Agent) registerLocalAgentsOnce(ctx context.Context) {
+ if n.accessClient == nil || n.opt.Creds == nil || !n.opt.Config.AgentsTalk {
+ return
+ }
+ for _, cli := range n.codesAgents.latest(n.probeCodesAgents) {
+ if cli.State == "missing" {
+ continue
+ }
+ status := "offline"
+ if cli.State == "ready" {
+ status = "online"
+ }
+ entry := map[string]any{"kind": "link-local", "local_id": cli.ID, "name": cli.Name, "machine_id": n.opt.Creds.Principal, "skills": []string{}, "status": status, "allow_others": false}
+ n.mu.Lock()
+ known := n.localAgents[cli.ID]
+ n.mu.Unlock()
+ if known.ID != "" {
+ entry["id"] = known.ID
+ }
+ callCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ raw, err := n.accessClient.RegisterAgent(callCtx, entry)
+ cancel()
+ if err != nil {
+ n.log.Debug("agent registration unavailable", "cli", cli.ID, "err", err)
+ continue
+ }
+ var result struct {
+ ID string `json:"id"`
+ Owner string `json:"owner"`
+ Agent struct {
+ ID string `json:"id"`
+ Owner string `json:"owner"`
+ } `json:"agent"`
+ }
+ if json.Unmarshal(raw, &result) != nil {
+ continue
+ }
+ if result.ID == "" {
+ result.ID = result.Agent.ID
+ }
+ if result.Owner == "" {
+ result.Owner = result.Agent.Owner
+ }
+ if !strings.HasPrefix(result.ID, "agt_") || result.Owner == "" {
+ continue
+ }
+ n.mu.Lock()
+ n.localAgents[cli.ID] = localAgent{ID: result.ID, Owner: result.Owner, Name: cli.Name}
+ copy := make(map[string]localAgent, len(n.localAgents))
+ for key, value := range n.localAgents {
+ copy[key] = value
+ }
+ n.mu.Unlock()
+ if err := saveLocalAgents(n.opt.Paths.StateDir, copy); err != nil {
+ n.log.Warn("agent directory id not saved", "err", err)
+ }
+ }
+}
+
+func (n *Agent) sendAgentTask(name, caller string, raw json.RawMessage) localctl.Response {
+ if n.accessClient == nil {
+ return localctl.Response{Error: "pair this machine through Network first"}
+ }
+ local, err := n.localAgentFor(caller)
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ var args struct {
+ Agent string `json:"agent"`
+ Text string `json:"text"`
+ Thread string `json:"thread"`
+ WaitSeconds int `json:"wait_seconds"`
+ }
+ if json.Unmarshal(raw, &args) != nil || len(args.Text) > 8000 || args.Agent == "" || args.Text == "" {
+ return localctl.Response{Error: "invalid agent message"}
+ }
+ if name == "agent_ask" {
+ if args.WaitSeconds == 0 {
+ args.WaitSeconds = 30
+ }
+ if args.WaitSeconds < 1 || args.WaitSeconds > 120 {
+ return localctl.Response{Error: "wait_seconds must be 1 to 120"}
+ }
+ }
+ if args.Thread == "" {
+ args.Thread = access.NewThreadID()
+ }
+ if args.Thread == "" {
+ return localctl.Response{Error: "could not make a thread id"}
+ }
+ task := map[string]any{"to": args.Agent, "from": local.ID, "on_behalf_of": local.Owner, "thread": args.Thread, "message": map[string]any{"role": "user", "parts": []any{map[string]any{"type": "text", "text": args.Text}}}}
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ data, err := n.accessClient.Task(ctx, args.Agent, task)
+ cancel()
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ if name == "agent_tell" {
+ return localctl.Response{OK: true, Data: data}
+ }
+ var created struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Task struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ } `json:"task"`
+ }
+ if json.Unmarshal(data, &created) != nil {
+ return localctl.Response{Error: "Network did not return a task id"}
+ }
+ if created.ID == "" {
+ created.ID = created.Task.ID
+ created.Status = created.Task.Status
+ }
+ if created.ID == "" {
+ return localctl.Response{Error: "Network did not return a task id"}
+ }
+ if created.Status == "completed" || created.Status == "failed" || created.Status == "cancelled" {
+ return localctl.Response{OK: true, Data: data}
+ }
+ deadline := time.Now().Add(time.Duration(args.WaitSeconds) * time.Second)
+ for time.Now().Before(deadline) {
+ time.Sleep(500 * time.Millisecond)
+ pollCtx, pollCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ data, err = n.accessClient.TaskStatus(pollCtx, created.ID)
+ pollCancel()
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ var result struct {
+ Status string `json:"status"`
+ Task struct {
+ Status string `json:"status"`
+ } `json:"task"`
+ }
+ if json.Unmarshal(data, &result) != nil {
+ return localctl.Response{Error: "Network returned an invalid task"}
+ }
+ if result.Status == "" {
+ result.Status = result.Task.Status
+ }
+ if result.Status == "completed" || result.Status == "failed" || result.Status == "cancelled" {
+ return localctl.Response{OK: true, Data: data}
+ }
+ }
+ return responseJSON(map[string]any{"id": created.ID, "status": "pending", "thread": args.Thread})
+}
+
+func (n *Agent) accessControl(action string, raw json.RawMessage) localctl.Response {
+ if n.accessClient == nil {
+ return localctl.Response{Error: "pair this machine through OpenVibe.Network first"}
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ var args struct {
+ Machine string `json:"machine"`
+ Scopes []string `json:"scopes"`
+ Duration int64 `json:"duration_seconds"`
+ Reason string `json:"reason"`
+ ID string `json:"id"`
+ }
+ if len(raw) > 0 && json.Unmarshal(raw, &args) != nil {
+ return localctl.Response{Error: "invalid access arguments"}
+ }
+ var data json.RawMessage
+ var err error
+ switch action {
+ case "request":
+ data, err = n.accessClient.Request(ctx, args.Machine, args.Scopes, args.Duration, args.Reason, "")
+ case "list":
+ var requests, grants json.RawMessage
+ requests, err = n.accessClient.List(ctx)
+ if err == nil {
+ grants, err = n.accessClient.Grants(ctx)
+ }
+ if err == nil {
+ return responseJSON(map[string]any{"requests": publicJSON(requests), "grants": publicJSON(grants)})
+ }
+ case "approve", "deny":
+ data, err = n.accessClient.Answer(ctx, args.ID, action == "approve", args.Scopes, args.Duration)
+ case "revoke":
+ data, err = n.accessClient.Revoke(ctx, args.ID)
+ default:
+ return localctl.Response{Error: "unknown access action"}
+ }
+ if err != nil {
+ return localctl.Response{Error: err.Error()}
+ }
+ if action != "list" {
+ n.addActivity("Machine access " + action + " completed")
+ }
+ return localctl.Response{OK: true, Data: publicData(data)}
+}
+
+type incomingAccessRequest struct {
+ ID string `json:"id"`
+ FromPrincipal string `json:"from_principal"`
+ ToPrincipal string `json:"to_principal"`
+ FromOwner string `json:"from_owner"`
+ FromAgent string `json:"from_agent"`
+ FromMachineName string `json:"from_machine_name"`
+ FromOwnerName string `json:"from_owner_name"`
+ FromAgentName string `json:"from_agent_name"`
+ Scopes []string `json:"scopes"`
+ Reason string `json:"reason"`
+ Status string `json:"status"`
+ // Network currently returns asked_at/pending_until; link.access-request@1
+ // names them requested_at/expires_at. Accept either so Link follows the
+ // contract and the first Network build alike.
+ AskedAt string `json:"asked_at"`
+ RequestedAt string `json:"requested_at"`
+ PendingUntil string `json:"pending_until"`
+ ExpiresAt string `json:"expires_at"`
+ DurationSeconds int64 `json:"duration_seconds"`
+}
+
+func (r incomingAccessRequest) askedAt() string {
+ if r.AskedAt != "" {
+ return r.AskedAt
+ }
+ return r.RequestedAt
+}
+
+func (r incomingAccessRequest) expiresAt() string {
+ if r.PendingUntil != "" {
+ return r.PendingUntil
+ }
+ return r.ExpiresAt
+}
+
+func accessRequestWords(request incomingAccessRequest) (string, string) {
+ actor := request.FromPrincipal
+ if request.FromOwnerName != "" && request.FromMachineName != "" {
+ actor = request.FromOwnerName + "'s " + request.FromMachineName
+ } else if request.FromMachineName != "" {
+ actor = request.FromMachineName
+ }
+ if request.FromAgentName != "" {
+ actor = request.FromAgentName + " on " + actor
+ }
+ words := map[string]string{"agent": "message your agents", "send": "send you files", "screen": "watch the screen", "desk": "use a private desk", "computer": "control this computer", "worker": "run jobs in the sandbox"}
+ var parts []string
+ for _, scope := range request.Scopes {
+ if word := words[scope]; word != "" {
+ parts = append(parts, word)
+ }
+ }
+ if len(parts) == 0 {
+ parts = []string{"request access"}
+ }
+ phrase := parts[0]
+ if len(parts) > 1 {
+ phrase = strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
+ }
+ phrase = strings.ToUpper(phrase[:1]) + phrase[1:]
+ duration := "until you take it back"
+ if request.DurationSeconds > 0 {
+ duration = (time.Duration(request.DurationSeconds) * time.Second).String()
+ if request.DurationSeconds%86400 == 0 {
+ days := request.DurationSeconds / 86400
+ duration = fmt.Sprintf("%d days", days)
+ if days == 1 {
+ duration = "1 day"
+ }
+ } else if request.DurationSeconds%3600 == 0 {
+ hours := request.DurationSeconds / 3600
+ duration = fmt.Sprintf("%d hours", hours)
+ if hours == 1 {
+ duration = "1 hour"
+ }
+ }
+ }
+ body := phrase + ", for " + duration + "."
+ if request.DurationSeconds == 0 {
+ body = phrase + ", until you take it back."
+ }
+ if request.Reason != "" {
+ body += " " + strconv.Quote(request.Reason)
+ }
+ return actor + " asks for access", body
+}
+
+func (n *Agent) pollAccessRequests(ctx context.Context) {
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+ for {
+ n.refreshAccessRequests(ctx)
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ }
+}
+
+func (n *Agent) refreshAccessRequests(ctx context.Context) {
+ if n.accessClient == nil || n.opt.Creds == nil {
+ return
+ }
+ callCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
+ defer cancel()
+ raw, err := n.accessClient.List(callCtx)
+ if err != nil {
+ n.log.Debug("machine access requests unavailable", "err", err)
+ return
+ }
+ var result struct {
+ Requests []incomingAccessRequest `json:"requests"`
+ }
+ if json.Unmarshal(raw, &result) != nil {
+ return
+ }
+ want := map[string]incomingAccessRequest{}
+ for _, request := range result.Requests {
+ if request.Status == "pending" && request.ToPrincipal == n.opt.Creds.Principal {
+ want[request.ID] = request
+ }
+ }
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ for id, a := range n.approvals {
+ if a.Plugin == "machine-access" {
+ if _, ok := want[id]; !ok {
+ delete(n.approvals, id)
+ }
+ }
+ }
+ for id, request := range want {
+ if _, ok := n.approvals[id]; ok || len(n.approvals) >= 64 {
+ continue
+ }
+ nonce := access.NewTaskID()
+ if nonce == "" {
+ continue
+ }
+ title, summary := accessRequestWords(request)
+ at, _ := time.Parse(time.RFC3339, request.askedAt())
+ if at.IsZero() {
+ at = time.Now()
+ }
+ n.approvals[id] = Approval{ID: nonce, CommandID: id, Plugin: "machine-access", Kind: "Machine access", Title: title, Summary: summary, At: at.UnixMilli()}
+ n.appendActivityLocked("Machine access request from " + request.FromPrincipal + " is waiting")
+ }
+}
+
+func (n *Agent) answerAccessApproval(a Approval, approved bool) error {
+ if n.accessClient == nil {
+ return errors.New("machine access unavailable")
+ }
+ n.mu.Lock()
+ current, ok := n.approvals[a.CommandID]
+ if !ok || current.ID != a.ID {
+ n.mu.Unlock()
+ return errors.New("that machine access request no longer waits")
+ }
+ if approved && n.drivingLocked() {
+ n.mu.Unlock()
+ return errors.New("the agent is using the mouse or keyboard; answer again in a moment")
+ }
+ n.mu.Unlock()
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ if _, err := n.accessClient.Answer(ctx, a.CommandID, approved, nil, 0); err != nil {
+ return err
+ }
+ n.mu.Lock()
+ if current, ok := n.approvals[a.CommandID]; ok && current.ID == a.ID {
+ delete(n.approvals, a.CommandID)
+ }
+ n.mu.Unlock()
+ verb := "denied"
+ if approved {
+ verb = "approved"
+ }
+ n.addActivity("You " + verb + " machine access request " + a.CommandID)
+ return nil
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+package agent
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/access"
+ "github.com/OpenVibers/OpenVibe.Link/internal/config"
+ "github.com/OpenVibers/OpenVibe.Link/internal/credentials"
+ "github.com/OpenVibers/OpenVibe.Link/internal/protocol"
+)
+
+func TestAgentTaskQueueRequiresOwnerSwitch(t *testing.T) {
+ inbox, err := access.OpenInbox(filepath.Join(t.TempDir(), "inbox.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := config.Default()
+ n := &Agent{opt: Options{Config: cfg, Creds: &credentials.Credentials{Principal: "lnk_target", Network: "https://openvibe.network"}}, accessInbox: inbox, localAgents: map[string]localAgent{"codex": {ID: "agt_target", Owner: "usr_owner"}}}
+ task := access.Task{ID: "tsk_12345678", To: "agt_target", From: "agt_sender", OnBehalfOf: "usr_owner", Thread: "thr_12345678"}
+ task.Message.Parts = append(task.Message.Parts, struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ }{Type: "text", Text: "hello"})
+ raw, _ := json.Marshal(task)
+ frame := protocol.AgentTaskFrame{Task: raw}
+ if err := n.receiveAgentTask(frame); err == nil {
+ t.Fatal("off switch accepted task")
+ }
+ cfg.AgentsTalk = true
+ if err := n.receiveAgentTask(frame); err != nil {
+ t.Fatal(err)
+ }
+ if len(inbox.List("agt_target")) != 1 {
+ t.Fatal("task did not enter inbox")
+ }
+ frame.Task = json.RawMessage(`{"id":"tsk_12345678","to":"agt_elsewhere","from":"agt_sender","on_behalf_of":"usr_owner","thread":"thr_12345678","message":{"parts":[{"type":"text","text":"hello"}]}}`)
+ if err := n.receiveAgentTask(frame); err == nil {
+ t.Fatal("task for an unregistered agent accepted")
+ }
+}
+
+func TestMachineAccessAnswersKeepGrantTokenPrivate(t *testing.T) {
+ raw := json.RawMessage(`{"grant":{"id":"gra_12345678","grant_token":"fake-private-value"},"requests":[{"credential":"fake-private-value","status":"pending"}]}`)
+ public := string(publicData(raw))
+ if strings.Contains(public, "fake-private-value") || !strings.Contains(public, "gra_12345678") {
+ t.Fatalf("unsafe access answer: %s", public)
+ }
+}
+
+func TestMachineRelayIDIsSeparateFromBotCommandIDs(t *testing.T) {
+ if !validOperationID(access.NewRelayID()) || !validRelayID("rly_01J8Z4M2Q0R7T9YV3K6N8P1W2Z") ||
+ validOperationID("cmd_12345678") || validRelayID("mrl_12345678901234567890123456789012") ||
+ validRelayID("rly_cmd.12345678") || validOperationID("mrl_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz") {
+ t.Fatal("relay id namespace accepted a command or malformed id")
+ }
+}
+
+func TestLocalAgentRegistrationIDsSurviveRestart(t *testing.T) {
+ dir := t.TempDir()
+ items := map[string]localAgent{"codex": {ID: "agt_12345678", Owner: "usr_12345678"}}
+ if err := saveLocalAgents(dir, items); err != nil {
+ t.Fatal(err)
+ }
+ info, err := os.Stat(filepath.Join(dir, "agent-directory.json"))
+ if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0600) {
+ t.Fatalf("directory file permissions: %v %v", info, err)
+ }
+ if got := loadLocalAgents(dir)["codex"]; got != items["codex"] {
+ t.Fatalf("registration id changed: %+v", got)
+ }
+}
+
+func TestIncomingAccessRequestReadsBothContractAndFirstNetworkTimes(t *testing.T) {
+ first := incomingAccessRequest{FromMachineName: "laptop", Scopes: []string{"screen", "desk"}, DurationSeconds: 3600}
+ if err := json.Unmarshal([]byte(`{"asked_at":"2026-11-11T20:00:00Z","pending_until":"2026-11-12T20:00:00Z"}`), &first); err != nil {
+ t.Fatal(err)
+ }
+ if first.askedAt() != "2026-11-11T20:00:00Z" || first.expiresAt() != "2026-11-12T20:00:00Z" {
+ t.Fatalf("first Network fields: %+v", first)
+ }
+ contract := incomingAccessRequest{FromMachineName: "laptop", Scopes: []string{"screen", "desk"}, DurationSeconds: 3600}
+ if err := json.Unmarshal([]byte(`{"requested_at":"2026-11-11T20:00:00Z","expires_at":"2026-11-12T20:00:00Z"}`), &contract); err != nil {
+ t.Fatal(err)
+ }
+ if contract.askedAt() != "2026-11-11T20:00:00Z" || contract.expiresAt() != "2026-11-12T20:00:00Z" {
+ t.Fatalf("contract fields: %+v", contract)
+ }
+ title, body := accessRequestWords(contract)
+ if title != "laptop asks for access" || body != "Watch the screen and use a private desk, for 1 hour." {
+ t.Fatalf("tray words: %q / %q", title, body)
+ }
+}
+
+func TestEveryRelayScopeMapsToItsLocalFeature(t *testing.T) {
+ valid := map[string][]string{
+ "agent": {"task"},
+ "send": {"offer"},
+ "screen": {protocol.KindScreen, protocol.KindScreenStream},
+ "desk": {protocol.KindDesk, protocol.KindDeskStream},
+ "computer": {protocol.KindExec, protocol.KindFS, protocol.KindInput, protocol.KindBrowser, protocol.KindProbe},
+ "worker": {"run", "status", "cancel"},
+ }
+ for scope, actions := range valid {
+ for _, action := range actions {
+ if !validRelayAction(scope, action) {
+ t.Errorf("%s/%s refused", scope, action)
+ }
+ }
+ }
+ for _, bad := range [][2]string{{"agent", "run"}, {"send", "task"}, {"screen", "exec"}, {"worker", "browse"}, {"admin", "run"}} {
+ if validRelayAction(bad[0], bad[1]) {
+ t.Errorf("%s/%s accepted", bad[0], bad[1])
+ }
+ }
+ if relayCapability("screen", protocol.KindScreen) != protocol.KindScreen ||
+ relayCapability("desk", protocol.KindDesk) != protocol.KindDesk ||
+ relayCapability("computer", protocol.KindExec) != protocol.KindExec ||
+ relayCapability("worker", "run") != "" {
+ t.Fatal("capability mapping")
+ }
+}
@@ -25,6 +25,7 @@ import (
@@ -25,6 +25,7 @@ import (
"sync/atomic"
"sync/atomic"
"time"
"time"
+ "github.com/OpenVibers/OpenVibe.Link/internal/access"
"github.com/OpenVibers/OpenVibe.Link/internal/config"
"github.com/OpenVibers/OpenVibe.Link/internal/config"
"github.com/OpenVibers/OpenVibe.Link/internal/connect"
"github.com/OpenVibers/OpenVibe.Link/internal/connect"
"github.com/OpenVibers/OpenVibe.Link/internal/credentials"
"github.com/OpenVibers/OpenVibe.Link/internal/credentials"
@@ -37,6 +38,7 @@ import (
@@ -37,6 +38,7 @@ import (
"github.com/OpenVibers/OpenVibe.Link/internal/plugins"
"github.com/OpenVibers/OpenVibe.Link/internal/plugins"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
"github.com/OpenVibers/OpenVibe.Link/internal/safety"
"github.com/OpenVibers/OpenVibe.Link/internal/safety"
+ "github.com/OpenVibers/OpenVibe.Link/internal/send"
"github.com/OpenVibers/OpenVibe.Link/internal/stream"
"github.com/OpenVibers/OpenVibe.Link/internal/stream"
"github.com/OpenVibers/OpenVibe.Link/internal/video"
"github.com/OpenVibers/OpenVibe.Link/internal/video"
"github.com/OpenVibers/OpenVibe.Link/internal/worker"
"github.com/OpenVibers/OpenVibe.Link/internal/worker"
@@ -159,6 +161,18 @@ type Agent struct {
@@ -159,6 +161,18 @@ type Agent struct {
codesAgents codesAgentsCache
codesAgents codesAgentsCache
// shares keeps OpenVibe Connect shares up and lets the window add or end one.
// shares keeps OpenVibe Connect shares up and lets the window add or end one.
shares *shareSet
shares *shareSet
+ // accessInbox keeps received agent tasks until answered or 24 hours old.
+ accessInbox *access.Inbox
+ grantVerifier *access.Verifier
+ accessClient *access.Client
+ localAgents map[string]localAgent
+ relayWaiting map[string]chan relayReply
+ relayOpening map[string]chan relayOpened
+ relaySessions map[string]*machineRelay
+ relaySources map[string]relaySource
+ agentResults map[string]chan string
+ remoteJobs map[string]string
+ grantedSend *send.LAN
}
}
// ErrQuit is what Run returns after `openvibe-link quit`: every actuator stopped, nothing latched, the process may exit.
// ErrQuit is what Run returns after `openvibe-link quit`: every actuator stopped, nothing latched, the process may exit.
@@ -192,6 +206,7 @@ type Approval struct {
@@ -192,6 +206,7 @@ type Approval struct {
binding *plugins.ApprovalRequest
binding *plugins.ApprovalRequest
Plugin string `json:"plugin"`
Plugin string `json:"plugin"`
Kind string `json:"kind,omitempty"`
Kind string `json:"kind,omitempty"`
+ Title string `json:"title,omitempty"`
Summary string `json:"summary,omitempty"`
Summary string `json:"summary,omitempty"`
At int64 `json:"at"`
At int64 `json:"at"`
ExpiresS int64 `json:"expires_s,omitempty"`
ExpiresS int64 `json:"expires_s,omitempty"`
@@ -213,10 +228,18 @@ func New(opt Options) (*Agent, error) {
@@ -213,10 +228,18 @@ func New(opt Options) (*Agent, error) {
panelClipboard, panelAutoAccept := loadPanelSettings(opt.Paths.StateDir)
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, panelClipboard: panelClipboard, panelAutoAccept: panelAutoAccept}
+ panelOff: panelOff, panelFirst: panelFirst, panelClipboard: panelClipboard, panelAutoAccept: panelAutoAccept, localAgents: loadLocalAgents(opt.Paths.StateDir), relayWaiting: map[string]chan relayReply{}, relayOpening: map[string]chan relayOpened{}, relaySessions: map[string]*machineRelay{}, relaySources: map[string]relaySource{}, agentResults: map[string]chan string{}, remoteJobs: map[string]string{}}
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
+ n.accessInbox, err = access.OpenInbox(opt.Paths.StateDir + string(os.PathSeparator) + "agent-inbox.json")
+ if err != nil {
+ return nil, err
+ }
+ if opt.Creds != nil && opt.Creds.NetworkPaired() {
+ n.grantVerifier = access.NewVerifier(opt.Creds.Network, opt.Creds.Principal)
+ n.accessClient = &access.Client{Origin: opt.Creds.Network, Tokens: link.NewTokenSourceFor(opt.Creds, nil, "openvibe-link/"+opt.Version, "openvibe.network")}
+ }
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
var specs []plugins.Spec
@@ -356,6 +379,14 @@ func (n *Agent) Run(parent context.Context) error {
@@ -356,6 +379,14 @@ func (n *Agent) Run(parent context.Context) error {
wg.Add(1)
wg.Add(1)
go func() { defer wg.Done(); n.fleet.Run(ctx) }()
go func() { defer wg.Done(); n.fleet.Run(ctx) }()
}
}
+ if n.accessClient != nil {
+ wg.Add(1)
+ go func() { defer wg.Done(); n.pollAccessRequests(ctx) }()
+ if n.opt.Config.AgentsTalk {
+ wg.Add(1)
+ go func() { defer wg.Done(); n.registerLocalAgents(ctx) }()
+ }
+ }
// OpenVibe's own computer pool (docs/party-desktop.md): a machine the owner registered asks Chat what to host.
// OpenVibe's own computer pool (docs/party-desktop.md): a machine the owner registered asks Chat what to host.
if n.opt.Config.PartyPool.Enabled {
if n.opt.Config.PartyPool.Enabled {
wg.Add(1)
wg.Add(1)
@@ -448,6 +479,29 @@ func (n *Agent) Frame(f protocol.Frame) {
@@ -448,6 +479,29 @@ func (n *Agent) Frame(f protocol.Frame) {
} else {
} else {
n.log.Debug("job_exit_ack ignored: no worker", "id", m.ID)
n.log.Debug("job_exit_ack ignored: no worker", "id", m.ID)
}
}
+ case protocol.AgentTaskFrame:
+ if err := n.receiveAgentTask(m); err != nil {
+ n.log.Warn("agent task refused", "err", err)
+ }
+ case protocol.AgentTaskResultAck:
+ n.agentResultAck(m.TaskID, "")
+ case protocol.AccessRevoked:
+ n.revokeAccess(m)
+ case protocol.RelayOpened:
+ // Store the session before the next frame is read: relay.frame may follow
+ // on the same socket, and its goroutine must find the session.
+ n.receiveRelayOpened(m)
+ case protocol.RelayFrame:
+ go n.receiveRelayFrame(m)
+ case protocol.RelayClosed:
+ n.receiveRelayClosed(m)
+ case protocol.Error:
+ if strings.HasPrefix(m.Code, "bot.relay_") || strings.HasPrefix(m.Code, "bot.agent_task_") {
+ n.failRelayWaiters(m.Code + ": " + m.Detail)
+ }
+ if strings.HasPrefix(m.Code, "bot.agent_task_") {
+ n.failAgentResults(m.Code + ": " + m.Detail)
+ }
}
}
}
}
@@ -1093,6 +1147,9 @@ func (n *Agent) serverNow(sentMS int64, received mono.Instant) int64 {
@@ -1093,6 +1147,9 @@ func (n *Agent) serverNow(sentMS int64, received mono.Instant) int64 {
}
}
func (n *Agent) send(m protocol.Message) {
func (n *Agent) send(m protocol.Message) {
+ if n.relayResult(m) {
+ return
+ }
if n.link == nil {
if n.link == nil {
return
return
}
}
@@ -1617,6 +1674,10 @@ func (n *Agent) Approvals() []Approval {
@@ -1617,6 +1674,10 @@ func (n *Agent) Approvals() []Approval {
defer n.mu.Unlock()
defer n.mu.Unlock()
out := make([]Approval, 0, len(n.approvals))
out := make([]Approval, 0, len(n.approvals))
for id, a := range n.approvals {
for id, a := range n.approvals {
+ if a.Plugin == "machine-access" {
+ out = append(out, a)
+ continue
+ }
p := n.mgr.Get(a.Plugin)
p := n.mgr.Get(a.Plugin)
if p == nil || !p.ApprovalValid(a.binding) {
if p == nil || !p.ApprovalValid(a.binding) {
delete(n.approvals, id)
delete(n.approvals, id)
@@ -1672,6 +1733,10 @@ func (n *Agent) control(r localctl.Request) localctl.Response {
@@ -1672,6 +1733,10 @@ func (n *Agent) control(r localctl.Request) localctl.Response {
return localctl.Response{Error: err.Error()}
return localctl.Response{Error: err.Error()}
}
}
return localctl.Response{OK: true, Data: b}
return localctl.Response{OK: true, Data: b}
+ case localctl.CmdMCP:
+ return n.mcpControl(r.ID, r.Agent, r.Data)
+ case localctl.CmdAccess:
+ return n.accessControl(r.ID, r.Data)
case localctl.CmdApprove, localctl.CmdDeny:
case localctl.CmdApprove, localctl.CmdDeny:
if err := n.answerApproval(r.ID, r.Cmd == localctl.CmdApprove); err != nil {
if err := n.answerApproval(r.ID, r.Cmd == localctl.CmdApprove); err != nil {
return localctl.Response{Error: err.Error()}
return localctl.Response{Error: err.Error()}
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
package agent
package agent
import (
import (
+ "errors"
"sync"
"sync"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
@@ -78,6 +79,13 @@ func (j *jobs) accepted(id string) bool {
@@ -78,6 +79,13 @@ func (j *jobs) accepted(id string) bool {
return ok
return ok
}
}
+func (j *jobs) answer(id string) (protocol.Message, bool) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ m, ok := j.answers[id]
+ return m, ok
+}
+
// job answers a job frame with ack or nack, keyed by the job id, and runs an accepted job. A job the worker holds is
// job answers a job frame with ack or nack, keyed by the job id, and runs an accepted job. A job the worker holds is
// answered from it (an ack while it runs, its job_exit once it ended) and never started twice.
// answered from it (an ack while it runs, its job_exit once it ended) and never started twice.
func (n *Agent) job(r protocol.JobRequest) {
func (n *Agent) job(r protocol.JobRequest) {
@@ -104,6 +112,43 @@ func (n *Agent) job(r protocol.JobRequest) {
@@ -104,6 +112,43 @@ func (n *Agent) job(r protocol.JobRequest) {
}
}
}
}
+// jobRelayed accepts only a fresh worker job. Its owner is recorded before the
+// acknowledgement leaves Link, so status and cancel cannot reach Bot jobs or
+// jobs started by another machine.
+func (n *Agent) jobRelayed(r protocol.JobRequest, source relaySource) error {
+ id := r.Job.ID
+ n.mu.Lock()
+ _, claimed := n.remoteJobs[id]
+ n.mu.Unlock()
+ if claimed {
+ return errors.New("job id is already in use")
+ }
+ if n.worker != nil {
+ if _, known := n.worker.Known(id); known {
+ return errors.New("job id is already in use")
+ }
+ }
+ answer, accepted := n.jobs.request(r)
+ if !accepted {
+ if nk, ok := answer.(protocol.Nack); ok {
+ return errors.New(nk.Message)
+ }
+ return errors.New("job id is already in use")
+ }
+ n.mu.Lock()
+ n.remoteJobs[id] = source.to
+ n.relaySources[id] = source
+ n.mu.Unlock()
+ n.send(answer)
+ if n.codes != nil {
+ n.codes.accepted(r.Job)
+ }
+ if n.worker != nil {
+ n.worker.Launch(id)
+ }
+ return nil
+}
+
// sendJobFrame sends a worker frame; a job_exit also becomes the answer to a resent job frame.
// sendJobFrame sends a worker frame; a job_exit also becomes the answer to a resent job frame.
func (n *Agent) sendJobFrame(m protocol.Message) {
func (n *Agent) sendJobFrame(m protocol.Message) {
if e, ok := m.(protocol.JobExit); ok {
if e, ok := m.(protocol.JobExit); ok {
@@ -8,6 +8,7 @@ import (
@@ -8,6 +8,7 @@ import (
"testing"
"testing"
"time"
"time"
+ "github.com/OpenVibers/OpenVibe.Link/internal/credentials"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
)
)
@@ -18,6 +19,31 @@ func testJob(id string) protocol.Job {
@@ -18,6 +19,31 @@ func testJob(id string) protocol.Job {
Args: json.RawMessage(`{"width":320}`), TTLMS: 60000, Limits: protocol.JobLimits{WallMS: 30000, CPUMS: 30000, MemBytes: 268435456}}
Args: json.RawMessage(`{"width":320}`), TTLMS: 60000, Limits: protocol.JobLimits{WallMS: 30000, CPUMS: 30000, MemBytes: 268435456}}
}
}
+func TestRelayedWorkerJobBelongsOnlyToRequestingMachine(t *testing.T) {
+ n := &Agent{opt: Options{Creds: &credentials.Credentials{Principal: "lnk_receiver"}}, jobs: newJobs([]string{protocol.ClassFunction}),
+ remoteJobs: map[string]string{}, relaySources: map[string]relaySource{}}
+ first := testJob(jobID(950))
+ if err := n.jobRelayed(protocol.JobRequest{Job: first}, relaySource{to: "lnk_sender", scope: "worker", relay: "rly_one", bodyID: "mrl_one"}); err != nil {
+ t.Fatal(err)
+ }
+ if n.remoteJobs[first.ID] != "lnk_sender" {
+ t.Fatal("accepted job has no machine owner")
+ }
+ if err := n.jobRelayed(protocol.JobRequest{Job: first}, relaySource{to: "lnk_other", scope: "worker", relay: "rly_two", bodyID: "mrl_two"}); err == nil {
+ t.Fatal("another machine reused the job id")
+ }
+ botJob := testJob(jobID(951))
+ if _, accepted := n.jobs.request(protocol.JobRequest{Job: botJob}); !accepted {
+ t.Fatal("could not seed Bot job")
+ }
+ if err := n.jobRelayed(protocol.JobRequest{Job: botJob}, relaySource{to: "lnk_sender", scope: "worker", relay: "rly_three", bodyID: "mrl_three"}); err == nil {
+ t.Fatal("remote machine took a Bot job id")
+ }
+ if n.remoteJobs[botJob.ID] != "" {
+ t.Fatal("Bot job was assigned a machine owner")
+ }
+}
+
func (e *env) jobReply(j protocol.Job) protocol.Message {
func (e *env) jobReply(j protocol.Job) protocol.Message {
e.t.Helper()
e.t.Helper()
if err := e.conn.Job(j); err != nil {
if err := e.conn.Job(j); err != nil {
new file mode 100644
new file mode 100644
@@ -0,0 +1,249 @@
@@ -0,0 +1,249 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/config"
+ "github.com/OpenVibers/OpenVibe.Link/internal/fakebot"
+ "github.com/OpenVibers/OpenVibe.Link/internal/fakenetwork"
+ "github.com/OpenVibers/OpenVibe.Link/internal/link"
+ "github.com/OpenVibers/OpenVibe.Link/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Link/internal/panel"
+ "github.com/OpenVibers/OpenVibe.Link/internal/protocol"
+)
+
+func TestTwoLinksRequestApproveUseRevoke(t *testing.T) {
+ bot := fakebot.New()
+ t.Cleanup(bot.Close)
+ network := fakenetwork.New(bot)
+ t.Cleanup(network.Close)
+ network.AddCode("ACCESS11")
+ const other = "lnk_01J8Z4M2Q0R7T9YV3K6N8P1W2Z"
+ network.AddMachine("ACCESS22", other, "dev_net02")
+ start := func(code string) (*Agent, config.Paths, context.CancelFunc) {
+ t.Helper()
+ creds, err := link.PairNetwork(context.Background(), nil, network.URL(), protocol.LinkPairingRequest{Code: code}, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ creds.Server = bot.URL()
+ creds, err = link.Bind(context.Background(), nil, creds, link.NewTokenSource(creds, nil, "test"), "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ home, err := os.MkdirTemp("", "ova")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.RemoveAll(home) })
+ paths := config.HomePaths(home)
+ cfg := config.Default()
+ cfg.Plugins = nil
+ cfg.AgentsTalk = true
+ cfg.Video.Source = "off"
+ n, err := New(Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", NoShares: true, HeartbeatInterval: 100 * time.Millisecond, LinkBackoffMin: 50 * time.Millisecond,
+ Log: slog.New(slog.NewTextHandler(io.Discard, nil)), CodesAgents: func() []panel.CodesAgent { return []panel.CodesAgent{{ID: "codex", Name: "Codex", State: "ready"}} }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() { _ = n.Run(ctx) }()
+ t.Cleanup(cancel)
+ if _, err := bot.NextConn(5 * time.Second); err != nil {
+ t.Fatal(err)
+ }
+ return n, paths, cancel
+ }
+ a, ap, _ := start("ACCESS11")
+ b, bp, _ := start("ACCESS22")
+ t.Logf("two Links, two state directories: A=%s B=%s", ap.StateDir, bp.StateDir)
+ a.registerLocalAgentsOnce(context.Background())
+ b.registerLocalAgentsOnce(context.Background())
+ call := func(paths config.Paths, req localctl.Request) (json.RawMessage, error) {
+ t.Helper()
+ r, err := localctl.Call(paths.SocketPath(), req, 10*time.Second)
+ return r.Data, err
+ }
+ request, err := call(ap, localctl.Request{Cmd: localctl.CmdAccess, ID: "request", Data: json.RawMessage(`{"machine":"` + other + `","scopes":["send"],"duration_seconds":3600,"reason":"test two links"}`)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var requested struct {
+ Request struct {
+ ID string `json:"id"`
+ } `json:"request"`
+ }
+ if json.Unmarshal(request, &requested) != nil || requested.Request.ID == "" {
+ t.Fatalf("request: %s", request)
+ }
+ t.Logf("A asked B: %s", request)
+ b.refreshAccessRequests(context.Background())
+ var approval Approval
+ for _, item := range b.Approvals() {
+ if item.Plugin == "machine-access" && item.CommandID == requested.Request.ID {
+ approval = item
+ break
+ }
+ }
+ if approval.ID == "" {
+ t.Fatal("target Link did not show tray approval")
+ }
+ if approval.Title != "Alex's laptop asks for access" || approval.Summary != `Send you files, for 1 hour. "test two links"` {
+ t.Fatalf("tray words: %q / %q", approval.Title, approval.Summary)
+ }
+ t.Logf("B's tray: %q / %q (approval %s)", approval.Title, approval.Summary, approval.ID)
+ if _, err = call(bp, localctl.Request{Cmd: localctl.CmdApprove, ID: approval.ID}); err != nil {
+ t.Fatal(err)
+ }
+ t.Log("B's owner clicked Allow")
+ use := json.RawMessage(`{"machine":"` + other + `","scope":"send","action":"offer","data":{"envelope":"sealed-test-item"}}`)
+ result, err := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "machine_use", Data: use})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(result), `"scope":"send"`) {
+ t.Fatalf("machine_use: %s", result)
+ }
+ t.Logf("A used send: %s", result)
+ b.mu.Lock()
+ lan := b.grantedSend
+ b.mu.Unlock()
+ if lan == nil || len(lan.Items()) != 1 || lan.Items()[0].Sender != fakenetwork.Principal {
+ t.Fatal("sealed item did not reach the target inbox")
+ }
+ grants, err := a.accessClient.Grants(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var list struct {
+ Grants []struct {
+ ID string `json:"id"`
+ } `json:"grants"`
+ }
+ if json.Unmarshal(grants, &list) != nil || len(list.Grants) != 1 {
+ t.Fatal("grant missing")
+ }
+ t.Logf("A holds grant %s", list.Grants[0].ID)
+ if _, err = call(bp, localctl.Request{Cmd: localctl.CmdAccess, ID: "revoke", Data: json.RawMessage(`{"id":"` + list.Grants[0].ID + `"}`)}); err != nil {
+ t.Fatal(err)
+ }
+ t.Log("B revoked the grant")
+ if _, err = call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "machine_use", Data: use}); err == nil || !strings.Contains(err.Error(), "no active grant") {
+ t.Fatalf("revoke did not stop use: %v", err)
+ }
+ t.Logf("A's next use was refused: %v", err)
+ if listed, listErr := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "agents_list", Agent: "codex", Data: json.RawMessage(`{}`)}); listErr != nil || !strings.Contains(string(listed), "Codex") {
+ t.Fatalf("agents_list: %s %v", listed, listErr)
+ } else {
+ t.Logf("agents_list: %s", listed)
+ }
+ if listed, listErr := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "machines_list", Agent: "codex", Data: json.RawMessage(`{}`)}); listErr != nil || !strings.Contains(string(listed), other) {
+ t.Fatalf("machines_list: %s %v", listed, listErr)
+ } else {
+ t.Logf("machines_list: %s", listed)
+ }
+ b.mu.Lock()
+ remoteAgent := b.localAgents["codex"].ID
+ b.mu.Unlock()
+ if remoteAgent == "" {
+ t.Fatal("local CLI did not register")
+ }
+ if told, tellErr := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_tell", Agent: "codex", Data: json.RawMessage(`{"agent":"` + remoteAgent + `","text":"hello"}`)}); tellErr != nil {
+ t.Fatal(tellErr)
+ } else {
+ t.Logf("agent_tell: %s", told)
+ }
+ var inbox json.RawMessage
+ for until := time.Now().Add(2 * time.Second); time.Now().Before(until); time.Sleep(20 * time.Millisecond) {
+ inbox, err = call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_inbox", Agent: "codex", Data: json.RawMessage(`{}`)})
+ if err == nil && strings.Contains(string(inbox), "hello") {
+ break
+ }
+ }
+ if !strings.Contains(string(inbox), "hello") {
+ t.Fatalf("agent task not in inbox: %s %v", inbox, err)
+ }
+ t.Logf("agent_inbox: %s", inbox)
+ var found struct {
+ Tasks []struct {
+ Task struct {
+ ID string `json:"id"`
+ } `json:"task"`
+ } `json:"tasks"`
+ }
+ if json.Unmarshal(inbox, &found) != nil || len(found.Tasks) != 1 {
+ t.Fatalf("inbox: %s", inbox)
+ }
+ if replied, replyErr := call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_reply", Agent: "codex", Data: json.RawMessage(`{"task":"` + found.Tasks[0].Task.ID + `","text":"received"}`)}); replyErr != nil {
+ t.Fatal(replyErr)
+ } else {
+ t.Logf("agent_reply: %s", replied)
+ }
+ if _, err = call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_inbox", Agent: "unregistered", Data: json.RawMessage(`{}`)}); err == nil {
+ t.Fatal("unregistered CLI read inbox")
+ }
+ type askResult struct {
+ answer json.RawMessage
+ err error
+ }
+ askDone := make(chan askResult, 1)
+ go func() {
+ answer, askErr := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_ask", Agent: "codex", Data: json.RawMessage(`{"agent":"` + remoteAgent + `","text":"status?","wait_seconds":4}`)})
+ if askErr == nil && !strings.Contains(string(answer), "all clear") {
+ askErr = errors.New("agent_ask did not return the reply")
+ }
+ askDone <- askResult{answer, askErr}
+ }()
+ var nextID string
+ for until := time.Now().Add(3 * time.Second); time.Now().Before(until); time.Sleep(20 * time.Millisecond) {
+ raw, readErr := call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_inbox", Agent: "codex", Data: json.RawMessage(`{}`)})
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ var entries struct {
+ Tasks []struct {
+ Status string `json:"status"`
+ Task struct {
+ ID string `json:"id"`
+ } `json:"task"`
+ } `json:"tasks"`
+ }
+ if json.Unmarshal(raw, &entries) != nil {
+ t.Fatal("invalid inbox")
+ }
+ for _, entry := range entries.Tasks {
+ if entry.Status == "pending" {
+ nextID = entry.Task.ID
+ }
+ }
+ if nextID != "" {
+ break
+ }
+ }
+ if nextID == "" {
+ t.Fatal("agent_ask task did not arrive")
+ }
+ if _, err = call(bp, localctl.Request{Cmd: localctl.CmdMCP, ID: "agent_reply", Agent: "codex", Data: json.RawMessage(`{"task":"` + nextID + `","text":"all clear"}`)}); err != nil {
+ t.Fatal(err)
+ }
+ if asked := <-askDone; asked.err != nil {
+ t.Fatal(asked.err)
+ } else {
+ t.Logf("agent_ask: %s", asked.answer)
+ }
+ requestedByAgent, err := call(ap, localctl.Request{Cmd: localctl.CmdMCP, ID: "machine_request_access", Agent: "codex", Data: json.RawMessage(`{"machine":"` + other + `","scopes":["worker"],"for_seconds":3600}`)})
+ if err != nil || !strings.Contains(string(requestedByAgent), `"from_agent"`) {
+ t.Fatalf("machine_request_access: %s %v", requestedByAgent, err)
+ }
+ t.Logf("machine_request_access: %s", requestedByAgent)
+ t.Log("request -> target tray approval -> granted send use -> revoke -> use refused")
+ t.Log("agents_list -> agent_tell -> agent_inbox -> agent_reply -> agent_ask; machine_request_access; unregistered inbox refused")
+}
@@ -17,6 +17,7 @@ import (
@@ -17,6 +17,7 @@ import (
"strings"
"strings"
"time"
"time"
+ "github.com/OpenVibers/OpenVibe.Link/internal/access"
"github.com/OpenVibers/OpenVibe.Link/internal/firstrun"
"github.com/OpenVibers/OpenVibe.Link/internal/firstrun"
"github.com/OpenVibers/OpenVibe.Link/internal/link"
"github.com/OpenVibers/OpenVibe.Link/internal/link"
"github.com/OpenVibers/OpenVibe.Link/internal/panel"
"github.com/OpenVibers/OpenVibe.Link/internal/panel"
@@ -97,6 +98,23 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
@@ -97,6 +98,23 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
// A random local port accepts a sealed envelope from another of the owner's machines on this network. The relay
// 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.
// stays the fallback, and nothing unauthenticated can reach the inbox.
lan := send.NewLAN(n.opt.Creds.Principal)
lan := send.NewLAN(n.opt.Creds.Principal)
+ lan.SetGrantCheck(func(ctx context.Context, token, sender string) error {
+ if n.grantVerifier == nil || n.accessClient == nil {
+ return access.ErrGrant
+ }
+ claims, err := n.grantVerifier.Verify(ctx, token, sender, "send")
+ if err != nil {
+ return access.ErrGrant
+ }
+ grants, err := n.accessClient.Grants(ctx)
+ if err != nil || !grantStillActive(grants, claims.ID, sender, n.opt.Creds.Principal, "send") {
+ return access.ErrGrant
+ }
+ return nil
+ })
+ n.mu.Lock()
+ n.grantedSend = lan
+ n.mu.Unlock()
if err := lan.Listen(""); err != nil {
if err := lan.Listen(""); err != nil {
n.log.Warn("Link Send stays on the relay: no local listening port", "err", err)
n.log.Warn("Link Send stays on the relay: no local listening port", "err", err)
} else {
} else {
@@ -702,6 +720,14 @@ func (n *Agent) panelStop(id string) error {
@@ -702,6 +720,14 @@ func (n *Agent) panelStop(id string) error {
// answerApproval lets one waiting request run or refuses it; the tray, the CLI and the window all end here.
// answerApproval lets one waiting request run or refuses it; the tray, the CLI and the window all end here.
func (n *Agent) answerApproval(nonce string, approved bool) error {
func (n *Agent) answerApproval(nonce string, approved bool) error {
+ n.mu.Lock()
+ for _, a := range n.approvals {
+ if a.ID == nonce && a.Plugin == "machine-access" {
+ n.mu.Unlock()
+ return n.answerAccessApproval(a, approved)
+ }
+ }
+ n.mu.Unlock()
n.mu.Lock()
n.mu.Lock()
var a Approval
var a Approval
ok := false
ok := false
@@ -123,6 +123,9 @@ type Config struct {
@@ -123,6 +123,9 @@ type Config struct {
// PartyPool offers this machine to OpenVibe's own computer pool for openvibe.party (docs/party-desktop.md).
// PartyPool offers this machine to OpenVibe's own computer pool for openvibe.party (docs/party-desktop.md).
// Off unless enabled. The machine still has to be registered by the owner on openvibe.party/pool.
// Off unless enabled. The machine still has to be registered by the owner on openvibe.party/pool.
PartyPool PartyPoolConfig `json:"party_pool,omitzero"`
PartyPool PartyPoolConfig `json:"party_pool,omitzero"`
+ // AgentsTalk lets this machine register its local CLIs and receive agent tasks.
+ // The owner must turn it on explicitly; the default is false.
+ AgentsTalk bool `json:"agents_talk,omitempty"`
}
}
// PartyPoolConfig is this machine's part in OpenVibe's own computer pool: the Link asks Chat, as this machine, what
// PartyPoolConfig is this machine's part in OpenVibe's own computer pool: the Link asks Chat, as this machine, what
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
package fakebot
package fakebot
import (
import (
+ "context"
"crypto/rand"
"crypto/rand"
"encoding/hex"
"encoding/hex"
"encoding/json"
"encoding/json"
@@ -26,6 +27,7 @@ import (
@@ -26,6 +27,7 @@ import (
"github.com/gorilla/websocket"
"github.com/gorilla/websocket"
+ "github.com/OpenVibers/OpenVibe.Link/internal/access"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
"github.com/OpenVibers/OpenVibe.Link/internal/protocol"
)
)
@@ -46,17 +48,23 @@ type Server struct {
@@ -46,17 +48,23 @@ type Server struct {
// no measured round trip, so no bound on its clock estimate.
// no measured round trip, so no bound on its clock estimate.
HoldHeartbeatAcks bool
HoldHeartbeatAcks bool
- mu sync.Mutex
- codes map[string]string // code → robot id
- creds map[string]string // credential → device id
- robots map[string]string // device id → robot id
- live map[string]*Conn // device id → its current connection
- estop map[string]bool // robot id → the robot's e-stop latch, as Bot keeps it
- pairRequests []protocol.PairRequest
- pairRefusal *refusal
- bindRefusals []refusal
- credInURL bool
- binds atomic.Int64
+ mu sync.Mutex
+ codes map[string]string // code → robot id
+ creds map[string]string // credential → device id
+ robots map[string]string // device id → robot id
+ live map[string]*Conn // device id → its current connection
+ machineDevices map[string]string // node principal → device id
+ deviceMachines map[string]string // device id → node principal
+ relays map[string]*fakeRelay
+ relayOrigin string
+ relayAllowed func(string, string, string, string) bool
+ agentResult func(string, string, json.RawMessage)
+ estop map[string]bool // robot id → the robot's e-stop latch, as Bot keeps it
+ pairRequests []protocol.PairRequest
+ pairRefusal *refusal
+ bindRefusals []refusal
+ credInURL bool
+ binds atomic.Int64
conns chan *Conn
conns chan *Conn
count atomic.Int64
count atomic.Int64
@@ -69,6 +77,13 @@ type refusal struct {
@@ -69,6 +77,13 @@ type refusal struct {
code, detail string
code, detail string
}
}
+type fakeRelay struct {
+ ID, GrantID, From, To, Token string
+ Scopes map[string]bool
+ Expires time.Time
+ FromConn, ToConn *Conn
+}
+
// RobotID is the robot AddCode and AddCredential attach devices to.
// RobotID is the robot AddCode and AddCredential attach devices to.
const RobotID = "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"
const RobotID = "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"
@@ -77,7 +92,7 @@ func (s *Server) SetHealth(status int) { s.health.Store(int64(status)) }
@@ -77,7 +92,7 @@ func (s *Server) SetHealth(status int) { s.health.Store(int64(status)) }
// New starts the fake server.
// New starts the fake server.
func New() *Server {
func New() *Server {
- s := &Server{codes: map[string]string{}, creds: map[string]string{}, robots: map[string]string{}, live: map[string]*Conn{}}
+ s := &Server{codes: map[string]string{}, creds: map[string]string{}, robots: map[string]string{}, live: map[string]*Conn{}, machineDevices: map[string]string{}, deviceMachines: map[string]string{}, relays: map[string]*fakeRelay{}}
s.health.Store(http.StatusOK)
s.health.Store(http.StatusOK)
s.estop = map[string]bool{}
s.estop = map[string]bool{}
s.conns = make(chan *Conn, 16)
s.conns = make(chan *Conn, 16)
@@ -134,6 +149,157 @@ func (s *Server) AddCredential(cred, deviceID string) {
@@ -134,6 +149,157 @@ func (s *Server) AddCredential(cred, deviceID string) {
s.mu.Unlock()
s.mu.Unlock()
}
}
+// SetRelayAuthority gives the fake Bot Network's JWKS and live grant lookup.
+func (s *Server) SetRelayAuthority(origin string, allowed func(string, string, string, string) bool) {
+ s.mu.Lock()
+ s.relayOrigin, s.relayAllowed = origin, allowed
+ s.mu.Unlock()
+}
+
+func (s *Server) BindMachine(principal, deviceID string) {
+ s.mu.Lock()
+ s.machineDevices[principal] = deviceID
+ s.deviceMachines[deviceID] = principal
+ s.mu.Unlock()
+}
+
+func (s *Server) RevokeGrant(id, to string) {
+ s.mu.Lock()
+ closed := make([]*fakeRelay, 0)
+ for _, relay := range s.relays {
+ if relay.GrantID == id {
+ delete(s.relays, relay.ID)
+ closed = append(closed, relay)
+ }
+ }
+ s.mu.Unlock()
+ for _, relay := range closed {
+ for _, c := range []*Conn{relay.FromConn, relay.ToConn} {
+ if c != nil {
+ _ = c.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "revoked"})
+ }
+ }
+ }
+ if c := s.live[s.machineDevices[to]]; c != nil {
+ _ = c.Send(protocol.AccessRevoked{GrantID: id})
+ }
+}
+
+// SetAgentResult receives agent.task_result from the machine an agent.task was delivered to.
+func (s *Server) SetAgentResult(fn func(string, string, json.RawMessage)) {
+ s.mu.Lock()
+ s.agentResult = fn
+ s.mu.Unlock()
+}
+
+func (s *Server) DeliverAgentTask(to string, task protocol.AgentTaskFrame) bool {
+ s.mu.Lock()
+ c := s.live[s.machineDevices[to]]
+ s.mu.Unlock()
+ return c != nil && c.Send(protocol.AgentTaskFrame{Task: task.Task}) == nil
+}
+
+func relayError(c *Conn, code, detail string) {
+ _ = c.Send(protocol.Error{Code: code, Detail: detail})
+}
+
+// relayFrame is Bot's machine-to-machine relay and agent task result path
+// (server/realtime.js): relay.open checks the grant token, relay.frame carries
+// one operation or answer, relay.close ends the session.
+func (s *Server) relayFrame(c *Conn, m protocol.Message) {
+ switch frame := m.(type) {
+ case protocol.RelayOpen:
+ s.mu.Lock()
+ from := s.deviceMachines[c.DeviceID]
+ target := s.live[s.machineDevices[frame.To]]
+ origin, allowed := s.relayOrigin, s.relayAllowed
+ s.mu.Unlock()
+ if from == "" || target == nil || origin == "" || allowed == nil || !strings.HasPrefix(frame.To, "lnk_") || frame.To == from {
+ relayError(c, "bot.relay_target_offline", "the receiving machine is not connected")
+ return
+ }
+ claims, err := access.NewVerifier(origin, frame.To).VerifyAny(context.Background(), frame.GrantToken, from)
+ if err != nil || claims.From != from || claims.To != frame.To {
+ relayError(c, "bot.relay_grant_denied", "no live Network grant lets this machine relay to that one")
+ return
+ }
+ scopes := map[string]bool{}
+ for _, scope := range claims.Scopes {
+ if !access.ValidScope(scope) || !allowed(claims.ID, from, frame.To, scope) {
+ relayError(c, "bot.relay_grant_denied", "no live Network grant lets this machine relay to that one")
+ return
+ }
+ scopes[scope] = true
+ }
+ id := "rly_" + token()[:26]
+ relay := &fakeRelay{ID: id, GrantID: claims.ID, From: from, To: frame.To, Token: frame.GrantToken,
+ Scopes: scopes, Expires: time.Unix(claims.Expiry, 0), FromConn: c, ToConn: target}
+ s.mu.Lock()
+ s.relays[id] = relay
+ s.mu.Unlock()
+ opened := protocol.RelayOpened{Relay: id, To: frame.To, GrantToken: frame.GrantToken, Scopes: claims.Scopes,
+ ExpiresAt: time.Unix(claims.Expiry, 0).UTC().Format(time.RFC3339), From: from}
+ _ = c.Send(opened)
+ _ = target.Send(opened)
+ case protocol.RelayFrame:
+ s.mu.Lock()
+ relay := s.relays[frame.Relay]
+ var other *Conn
+ if relay != nil {
+ switch c {
+ case relay.FromConn:
+ other = relay.ToConn
+ case relay.ToConn:
+ other = relay.FromConn
+ default:
+ relay = nil
+ }
+ }
+ s.mu.Unlock()
+ if relay == nil || !relay.Scopes[frame.Scope] || len(frame.Body) == 0 || len(frame.Body) > 250<<10 {
+ relayError(c, "bot.relay_scope_denied", "the relay is closed or the grant does not include that scope")
+ return
+ }
+ if time.Now().After(relay.Expires) {
+ s.mu.Lock()
+ delete(s.relays, relay.ID)
+ s.mu.Unlock()
+ _ = c.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "expired"})
+ if other != nil {
+ _ = other.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "expired"})
+ }
+ relayError(c, "bot.relay_expired", "the grant token expired")
+ return
+ }
+ from := relay.From
+ if c == relay.ToConn {
+ from = relay.To
+ }
+ _ = other.Send(protocol.RelayFrame{Relay: relay.ID, From: from, Scope: frame.Scope, Body: frame.Body})
+ case protocol.RelayClose:
+ s.mu.Lock()
+ relay := s.relays[frame.Relay]
+ if relay != nil {
+ delete(s.relays, frame.Relay)
+ }
+ s.mu.Unlock()
+ if relay == nil {
+ relayError(c, "bot.relay_unknown", "the relay is already closed")
+ return
+ }
+ _ = relay.FromConn.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "closed"})
+ _ = relay.ToConn.Send(protocol.RelayClosed{Relay: relay.ID, Reason: "closed"})
+ case protocol.AgentTaskResult:
+ s.mu.Lock()
+ fn := s.agentResult
+ s.mu.Unlock()
+ if fn != nil {
+ fn(frame.TaskID, frame.Status, frame.Result)
+ }
+ _ = c.Send(protocol.AgentTaskResultAck{TaskID: frame.TaskID, Status: frame.Status})
+ }
+}
+
// Revoke invalidates a credential and closes the device's live connection with 4003, as Bot's revoke does.
// Revoke invalidates a credential and closes the device's live connection with 4003, as Bot's revoke does.
func (s *Server) Revoke(cred string) {
func (s *Server) Revoke(cred string) {
s.mu.Lock()
s.mu.Lock()
@@ -540,6 +706,9 @@ func (c *Conn) read() {
@@ -540,6 +706,9 @@ func (c *Conn) read() {
}
}
_ = c.Send(ack)
_ = c.Send(ack)
}
}
+ if c.srv != nil {
+ c.srv.relayFrame(c, f.Msg)
+ }
c.fmu.Lock()
c.fmu.Lock()
c.frames = append(c.frames, f)
c.frames = append(c.frames, f)
c.fmu.Unlock()
c.fmu.Unlock()
new file mode 100644
new file mode 100644
@@ -0,0 +1,347 @@
@@ -0,0 +1,347 @@
+package fakenetwork
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/access"
+)
+
+const fakeOwner = "usr_01J8Z4M2Q0R7T9YV3K6N8P1W2X"
+
+type fakeAccessRequest struct {
+ ID string `json:"id"`
+ FromPrincipal string `json:"from_principal"`
+ ToPrincipal string `json:"to_principal"`
+ FromOwner string `json:"from_owner"`
+ ToOwner string `json:"to_owner"`
+ FromAgent string `json:"from_agent,omitempty"`
+ FromMachineName string `json:"from_machine_name,omitempty"`
+ FromOwnerName string `json:"from_owner_name,omitempty"`
+ FromAgentName string `json:"from_agent_name,omitempty"`
+ Scopes []string `json:"scopes"`
+ Reason string `json:"reason,omitempty"`
+ Status string `json:"status"`
+ RequestedAt string `json:"asked_at"`
+ ExpiresAt string `json:"pending_until"`
+ DurationSeconds int64 `json:"duration_seconds"`
+}
+
+type fakeAccessGrant struct {
+ ID string `json:"id"`
+ RequestID string `json:"request_id"`
+ FromPrincipal string `json:"from_principal"`
+ ToPrincipal string `json:"to_principal"`
+ Scopes []string `json:"scopes"`
+ ApprovedBy string `json:"approved_by"`
+ CreatedAt string `json:"created_at"`
+ ExpiresAt *string `json:"expires_at"`
+ RevokedAt *string `json:"revoked_at"`
+}
+
+// GrantActive is fake Bot's live Network authorization on each relayed frame.
+func (s *Server) GrantActive(id, from, to, scope string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ g := s.grants[id]
+ if g == nil || g.FromPrincipal != from || g.ToPrincipal != to || g.RevokedAt != nil {
+ return false
+ }
+ if g.ExpiresAt != nil {
+ at, err := time.Parse(time.RFC3339, *g.ExpiresAt)
+ if err != nil || !at.After(time.Now()) {
+ return false
+ }
+ }
+ for _, v := range g.Scopes {
+ if v == scope {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *Server) registerAccessRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("/api/.well-known/jwks", s.jwks)
+ mux.HandleFunc("/api/v1/link/self/access-requests", s.accessRequests)
+ mux.HandleFunc("/api/v1/link/self/access-requests/", s.accessRequestAction)
+ mux.HandleFunc("/api/v1/link/self/access-grants", s.accessGrants)
+ mux.HandleFunc("/api/v1/link/self/access-grants/", s.accessGrantAction)
+ mux.HandleFunc("/api/v1/link/self/send/targets", s.accessMachines)
+}
+
+func (s *Server) accessPrincipal(r *http.Request) string {
+ token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.tokenAudience[token] != "openvibe.network" {
+ return ""
+ }
+ return s.tokenOwners[token]
+}
+
+func (s *Server) jwks(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ w.WriteHeader(405)
+ return
+ }
+ if s.signing == nil {
+ w.WriteHeader(503)
+ return
+ }
+ pub := s.signing.PublicKey
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{map[string]any{
+ "kid": "fake-network-1", "kty": "RSA", "alg": "RS256", "use": "sig",
+ "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
+ "e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
+ }}})
+}
+
+func (s *Server) accessRequests(w http.ResponseWriter, r *http.Request) {
+ principal := s.accessPrincipal(r)
+ if principal == "" {
+ problem(w, 401, "access.node_required")
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ s.mu.Lock()
+ list := []fakeAccessRequest{}
+ for _, v := range s.requests {
+ if v.FromPrincipal == principal || v.ToPrincipal == principal {
+ list = append(list, *v)
+ }
+ }
+ s.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]any{"requests": list})
+ case http.MethodPost:
+ var body struct {
+ Target string `json:"target"`
+ Scopes []string `json:"scopes"`
+ Duration int64 `json:"duration_seconds"`
+ Reason string `json:"reason"`
+ FromAgent string `json:"from_agent"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body.Target == principal || body.Duration < 0 || body.Duration > 30*86400 || len(body.Scopes) == 0 {
+ problem(w, 400, "access.invalid_request")
+ return
+ }
+ for _, scope := range body.Scopes {
+ if !access.ValidScope(scope) {
+ problem(w, 400, "access.invalid_scope")
+ return
+ }
+ if body.Duration == 0 && scope != "agent" && scope != "send" {
+ problem(w, 400, "access.forever_scope")
+ return
+ }
+ }
+ s.mu.Lock()
+ _, ok := s.machines[body.Target]
+ if !ok {
+ s.mu.Unlock()
+ problem(w, 404, "access.machine_not_found")
+ return
+ }
+ now := time.Now().UTC()
+ req := &fakeAccessRequest{ID: "req_" + random()[:16], FromPrincipal: principal, ToPrincipal: body.Target,
+ FromOwner: fakeOwner, ToOwner: fakeOwner, FromAgent: body.FromAgent, Scopes: body.Scopes, Reason: body.Reason, Status: "pending",
+ FromMachineName: "laptop", FromOwnerName: "Alex",
+ RequestedAt: now.Format(time.RFC3339), ExpiresAt: now.Add(24 * time.Hour).Format(time.RFC3339), DurationSeconds: body.Duration}
+ s.requests[req.ID] = req
+ s.mu.Unlock()
+ w.WriteHeader(201)
+ _ = json.NewEncoder(w).Encode(map[string]any{"request": req})
+ default:
+ w.WriteHeader(405)
+ }
+}
+
+func (s *Server) accessRequestAction(w http.ResponseWriter, r *http.Request) {
+ principal := s.accessPrincipal(r)
+ if principal == "" {
+ problem(w, 401, "access.node_required")
+ return
+ }
+ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/link/self/access-requests/"), "/")
+ if r.Method != http.MethodPost || len(parts) != 2 || parts[1] != "answer" {
+ w.WriteHeader(404)
+ return
+ }
+ var body struct {
+ Approved bool `json:"approve"`
+ Scopes []string `json:"scopes"`
+ Duration int64 `json:"duration_seconds"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ problem(w, 400, "access.invalid_answer")
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ req := s.requests[parts[0]]
+ if req == nil || req.ToPrincipal != principal || req.Status != "pending" {
+ problem(w, 403, "access.not_pending")
+ return
+ }
+ if !body.Approved {
+ req.Status = "denied"
+ _ = json.NewEncoder(w).Encode(req)
+ return
+ }
+ if len(body.Scopes) == 0 {
+ body.Scopes = req.Scopes
+ }
+ allowed := map[string]bool{}
+ for _, scope := range req.Scopes {
+ allowed[scope] = true
+ }
+ for _, scope := range body.Scopes {
+ if !allowed[scope] {
+ problem(w, 400, "access.cannot_widen")
+ return
+ }
+ }
+ if body.Duration == 0 {
+ body.Duration = req.DurationSeconds
+ }
+ if body.Duration > 30*86400 || (req.DurationSeconds > 0 && body.Duration > req.DurationSeconds) {
+ problem(w, 400, "access.cannot_extend")
+ return
+ }
+ req.Status = "approved"
+ now := time.Now().UTC()
+ var expiry *string
+ if body.Duration > 0 {
+ value := now.Add(time.Duration(body.Duration) * time.Second).Format(time.RFC3339)
+ expiry = &value
+ }
+ grant := &fakeAccessGrant{ID: "grt_" + random()[:16], RequestID: req.ID, FromPrincipal: req.FromPrincipal, ToPrincipal: req.ToPrincipal,
+ Scopes: body.Scopes, ApprovedBy: fakeOwner, CreatedAt: now.Format(time.RFC3339), ExpiresAt: expiry}
+ s.grants[grant.ID] = grant
+ _ = json.NewEncoder(w).Encode(map[string]any{"request": req, "grant": grant})
+}
+
+func (s *Server) accessGrants(w http.ResponseWriter, r *http.Request) {
+ principal := s.accessPrincipal(r)
+ if principal == "" {
+ problem(w, 401, "access.node_required")
+ return
+ }
+ if r.Method != http.MethodGet {
+ w.WriteHeader(405)
+ return
+ }
+ s.mu.Lock()
+ list := []map[string]any{}
+ for _, g := range s.grants {
+ if g.FromPrincipal != principal && g.ToPrincipal != principal {
+ continue
+ }
+ raw, _ := json.Marshal(g)
+ var row map[string]any
+ _ = json.Unmarshal(raw, &row)
+ list = append(list, row)
+ }
+ s.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]any{"grants": list})
+}
+
+func (s *Server) grantToken(g *fakeAccessGrant) string {
+ if s.signing == nil {
+ return ""
+ }
+ until := time.Now().Add(time.Hour).Unix()
+ if g.ExpiresAt != nil {
+ if t, err := time.Parse(time.RFC3339, *g.ExpiresAt); err == nil {
+ until = t.Unix()
+ }
+ }
+ c := access.Claims{ID: g.ID, JTI: "jti_" + random()[:16], Issuer: s.URL(), From: g.FromPrincipal, To: g.ToPrincipal, Scopes: g.Scopes, Issued: time.Now().Unix(), Expiry: until}
+ h, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "fake-network-1", "typ": "JWT"})
+ p, _ := json.Marshal(c)
+ message := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p)
+ hash := sha256.Sum256([]byte(message))
+ sig, err := rsa.SignPKCS1v15(rand.Reader, s.signing, crypto.SHA256, hash[:])
+ if err != nil {
+ return ""
+ }
+ return message + "." + base64.RawURLEncoding.EncodeToString(sig)
+}
+
+func (s *Server) accessGrantAction(w http.ResponseWriter, r *http.Request) {
+ principal := s.accessPrincipal(r)
+ if principal == "" {
+ problem(w, 401, "access.node_required")
+ return
+ }
+ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/link/self/access-grants/"), "/")
+ if r.Method != http.MethodPost || len(parts) != 2 || (parts[1] != "revoke" && parts[1] != "token") {
+ w.WriteHeader(404)
+ return
+ }
+ if parts[1] == "token" {
+ s.mu.Lock()
+ g := s.grants[parts[0]]
+ if g == nil || g.FromPrincipal != principal || !s.GrantActiveLocked(g) {
+ s.mu.Unlock()
+ problem(w, 403, "access.grant_not_found")
+ return
+ }
+ token := s.grantToken(g)
+ copy := *g
+ s.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]any{"grant": copy, "grant_token": token})
+ return
+ }
+ s.mu.Lock()
+ g := s.grants[parts[0]]
+ if g == nil || (g.FromPrincipal != principal && g.ToPrincipal != principal) {
+ s.mu.Unlock()
+ problem(w, 403, "access.not_allowed")
+ return
+ }
+ now := time.Now().UTC().Format(time.RFC3339)
+ g.RevokedAt = &now
+ id, to := g.ID, g.ToPrincipal
+ s.mu.Unlock()
+ if s.bot != nil {
+ s.bot.RevokeGrant(id, to)
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "status": "revoked"})
+}
+
+func (s *Server) GrantActiveLocked(g *fakeAccessGrant) bool {
+ if g.RevokedAt != nil {
+ return false
+ }
+ if g.ExpiresAt != nil {
+ at, err := time.Parse(time.RFC3339, *g.ExpiresAt)
+ if err != nil || !at.After(time.Now()) {
+ return false
+ }
+ }
+ return true
+}
+
+func (s *Server) accessMachines(w http.ResponseWriter, r *http.Request) {
+ if s.accessPrincipal(r) == "" {
+ problem(w, 401, "access.node_required")
+ return
+ }
+ s.mu.Lock()
+ list := []map[string]any{}
+ for _, m := range s.machines {
+ list = append(list, map[string]any{"id": m.Principal, "name": m.Principal})
+ }
+ s.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]any{"machines": list})
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+package fakenetwork
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/access"
+ "github.com/OpenVibers/OpenVibe.Link/internal/protocol"
+)
+
+type fakeAgent struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ LocalID string `json:"local_id"`
+ Name string `json:"name"`
+ MachineID string `json:"machine_id"`
+ Owner string `json:"owner"`
+ Status string `json:"status"`
+}
+
+type fakeAgentTask struct {
+ ID string `json:"id"`
+ To string `json:"to"`
+ From string `json:"from"`
+ OnBehalfOf string `json:"on_behalf_of"`
+ Thread string `json:"thread"`
+ Message json.RawMessage `json:"message"`
+ CreatedAt string `json:"created_at"`
+ Status string `json:"status"`
+ Result json.RawMessage `json:"result,omitempty"`
+ fromMachine string
+ toMachine string
+}
+
+func (s *Server) registerAgentRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("/api/v1/agents/register", s.agentRegister)
+ mux.HandleFunc("/api/v1/link/self/agents", s.agentList)
+ mux.HandleFunc("/api/v1/agents/", s.agentRoute)
+}
+
+// AgentResult is Bot's report of an agent.task_result from the receiving machine.
+func (s *Server) AgentResult(taskID, status string, result json.RawMessage) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ task := s.agentTasks[taskID]
+ if task == nil {
+ return
+ }
+ task.Status = status
+ if len(result) > 0 {
+ task.Result = result
+ }
+}
+
+func (s *Server) agentRegister(w http.ResponseWriter, r *http.Request) {
+ principal := s.accessPrincipal(r)
+ if principal == "" || r.Method != http.MethodPost {
+ problem(w, 403, "agents.node_required")
+ return
+ }
+ var body fakeAgent
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body.Kind != "link-local" || body.LocalID == "" || body.Name == "" || body.MachineID != principal {
+ problem(w, 400, "agents.invalid_registration")
+ return
+ }
+ s.mu.Lock()
+ for _, a := range s.agents {
+ if a.MachineID == principal && a.LocalID == body.LocalID {
+ a.Status = body.Status
+ result := *a
+ s.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(result)
+ return
+ }
+ }
+ created := body
+ created.ID = "agt_" + random()[:16]
+ created.Owner = fakeOwner
+ s.agents[created.ID] = &created
+ s.mu.Unlock()
+ w.WriteHeader(201)
+ _ = json.NewEncoder(w).Encode(created)
+}
+
+func (s *Server) agentList(w http.ResponseWriter, r *http.Request) {
+ if s.accessPrincipal(r) == "" || r.Method != http.MethodGet {
+ problem(w, 403, "agents.node_required")
+ return
+ }
+ s.mu.Lock()
+ list := make([]fakeAgent, 0, len(s.agents))
+ for _, a := range s.agents {
+ list = append(list, *a)
+ }
+ s.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]any{"agents": list})
+}
+
+func (s *Server) agentRoute(w http.ResponseWriter, r *http.Request) {
+ principal := s.accessPrincipal(r)
+ if principal == "" {
+ problem(w, 403, "agents.node_required")
+ return
+ }
+ parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/agents/"), "/")
+ if len(parts) == 2 && parts[1] == "tasks" && r.Method == http.MethodPost {
+ var body struct {
+ From, OnBehalfOf, Thread string
+ Message json.RawMessage
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body.From == "" || !strings.HasPrefix(body.Thread, "thr_") || len(body.Message) == 0 {
+ problem(w, 400, "agents.invalid_task")
+ return
+ }
+ s.mu.Lock()
+ dest := s.agents[parts[0]]
+ sender := s.agents[body.From]
+ if dest == nil || sender == nil || sender.MachineID != principal || dest.Status == "offline" {
+ s.mu.Unlock()
+ problem(w, 403, "agents.not_allowed")
+ return
+ }
+ task := &fakeAgentTask{ID: access.NewTaskID(), To: dest.ID, From: sender.ID, OnBehalfOf: fakeOwner, Thread: body.Thread, Message: body.Message, CreatedAt: time.Now().UTC().Format(time.RFC3339), Status: "pending", fromMachine: principal, toMachine: dest.MachineID}
+ s.agentTasks[task.ID] = task
+ s.mu.Unlock()
+ payload, _ := json.Marshal(task)
+ if s.bot == nil || !s.bot.DeliverAgentTask(dest.MachineID, protocol.AgentTaskFrame{Task: payload}) {
+ problem(w, 503, "agents.target_offline")
+ return
+ }
+ w.WriteHeader(201)
+ _ = json.NewEncoder(w).Encode(map[string]any{"task": task, "delivery": map[string]any{"delivered": true}})
+ return
+ }
+ if len(parts) >= 2 && parts[0] == "tasks" {
+ s.mu.Lock()
+ task := s.agentTasks[parts[1]]
+ if task == nil || principal != task.fromMachine && principal != task.toMachine {
+ s.mu.Unlock()
+ problem(w, 404, "agents.task_missing")
+ return
+ }
+ if len(parts) == 2 && r.Method == http.MethodGet {
+ copy := *task
+ s.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]any{"task": copy})
+ return
+ }
+ if len(parts) == 3 && parts[2] == "result" && r.Method == http.MethodPost && principal == task.toMachine {
+ var body struct {
+ Status string `json:"status"`
+ Result json.RawMessage `json:"result"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil || body.Status != "completed" || len(body.Result) == 0 {
+ s.mu.Unlock()
+ problem(w, 400, "agents.invalid_result")
+ return
+ }
+ task.Status = "completed"
+ task.Result = body.Result
+ copy := *task
+ s.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]any{"task": copy})
+ return
+ }
+ s.mu.Unlock()
+ }
+ w.WriteHeader(404)
+}
@@ -7,6 +7,7 @@ package fakenetwork
@@ -7,6 +7,7 @@ package fakenetwork
import (
import (
"crypto/rand"
"crypto/rand"
+ "crypto/rsa"
"encoding/hex"
"encoding/hex"
"encoding/json"
"encoding/json"
"net/http"
"net/http"
@@ -33,22 +34,38 @@ type Server struct {
@@ -33,22 +34,38 @@ type Server struct {
bot *fakebot.Server
bot *fakebot.Server
- mu sync.Mutex
- health int // GET /api/health status; 200 unless a test sets it
- codes map[string]bool // normalized code → live
- credential string // the node credential, once paired
- revoked bool
- tokens []string
- pairings []protocol.LinkPairingRequest
- tokenForms []map[string]string
+ mu sync.Mutex
+ health int // GET /api/health status; 200 unless a test sets it
+ codes map[string]bool // normalized code → live
+ credential string // the node credential, once paired
+ revoked bool
+ tokens []string
+ pairings []protocol.LinkPairingRequest
+ tokenForms []map[string]string
+ machines map[string]fakeMachine
+ codeMachines map[string]fakeMachine
+ tokenOwners map[string]string
+ tokenAudience map[string]string
+ requests map[string]*fakeAccessRequest
+ grants map[string]*fakeAccessGrant
+ agents map[string]*fakeAgent
+ agentTasks map[string]*fakeAgentTask
+ signing *rsa.PrivateKey
}
}
+type fakeMachine struct{ Principal, DeviceID, Credential string }
+
// New starts the fake Network; bot (may be nil) learns every token it mints.
// New starts the fake Network; bot (may be nil) learns every token it mints.
func New(bot *fakebot.Server) *Server {
func New(bot *fakebot.Server) *Server {
- s := &Server{TTL: 300, bot: bot, codes: map[string]bool{}, health: http.StatusOK}
+ s := &Server{TTL: 300, bot: bot, codes: map[string]bool{}, health: http.StatusOK,
+ machines: map[string]fakeMachine{}, codeMachines: map[string]fakeMachine{}, tokenOwners: map[string]string{}, tokenAudience: map[string]string{},
+ requests: map[string]*fakeAccessRequest{}, grants: map[string]*fakeAccessGrant{}, agents: map[string]*fakeAgent{}, agentTasks: map[string]*fakeAgentTask{}}
+ s.signing, _ = rsa.GenerateKey(rand.Reader, 2048)
mux := http.NewServeMux()
mux := http.NewServeMux()
mux.HandleFunc(protocol.LinkPairingPath, s.pair)
mux.HandleFunc(protocol.LinkPairingPath, s.pair)
mux.HandleFunc(protocol.TokenPath, s.token)
mux.HandleFunc(protocol.TokenPath, s.token)
+ s.registerAccessRoutes(mux)
+ s.registerAgentRoutes(mux)
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.mu.Lock()
status := s.health
status := s.health
@@ -58,6 +75,10 @@ func New(bot *fakebot.Server) *Server {
@@ -58,6 +75,10 @@ func New(bot *fakebot.Server) *Server {
_, _ = w.Write([]byte(`{"ok":true}`))
_, _ = w.Write([]byte(`{"ok":true}`))
})
})
s.HTTP = httptest.NewServer(mux)
s.HTTP = httptest.NewServer(mux)
+ if bot != nil {
+ bot.SetRelayAuthority(s.URL(), s.GrantActive)
+ bot.SetAgentResult(s.AgentResult)
+ }
return s
return s
}
}
@@ -78,6 +99,17 @@ func (s *Server) AddCode(code string) {
@@ -78,6 +99,17 @@ func (s *Server) AddCode(code string) {
s.mu.Unlock()
s.mu.Unlock()
}
}
+// AddMachine pairs a second fake Link with the same Network and Bot.
+func (s *Server) AddMachine(code, principal, deviceID string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.codes[code] = true
+ s.codeMachines[code] = fakeMachine{Principal: principal, DeviceID: deviceID}
+ if s.bot != nil {
+ s.bot.BindMachine(principal, deviceID)
+ }
+}
+
// Revoke makes /oauth/token refuse the node credential (401 invalid_client), as a revoked principal is.
// Revoke makes /oauth/token refuse the node credential (401 invalid_client), as a revoked principal is.
func (s *Server) Revoke() {
func (s *Server) Revoke() {
s.mu.Lock()
s.mu.Lock()
@@ -135,9 +167,15 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {
@@ -135,9 +167,15 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.mu.Lock()
s.pairings = append(s.pairings, req)
s.pairings = append(s.pairings, req)
ok := s.codes[code] && (req.Pairing == "" || req.Pairing == PairingID)
ok := s.codes[code] && (req.Pairing == "" || req.Pairing == PairingID)
+ machine, defined := s.codeMachines[code]
+ if !defined {
+ machine = fakeMachine{Principal: Principal, DeviceID: DeviceID}
+ }
if ok {
if ok {
delete(s.codes, code)
delete(s.codes, code)
s.credential, s.revoked = random(), false
s.credential, s.revoked = random(), false
+ machine.Credential = s.credential
+ s.machines[machine.Principal] = machine
}
}
cred := s.credential
cred := s.credential
s.mu.Unlock()
s.mu.Unlock()
@@ -145,10 +183,13 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {
@@ -145,10 +183,13 @@ func (s *Server) pair(w http.ResponseWriter, r *http.Request) {
problem(w, http.StatusForbidden, "registry.pairing_code_invalid")
problem(w, http.StatusForbidden, "registry.pairing_code_invalid")
return
return
}
}
+ if s.bot != nil {
+ s.bot.BindMachine(machine.Principal, machine.DeviceID)
+ }
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusCreated)
w.WriteHeader(http.StatusCreated)
- _ = json.NewEncoder(w).Encode(protocol.LinkPairingResponse{Principal: Principal, LinkID: "n-01j8z4m2q0r7t9yv3k6n8p1w2x",
+ _ = json.NewEncoder(w).Encode(protocol.LinkPairingResponse{Principal: machine.Principal, LinkID: "n-01j8z4m2q0r7t9yv3k6n8p1w2x",
HomeCell: "eu-1", Credential: cred, TokenEndpoint: s.HTTP.URL + protocol.TokenPath,
HomeCell: "eu-1", Credential: cred, TokenEndpoint: s.HTTP.URL + protocol.TokenPath,
PairedFor: &protocol.PairedFor{Service: "bot", Ref: fakebot.RobotID}})
PairedFor: &protocol.PairedFor{Service: "bot", Ref: fakebot.RobotID}})
}
}
@@ -164,8 +205,9 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {
@@ -164,8 +205,9 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {
}
}
s.mu.Lock()
s.mu.Lock()
s.tokenForms = append(s.tokenForms, form)
s.tokenForms = append(s.tokenForms, form)
- good := !s.revoked && s.credential != "" && form["grant_type"] == "client_credentials" &&
- form["client_id"] == Principal && form["client_secret"] == s.credential
+ machine, exists := s.machines[form["client_id"]]
+ good := !s.revoked && exists && machine.Credential != "" && form["grant_type"] == "client_credentials" &&
+ form["client_secret"] == machine.Credential
s.mu.Unlock()
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", "application/json")
if !good {
if !good {
@@ -173,7 +215,7 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {
@@ -173,7 +215,7 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(protocol.OAuthError{Error: "invalid_client", Description: "Invalid client credentials"})
_ = json.NewEncoder(w).Encode(protocol.OAuthError{Error: "invalid_client", Description: "Invalid client credentials"})
return
return
}
}
- if form["audience"] != "openvibe.bot" {
+ if form["audience"] != "openvibe.bot" && form["audience"] != "openvibe.network" {
w.WriteHeader(http.StatusBadRequest)
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(protocol.OAuthError{Error: "invalid_scope", Description: "a node token is for openvibe.network or openvibe.bot"})
_ = json.NewEncoder(w).Encode(protocol.OAuthError{Error: "invalid_scope", Description: "a node token is for openvibe.network or openvibe.bot"})
return
return
@@ -181,9 +223,11 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {
@@ -181,9 +223,11 @@ func (s *Server) token(w http.ResponseWriter, r *http.Request) {
tok := "eyJ" + random()
tok := "eyJ" + random()
s.mu.Lock()
s.mu.Lock()
s.tokens = append(s.tokens, tok)
s.tokens = append(s.tokens, tok)
+ s.tokenOwners[tok] = machine.Principal
+ s.tokenAudience[tok] = form["audience"]
s.mu.Unlock()
s.mu.Unlock()
- if s.bot != nil {
- s.bot.AddCredential(tok, DeviceID)
+ if s.bot != nil && form["audience"] == "openvibe.bot" {
+ s.bot.AddCredential(tok, machine.DeviceID)
}
}
_ = json.NewEncoder(w).Encode(protocol.TokenResponse{AccessToken: tok, TokenType: "Bearer", ExpiresIn: s.TTL})
_ = json.NewEncoder(w).Encode(protocol.TokenResponse{AccessToken: tok, TokenType: "Bearer", ExpiresIn: s.TTL})
}
}
@@ -37,13 +37,17 @@ const (
@@ -37,13 +37,17 @@ const (
CmdPanel = "panel"
CmdPanel = "panel"
CmdPartyHost = "party_host"
CmdPartyHost = "party_host"
CmdPartyStop = "party_stop"
CmdPartyStop = "party_stop"
+ // CmdMCP runs one named MCP tool through the same local authorization path as the CLI.
+ CmdMCP = "mcp"
+ CmdAccess = "access"
)
)
type Request struct {
type Request struct {
- Cmd string `json:"cmd"`
- ID string `json:"id,omitempty"` // approve and deny: the locally minted approval nonce (never the cloud command ID)
- On bool `json:"on,omitempty"` // tray: the tray is running
- Data json.RawMessage `json:"data,omitempty"` // first_run: the telemetry events to report
+ Cmd string `json:"cmd"`
+ ID string `json:"id,omitempty"` // approve and deny: the locally minted approval nonce (never the cloud command ID)
+ Agent string `json:"agent,omitempty"` // MCP: which local CLI launched the server
+ On bool `json:"on,omitempty"` // tray: the tray is running
+ Data json.RawMessage `json:"data,omitempty"` // first_run: the telemetry events to report
}
}
type Response struct {
type Response struct {
@@ -94,6 +98,11 @@ func Serve(ctx context.Context, path string, h Handler) error {
@@ -94,6 +98,11 @@ func Serve(ctx context.Context, path string, h Handler) error {
_ = json.NewEncoder(c).Encode(Response{Error: "bad request"})
_ = json.NewEncoder(c).Encode(Response{Error: "bad request"})
return
return
}
}
+ if req.Cmd == CmdMCP {
+ // agent_ask may wait for a remote agent's reply. The socket is local and
+ // the MCP server still caps that wait at 120 seconds.
+ _ = c.SetDeadline(time.Now().Add(123 * time.Second))
+ }
_ = json.NewEncoder(c).Encode(h(req))
_ = json.NewEncoder(c).Encode(h(req))
}(c)
}(c)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,214 @@
@@ -0,0 +1,214 @@
+// Package mcp serves Link's local machine and agent tools over MCP stdio.
+package mcp
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+)
+
+const Version = "2025-11-25"
+
+type Caller interface {
+ Call(name string, arguments json.RawMessage) (json.RawMessage, error)
+}
+
+type Tool struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ InputSchema map[string]any `json:"inputSchema"`
+}
+
+func schema(properties map[string]any, required ...string) map[string]any {
+ if required == nil {
+ required = []string{}
+ }
+ return map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false}
+}
+
+var textField = map[string]any{"type": "string", "minLength": 1, "maxLength": 8000}
+var idField = map[string]any{"type": "string", "minLength": 1, "maxLength": 128}
+var scopeField = map[string]any{"type": "string", "enum": []string{"agent", "send", "screen", "desk", "computer", "worker"}}
+
+var Tools = []Tool{
+ {"agents_list", "List agents available to this person and their online status.", schema(map[string]any{})},
+ {"agent_ask", "Ask an agent and wait for its answer for up to 120 seconds.", schema(map[string]any{"agent": idField, "text": textField, "wait_seconds": map[string]any{"type": "integer", "minimum": 1, "maximum": 120}}, "agent", "text")},
+ {"agent_tell", "Send a message to an agent without waiting for an answer.", schema(map[string]any{"agent": idField, "text": textField, "thread": idField}, "agent", "text")},
+ {"agent_inbox", "Read tasks addressed to this local agent.", schema(map[string]any{"agent": idField})},
+ {"agent_reply", "Reply to a task in the local agent inbox.", schema(map[string]any{"task": idField, "text": textField}, "task", "text")},
+ {"machines_list", "List machines and grants available to this machine.", schema(map[string]any{})},
+ {"machine_request_access", "Ask another machine's owner for limited access.", schema(map[string]any{"machine": idField, "scopes": map[string]any{"type": "array", "minItems": 1, "items": scopeField}, "for_seconds": map[string]any{"type": "integer", "minimum": 60, "maximum": 2592000}, "reason": map[string]any{"type": "string", "maxLength": 500}}, "machine", "scopes")},
+ {"machine_use", "Use a held grant for one operation on another machine.", schema(map[string]any{"machine": idField, "scope": scopeField, "action": idField, "data": map[string]any{"type": "object"}}, "machine", "scope", "action")},
+}
+
+type request struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID json.RawMessage `json:"id"`
+ Method string `json:"method"`
+ Params json.RawMessage `json:"params"`
+}
+
+func answer(id json.RawMessage, value any, errCode int, errText string) any {
+ if errCode != 0 {
+ return map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": errCode, "message": errText}}
+ }
+ return map[string]any{"jsonrpc": "2.0", "id": id, "result": value}
+}
+
+// Serve reads one JSON-RPC message per line. Stdout is only the protocol.
+func Serve(in io.Reader, out io.Writer, caller Caller) error {
+ scan := bufio.NewScanner(in)
+ scan.Buffer(make([]byte, 4096), 1<<20)
+ enc := json.NewEncoder(out)
+ for scan.Scan() {
+ var req request
+ if json.Unmarshal(scan.Bytes(), &req) != nil || req.JSONRPC != "2.0" {
+ if err := enc.Encode(answer(json.RawMessage("null"), nil, -32700, "invalid JSON-RPC message")); err != nil {
+ return err
+ }
+ continue
+ }
+ if len(req.ID) == 0 {
+ continue
+ } // notifications need no response
+ var result any
+ var code int
+ var msg string
+ switch req.Method {
+ case "initialize":
+ result = map[string]any{"protocolVersion": Version, "capabilities": map[string]any{"tools": map[string]any{}}, "serverInfo": map[string]any{"name": "openvibe-link", "version": "1"}}
+ case "ping":
+ result = map[string]any{}
+ case "tools/list":
+ result = map[string]any{"tools": Tools}
+ case "tools/call":
+ var p struct {
+ Name string `json:"name"`
+ Arguments json.RawMessage `json:"arguments"`
+ }
+ if json.Unmarshal(req.Params, &p) != nil {
+ code, msg = -32602, "invalid tool parameters"
+ break
+ }
+ if err := validate(p.Name, p.Arguments); err != nil {
+ code, msg = -32602, err.Error()
+ break
+ }
+ if caller == nil {
+ code, msg = -32603, "local Link unavailable"
+ break
+ }
+ data, err := caller.Call(p.Name, p.Arguments)
+ if err != nil {
+ result = map[string]any{"content": []any{map[string]any{"type": "text", "text": err.Error()}}, "isError": true}
+ } else {
+ result = map[string]any{"content": []any{map[string]any{"type": "text", "text": string(data)}}, "structuredContent": map[string]any{"data": json.RawMessage(data)}}
+ }
+ default:
+ code, msg = -32601, "method not found"
+ }
+ if err := enc.Encode(answer(req.ID, result, code, msg)); err != nil {
+ return err
+ }
+ }
+ return scan.Err()
+}
+
+func validate(name string, raw json.RawMessage) error {
+ var tool *Tool
+ for i := range Tools {
+ if Tools[i].Name == name {
+ tool = &Tools[i]
+ break
+ }
+ }
+ if tool == nil {
+ return errors.New("unknown tool")
+ }
+ if len(raw) == 0 {
+ raw = []byte("{}")
+ }
+ var args map[string]json.RawMessage
+ if json.Unmarshal(raw, &args) != nil || args == nil {
+ return errors.New("arguments must be an object")
+ }
+ properties := tool.InputSchema["properties"].(map[string]any)
+ for _, required := range tool.InputSchema["required"].([]string) {
+ if _, ok := args[required]; !ok {
+ return fmt.Errorf("missing %s", required)
+ }
+ }
+ for key, value := range args {
+ property, ok := properties[key]
+ if !ok {
+ return fmt.Errorf("unknown argument %s", key)
+ }
+ definition := property.(map[string]any)
+ switch definition["type"] {
+ case "string":
+ var s string
+ if json.Unmarshal(value, &s) != nil {
+ return fmt.Errorf("%s must be text", key)
+ }
+ if min, ok := definition["minLength"].(int); ok && len(strings.TrimSpace(s)) < min {
+ return fmt.Errorf("%s is empty", key)
+ }
+ if max, ok := definition["maxLength"].(int); ok && len(s) > max {
+ return fmt.Errorf("%s is too long", key)
+ }
+ if allowed, ok := definition["enum"].([]string); ok {
+ found := false
+ for _, v := range allowed {
+ found = found || s == v
+ }
+ if !found {
+ return fmt.Errorf("invalid %s", key)
+ }
+ }
+ case "integer":
+ var n int64
+ if json.Unmarshal(value, &n) != nil {
+ return fmt.Errorf("%s must be an integer", key)
+ }
+ if min, ok := definition["minimum"].(int); ok && n < int64(min) {
+ return fmt.Errorf("%s is too small", key)
+ }
+ if max, ok := definition["maximum"].(int); ok && n > int64(max) {
+ return fmt.Errorf("%s is too large", key)
+ }
+ case "array":
+ var a []string
+ if json.Unmarshal(value, &a) != nil {
+ return fmt.Errorf("%s must be a list", key)
+ }
+ if len(a) == 0 {
+ return fmt.Errorf("%s is empty", key)
+ }
+ seen := map[string]bool{}
+ for _, s := range a {
+ if !scoped(s) || seen[s] {
+ return fmt.Errorf("invalid %s", key)
+ }
+ seen[s] = true
+ }
+ case "object":
+ var object map[string]any
+ if json.Unmarshal(value, &object) != nil || object == nil {
+ return fmt.Errorf("%s must be an object", key)
+ }
+ }
+ }
+ return nil
+}
+
+func scoped(s string) bool {
+ for _, v := range []string{"agent", "send", "screen", "desk", "computer", "worker"} {
+ if s == v {
+ return true
+ }
+ }
+ return false
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+package mcp
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+)
+
+type fakeCaller struct {
+ name string
+ args json.RawMessage
+ refuse bool
+}
+
+func (f *fakeCaller) Call(name string, args json.RawMessage) (json.RawMessage, error) {
+ f.name, f.args = name, args
+ if f.refuse {
+ return nil, errors.New("grant refused")
+ }
+ return json.RawMessage(`{"ok":true}`), nil
+}
+
+func TestEveryToolAndRefusals(t *testing.T) {
+ cases := []struct{ name, args string }{
+ {"agents_list", `{}`}, {"agent_ask", `{"agent":"agt_1","text":"question","wait_seconds":2}`},
+ {"agent_tell", `{"agent":"agt_1","text":"hello"}`}, {"agent_inbox", `{}`},
+ {"agent_reply", `{"task":"tsk_1","text":"answer"}`}, {"machines_list", `{}`},
+ {"machine_request_access", `{"machine":"lnk_1","scopes":["agent","send"],"for_seconds":3600}`},
+ {"machine_use", `{"machine":"lnk_1","scope":"screen","action":"status","data":{}}`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ f := &fakeCaller{}
+ input := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"` + tc.name + `","arguments":` + tc.args + `}}` + "\n"
+ var output bytes.Buffer
+ if err := Serve(strings.NewReader(input), &output, f); err != nil {
+ t.Fatal(err)
+ }
+ if f.name != tc.name || string(f.args) != tc.args {
+ t.Fatalf("forwarded %q %s", f.name, f.args)
+ }
+ if !strings.Contains(output.String(), `"structuredContent":{"data":{"ok":true}}`) {
+ t.Fatalf("missing result: %s", output.String())
+ }
+ f.refuse = true
+ output.Reset()
+ if err := Serve(strings.NewReader(input), &output, f); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(output.String(), `"isError":true`) {
+ t.Fatalf("refusal missing: %s", output.String())
+ }
+ })
+ }
+ for _, tc := range []struct{ name, args string }{
+ {"agent_ask", `{"agent":"agt_1","text":""}`},
+ {"agent_ask", `{"agent":"agt_1","text":"x","wait_seconds":121}`},
+ {"machine_request_access", `{"machine":"lnk_1","scopes":["admin"]}`},
+ {"machine_use", `{"machine":"lnk_1","scope":"computer","action":""}`},
+ {"agent_reply", `{"task":"tsk_1","text":"ok","grant":"fake"}`},
+ } {
+ t.Run("invalid-"+tc.name, func(t *testing.T) {
+ if validate(tc.name, json.RawMessage(tc.args)) == nil {
+ t.Fatal("accepted")
+ }
+ })
+ }
+}
+
+func TestHandshakeAndToolsList(t *testing.T) {
+ input := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}` + "\n" +
+ `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" +
+ `{"jsonrpc":"2.0","id":2,"method":"tools/list"}` + "\n"
+ var out bytes.Buffer
+ if err := Serve(strings.NewReader(input), &out, &fakeCaller{}); err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")
+ if len(lines) != 2 {
+ t.Fatalf("responses: %d", len(lines))
+ }
+ if !strings.Contains(lines[0], Version) {
+ t.Fatal(lines[0])
+ }
+ for _, tool := range Tools {
+ if !strings.Contains(lines[1], `"name":"`+tool.Name+`"`) {
+ t.Fatalf("missing %s", tool.Name)
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+package protocol
+
+import "encoding/json"
+
+// Machine-to-machine relay frames, as OpenVibe.Bot speaks them
+// (server/realtime.js: relay.open → relay.opened, relay.frame both ways,
+// relay.close → relay.closed). Bot verifies the grant token on open and again
+// on every frame; the receiving Link verifies it too and caps each scope at its
+// own local level.
+const (
+ TypeRelayOpen = "relay.open"
+ TypeRelayOpened = "relay.opened"
+ TypeRelayFrame = "relay.frame"
+ TypeRelayClose = "relay.close"
+ TypeRelayClosed = "relay.closed"
+
+ TypeAgentTask = "agent.task"
+ TypeAgentTaskResult = "agent.task_result"
+ TypeAgentTaskResultAck = "agent.task_result_ack"
+ TypeAccessRevoked = "access.revoked"
+)
+
+// RelayOpen asks Bot for a relay to To under a Network-signed grant token.
+type RelayOpen struct {
+ To string `json:"to"`
+ GrantToken string `json:"grant_token"`
+}
+
+func (RelayOpen) MessageType() string { return TypeRelayOpen }
+
+// RelayOpened is Bot's answer to both machines: the relay id, the grant's
+// scopes and the same token, so each Link checks Network's signature itself.
+type RelayOpened struct {
+ Relay string `json:"relay"`
+ To string `json:"to"`
+ GrantToken string `json:"grant_token"`
+ Scopes []string `json:"scopes"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+ From string `json:"from"`
+}
+
+func (RelayOpened) MessageType() string { return TypeRelayOpened }
+
+// RelayFrame carries one operation or its answer. From is filled in by Bot.
+type RelayFrame struct {
+ Relay string `json:"relay"`
+ From string `json:"from,omitempty"`
+ Scope string `json:"scope"`
+ Body json.RawMessage `json:"body"`
+}
+
+func (RelayFrame) MessageType() string { return TypeRelayFrame }
+
+// RelayClose asks Bot to end a relay when either side is done with it.
+type RelayClose struct {
+ Relay string `json:"relay"`
+}
+
+func (RelayClose) MessageType() string { return TypeRelayClose }
+
+// RelayClosed tells both machines a relay ended, with Bot's reason (closed,
+// expired or revoked).
+type RelayClosed struct {
+ Relay string `json:"relay"`
+ Reason string `json:"reason,omitempty"`
+}
+
+func (RelayClosed) MessageType() string { return TypeRelayClosed }
+
+// AgentTaskFrame is Network's A2A task delivered by Bot. Network has already
+// checked the sender, the receiving agent and any machine grant.
+type AgentTaskFrame struct {
+ Task json.RawMessage `json:"task"`
+}
+
+func (AgentTaskFrame) MessageType() string { return TypeAgentTask }
+
+// AgentTaskResult answers a delivered task; Bot records it on Network, so only
+// the machine the task was delivered to may send one.
+type AgentTaskResult struct {
+ TaskID string `json:"task_id"`
+ Status string `json:"status"`
+ Result json.RawMessage `json:"result,omitempty"`
+}
+
+func (AgentTaskResult) MessageType() string { return TypeAgentTaskResult }
+
+type AgentTaskResultAck struct {
+ TaskID string `json:"task_id"`
+ Status string `json:"status"`
+}
+
+func (AgentTaskResultAck) MessageType() string { return TypeAgentTaskResultAck }
+
+// AccessRevoked is kept for a Network/Bot build that closes a grant with this
+// frame instead of relay.closed; it ends locally cached access at once.
+type AccessRevoked struct {
+ GrantID string `json:"grant_id"`
+}
+
+func (AccessRevoked) MessageType() string { return TypeAccessRevoked }
@@ -760,28 +760,37 @@ func (JobUsage) MessageType() string { return TypeJobUsage }
@@ -760,28 +760,37 @@ func (JobUsage) MessageType() string { return TypeJobUsage }
func (JobExit) MessageType() string { return TypeJobExit }
func (JobExit) MessageType() string { return TypeJobExit }
var registry = map[string]func() Message{
var registry = map[string]func() Message{
- TypeHello: func() Message { return &Hello{} },
- TypeConfig: func() Message { return &Config{} },
- TypeCommand: func() Message { return &Command{} },
- TypeEstop: func() Message { return &Estop{} },
- TypeHeartbeatAck: func() Message { return &HeartbeatAck{} },
- TypeError: func() Message { return &Error{} },
- TypeStatus: func() Message { return &Status{} },
- TypeTelemetry: func() Message { return &Telemetry{} },
- TypeAck: func() Message { return &AckData{} }, // an Ack unless it carries data
- TypeNack: func() Message { return &Nack{} },
- TypeHeartbeat: func() Message { return &Heartbeat{} },
- TypeReauth: func() Message { return &Reauth{} },
- TypeEstopState: func() Message { return &EstopState{} },
- TypeJob: func() Message { return &JobRequest{} },
- TypeJobCancel: func() Message { return &JobCancel{} },
- TypeJobExitAck: func() Message { return &JobExitAck{} },
- TypeJobStarted: func() Message { return &JobStarted{} },
- TypeJobStdout: func() Message { return &JobStdout{} },
- TypeJobUsage: func() Message { return &JobUsage{} },
- TypeJobExit: func() Message { return &JobExit{} },
- TypeVoiceAsk: func() Message { return &VoiceAsk{} },
- TypeVoiceAnswer: func() Message { return &VoiceAnswer{} },
+ TypeHello: func() Message { return &Hello{} },
+ TypeConfig: func() Message { return &Config{} },
+ TypeCommand: func() Message { return &Command{} },
+ TypeEstop: func() Message { return &Estop{} },
+ TypeHeartbeatAck: func() Message { return &HeartbeatAck{} },
+ TypeError: func() Message { return &Error{} },
+ TypeStatus: func() Message { return &Status{} },
+ TypeTelemetry: func() Message { return &Telemetry{} },
+ TypeAck: func() Message { return &AckData{} }, // an Ack unless it carries data
+ TypeNack: func() Message { return &Nack{} },
+ TypeHeartbeat: func() Message { return &Heartbeat{} },
+ TypeReauth: func() Message { return &Reauth{} },
+ TypeEstopState: func() Message { return &EstopState{} },
+ TypeJob: func() Message { return &JobRequest{} },
+ TypeJobCancel: func() Message { return &JobCancel{} },
+ TypeJobExitAck: func() Message { return &JobExitAck{} },
+ TypeJobStarted: func() Message { return &JobStarted{} },
+ TypeJobStdout: func() Message { return &JobStdout{} },
+ TypeJobUsage: func() Message { return &JobUsage{} },
+ TypeJobExit: func() Message { return &JobExit{} },
+ TypeVoiceAsk: func() Message { return &VoiceAsk{} },
+ TypeVoiceAnswer: func() Message { return &VoiceAnswer{} },
+ TypeRelayOpen: func() Message { return &RelayOpen{} },
+ TypeRelayOpened: func() Message { return &RelayOpened{} },
+ TypeRelayFrame: func() Message { return &RelayFrame{} },
+ TypeRelayClose: func() Message { return &RelayClose{} },
+ TypeRelayClosed: func() Message { return &RelayClosed{} },
+ TypeAgentTask: func() Message { return &AgentTaskFrame{} },
+ TypeAgentTaskResult: func() Message { return &AgentTaskResult{} },
+ TypeAgentTaskResultAck: func() Message { return &AgentTaskResultAck{} },
+ TypeAccessRevoked: func() Message { return &AccessRevoked{} },
}
}
// ErrUnknownType is returned (wrapped) by Decode for a frame whose type is not in this version's message set.
// ErrUnknownType is returned (wrapped) by Decode for a frame whose type is not in this version's message set.
@@ -885,6 +894,24 @@ func derefMessage(m Message) Message {
@@ -885,6 +894,24 @@ func derefMessage(m Message) Message {
return *v
return *v
case *VoiceAnswer:
case *VoiceAnswer:
return *v
return *v
+ case *AgentTaskFrame:
+ return *v
+ case *AccessRevoked:
+ return *v
+ case *RelayOpen:
+ return *v
+ case *RelayOpened:
+ return *v
+ case *RelayFrame:
+ return *v
+ case *RelayClose:
+ return *v
+ case *RelayClosed:
+ return *v
+ case *AgentTaskResult:
+ return *v
+ case *AgentTaskResultAck:
+ return *v
}
}
return m
return m
}
}
@@ -27,6 +27,7 @@ import (
@@ -27,6 +27,7 @@ import (
const (
const (
lanPath = "/api/link-send/v1"
lanPath = "/api/link-send/v1"
+ lanGrantPath = "/api/link-send/v2"
lanMaxBody = 40 << 20
lanMaxBody = 40 << 20
lanWindow = 5 * time.Minute
lanWindow = 5 * time.Minute
lanMaxItems = 100
lanMaxItems = 100
@@ -46,19 +47,28 @@ type Incoming struct {
@@ -46,19 +47,28 @@ type Incoming struct {
// LAN is one Link's direct listener and its small queue of received items.
// LAN is one Link's direct listener and its small queue of received items.
type LAN struct {
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
+ target string
+ mu sync.Mutex
+ secret []byte
+ items map[string]Incoming
+ nonces map[string]time.Time
+ ln net.Listener
+ server *http.Server
+ grantCheck func(context.Context, string, string) error
}
}
func NewLAN(target string) *LAN {
func NewLAN(target string) *LAN {
return &LAN{target: target, items: map[string]Incoming{}, nonces: map[string]time.Time{}}
return &LAN{target: target, items: map[string]Incoming{}, nonces: map[string]time.Time{}}
}
}
+// SetGrantCheck makes the direct granted path use the same signed grant and
+// live revocation check as Bot-relayed transfers.
+func (l *LAN) SetGrantCheck(check func(context.Context, string, string) error) {
+ l.mu.Lock()
+ l.grantCheck = check
+ l.mu.Unlock()
+}
+
// Listen opens a random port. It is loopback-only in tests; on a real machine it is reachable on the local network,
// 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.
// which is the point, and every request still has to carry the owner's HMAC.
func (l *LAN) Listen(addr string) error {
func (l *LAN) Listen(addr string) error {
@@ -151,6 +161,23 @@ func (l *LAN) Items() []Incoming {
@@ -151,6 +161,23 @@ func (l *LAN) Items() []Incoming {
return out
return out
}
}
+// ReceiveGranted queues a sealed item after the Link agent has verified a
+// Network-signed send grant. The envelope stays encrypted until its owner opens
+// it through the existing Link Send inbox.
+func (l *LAN) ReceiveGranted(sender, envelope string) (string, error) {
+ if sender == "" || sender == l.target || len(envelope) == 0 || len(envelope) > 32*1024*1024 {
+ return "", errors.New("invalid granted transfer")
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if len(l.items) >= lanMaxItems {
+ return "", errors.New("this machine's transfer list is full")
+ }
+ id := "lan_" + hex.EncodeToString(random(16))
+ l.items[id] = Incoming{ID: id, Sender: sender, Envelope: envelope, Bytes: int64(len(envelope)), CreatedAt: time.Now()}
+ return id, nil
+}
+
func (l *LAN) Accept(id string) (string, error) {
func (l *LAN) Accept(id string) (string, error) {
l.mu.Lock()
l.mu.Lock()
defer l.mu.Unlock()
defer l.mu.Unlock()
@@ -182,80 +209,94 @@ func (l *LAN) Complete(id string) error {
@@ -182,80 +209,94 @@ func (l *LAN) Complete(id string) error {
}
}
type lanRequest struct {
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"`
+ 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"`
+ GrantToken string `json:"grant_token,omitempty"`
}
}
func (l *LAN) handler() http.Handler {
func (l *LAN) handler() http.Handler {
mux := http.NewServeMux()
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)
+ serve := func(granted bool) http.HandlerFunc {
+ return 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
+ }
+ if granted {
+ l.mu.Lock()
+ check := l.grantCheck
+ l.mu.Unlock()
+ if check == nil || req.GrantToken == "" || check(r.Context(), req.GrantToken, req.Sender) != nil {
+ http.Error(w, "machine access grant refused", 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 + `"}`))
}
}
- 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 + `"}`))
- })
+ }
+ mux.HandleFunc(lanPath, serve(false))
+ mux.HandleFunc(lanGrantPath, serve(true))
return mux
return mux
}
}
@@ -277,12 +318,26 @@ func random(n int) []byte {
@@ -277,12 +318,26 @@ func random(n int) []byte {
// sendLAN tries the target's advertised local endpoints. Any failure (not on the network, a timeout, an old secret)
// 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.
// falls back to the relay by returning an error.
func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {
func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {
+ _, err := c.sendLANWithGrant(ctx, target, envelope, "")
+ return err
+}
+
+// SendGrantedLAN attempts the same-owner LAN hand-over with a signed send
+// grant. A caller may fall back to Bot's authenticated relay on error.
+func (c *Client) SendGrantedLAN(ctx context.Context, target, envelope, grantToken string) (string, error) {
+ if grantToken == "" {
+ return "", errors.New("send grant missing")
+ }
+ return c.sendLANWithGrant(ctx, target, envelope, grantToken)
+}
+
+func (c *Client) sendLANWithGrant(ctx context.Context, target, envelope, grantToken string) (string, error) {
if c.Self == "" {
if c.Self == "" {
- return errors.New("this machine has no id for a direct transfer")
+ return "", errors.New("this machine has no id for a direct transfer")
}
}
machines, err := c.Targets(ctx)
machines, err := c.Targets(ctx)
if err != nil {
if err != nil {
- return err
+ return "", err
}
}
var endpoints []string
var endpoints []string
for _, m := range machines {
for _, m := range machines {
@@ -292,24 +347,28 @@ func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {
@@ -292,24 +347,28 @@ func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {
}
}
}
}
if len(endpoints) == 0 {
if len(endpoints) == 0 {
- return errors.New("the receiving machine is not on this network")
+ return "", errors.New("the receiving machine is not on this network")
}
}
encoded, err := c.LANKey(ctx)
encoded, err := c.LANKey(ctx)
if err != nil {
if err != nil {
- return err
+ return "", err
}
}
secret, err := base64.RawURLEncoding.DecodeString(encoded)
secret, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil || len(secret) < 16 {
if err != nil || len(secret) < 16 {
- return errors.New("the LAN sender secret is not valid")
+ return "", errors.New("the LAN sender secret is not valid")
}
}
for _, base := range endpoints {
for _, base := range endpoints {
at := time.Now().Unix()
at := time.Now().Unix()
nonce := hex.EncodeToString(random(16))
nonce := hex.EncodeToString(random(16))
body, _ := json.Marshal(lanRequest{
body, _ := json.Marshal(lanRequest{
Sender: c.Self, Target: target, Envelope: envelope, At: at, Nonce: nonce,
Sender: c.Self, Target: target, Envelope: envelope, At: at, Nonce: nonce,
- Mac: lanMAC(secret, c.Self, target, at, nonce, envelope),
+ Mac: lanMAC(secret, c.Self, target, at, nonce, envelope), GrantToken: grantToken,
})
})
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+lanPath, strings.NewReader(string(body)))
+ path := lanPath
+ if grantToken != "" {
+ path = lanGrantPath
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+path, strings.NewReader(string(body)))
if err != nil {
if err != nil {
continue
continue
}
}
@@ -319,11 +378,14 @@ func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {
@@ -319,11 +378,14 @@ func (c *Client) sendLAN(ctx context.Context, target, envelope string) error {
if err != nil {
if err != nil {
continue
continue
}
}
- _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
+ var reply struct {
+ ID string `json:"id"`
+ }
+ _ = json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&reply)
resp.Body.Close()
resp.Body.Close()
if resp.StatusCode == http.StatusCreated {
if resp.StatusCode == http.StatusCreated {
- return nil
+ return reply.ID, nil
}
}
}
}
- return errors.New("the direct transfer did not arrive")
+ return "", errors.New("the direct transfer did not arrive")
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+package send
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+func TestGrantedLANRequiresLiveSendGrant(t *testing.T) {
+ lan := NewLAN("lnk_target")
+ secret := []byte("same-owner-lan-secret-123456")
+ if err := lan.SetSecret(base64.RawURLEncoding.EncodeToString(secret)); err != nil {
+ t.Fatal(err)
+ }
+ var allowed atomic.Bool
+ allowed.Store(true)
+ lan.SetGrantCheck(func(_ context.Context, token, sender string) error {
+ if allowed.Load() && token == "signed-grant" && sender == "lnk_sender" {
+ return nil
+ }
+ return ErrGrantTest{}
+ })
+ server := httptest.NewServer(lan.handler())
+ defer server.Close()
+ post := func(token string) int {
+ t.Helper()
+ at := time.Now().Unix()
+ nonce := base64.RawURLEncoding.EncodeToString(random(16))
+ envelope := "sealed-file"
+ body, _ := json.Marshal(lanRequest{Sender: "lnk_sender", Target: "lnk_target", Envelope: envelope, At: at, Nonce: nonce,
+ Mac: lanMAC(secret, "lnk_sender", "lnk_target", at, nonce, envelope), GrantToken: token})
+ resp, err := http.Post(server.URL+lanGrantPath, "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ return resp.StatusCode
+ }
+ if got := post(""); got != http.StatusForbidden {
+ t.Fatalf("missing grant: %d", got)
+ }
+ if got := post("signed-grant"); got != http.StatusCreated {
+ t.Fatalf("live grant: %d", got)
+ }
+ allowed.Store(false)
+ if got := post("signed-grant"); got != http.StatusForbidden {
+ t.Fatalf("revoked grant: %d", got)
+ }
+ if len(lan.Items()) != 1 {
+ t.Fatal("refused transfer reached the inbox")
+ }
+}
+
+type ErrGrantTest struct{}
+
+func (ErrGrantTest) Error() string { return "grant refused" }