openvibers/openvibe.link Commits
Clone to this computer, from the page's button (#149)
Changes
@@@ -135,7 +135,9 @@ chose (`--root`, default `~/OpenVibe`), reports progress to the page, and refuseoutside the root, or a non-empty folder without a second answer. One clone runs at a time per machine. The page's`openvibe-link://clone?url=…` deep link is the same request. `openvibe-link git connect '<link>'` publishes therepositories under `--root` to OpenVibe Git, read-only, until Ctrl-C: the page lists them and browses tree, file and--log through the poll the Link opened. Neither command opens a port on this computer; both connect out to Git.++log through the poll the Link opened. The same poll also carries a **"Clone to <this computer>"** from the repository++page: the terminal shows the repository, who asked, the folder and the time left, and nothing is written until you++answer `y` (`--yes` answers for a script). Neither command opens a port on this computer; both connect out to Git.Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driverprotocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),@@@ -5,14 +5,14 @@"wave": "T15","plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)","kind": "client",-- "version": "0.52.0",-- "release": "v0.52.0: Git on this computer (Git plan G3). `openvibe-link clone` serves a Clone to my computer request from git.openvibe.codes: approval in Link, the root the person chose, a non-empty folder needing a second answer, one clone at a time, a two-minute request, progress to the page. `openvibe-link git connect` publishes the repositories under the root, read-only, and browses tree, file and log through the poll the machine opened. Both connect out to Git; neither opens a port.",++ "version": "0.53.0",++ "release": "v0.53.0: clone to this computer from the page (Git plan G3, step 3). The repository page's Get it panel lists the person's own Link computers and sends the clone down the watch the chosen one already holds: Link shows the repository, who asked, the folder and the time left, and writes nothing until `y` at the terminal (`--yes` for a script). The same checks as `openvibe-link clone`: the root the person chose, no path out of it, a non-empty folder needing a second answer, one clone at a time, two minutes, progress with git's percentage, done with the folder, refused and failed with git's last line.","runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library","database": "none","deployed": false,"deployment": "none of its own: people install it on their machines and robots (curl -fsSL https://openvibe.link/install | sh, or a release binary); it connects out to OpenVibe.Bot (wss://openvibe.bot/device) and pairs through OpenVibe.Bot or OpenVibe.Network. On 2026-10-22 production held 1 platform node with 4 pairings and 5 node principals on Network, and 3 robots with 5 devices on Bot.","features": [-- "Git on this computer (G3): `openvibe-link clone <link>` clones into the root you chose in Link after you approve it there, reporting progress to git.openvibe.codes; `openvibe-link git connect <link>` publishes the repositories under that root, read-only, browsed through Link while the machine is online",++ "Git on this computer (G3): `openvibe-link clone <link>` clones into the root you chose in Link after you approve it there, reporting progress to git.openvibe.codes; `openvibe-link git connect <link>` publishes the repositories under that root, read-only, browsed through Link while the machine is online, and carries a repository page's \"Clone to <this computer>\" to the same approval there","pairing with a code approved on openvibe.bot (or through Network), one outbound WebSocket, no open ports","safety: deadlines, owner limits clamped before any driver, heartbeats and a deadman, a persisted e-stop and the local kill switch (openvibe-link stop)","plugins as separate processes (JSON lines): dryrun, adeept_adr036, cozmo, relay and ros2 (rclpy or rosbridge); the community LOOI driver's contract; signed community profiles (openvibe-link profile verify)",@@@ -34,5 +34,5 @@"a tray on macOS (approvals there are a dialog with Deny as its default)","the computer plugin's browser on Windows"],-- "updated": "2026-11-07"++ "updated": "2026-11-10"}@@@ -11,6 +11,7 @@ import ("path/filepath""strings""syscall"++ "time""github.com/OpenVibers/OpenVibe.Link/internal/gitrelay")@@@ -27,13 +28,40 @@ func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io}ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)defer stop()++ approve, overwrite := cloneApprover(root, yes, stdin, stdout)++ res, err := gitrelay.Clone(ctx, raw, gitrelay.CloneOptions{Root: root, Approve: approve, ApproveOverwrite: overwrite})++ if err != nil {++ return err++ }++ if res.Reason != "" {++ fmt.Fprintln(stdout, res.Reason+"; nothing was written.")++ return nil++ }++ fmt.Fprintln(stdout, "Cloned into", res.Folder)++ return nil++}++++// cloneApprover is how a clone is allowed on this machine: the repository, who asked on the page, the folder and++// how long is left, then one y/N here. --yes answers yes without asking, for a script that means it. Without a++// terminal there is no one to ask, and the answer is no.++func cloneApprover(root string, yes bool, stdin io.Reader, stdout io.Writer) (++ func(context.Context, gitrelay.CloneRequest) (gitrelay.CloneDecision, error),++ func(context.Context, gitrelay.CloneRequest, string) (bool, error),++) {approve := func(_ context.Context, req gitrelay.CloneRequest) (gitrelay.CloneDecision, error) {_, name, err := gitrelay.RepoName(req.Repo)if err != nil {return gitrelay.CloneDecision{}, err}folder := filepath.Join(root, name)-- fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n", req.Repo, folder)++ if !req.ExpiresAt.IsZero() {++ left := time.Until(req.ExpiresAt).Round(time.Second)++ if left > 0 {++ fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n asked by %s, %s left\n", req.Repo, folder, orSomeone(req.RequestedBy), left)++ }++ } else {++ fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n", req.Repo, folder)++ }if yes {return gitrelay.CloneDecision{Approve: true, Folder: folder}, nil}@@@ -50,21 +78,20 @@ func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io}return confirm(stdin, stdout, "Clone into it anyway?")}-- res, err := gitrelay.Clone(ctx, raw, gitrelay.CloneOptions{Root: root, Approve: approve, ApproveOverwrite: overwrite})-- if err != nil {-- return err-- }-- if res.Reason != "" {-- fmt.Fprintln(stdout, res.Reason+"; nothing was written.")-- return nil++ return approve, overwrite++}++++func orSomeone(who string) string {++ if strings.TrimSpace(who) == "" {++ return "someone on the page"}-- fmt.Fprintln(stdout, "Cloned into", res.Folder)-- return nil++ return who}--// cmdGitConnect publishes this computer's repositories to OpenVibe Git, read-only, until Ctrl-C. It needs a link++// cmdGitConnect publishes this computer's repositories to OpenVibe Git, read-only, until Ctrl-C, and lets the person++// clone one of their repositories here: a page's request waits until it is answered with y here. It needs a link// from the person's Git page; the machine only ever polls Git, so nothing is opened on this computer.--func cmdGitConnect(g *globals, raw, root string, stdout io.Writer) error {++func cmdGitConnect(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io.Writer) error {if root == "" {home, err := os.UserHomeDir()if err != nil {@@@ -75,7 +102,11 @@ func cmdGitConnect(g *globals, raw, root string, stdout io.Writer) error {ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)defer stop()fmt.Fprintf(stdout, "Publishing the repositories under %s to OpenVibe Git, read-only, until Ctrl-C.\n", root)-- return gitrelay.MachineConnect(ctx, raw, gitrelay.MachineOptions{Root: root, Log: g.log, Name: hostName()})++ fmt.Fprintln(stdout, "A clone asked for from your repository page waits here for your answer; nothing is written before it.")++ approve, overwrite := cloneApprover(root, yes, stdin, stdout)++ return gitrelay.MachineConnect(ctx, raw, gitrelay.MachineOptions{++ Root: root, Log: g.log, Name: hostName(), Approve: approve, ApproveOverwrite: overwrite,++ })}func hostName() string {nnew file mode 100644@@@ -0,0 +1,72 @@++package main++++import (++ "bytes"++ "context"++ "io"++ "path/filepath"++ "strings"++ "testing"++ "time"++++ "github.com/OpenVibers/OpenVibe.Link/internal/gitrelay"++)++++// A clone the page addressed to this computer waits for the same y/N as a link: yes runs it, anything else refuses,++// and an input that ends without an answer refuses too.++func TestCloneApproverReadsY(t *testing.T) {++ root := t.TempDir()++ req := gitrelay.CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo",++ ExpiresAt: time.Now().Add(90 * time.Second), RequestedBy: "sam"}++ for in, want := range map[string]bool{"y\n": true, "yes\n": true, "n\n": false, "\n": false, "": false} {++ var out bytes.Buffer++ approve, _ := cloneApprover(root, false, strings.NewReader(in), &out)++ got, err := approve(context.Background(), req)++ if err != nil {++ t.Fatalf("%q: %v", in, err)++ }++ if got.Approve != want {++ t.Fatalf("%q: approve=%v want %v", in, got.Approve, want)++ }++ if !strings.Contains(out.String(), "openvibers/demo") || !strings.Contains(out.String(), "asked by sam") {++ t.Fatalf("%q: the prompt does not say what it would do: %q", in, out.String())++ }++ }++}++++func TestCloneApproverYesAnswersWithoutAsking(t *testing.T) {++ var out bytes.Buffer++ approve, overwrite := cloneApprover(t.TempDir(), true, nil, &out)++ got, err := approve(context.Background(), gitrelay.CloneRequest{Repo: "openvibers/demo"})++ if err != nil || !got.Approve {++ t.Fatalf("approve=%v err=%v", got.Approve, err)++ }++ if filepath.Base(got.Folder) != "demo" {++ t.Fatalf("folder=%q", got.Folder)++ }++ ok, err := overwrite(context.Background(), gitrelay.CloneRequest{Repo: "openvibers/demo"}, "/tmp/demo")++ if err != nil || !ok {++ t.Fatalf("overwrite=%v err=%v", ok, err)++ }++}++++func TestCloneApproverWithoutTerminalRefuses(t *testing.T) {++ approve, _ := cloneApprover(t.TempDir(), false, nil, io.Discard)++ got, err := approve(context.Background(), gitrelay.CloneRequest{Repo: "openvibers/demo"})++ if err == nil || got.Approve {++ t.Fatalf("with no terminal: approve=%v err=%v (it must refuse)", got.Approve, err)++ }++ if !strings.Contains(err.Error(), "no terminal") {++ t.Fatalf("err=%v", err)++ }++}++++func TestCloneApproverRefusesBadRepository(t *testing.T) {++ approve, _ := cloneApprover(t.TempDir(), true, nil, io.Discard)++ if _, err := approve(context.Background(), gitrelay.CloneRequest{Repo: "../etc"}); err == nil {++ t.Fatal("a repository that is not owner/name was accepted")++ }++ if _, err := approve(context.Background(), gitrelay.CloneRequest{Repo: "openvibers/demo"}); err != nil {++ t.Fatalf("valid repository: %v", err)++ }++}@@@ -311,10 +311,10 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) interr = cmdClone(g, positional[0], *gitRoot, *yes, stdin, stdout)case "git":if len(positional) != 2 || positional[0] != "connect" {-- fmt.Fprintln(stderr, "usage: openvibe-link git connect <LINK> [--root DIR]")++ fmt.Fprintln(stderr, "usage: openvibe-link git connect <LINK> [--root DIR] [--yes]")return 2}-- err = cmdGitConnect(g, positional[1], *gitRoot, stdout)++ err = cmdGitConnect(g, positional[1], *gitRoot, *yes, stdin, stdout)case "share":err = cmdShare(g, positional, shareArgs{key: *shKey, port: *shPort, server: *shSrv, share: *shID}, stdout, stderr)if errors.Is(err, errUsage) {@@@ -82,11 +82,34 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)if req.State != "pending" {return CloneResult{Reason: req.Reason}, fmt.Errorf("the request is already %s", req.State)}++ return serveClone(ctx, o, req, func(ctx context.Context, body map[string]any) error {++ return postState(ctx, o, ep, body)++ })++}++++// ServeClone is one clone whose request arrived over a computer's own watch (the page's button addressed that++// computer). It runs the same checks and steps as Clone and reports each one through post; the caller owns both the++// request and the transport, so nothing here knows where it came from.++func ServeClone(ctx context.Context, o CloneOptions, req CloneRequest, post func(context.Context, map[string]any) error) (CloneResult, error) {++ if o.Log == nil {++ o.Log = slog.Default()++ }++ if o.Now == nil {++ o.Now = time.Now++ }++ if o.GitBin == "" {++ o.GitBin = "git"++ }++ return serveClone(ctx, o, req, post)++}++++func serveClone(ctx context.Context, o CloneOptions, req CloneRequest, post func(context.Context, map[string]any) error) (CloneResult, error) {if req.ExpiresAt.Before(o.Now()) {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the request expired before your Link answered it"})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "the request expired before your Link answered it"})return CloneResult{}, errors.New("the request expired after two minutes; ask the page again")}if !cloneBusy.CompareAndSwap(false, true) {++ _ = post(ctx, map[string]any{"state": "refused", "reason": ErrBusy.Error()})return CloneResult{}, ErrBusy}defer cloneBusy.Store(false)@@@ -103,21 +126,21 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)_, repoName, err := RepoName(req.Repo)if err != nil {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the repository name is not valid"})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "the repository name is not valid"})return CloneResult{}, err}folder := filepath.Join(root, repoName)if o.Approve == nil {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "nothing on this computer asked for approval"})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "nothing on this computer asked for approval"})return CloneResult{}, errors.New("no approval on this computer")}decision, err := o.Approve(ctx, req)if err != nil {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the approval could not be asked: " + short(err.Error())})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "the approval could not be asked: " + short(err.Error())})return CloneResult{}, err}if !decision.Approve {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "refused in Link"})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "refused in Link"})return CloneResult{Reason: "refused in Link"}, nil}if decision.Folder != "" {@@@ -128,16 +151,16 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)}folder, err = insideRoot(root, folder)if err != nil {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the folder is outside the root you chose in Link"})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "the folder is outside the root you chose in Link"})return CloneResult{}, err}if o.Now().After(req.ExpiresAt) {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the request expired before the clone started"})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "the request expired before the clone started"})return CloneResult{}, errors.New("the request expired after two minutes; ask the page again")}nonEmpty, err := folderNonEmpty(folder)if err != nil {-- _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "the folder could not be read"})++ _ = post(ctx, map[string]any{"state": "failed", "reason": "the folder could not be read"})return CloneResult{}, err}if nonEmpty {@@@ -145,27 +168,27 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)if o.ApproveOverwrite != nil {overwrite, err = o.ApproveOverwrite(ctx, req, folder)if err != nil {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the overwrite could not be asked: " + short(err.Error())})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "the overwrite could not be asked: " + short(err.Error())})return CloneResult{}, err}}if !overwrite {-- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the folder already has files in it"})++ _ = post(ctx, map[string]any{"state": "refused", "reason": "the folder already has files in it"})return CloneResult{Reason: "the folder already has files in it"}, nil}}if !o.AllowLocal && !strings.HasPrefix(req.CloneURL, "https://") {-- _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "the clone URL is not https"})++ _ = post(ctx, map[string]any{"state": "failed", "reason": "the clone URL is not https"})return CloneResult{}, errors.New("the clone URL is not https")}-- _ = postState(ctx, o, ep, map[string]any{"state": "approved"})-- if err := postState(ctx, o, ep, map[string]any{"state": "cloning", "progress": Progress{Percent: 0, Line: "Starting"}}); err != nil {++ _ = post(ctx, map[string]any{"state": "approved"})++ if err := post(ctx, map[string]any{"state": "cloning", "progress": Progress{Percent: 0, Line: "Starting"}}); err != nil {return CloneResult{}, err}-- if err := runClone(ctx, o, ep, req.CloneURL, folder, repoName); err != nil {++ if err := runClone(ctx, o, post, req.CloneURL, folder, repoName); err != nil {return CloneResult{Folder: folder, Reason: err.Error()}, err}-- _ = postState(ctx, o, ep, map[string]any{++ _ = post(ctx, map[string]any{"state": "done", "path": folder,"progress": Progress{Percent: 100, Line: "Cloned into " + repoName},})@@@ -288,7 +311,7 @@ func readPID(path string) int {return pid}--func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder, repoName string) error {++func runClone(ctx context.Context, o CloneOptions, post func(context.Context, map[string]any) error, cloneURL, folder, repoName string) error {cmd := exec.CommandContext(ctx, o.GitBin, "clone", "--progress", "--", cloneURL, folder)cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never")stderr, err := cmd.StderrPipe()@@@ -300,7 +323,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folderreturn err}if err := cmd.Start(); err != nil {-- _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "git could not start"})++ _ = post(ctx, map[string]any{"state": "failed", "reason": "git could not start"})return errors.New("git could not start")}var lastLine atomic.Value@@@ -332,7 +355,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folderpercent = 100}}-- _ = postState(ctx, o, ep, map[string]any{"state": "cloning", "progress": Progress{Percent: percent, Line: line}})++ _ = post(ctx, map[string]any{"state": "cloning", "progress": Progress{Percent: percent, Line: line}})}}wg.Add(2)@@@ -345,7 +368,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folderif reason == "" {reason = "git clone failed"}-- _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": reason})++ _ = post(ctx, map[string]any{"state": "failed", "reason": reason})return fmt.Errorf("git clone failed: %s", reason)}_ = repoNamennew file mode 100644@@@ -0,0 +1,189 @@++package gitrelay++++import (++ "context"++ "encoding/json"++ "net/http"++ "net/http/httptest"++ "os"++ "path/filepath"++ "strings"++ "sync"++ "testing"++ "time"++)++++// fakeMachine is Git's end of one published computer: the clone reports the machine posts, in order.++type fakeMachine struct {++ mu sync.Mutex++ posts []map[string]any++ path string++}++++func newFakeMachine(t *testing.T) (*fakeMachine, string) {++ f := &fakeMachine{}++ mux := http.NewServeMux()++ mux.HandleFunc("/api/v1/link/machines/mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA/clone-requests/clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", func(w http.ResponseWriter, r *http.Request) {++ var body map[string]any++ _ = json.NewDecoder(r.Body).Decode(&body)++ f.mu.Lock()++ f.posts = append(f.posts, body)++ f.mu.Unlock()++ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})++ })++ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })++ f.path = "/api/v1/link/machines/mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA/clone-requests/clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA"++ srv := httptest.NewServer(mux)++ t.Cleanup(srv.Close)++ return f, srv.URL++}++++func (f *fakeMachine) states() []string {++ f.mu.Lock()++ defer f.mu.Unlock()++ out := make([]string, 0, len(f.posts))++ for _, p := range f.posts {++ s, _ := p["state"].(string)++ out = append(out, s)++ }++ return out++}++++func (f *fakeMachine) last() map[string]any {++ f.mu.Lock()++ defer f.mu.Unlock()++ if len(f.posts) == 0 {++ return nil++ }++ return f.posts[len(f.posts)-1]++}++++func machineRequest(srcURL string, expires time.Time) MachineRequest {++ return MachineRequest{++ ID: "req_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Kind: "clone", Repo: "openvibers/demo",++ CloneID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", URL: srcURL, RequestedBy: "sam", ExpiresAt: expires,++ }++}++++func machineEndpoint(base string) Endpoint {++ return Endpoint{Base: base, Kind: "machine", ID: "mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Token: strings.Repeat("a", 43)}++}++++func TestMachineCloneApproved(t *testing.T) {++ _, srcURL := gitRepo(t)++ root := t.TempDir()++ f, base := newFakeMachine(t)++ opts := MachineOptions{++ Root: root, AllowLocal: true,++ Approve: func(context.Context, CloneRequest) (CloneDecision, error) { return CloneDecision{Approve: true}, nil },++ }++ serveMachineClone(context.Background(), machineEndpoint(base), nil, opts, machineRequest(srcURL, time.Now().Add(2*time.Minute)))++++ want := []string{"waiting", "approved", "cloning", "done"}++ if got := f.states(); len(got) < len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] || got[len(got)-1] != want[3] {++ t.Fatalf("states=%v", got)++ }++ // The root may be reached through a symlink (macOS /var) or a short name (Windows RUNNER~1): the machine reports++ // the resolved folder, so compare its name, and read through the path it reported.++ done := f.last()++ folder, _ := done["path"].(string)++ if filepath.Base(folder) != "demo" {++ t.Fatalf("done path=%v", done["path"])++ }++ if b, err := os.ReadFile(filepath.Join(folder, "README.md")); err != nil || strings.ReplaceAll(string(b), "\r\n", "\n") != "hello\n" {++ t.Fatalf("clone contents: %q %v", b, err)++ }++}++++func TestMachineCloneRefusalsWriteNothing(t *testing.T) {++ outside := filepath.Join(t.TempDir(), "elsewhere")++ cases := []struct {++ name string++ expires time.Time++ approve func(context.Context, CloneRequest) (CloneDecision, error)++ over func(context.Context, CloneRequest, string) (bool, error)++ nonEmpty bool++ want string++ }{++ {name: "nobody to ask", expires: time.Now().Add(2 * time.Minute), want: "refused"},++ {name: "refused in Link", expires: time.Now().Add(2 * time.Minute), approve: func(context.Context, CloneRequest) (CloneDecision, error) {++ return CloneDecision{Approve: false}, nil++ }, want: "refused"},++ {name: "expired", expires: time.Now().Add(-time.Minute), approve: func(context.Context, CloneRequest) (CloneDecision, error) {++ return CloneDecision{Approve: true}, nil++ }, want: "refused"},++ {name: "outside the root", expires: time.Now().Add(2 * time.Minute), approve: func(context.Context, CloneRequest) (CloneDecision, error) {++ return CloneDecision{Approve: true, Folder: outside}, nil++ }, want: "refused"},++ {name: "folder has files", expires: time.Now().Add(2 * time.Minute), nonEmpty: true,++ approve: func(context.Context, CloneRequest) (CloneDecision, error) { return CloneDecision{Approve: true}, nil },++ over: func(context.Context, CloneRequest, string) (bool, error) { return false, nil }, want: "refused"},++ }++ for _, c := range cases {++ t.Run(c.name, func(t *testing.T) {++ _, srcURL := gitRepo(t)++ root := t.TempDir()++ if c.nonEmpty {++ if err := os.MkdirAll(filepath.Join(root, "demo"), 0o700); err != nil {++ t.Fatal(err)++ }++ if err := os.WriteFile(filepath.Join(root, "demo", "keep.txt"), []byte("mine\n"), 0o600); err != nil {++ t.Fatal(err)++ }++ }++ f, base := newFakeMachine(t)++ opts := MachineOptions{Root: root, AllowLocal: true, Approve: c.approve, ApproveOverwrite: c.over}++ serveMachineClone(context.Background(), machineEndpoint(base), nil, opts, machineRequest(srcURL, c.expires))++ last := f.last()++ if last == nil || last["state"] != c.want {++ t.Fatalf("last post=%v states=%v", last, f.states())++ }++ if c.name != "folder has files" {++ if _, err := os.Stat(filepath.Join(root, "demo")); !os.IsNotExist(err) {++ t.Fatal("something was written without an approval that allowed it")++ }++ }++ if _, err := os.Stat(outside); !os.IsNotExist(err) {++ t.Fatal("something was made outside the root")++ }++ if c.nonEmpty {++ if b, _ := os.ReadFile(filepath.Join(root, "demo", "keep.txt")); string(b) != "mine\n" {++ t.Fatal("the folder was changed without the second approval")++ }++ }++ })++ }++}++++func TestMachineCloneWithoutIDIsIgnored(t *testing.T) {++ f, base := newFakeMachine(t)++ req := machineRequest("https://example.invalid/x.git", time.Now().Add(2*time.Minute))++ req.CloneID = "../../elsewhere"++ serveMachineClone(context.Background(), machineEndpoint(base), nil, MachineOptions{++ Root: t.TempDir(), AllowLocal: true,++ Approve: func(context.Context, CloneRequest) (CloneDecision, error) { return CloneDecision{Approve: true}, nil },++ }, req)++ if got := f.states(); len(got) != 0 {++ t.Fatalf("a request with no usable id posted %v", got)++ }++}++++func TestServeCloneExpiredRefusesBeforeAsking(t *testing.T) {++ asked := false++ posts := []map[string]any{}++ res, err := ServeClone(context.Background(), CloneOptions{++ Root: t.TempDir(), AllowLocal: true,++ Approve: func(context.Context, CloneRequest) (CloneDecision, error) {++ asked = true++ return CloneDecision{Approve: true}, nil++ },++ }, CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo",++ CloneURL: "https://example.invalid/x.git", ExpiresAt: time.Now().Add(-time.Second)},++ func(_ context.Context, body map[string]any) error { posts = append(posts, body); return nil })++ if err == nil || asked || res.Reason != "" {++ t.Fatalf("err=%v asked=%v reason=%q", err, asked, res.Reason)++ }++ if len(posts) != 1 || posts[0]["state"] != "refused" {++ t.Fatalf("posts=%v", posts)++ }++}@@@ -117,6 +117,9 @@ type CloneRequest struct {Progress *Progress `json:"progress,omitempty"`Path string `json:"path,omitempty"`Reason string `json:"reason,omitempty"`++ // RequestedBy is who asked on the repository page, when the request came over a computer's watch: the person++ // answering here is told who it was. It is a username, never an address.++ RequestedBy string `json:"requested_by,omitempty"`}// RepoName is the folder name of a repository (the part after the slash), once the shape is checked.@@@ -38,15 +38,29 @@ type MachineOptions struct {GitBin string// PollTimeout is how long Git may hold one watch; it is under the client's deadline.PollTimeout time.Duration++ // Approve asks the person on this machine to allow one clone the page addressed here, and ApproveOverwrite asks++ // again when the folder already has files. A nil Approve refuses every clone: a page's wish alone writes nothing.++ Approve func(ctx context.Context, req CloneRequest) (CloneDecision, error)++ ApproveOverwrite func(ctx context.Context, req CloneRequest, folder string) (bool, error)++ Now func() time.Time++ // AllowLocal lets a test clone a file:// repository. Production requests are https only.++ AllowLocal bool}--// MachineRequest is one read-only question from the page, carried on the watch the machine opened.++// MachineRequest is one question from the page, carried on the watch the machine opened: a read-only tree, file or++// log, or a clone the person asked for on their repository page ("Clone to <this computer>").type MachineRequest struct {ID string `json:"id"`-- Kind string `json:"kind"` // tree, file or log++ Kind string `json:"kind"` // tree, file or log, or cloneRepo string `json:"repo"`Ref string `json:"ref,omitempty"`Path string `json:"path,omitempty"`++ // A clone request: its clr_… id (the row the page follows), the https URL with a short read token (only this++ // machine ever sees it), who asked on the page, and when the two minutes end.++ CloneID string `json:"clone_id,omitempty"`++ URL string `json:"url,omitempty"`++ RequestedBy string `json:"requested_by,omitempty"`++ ExpiresAt time.Time `json:"expires_at,omitempty"`}// Watch is Git's answer to one watch.@@@ -131,6 +145,10 @@ func MachineConnect(ctx context.Context, raw string, o MachineOptions) error {if w.Request == nil {continue}++ if w.Request.Kind == "clone" {++ serveMachineClone(ctx, ep, client, o, *w.Request)++ continue++ }body := answer(ctx, root, o.GitBin, *w.Request)if err := call(ctx, client, "POST", linkURL(ep, "/results"), body, nil); err != nil {if terminal(err) {@@@ -146,6 +164,50 @@ func MachineConnect(ctx context.Context, raw string, o MachineOptions) error {}}++// serveMachineClone runs one clone the page addressed to this computer. The request came over the same watch the++// browsing questions do; the person here is asked before anything runs (ServeClone refuses without an Approve), the++// folder is checked against the root they chose, and each step goes back over the machine's own link.++func serveMachineClone(ctx context.Context, ep Endpoint, client *http.Client, o MachineOptions, req MachineRequest) {++ log := o.Log++ if log == nil {++ log = slog.Default()++ }++ if !requestIDRe.MatchString(req.CloneID) {++ log.Warn("gitrelay: a clone request with no usable id was ignored", "repo", req.Repo)++ return++ }++ clone := CloneRequest{ID: req.CloneID, Repo: req.Repo, State: "waiting", CloneURL: req.URL, ExpiresAt: req.ExpiresAt, RequestedBy: req.RequestedBy}++ target := linkURL(ep, "/clone-requests/"+req.CloneID)++ post := func(ctx context.Context, body map[string]any) error {++ // The last answer should not be lost because the watch's context ended.++ postCtx := ctx++ if ctx.Err() != nil {++ var cancel context.CancelFunc++ postCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)++ defer cancel()++ }++ return call(postCtx, client, "POST", target, body, nil)++ }++ // The page says "waiting for your answer in Link" from here until the person answers.++ if err := post(ctx, map[string]any{"state": "waiting"}); err != nil {++ log.Warn("gitrelay: the clone request could not be acknowledged", "err", err)++ return++ }++ log.Info("gitrelay: a clone was asked for here", "repo", req.Repo, "by", req.RequestedBy, "expires", req.ExpiresAt)++ res, err := ServeClone(ctx, CloneOptions{++ Root: o.Root, Client: client, Approve: o.Approve, ApproveOverwrite: o.ApproveOverwrite,++ Log: log, Now: o.Now, GitBin: o.GitBin, AllowLocal: o.AllowLocal,++ }, clone, post)++ switch {++ case err != nil:++ log.Warn("gitrelay: the clone did not finish", "repo", req.Repo, "err", err)++ case res.Reason != "":++ log.Info("gitrelay: the clone was refused", "repo", req.Repo, "reason", res.Reason)++ default:++ log.Info("gitrelay: cloned", "repo", req.Repo, "into", res.Folder)++ }++}++func osName() string { return runtime.GOOS }func terminal(err error) bool {
@@ -135,7 +135,9 @@ chose (`--root`, default `~/OpenVibe`), reports progress to the page, and refuse
@@ -135,7 +135,9 @@ chose (`--root`, default `~/OpenVibe`), reports progress to the page, and refuse
outside the root, or a non-empty folder without a second answer. One clone runs at a time per machine. The page's
outside the root, or a non-empty folder without a second answer. One clone runs at a time per machine. The page's
`openvibe-link://clone?url=…` deep link is the same request. `openvibe-link git connect '<link>'` publishes the
`openvibe-link://clone?url=…` deep link is the same request. `openvibe-link git connect '<link>'` publishes the
repositories under `--root` to OpenVibe Git, read-only, until Ctrl-C: the page lists them and browses tree, file and
repositories under `--root` to OpenVibe Git, read-only, until Ctrl-C: the page lists them and browses tree, file and
-log through the poll the Link opened. Neither command opens a port on this computer; both connect out to Git.
+log through the poll the Link opened. The same poll also carries a **"Clone to <this computer>"** from the repository
+page: the terminal shows the repository, who asked, the folder and the time left, and nothing is written until you
+answer `y` (`--yes` answers for a script). Neither command opens a port on this computer; both connect out to Git.
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
@@ -5,14 +5,14 @@
@@ -5,14 +5,14 @@
"wave": "T15",
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
"kind": "client",
- "version": "0.52.0",
- "release": "v0.52.0: Git on this computer (Git plan G3). `openvibe-link clone` serves a Clone to my computer request from git.openvibe.codes: approval in Link, the root the person chose, a non-empty folder needing a second answer, one clone at a time, a two-minute request, progress to the page. `openvibe-link git connect` publishes the repositories under the root, read-only, and browses tree, file and log through the poll the machine opened. Both connect out to Git; neither opens a port.",
+ "version": "0.53.0",
+ "release": "v0.53.0: clone to this computer from the page (Git plan G3, step 3). The repository page's Get it panel lists the person's own Link computers and sends the clone down the watch the chosen one already holds: Link shows the repository, who asked, the folder and the time left, and writes nothing until `y` at the terminal (`--yes` for a script). The same checks as `openvibe-link clone`: the root the person chose, no path out of it, a non-empty folder needing a second answer, one clone at a time, two minutes, progress with git's percentage, done with the folder, refused and failed with git's last line.",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
"deployment": "none of its own: people install it on their machines and robots (curl -fsSL https://openvibe.link/install | sh, or a release binary); it connects out to OpenVibe.Bot (wss://openvibe.bot/device) and pairs through OpenVibe.Bot or OpenVibe.Network. On 2026-10-22 production held 1 platform node with 4 pairings and 5 node principals on Network, and 3 robots with 5 devices on Bot.",
"deployment": "none of its own: people install it on their machines and robots (curl -fsSL https://openvibe.link/install | sh, or a release binary); it connects out to OpenVibe.Bot (wss://openvibe.bot/device) and pairs through OpenVibe.Bot or OpenVibe.Network. On 2026-10-22 production held 1 platform node with 4 pairings and 5 node principals on Network, and 3 robots with 5 devices on Bot.",
"features": [
"features": [
- "Git on this computer (G3): `openvibe-link clone <link>` clones into the root you chose in Link after you approve it there, reporting progress to git.openvibe.codes; `openvibe-link git connect <link>` publishes the repositories under that root, read-only, browsed through Link while the machine is online",
+ "Git on this computer (G3): `openvibe-link clone <link>` clones into the root you chose in Link after you approve it there, reporting progress to git.openvibe.codes; `openvibe-link git connect <link>` publishes the repositories under that root, read-only, browsed through Link while the machine is online, and carries a repository page's \"Clone to <this computer>\" to the same approval there",
"pairing with a code approved on openvibe.bot (or through Network), one outbound WebSocket, no open ports",
"pairing with a code approved on openvibe.bot (or through Network), one outbound WebSocket, no open ports",
"safety: deadlines, owner limits clamped before any driver, heartbeats and a deadman, a persisted e-stop and the local kill switch (openvibe-link stop)",
"safety: deadlines, owner limits clamped before any driver, heartbeats and a deadman, a persisted e-stop and the local kill switch (openvibe-link stop)",
"plugins as separate processes (JSON lines): dryrun, adeept_adr036, cozmo, relay and ros2 (rclpy or rosbridge); the community LOOI driver's contract; signed community profiles (openvibe-link profile verify)",
"plugins as separate processes (JSON lines): dryrun, adeept_adr036, cozmo, relay and ros2 (rclpy or rosbridge); the community LOOI driver's contract; signed community profiles (openvibe-link profile verify)",
@@ -34,5 +34,5 @@
@@ -34,5 +34,5 @@
"a tray on macOS (approvals there are a dialog with Deny as its default)",
"a tray on macOS (approvals there are a dialog with Deny as its default)",
"the computer plugin's browser on Windows"
"the computer plugin's browser on Windows"
],
],
- "updated": "2026-11-07"
+ "updated": "2026-11-10"
}
}
@@ -11,6 +11,7 @@ import (
@@ -11,6 +11,7 @@ import (
"path/filepath"
"path/filepath"
"strings"
"strings"
"syscall"
"syscall"
+ "time"
"github.com/OpenVibers/OpenVibe.Link/internal/gitrelay"
"github.com/OpenVibers/OpenVibe.Link/internal/gitrelay"
)
)
@@ -27,13 +28,40 @@ func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io
@@ -27,13 +28,40 @@ func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io
}
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
defer stop()
+ approve, overwrite := cloneApprover(root, yes, stdin, stdout)
+ res, err := gitrelay.Clone(ctx, raw, gitrelay.CloneOptions{Root: root, Approve: approve, ApproveOverwrite: overwrite})
+ if err != nil {
+ return err
+ }
+ if res.Reason != "" {
+ fmt.Fprintln(stdout, res.Reason+"; nothing was written.")
+ return nil
+ }
+ fmt.Fprintln(stdout, "Cloned into", res.Folder)
+ return nil
+}
+
+// cloneApprover is how a clone is allowed on this machine: the repository, who asked on the page, the folder and
+// how long is left, then one y/N here. --yes answers yes without asking, for a script that means it. Without a
+// terminal there is no one to ask, and the answer is no.
+func cloneApprover(root string, yes bool, stdin io.Reader, stdout io.Writer) (
+ func(context.Context, gitrelay.CloneRequest) (gitrelay.CloneDecision, error),
+ func(context.Context, gitrelay.CloneRequest, string) (bool, error),
+) {
approve := func(_ context.Context, req gitrelay.CloneRequest) (gitrelay.CloneDecision, error) {
approve := func(_ context.Context, req gitrelay.CloneRequest) (gitrelay.CloneDecision, error) {
_, name, err := gitrelay.RepoName(req.Repo)
_, name, err := gitrelay.RepoName(req.Repo)
if err != nil {
if err != nil {
return gitrelay.CloneDecision{}, err
return gitrelay.CloneDecision{}, err
}
}
folder := filepath.Join(root, name)
folder := filepath.Join(root, name)
- fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n", req.Repo, folder)
+ if !req.ExpiresAt.IsZero() {
+ left := time.Until(req.ExpiresAt).Round(time.Second)
+ if left > 0 {
+ fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n asked by %s, %s left\n", req.Repo, folder, orSomeone(req.RequestedBy), left)
+ }
+ } else {
+ fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n", req.Repo, folder)
+ }
if yes {
if yes {
return gitrelay.CloneDecision{Approve: true, Folder: folder}, nil
return gitrelay.CloneDecision{Approve: true, Folder: folder}, nil
}
}
@@ -50,21 +78,20 @@ func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io
@@ -50,21 +78,20 @@ func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io
}
}
return confirm(stdin, stdout, "Clone into it anyway?")
return confirm(stdin, stdout, "Clone into it anyway?")
}
}
- res, err := gitrelay.Clone(ctx, raw, gitrelay.CloneOptions{Root: root, Approve: approve, ApproveOverwrite: overwrite})
- if err != nil {
- return err
- }
- if res.Reason != "" {
- fmt.Fprintln(stdout, res.Reason+"; nothing was written.")
- return nil
+ return approve, overwrite
+}
+
+func orSomeone(who string) string {
+ if strings.TrimSpace(who) == "" {
+ return "someone on the page"
}
}
- fmt.Fprintln(stdout, "Cloned into", res.Folder)
- return nil
+ return who
}
}
-// cmdGitConnect publishes this computer's repositories to OpenVibe Git, read-only, until Ctrl-C. It needs a link
+// cmdGitConnect publishes this computer's repositories to OpenVibe Git, read-only, until Ctrl-C, and lets the person
+// clone one of their repositories here: a page's request waits until it is answered with y here. It needs a link
// from the person's Git page; the machine only ever polls Git, so nothing is opened on this computer.
// from the person's Git page; the machine only ever polls Git, so nothing is opened on this computer.
-func cmdGitConnect(g *globals, raw, root string, stdout io.Writer) error {
+func cmdGitConnect(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io.Writer) error {
if root == "" {
if root == "" {
home, err := os.UserHomeDir()
home, err := os.UserHomeDir()
if err != nil {
if err != nil {
@@ -75,7 +102,11 @@ func cmdGitConnect(g *globals, raw, root string, stdout io.Writer) error {
@@ -75,7 +102,11 @@ func cmdGitConnect(g *globals, raw, root string, stdout io.Writer) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
defer stop()
fmt.Fprintf(stdout, "Publishing the repositories under %s to OpenVibe Git, read-only, until Ctrl-C.\n", root)
fmt.Fprintf(stdout, "Publishing the repositories under %s to OpenVibe Git, read-only, until Ctrl-C.\n", root)
- return gitrelay.MachineConnect(ctx, raw, gitrelay.MachineOptions{Root: root, Log: g.log, Name: hostName()})
+ fmt.Fprintln(stdout, "A clone asked for from your repository page waits here for your answer; nothing is written before it.")
+ approve, overwrite := cloneApprover(root, yes, stdin, stdout)
+ return gitrelay.MachineConnect(ctx, raw, gitrelay.MachineOptions{
+ Root: root, Log: g.log, Name: hostName(), Approve: approve, ApproveOverwrite: overwrite,
+ })
}
}
func hostName() string {
func hostName() string {
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Link/internal/gitrelay"
+)
+
+// A clone the page addressed to this computer waits for the same y/N as a link: yes runs it, anything else refuses,
+// and an input that ends without an answer refuses too.
+func TestCloneApproverReadsY(t *testing.T) {
+ root := t.TempDir()
+ req := gitrelay.CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo",
+ ExpiresAt: time.Now().Add(90 * time.Second), RequestedBy: "sam"}
+ for in, want := range map[string]bool{"y\n": true, "yes\n": true, "n\n": false, "\n": false, "": false} {
+ var out bytes.Buffer
+ approve, _ := cloneApprover(root, false, strings.NewReader(in), &out)
+ got, err := approve(context.Background(), req)
+ if err != nil {
+ t.Fatalf("%q: %v", in, err)
+ }
+ if got.Approve != want {
+ t.Fatalf("%q: approve=%v want %v", in, got.Approve, want)
+ }
+ if !strings.Contains(out.String(), "openvibers/demo") || !strings.Contains(out.String(), "asked by sam") {
+ t.Fatalf("%q: the prompt does not say what it would do: %q", in, out.String())
+ }
+ }
+}
+
+func TestCloneApproverYesAnswersWithoutAsking(t *testing.T) {
+ var out bytes.Buffer
+ approve, overwrite := cloneApprover(t.TempDir(), true, nil, &out)
+ got, err := approve(context.Background(), gitrelay.CloneRequest{Repo: "openvibers/demo"})
+ if err != nil || !got.Approve {
+ t.Fatalf("approve=%v err=%v", got.Approve, err)
+ }
+ if filepath.Base(got.Folder) != "demo" {
+ t.Fatalf("folder=%q", got.Folder)
+ }
+ ok, err := overwrite(context.Background(), gitrelay.CloneRequest{Repo: "openvibers/demo"}, "/tmp/demo")
+ if err != nil || !ok {
+ t.Fatalf("overwrite=%v err=%v", ok, err)
+ }
+}
+
+func TestCloneApproverWithoutTerminalRefuses(t *testing.T) {
+ approve, _ := cloneApprover(t.TempDir(), false, nil, io.Discard)
+ got, err := approve(context.Background(), gitrelay.CloneRequest{Repo: "openvibers/demo"})
+ if err == nil || got.Approve {
+ t.Fatalf("with no terminal: approve=%v err=%v (it must refuse)", got.Approve, err)
+ }
+ if !strings.Contains(err.Error(), "no terminal") {
+ t.Fatalf("err=%v", err)
+ }
+}
+
+func TestCloneApproverRefusesBadRepository(t *testing.T) {
+ approve, _ := cloneApprover(t.TempDir(), true, nil, io.Discard)
+ if _, err := approve(context.Background(), gitrelay.CloneRequest{Repo: "../etc"}); err == nil {
+ t.Fatal("a repository that is not owner/name was accepted")
+ }
+ if _, err := approve(context.Background(), gitrelay.CloneRequest{Repo: "openvibers/demo"}); err != nil {
+ t.Fatalf("valid repository: %v", err)
+ }
+}
@@ -311,10 +311,10 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -311,10 +311,10 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
err = cmdClone(g, positional[0], *gitRoot, *yes, stdin, stdout)
err = cmdClone(g, positional[0], *gitRoot, *yes, stdin, stdout)
case "git":
case "git":
if len(positional) != 2 || positional[0] != "connect" {
if len(positional) != 2 || positional[0] != "connect" {
- fmt.Fprintln(stderr, "usage: openvibe-link git connect <LINK> [--root DIR]")
+ fmt.Fprintln(stderr, "usage: openvibe-link git connect <LINK> [--root DIR] [--yes]")
return 2
return 2
}
}
- err = cmdGitConnect(g, positional[1], *gitRoot, stdout)
+ err = cmdGitConnect(g, positional[1], *gitRoot, *yes, stdin, stdout)
case "share":
case "share":
err = cmdShare(g, positional, shareArgs{key: *shKey, port: *shPort, server: *shSrv, share: *shID}, stdout, stderr)
err = cmdShare(g, positional, shareArgs{key: *shKey, port: *shPort, server: *shSrv, share: *shID}, stdout, stderr)
if errors.Is(err, errUsage) {
if errors.Is(err, errUsage) {
@@ -82,11 +82,34 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)
@@ -82,11 +82,34 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)
if req.State != "pending" {
if req.State != "pending" {
return CloneResult{Reason: req.Reason}, fmt.Errorf("the request is already %s", req.State)
return CloneResult{Reason: req.Reason}, fmt.Errorf("the request is already %s", req.State)
}
}
+ return serveClone(ctx, o, req, func(ctx context.Context, body map[string]any) error {
+ return postState(ctx, o, ep, body)
+ })
+}
+
+// ServeClone is one clone whose request arrived over a computer's own watch (the page's button addressed that
+// computer). It runs the same checks and steps as Clone and reports each one through post; the caller owns both the
+// request and the transport, so nothing here knows where it came from.
+func ServeClone(ctx context.Context, o CloneOptions, req CloneRequest, post func(context.Context, map[string]any) error) (CloneResult, error) {
+ if o.Log == nil {
+ o.Log = slog.Default()
+ }
+ if o.Now == nil {
+ o.Now = time.Now
+ }
+ if o.GitBin == "" {
+ o.GitBin = "git"
+ }
+ return serveClone(ctx, o, req, post)
+}
+
+func serveClone(ctx context.Context, o CloneOptions, req CloneRequest, post func(context.Context, map[string]any) error) (CloneResult, error) {
if req.ExpiresAt.Before(o.Now()) {
if req.ExpiresAt.Before(o.Now()) {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the request expired before your Link answered it"})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "the request expired before your Link answered it"})
return CloneResult{}, errors.New("the request expired after two minutes; ask the page again")
return CloneResult{}, errors.New("the request expired after two minutes; ask the page again")
}
}
if !cloneBusy.CompareAndSwap(false, true) {
if !cloneBusy.CompareAndSwap(false, true) {
+ _ = post(ctx, map[string]any{"state": "refused", "reason": ErrBusy.Error()})
return CloneResult{}, ErrBusy
return CloneResult{}, ErrBusy
}
}
defer cloneBusy.Store(false)
defer cloneBusy.Store(false)
@@ -103,21 +126,21 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)
@@ -103,21 +126,21 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)
_, repoName, err := RepoName(req.Repo)
_, repoName, err := RepoName(req.Repo)
if err != nil {
if err != nil {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the repository name is not valid"})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "the repository name is not valid"})
return CloneResult{}, err
return CloneResult{}, err
}
}
folder := filepath.Join(root, repoName)
folder := filepath.Join(root, repoName)
if o.Approve == nil {
if o.Approve == nil {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "nothing on this computer asked for approval"})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "nothing on this computer asked for approval"})
return CloneResult{}, errors.New("no approval on this computer")
return CloneResult{}, errors.New("no approval on this computer")
}
}
decision, err := o.Approve(ctx, req)
decision, err := o.Approve(ctx, req)
if err != nil {
if err != nil {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the approval could not be asked: " + short(err.Error())})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "the approval could not be asked: " + short(err.Error())})
return CloneResult{}, err
return CloneResult{}, err
}
}
if !decision.Approve {
if !decision.Approve {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "refused in Link"})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "refused in Link"})
return CloneResult{Reason: "refused in Link"}, nil
return CloneResult{Reason: "refused in Link"}, nil
}
}
if decision.Folder != "" {
if decision.Folder != "" {
@@ -128,16 +151,16 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)
@@ -128,16 +151,16 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)
}
}
folder, err = insideRoot(root, folder)
folder, err = insideRoot(root, folder)
if err != nil {
if err != nil {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the folder is outside the root you chose in Link"})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "the folder is outside the root you chose in Link"})
return CloneResult{}, err
return CloneResult{}, err
}
}
if o.Now().After(req.ExpiresAt) {
if o.Now().After(req.ExpiresAt) {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the request expired before the clone started"})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "the request expired before the clone started"})
return CloneResult{}, errors.New("the request expired after two minutes; ask the page again")
return CloneResult{}, errors.New("the request expired after two minutes; ask the page again")
}
}
nonEmpty, err := folderNonEmpty(folder)
nonEmpty, err := folderNonEmpty(folder)
if err != nil {
if err != nil {
- _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "the folder could not be read"})
+ _ = post(ctx, map[string]any{"state": "failed", "reason": "the folder could not be read"})
return CloneResult{}, err
return CloneResult{}, err
}
}
if nonEmpty {
if nonEmpty {
@@ -145,27 +168,27 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)
@@ -145,27 +168,27 @@ func Clone(ctx context.Context, raw string, o CloneOptions) (CloneResult, error)
if o.ApproveOverwrite != nil {
if o.ApproveOverwrite != nil {
overwrite, err = o.ApproveOverwrite(ctx, req, folder)
overwrite, err = o.ApproveOverwrite(ctx, req, folder)
if err != nil {
if err != nil {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the overwrite could not be asked: " + short(err.Error())})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "the overwrite could not be asked: " + short(err.Error())})
return CloneResult{}, err
return CloneResult{}, err
}
}
}
}
if !overwrite {
if !overwrite {
- _ = postState(ctx, o, ep, map[string]any{"state": "refused", "reason": "the folder already has files in it"})
+ _ = post(ctx, map[string]any{"state": "refused", "reason": "the folder already has files in it"})
return CloneResult{Reason: "the folder already has files in it"}, nil
return CloneResult{Reason: "the folder already has files in it"}, nil
}
}
}
}
if !o.AllowLocal && !strings.HasPrefix(req.CloneURL, "https://") {
if !o.AllowLocal && !strings.HasPrefix(req.CloneURL, "https://") {
- _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "the clone URL is not https"})
+ _ = post(ctx, map[string]any{"state": "failed", "reason": "the clone URL is not https"})
return CloneResult{}, errors.New("the clone URL is not https")
return CloneResult{}, errors.New("the clone URL is not https")
}
}
- _ = postState(ctx, o, ep, map[string]any{"state": "approved"})
- if err := postState(ctx, o, ep, map[string]any{"state": "cloning", "progress": Progress{Percent: 0, Line: "Starting"}}); err != nil {
+ _ = post(ctx, map[string]any{"state": "approved"})
+ if err := post(ctx, map[string]any{"state": "cloning", "progress": Progress{Percent: 0, Line: "Starting"}}); err != nil {
return CloneResult{}, err
return CloneResult{}, err
}
}
- if err := runClone(ctx, o, ep, req.CloneURL, folder, repoName); err != nil {
+ if err := runClone(ctx, o, post, req.CloneURL, folder, repoName); err != nil {
return CloneResult{Folder: folder, Reason: err.Error()}, err
return CloneResult{Folder: folder, Reason: err.Error()}, err
}
}
- _ = postState(ctx, o, ep, map[string]any{
+ _ = post(ctx, map[string]any{
"state": "done", "path": folder,
"state": "done", "path": folder,
"progress": Progress{Percent: 100, Line: "Cloned into " + repoName},
"progress": Progress{Percent: 100, Line: "Cloned into " + repoName},
})
})
@@ -288,7 +311,7 @@ func readPID(path string) int {
@@ -288,7 +311,7 @@ func readPID(path string) int {
return pid
return pid
}
}
-func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder, repoName string) error {
+func runClone(ctx context.Context, o CloneOptions, post func(context.Context, map[string]any) error, cloneURL, folder, repoName string) error {
cmd := exec.CommandContext(ctx, o.GitBin, "clone", "--progress", "--", cloneURL, folder)
cmd := exec.CommandContext(ctx, o.GitBin, "clone", "--progress", "--", cloneURL, folder)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never")
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=", "GCM_INTERACTIVE=never")
stderr, err := cmd.StderrPipe()
stderr, err := cmd.StderrPipe()
@@ -300,7 +323,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder
@@ -300,7 +323,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder
return err
return err
}
}
if err := cmd.Start(); err != nil {
if err := cmd.Start(); err != nil {
- _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": "git could not start"})
+ _ = post(ctx, map[string]any{"state": "failed", "reason": "git could not start"})
return errors.New("git could not start")
return errors.New("git could not start")
}
}
var lastLine atomic.Value
var lastLine atomic.Value
@@ -332,7 +355,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder
@@ -332,7 +355,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder
percent = 100
percent = 100
}
}
}
}
- _ = postState(ctx, o, ep, map[string]any{"state": "cloning", "progress": Progress{Percent: percent, Line: line}})
+ _ = post(ctx, map[string]any{"state": "cloning", "progress": Progress{Percent: percent, Line: line}})
}
}
}
}
wg.Add(2)
wg.Add(2)
@@ -345,7 +368,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder
@@ -345,7 +368,7 @@ func runClone(ctx context.Context, o CloneOptions, ep Endpoint, cloneURL, folder
if reason == "" {
if reason == "" {
reason = "git clone failed"
reason = "git clone failed"
}
}
- _ = postState(ctx, o, ep, map[string]any{"state": "failed", "reason": reason})
+ _ = post(ctx, map[string]any{"state": "failed", "reason": reason})
return fmt.Errorf("git clone failed: %s", reason)
return fmt.Errorf("git clone failed: %s", reason)
}
}
_ = repoName
_ = repoName
new file mode 100644
new file mode 100644
@@ -0,0 +1,189 @@
@@ -0,0 +1,189 @@
+package gitrelay
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// fakeMachine is Git's end of one published computer: the clone reports the machine posts, in order.
+type fakeMachine struct {
+ mu sync.Mutex
+ posts []map[string]any
+ path string
+}
+
+func newFakeMachine(t *testing.T) (*fakeMachine, string) {
+ f := &fakeMachine{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/v1/link/machines/mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA/clone-requests/clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]any
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ f.mu.Lock()
+ f.posts = append(f.posts, body)
+ f.mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
+ })
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
+ f.path = "/api/v1/link/machines/mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA/clone-requests/clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA"
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return f, srv.URL
+}
+
+func (f *fakeMachine) states() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := make([]string, 0, len(f.posts))
+ for _, p := range f.posts {
+ s, _ := p["state"].(string)
+ out = append(out, s)
+ }
+ return out
+}
+
+func (f *fakeMachine) last() map[string]any {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if len(f.posts) == 0 {
+ return nil
+ }
+ return f.posts[len(f.posts)-1]
+}
+
+func machineRequest(srcURL string, expires time.Time) MachineRequest {
+ return MachineRequest{
+ ID: "req_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Kind: "clone", Repo: "openvibers/demo",
+ CloneID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", URL: srcURL, RequestedBy: "sam", ExpiresAt: expires,
+ }
+}
+
+func machineEndpoint(base string) Endpoint {
+ return Endpoint{Base: base, Kind: "machine", ID: "mch_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Token: strings.Repeat("a", 43)}
+}
+
+func TestMachineCloneApproved(t *testing.T) {
+ _, srcURL := gitRepo(t)
+ root := t.TempDir()
+ f, base := newFakeMachine(t)
+ opts := MachineOptions{
+ Root: root, AllowLocal: true,
+ Approve: func(context.Context, CloneRequest) (CloneDecision, error) { return CloneDecision{Approve: true}, nil },
+ }
+ serveMachineClone(context.Background(), machineEndpoint(base), nil, opts, machineRequest(srcURL, time.Now().Add(2*time.Minute)))
+
+ want := []string{"waiting", "approved", "cloning", "done"}
+ if got := f.states(); len(got) < len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] || got[len(got)-1] != want[3] {
+ t.Fatalf("states=%v", got)
+ }
+ // The root may be reached through a symlink (macOS /var) or a short name (Windows RUNNER~1): the machine reports
+ // the resolved folder, so compare its name, and read through the path it reported.
+ done := f.last()
+ folder, _ := done["path"].(string)
+ if filepath.Base(folder) != "demo" {
+ t.Fatalf("done path=%v", done["path"])
+ }
+ if b, err := os.ReadFile(filepath.Join(folder, "README.md")); err != nil || strings.ReplaceAll(string(b), "\r\n", "\n") != "hello\n" {
+ t.Fatalf("clone contents: %q %v", b, err)
+ }
+}
+
+func TestMachineCloneRefusalsWriteNothing(t *testing.T) {
+ outside := filepath.Join(t.TempDir(), "elsewhere")
+ cases := []struct {
+ name string
+ expires time.Time
+ approve func(context.Context, CloneRequest) (CloneDecision, error)
+ over func(context.Context, CloneRequest, string) (bool, error)
+ nonEmpty bool
+ want string
+ }{
+ {name: "nobody to ask", expires: time.Now().Add(2 * time.Minute), want: "refused"},
+ {name: "refused in Link", expires: time.Now().Add(2 * time.Minute), approve: func(context.Context, CloneRequest) (CloneDecision, error) {
+ return CloneDecision{Approve: false}, nil
+ }, want: "refused"},
+ {name: "expired", expires: time.Now().Add(-time.Minute), approve: func(context.Context, CloneRequest) (CloneDecision, error) {
+ return CloneDecision{Approve: true}, nil
+ }, want: "refused"},
+ {name: "outside the root", expires: time.Now().Add(2 * time.Minute), approve: func(context.Context, CloneRequest) (CloneDecision, error) {
+ return CloneDecision{Approve: true, Folder: outside}, nil
+ }, want: "refused"},
+ {name: "folder has files", expires: time.Now().Add(2 * time.Minute), nonEmpty: true,
+ approve: func(context.Context, CloneRequest) (CloneDecision, error) { return CloneDecision{Approve: true}, nil },
+ over: func(context.Context, CloneRequest, string) (bool, error) { return false, nil }, want: "refused"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ _, srcURL := gitRepo(t)
+ root := t.TempDir()
+ if c.nonEmpty {
+ if err := os.MkdirAll(filepath.Join(root, "demo"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "demo", "keep.txt"), []byte("mine\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ f, base := newFakeMachine(t)
+ opts := MachineOptions{Root: root, AllowLocal: true, Approve: c.approve, ApproveOverwrite: c.over}
+ serveMachineClone(context.Background(), machineEndpoint(base), nil, opts, machineRequest(srcURL, c.expires))
+ last := f.last()
+ if last == nil || last["state"] != c.want {
+ t.Fatalf("last post=%v states=%v", last, f.states())
+ }
+ if c.name != "folder has files" {
+ if _, err := os.Stat(filepath.Join(root, "demo")); !os.IsNotExist(err) {
+ t.Fatal("something was written without an approval that allowed it")
+ }
+ }
+ if _, err := os.Stat(outside); !os.IsNotExist(err) {
+ t.Fatal("something was made outside the root")
+ }
+ if c.nonEmpty {
+ if b, _ := os.ReadFile(filepath.Join(root, "demo", "keep.txt")); string(b) != "mine\n" {
+ t.Fatal("the folder was changed without the second approval")
+ }
+ }
+ })
+ }
+}
+
+func TestMachineCloneWithoutIDIsIgnored(t *testing.T) {
+ f, base := newFakeMachine(t)
+ req := machineRequest("https://example.invalid/x.git", time.Now().Add(2*time.Minute))
+ req.CloneID = "../../elsewhere"
+ serveMachineClone(context.Background(), machineEndpoint(base), nil, MachineOptions{
+ Root: t.TempDir(), AllowLocal: true,
+ Approve: func(context.Context, CloneRequest) (CloneDecision, error) { return CloneDecision{Approve: true}, nil },
+ }, req)
+ if got := f.states(); len(got) != 0 {
+ t.Fatalf("a request with no usable id posted %v", got)
+ }
+}
+
+func TestServeCloneExpiredRefusesBeforeAsking(t *testing.T) {
+ asked := false
+ posts := []map[string]any{}
+ res, err := ServeClone(context.Background(), CloneOptions{
+ Root: t.TempDir(), AllowLocal: true,
+ Approve: func(context.Context, CloneRequest) (CloneDecision, error) {
+ asked = true
+ return CloneDecision{Approve: true}, nil
+ },
+ }, CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo",
+ CloneURL: "https://example.invalid/x.git", ExpiresAt: time.Now().Add(-time.Second)},
+ func(_ context.Context, body map[string]any) error { posts = append(posts, body); return nil })
+ if err == nil || asked || res.Reason != "" {
+ t.Fatalf("err=%v asked=%v reason=%q", err, asked, res.Reason)
+ }
+ if len(posts) != 1 || posts[0]["state"] != "refused" {
+ t.Fatalf("posts=%v", posts)
+ }
+}
@@ -117,6 +117,9 @@ type CloneRequest struct {
@@ -117,6 +117,9 @@ type CloneRequest struct {
Progress *Progress `json:"progress,omitempty"`
Progress *Progress `json:"progress,omitempty"`
Path string `json:"path,omitempty"`
Path string `json:"path,omitempty"`
Reason string `json:"reason,omitempty"`
Reason string `json:"reason,omitempty"`
+ // RequestedBy is who asked on the repository page, when the request came over a computer's watch: the person
+ // answering here is told who it was. It is a username, never an address.
+ RequestedBy string `json:"requested_by,omitempty"`
}
}
// RepoName is the folder name of a repository (the part after the slash), once the shape is checked.
// RepoName is the folder name of a repository (the part after the slash), once the shape is checked.
@@ -38,15 +38,29 @@ type MachineOptions struct {
@@ -38,15 +38,29 @@ type MachineOptions struct {
GitBin string
GitBin string
// PollTimeout is how long Git may hold one watch; it is under the client's deadline.
// PollTimeout is how long Git may hold one watch; it is under the client's deadline.
PollTimeout time.Duration
PollTimeout time.Duration
+ // Approve asks the person on this machine to allow one clone the page addressed here, and ApproveOverwrite asks
+ // again when the folder already has files. A nil Approve refuses every clone: a page's wish alone writes nothing.
+ Approve func(ctx context.Context, req CloneRequest) (CloneDecision, error)
+ ApproveOverwrite func(ctx context.Context, req CloneRequest, folder string) (bool, error)
+ Now func() time.Time
+ // AllowLocal lets a test clone a file:// repository. Production requests are https only.
+ AllowLocal bool
}
}
-// MachineRequest is one read-only question from the page, carried on the watch the machine opened.
+// MachineRequest is one question from the page, carried on the watch the machine opened: a read-only tree, file or
+// log, or a clone the person asked for on their repository page ("Clone to <this computer>").
type MachineRequest struct {
type MachineRequest struct {
ID string `json:"id"`
ID string `json:"id"`
- Kind string `json:"kind"` // tree, file or log
+ Kind string `json:"kind"` // tree, file or log, or clone
Repo string `json:"repo"`
Repo string `json:"repo"`
Ref string `json:"ref,omitempty"`
Ref string `json:"ref,omitempty"`
Path string `json:"path,omitempty"`
Path string `json:"path,omitempty"`
+ // A clone request: its clr_… id (the row the page follows), the https URL with a short read token (only this
+ // machine ever sees it), who asked on the page, and when the two minutes end.
+ CloneID string `json:"clone_id,omitempty"`
+ URL string `json:"url,omitempty"`
+ RequestedBy string `json:"requested_by,omitempty"`
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
}
}
// Watch is Git's answer to one watch.
// Watch is Git's answer to one watch.
@@ -131,6 +145,10 @@ func MachineConnect(ctx context.Context, raw string, o MachineOptions) error {
@@ -131,6 +145,10 @@ func MachineConnect(ctx context.Context, raw string, o MachineOptions) error {
if w.Request == nil {
if w.Request == nil {
continue
continue
}
}
+ if w.Request.Kind == "clone" {
+ serveMachineClone(ctx, ep, client, o, *w.Request)
+ continue
+ }
body := answer(ctx, root, o.GitBin, *w.Request)
body := answer(ctx, root, o.GitBin, *w.Request)
if err := call(ctx, client, "POST", linkURL(ep, "/results"), body, nil); err != nil {
if err := call(ctx, client, "POST", linkURL(ep, "/results"), body, nil); err != nil {
if terminal(err) {
if terminal(err) {
@@ -146,6 +164,50 @@ func MachineConnect(ctx context.Context, raw string, o MachineOptions) error {
@@ -146,6 +164,50 @@ func MachineConnect(ctx context.Context, raw string, o MachineOptions) error {
}
}
}
}
+// serveMachineClone runs one clone the page addressed to this computer. The request came over the same watch the
+// browsing questions do; the person here is asked before anything runs (ServeClone refuses without an Approve), the
+// folder is checked against the root they chose, and each step goes back over the machine's own link.
+func serveMachineClone(ctx context.Context, ep Endpoint, client *http.Client, o MachineOptions, req MachineRequest) {
+ log := o.Log
+ if log == nil {
+ log = slog.Default()
+ }
+ if !requestIDRe.MatchString(req.CloneID) {
+ log.Warn("gitrelay: a clone request with no usable id was ignored", "repo", req.Repo)
+ return
+ }
+ clone := CloneRequest{ID: req.CloneID, Repo: req.Repo, State: "waiting", CloneURL: req.URL, ExpiresAt: req.ExpiresAt, RequestedBy: req.RequestedBy}
+ target := linkURL(ep, "/clone-requests/"+req.CloneID)
+ post := func(ctx context.Context, body map[string]any) error {
+ // The last answer should not be lost because the watch's context ended.
+ postCtx := ctx
+ if ctx.Err() != nil {
+ var cancel context.CancelFunc
+ postCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ }
+ return call(postCtx, client, "POST", target, body, nil)
+ }
+ // The page says "waiting for your answer in Link" from here until the person answers.
+ if err := post(ctx, map[string]any{"state": "waiting"}); err != nil {
+ log.Warn("gitrelay: the clone request could not be acknowledged", "err", err)
+ return
+ }
+ log.Info("gitrelay: a clone was asked for here", "repo", req.Repo, "by", req.RequestedBy, "expires", req.ExpiresAt)
+ res, err := ServeClone(ctx, CloneOptions{
+ Root: o.Root, Client: client, Approve: o.Approve, ApproveOverwrite: o.ApproveOverwrite,
+ Log: log, Now: o.Now, GitBin: o.GitBin, AllowLocal: o.AllowLocal,
+ }, clone, post)
+ switch {
+ case err != nil:
+ log.Warn("gitrelay: the clone did not finish", "repo", req.Repo, "err", err)
+ case res.Reason != "":
+ log.Info("gitrelay: the clone was refused", "repo", req.Repo, "reason", res.Reason)
+ default:
+ log.Info("gitrelay: cloned", "repo", req.Repo, "into", res.Folder)
+ }
+}
+
func osName() string { return runtime.GOOS }
func osName() string { return runtime.GOOS }
func terminal(err error) bool {
func terminal(err error) bool {