detached 28e6f55

feat(remote): add ssh stdio attach

commit
28e6f55b50d09d553bef4969e19c6bc1b096f6fa
author
brice · · 2026-07-02 16:56 UTC
parent
00a0215
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +377 -7
A internal/adapters/sshstdio/transport.go +134 -0
@@ -0,0 +1,134 @@
1+// Package sshstdio adapts an ssh subprocess' stdin/stdout into vev's framed
2+// Transport interface. It intentionally builds argv slices for os/exec instead
3+// of shell command strings so remote targets and session names are never shell-
4+// interpolated locally.
5+package sshstdio
6+
7+import (
8+ "encoding/binary"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os/exec"
13+ "sync"
14+
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+const (
19+ maxFrameLen = 16 << 20
20+ frameHeaderLen = 4
21+)
22+
23+var (
24+ ErrZeroLengthFrame = errors.New("sshstdio: zero-length frame")
25+ ErrFrameTooLarge = errors.New("sshstdio: frame exceeds maximum length")
26+)
27+
28+type closeFunc func() error
29+
30+// NewTransport wraps separate reader/writer streams as a framed Transport.
31+func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {
32+ if close == nil {
33+ close = func() error { return nil }
34+ }
35+ return &transport{r: r, w: w, close: close}
36+}
37+
38+type transport struct {
39+ r io.Reader
40+ w io.Writer
41+ close closeFunc
42+
43+ mu sync.Mutex
44+ readBuf []byte
45+}
46+
47+func (t *transport) Send(f ports.Frame) error {
48+ n := 1 + len(f.Payload)
49+ if n > maxFrameLen {
50+ return ErrFrameTooLarge
51+ }
52+
53+ buf := make([]byte, frameHeaderLen+n)
54+ binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
55+ buf[frameHeaderLen] = byte(f.Type)
56+ copy(buf[frameHeaderLen+1:], f.Payload)
57+
58+ t.mu.Lock()
59+ defer t.mu.Unlock()
60+ _, err := t.w.Write(buf)
61+ return err
62+}
63+
64+func (t *transport) Recv() (ports.Frame, error) {
65+ var hdr [frameHeaderLen]byte
66+ if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
67+ return ports.Frame{}, err
68+ }
69+
70+ n := binary.BigEndian.Uint32(hdr[:])
71+ if n == 0 {
72+ return ports.Frame{}, ErrZeroLengthFrame
73+ }
74+ if n > maxFrameLen {
75+ return ports.Frame{}, ErrFrameTooLarge
76+ }
77+
78+ if cap(t.readBuf) < int(n) {
79+ t.readBuf = make([]byte, n)
80+ } else {
81+ t.readBuf = t.readBuf[:n]
82+ }
83+ if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
84+ return ports.Frame{}, err
85+ }
86+
87+ payload := append([]byte(nil), t.readBuf[1:]...)
88+ return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
89+}
90+
91+func (t *transport) Close() error { return t.close() }
92+
93+// CommandSpec is the exact ssh argv vev will execute locally.
94+type CommandSpec struct {
95+ Path string
96+ Args []string
97+}
98+
99+// BuildCommand constructs the ssh subprocess argv without invoking a shell.
100+func BuildCommand(target, session string) CommandSpec {
101+ args := []string{target, "vev", "_stdio"}
102+ if session != "" {
103+ args = append(args, session)
104+ }
105+ return CommandSpec{Path: "ssh", Args: args}
106+}
107+
108+// Dial starts ssh target vev _stdio [session] and returns a Transport over the
109+// child process' stdio. The subprocess is started with exec.Command argv, never
110+// through a shell.
111+func Dial(target, session string) (ports.Transport, error) {
112+ spec := BuildCommand(target, session)
113+ cmd := exec.Command(spec.Path, spec.Args...)
114+ stdin, err := cmd.StdinPipe()
115+ if err != nil {
116+ return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)
117+ }
118+ stdout, err := cmd.StdoutPipe()
119+ if err != nil {
120+ return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err)
121+ }
122+ if err := cmd.Start(); err != nil {
123+ return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
124+ }
125+
126+ return NewTransport(stdout, stdin, func() error {
127+ _ = stdin.Close()
128+ err := cmd.Wait()
129+ if err != nil {
130+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
131+ }
132+ return nil
133+ }), nil
134+}
@@ -0,0 +1,134 @@
1+// Package sshstdio adapts an ssh subprocess' stdin/stdout into vev's framed
2+// Transport interface. It intentionally builds argv slices for os/exec instead
3+// of shell command strings so remote targets and session names are never shell-
4+// interpolated locally.
5+package sshstdio
6+
7+import (
8+ "encoding/binary"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "os/exec"
13+ "sync"
14+
15+ "github.com/bnema/vev/internal/ports"
16+)
17+
18+const (
19+ maxFrameLen = 16 << 20
20+ frameHeaderLen = 4
21+)
22+
23+var (
24+ ErrZeroLengthFrame = errors.New("sshstdio: zero-length frame")
25+ ErrFrameTooLarge = errors.New("sshstdio: frame exceeds maximum length")
26+)
27+
28+type closeFunc func() error
29+
30+// NewTransport wraps separate reader/writer streams as a framed Transport.
31+func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {
32+ if close == nil {
33+ close = func() error { return nil }
34+ }
35+ return &transport{r: r, w: w, close: close}
36+}
37+
38+type transport struct {
39+ r io.Reader
40+ w io.Writer
41+ close closeFunc
42+
43+ mu sync.Mutex
44+ readBuf []byte
45+}
46+
47+func (t *transport) Send(f ports.Frame) error {
48+ n := 1 + len(f.Payload)
49+ if n > maxFrameLen {
50+ return ErrFrameTooLarge
51+ }
52+
53+ buf := make([]byte, frameHeaderLen+n)
54+ binary.BigEndian.PutUint32(buf[:frameHeaderLen], uint32(n))
55+ buf[frameHeaderLen] = byte(f.Type)
56+ copy(buf[frameHeaderLen+1:], f.Payload)
57+
58+ t.mu.Lock()
59+ defer t.mu.Unlock()
60+ _, err := t.w.Write(buf)
61+ return err
62+}
63+
64+func (t *transport) Recv() (ports.Frame, error) {
65+ var hdr [frameHeaderLen]byte
66+ if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
67+ return ports.Frame{}, err
68+ }
69+
70+ n := binary.BigEndian.Uint32(hdr[:])
71+ if n == 0 {
72+ return ports.Frame{}, ErrZeroLengthFrame
73+ }
74+ if n > maxFrameLen {
75+ return ports.Frame{}, ErrFrameTooLarge
76+ }
77+
78+ if cap(t.readBuf) < int(n) {
79+ t.readBuf = make([]byte, n)
80+ } else {
81+ t.readBuf = t.readBuf[:n]
82+ }
83+ if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
84+ return ports.Frame{}, err
85+ }
86+
87+ payload := append([]byte(nil), t.readBuf[1:]...)
88+ return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
89+}
90+
91+func (t *transport) Close() error { return t.close() }
92+
93+// CommandSpec is the exact ssh argv vev will execute locally.
94+type CommandSpec struct {
95+ Path string
96+ Args []string
97+}
98+
99+// BuildCommand constructs the ssh subprocess argv without invoking a shell.
100+func BuildCommand(target, session string) CommandSpec {
101+ args := []string{target, "vev", "_stdio"}
102+ if session != "" {
103+ args = append(args, session)
104+ }
105+ return CommandSpec{Path: "ssh", Args: args}
106+}
107+
108+// Dial starts ssh target vev _stdio [session] and returns a Transport over the
109+// child process' stdio. The subprocess is started with exec.Command argv, never
110+// through a shell.
111+func Dial(target, session string) (ports.Transport, error) {
112+ spec := BuildCommand(target, session)
113+ cmd := exec.Command(spec.Path, spec.Args...)
114+ stdin, err := cmd.StdinPipe()
115+ if err != nil {
116+ return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)
117+ }
118+ stdout, err := cmd.StdoutPipe()
119+ if err != nil {
120+ return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err)
121+ }
122+ if err := cmd.Start(); err != nil {
123+ return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
124+ }
125+
126+ return NewTransport(stdout, stdin, func() error {
127+ _ = stdin.Close()
128+ err := cmd.Wait()
129+ if err != nil {
130+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
131+ }
132+ return nil
133+ }), nil
134+}
A internal/adapters/sshstdio/transport_test.go +99 -0
@@ -0,0 +1,99 @@
1+package sshstdio
2+
3+import (
4+ "errors"
5+ "io"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestBuildCommandUsesExecArgs(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ target string
16+ session string
17+ want []string
18+ }{
19+ {name: "no session", target: "user@example.com", want: []string{"user@example.com", "vev", "_stdio"}},
20+ {name: "session kept as single arg", target: "user@example.com", session: "work; rm -rf /", want: []string{"user@example.com", "vev", "_stdio", "work; rm -rf /"}},
21+ {name: "target kept as single arg", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"user@host; touch /tmp/pwn", "vev", "_stdio", "work"}},
22+ }
23+
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ got := BuildCommand(tt.target, tt.session)
27+ if got.Path != "ssh" {
28+ t.Fatalf("Path = %q, want ssh", got.Path)
29+ }
30+ if len(got.Args) != len(tt.want) {
31+ t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args)
32+ }
33+ for i := range tt.want {
34+ if got.Args[i] != tt.want[i] {
35+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args)
36+ }
37+ }
38+ })
39+ }
40+}
41+
42+func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
43+ clientRead, serverWrite := io.Pipe()
44+ serverRead, clientWrite := io.Pipe()
45+ client := NewTransport(clientRead, clientWrite, func() error {
46+ _ = clientRead.Close()
47+ return clientWrite.Close()
48+ })
49+ server := NewTransport(serverRead, serverWrite, func() error {
50+ _ = serverRead.Close()
51+ return serverWrite.Close()
52+ })
53+ defer func() { _ = client.Close() }()
54+ defer func() { _ = server.Close() }()
55+
56+ hello := ports.Hello{Version: ports.ProtocolVersion + 1, Intent: ports.IntentAttach, Name: "work"}
57+ go func() {
58+ f, err := server.Recv()
59+ if err != nil {
60+ t.Errorf("server Recv: %v", err)
61+ return
62+ }
63+ got, err := ports.UnmarshalHello(f.Payload)
64+ if err != nil {
65+ t.Errorf("UnmarshalHello: %v", err)
66+ return
67+ }
68+ if got.Version == ports.ProtocolVersion {
69+ t.Errorf("test did not send a mismatched version")
70+ }
71+ _ = server.Send(ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})})
72+ }()
73+
74+ if err := client.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
75+ t.Fatalf("client Send: %v", err)
76+ }
77+ reply, err := client.Recv()
78+ if err != nil {
79+ t.Fatalf("client Recv: %v", err)
80+ }
81+ if reply.Type != ports.MsgError {
82+ t.Fatalf("reply type = %d, want MsgError", reply.Type)
83+ }
84+ em, err := ports.UnmarshalErrorMsg(reply.Payload)
85+ if err != nil {
86+ t.Fatalf("UnmarshalErrorMsg: %v", err)
87+ }
88+ if em.Code != ports.ErrVersionMismatch {
89+ t.Fatalf("error code = %d, want ErrVersionMismatch", em.Code)
90+ }
91+}
92+
93+func TestTransportRejectsZeroLengthFrame(t *testing.T) {
94+ tr := NewTransport(strings.NewReader("\x00\x00\x00\x00"), io.Discard, nil)
95+ _, err := tr.Recv()
96+ if !errors.Is(err, ErrZeroLengthFrame) {
97+ t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err)
98+ }
99+}
@@ -0,0 +1,99 @@
1+package sshstdio
2+
3+import (
4+ "errors"
5+ "io"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestBuildCommandUsesExecArgs(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ target string
16+ session string
17+ want []string
18+ }{
19+ {name: "no session", target: "user@example.com", want: []string{"user@example.com", "vev", "_stdio"}},
20+ {name: "session kept as single arg", target: "user@example.com", session: "work; rm -rf /", want: []string{"user@example.com", "vev", "_stdio", "work; rm -rf /"}},
21+ {name: "target kept as single arg", target: "user@host; touch /tmp/pwn", session: "work", want: []string{"user@host; touch /tmp/pwn", "vev", "_stdio", "work"}},
22+ }
23+
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ got := BuildCommand(tt.target, tt.session)
27+ if got.Path != "ssh" {
28+ t.Fatalf("Path = %q, want ssh", got.Path)
29+ }
30+ if len(got.Args) != len(tt.want) {
31+ t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args)
32+ }
33+ for i := range tt.want {
34+ if got.Args[i] != tt.want[i] {
35+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args)
36+ }
37+ }
38+ })
39+ }
40+}
41+
42+func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
43+ clientRead, serverWrite := io.Pipe()
44+ serverRead, clientWrite := io.Pipe()
45+ client := NewTransport(clientRead, clientWrite, func() error {
46+ _ = clientRead.Close()
47+ return clientWrite.Close()
48+ })
49+ server := NewTransport(serverRead, serverWrite, func() error {
50+ _ = serverRead.Close()
51+ return serverWrite.Close()
52+ })
53+ defer func() { _ = client.Close() }()
54+ defer func() { _ = server.Close() }()
55+
56+ hello := ports.Hello{Version: ports.ProtocolVersion + 1, Intent: ports.IntentAttach, Name: "work"}
57+ go func() {
58+ f, err := server.Recv()
59+ if err != nil {
60+ t.Errorf("server Recv: %v", err)
61+ return
62+ }
63+ got, err := ports.UnmarshalHello(f.Payload)
64+ if err != nil {
65+ t.Errorf("UnmarshalHello: %v", err)
66+ return
67+ }
68+ if got.Version == ports.ProtocolVersion {
69+ t.Errorf("test did not send a mismatched version")
70+ }
71+ _ = server.Send(ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"})})
72+ }()
73+
74+ if err := client.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
75+ t.Fatalf("client Send: %v", err)
76+ }
77+ reply, err := client.Recv()
78+ if err != nil {
79+ t.Fatalf("client Recv: %v", err)
80+ }
81+ if reply.Type != ports.MsgError {
82+ t.Fatalf("reply type = %d, want MsgError", reply.Type)
83+ }
84+ em, err := ports.UnmarshalErrorMsg(reply.Payload)
85+ if err != nil {
86+ t.Fatalf("UnmarshalErrorMsg: %v", err)
87+ }
88+ if em.Code != ports.ErrVersionMismatch {
89+ t.Fatalf("error code = %d, want ErrVersionMismatch", em.Code)
90+ }
91+}
92+
93+func TestTransportRejectsZeroLengthFrame(t *testing.T) {
94+ tr := NewTransport(strings.NewReader("\x00\x00\x00\x00"), io.Discard, nil)
95+ _, err := tr.Recv()
96+ if !errors.Is(err, ErrZeroLengthFrame) {
97+ t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err)
98+ }
99+}
A internal/app/remote.go +22 -0
@@ -0,0 +1,22 @@
1+package app
2+
3+import "strings"
4+
5+// parseRemoteAttachTarget distinguishes remote attach syntax from ordinary
6+// local session names. Only values with a user@host prefix are remote; local
7+// names may still contain ':' when they do not contain '@'.
8+func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
9+ at := strings.LastIndexByte(s, '@')
10+ if at <= 0 || at == len(s)-1 {
11+ return "", "", false
12+ }
13+ afterAt := s[at+1:]
14+ colon := strings.LastIndexByte(afterAt, ':')
15+ if colon < 0 {
16+ return s, "", true
17+ }
18+ if colon == 0 {
19+ return "", "", false
20+ }
21+ return s[:at+1+colon], afterAt[colon+1:], true
22+}
@@ -0,0 +1,22 @@
1+package app
2+
3+import "strings"
4+
5+// parseRemoteAttachTarget distinguishes remote attach syntax from ordinary
6+// local session names. Only values with a user@host prefix are remote; local
7+// names may still contain ':' when they do not contain '@'.
8+func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
9+ at := strings.LastIndexByte(s, '@')
10+ if at <= 0 || at == len(s)-1 {
11+ return "", "", false
12+ }
13+ afterAt := s[at+1:]
14+ colon := strings.LastIndexByte(afterAt, ':')
15+ if colon < 0 {
16+ return s, "", true
17+ }
18+ if colon == 0 {
19+ return "", "", false
20+ }
21+ return s[:at+1+colon], afterAt[colon+1:], true
22+}
A internal/app/remote_test.go +37 -0
@@ -0,0 +1,37 @@
1+package app
2+
3+import "testing"
4+
5+func TestParseRemoteAttachTarget(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ input string
9+ wantTarget string
10+ wantSession string
11+ wantOK bool
12+ }{
13+ {name: "local plain", input: "work"},
14+ {name: "local with colon", input: "work:logs"},
15+ {name: "missing user", input: "@host"},
16+ {name: "missing host", input: "user@"},
17+ {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},
18+ {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},
19+ {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},
20+ {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ gotTarget, gotSession, gotOK := parseRemoteAttachTarget(tt.input)
26+ if gotOK != tt.wantOK {
27+ t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK)
28+ }
29+ if gotTarget != tt.wantTarget {
30+ t.Fatalf("target = %q, want %q", gotTarget, tt.wantTarget)
31+ }
32+ if gotSession != tt.wantSession {
33+ t.Fatalf("session = %q, want %q", gotSession, tt.wantSession)
34+ }
35+ })
36+ }
37+}
@@ -0,0 +1,37 @@
1+package app
2+
3+import "testing"
4+
5+func TestParseRemoteAttachTarget(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ input string
9+ wantTarget string
10+ wantSession string
11+ wantOK bool
12+ }{
13+ {name: "local plain", input: "work"},
14+ {name: "local with colon", input: "work:logs"},
15+ {name: "missing user", input: "@host"},
16+ {name: "missing host", input: "user@"},
17+ {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},
18+ {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},
19+ {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},
20+ {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},
21+ }
22+
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ gotTarget, gotSession, gotOK := parseRemoteAttachTarget(tt.input)
26+ if gotOK != tt.wantOK {
27+ t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK)
28+ }
29+ if gotTarget != tt.wantTarget {
30+ t.Fatalf("target = %q, want %q", gotTarget, tt.wantTarget)
31+ }
32+ if gotSession != tt.wantSession {
33+ t.Fatalf("session = %q, want %q", gotSession, tt.wantSession)
34+ }
35+ })
36+ }
37+}
M internal/app/run.go +76 -7
@@ -20,6 +20,7 @@ import (
2020 "github.com/bnema/vev/internal/adapters/clock"
2121 "github.com/bnema/vev/internal/adapters/ipc"
2222 "github.com/bnema/vev/internal/adapters/pty"
23+ "github.com/bnema/vev/internal/adapters/sshstdio"
2324 "github.com/bnema/vev/internal/adapters/term"
2425 "github.com/bnema/vev/internal/ports"
2526 "github.com/bnema/vev/internal/usecase/client"
@@ -34,6 +35,7 @@ const (
3435 kindList
3536 kindKill
3637 kindDaemon
38+ kindStdio
3739 kindHelp
3840 kindVersion
3941 )
@@ -41,9 +43,10 @@ const (
4143 // command is the parsed CLI invocation: what to do, plus the attach intent
4244 // and session name where relevant.
4345 type command struct {
44- kind cmdKind
45- intent uint8
46- name string
46+ kind cmdKind
47+ intent uint8
48+ name string
49+ remoteTarget string
4750 }
4851
4952 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -62,6 +65,8 @@ usage:
6265 vev attach to (or create) an ephemeral session
6366 vev new <name> create and attach to a named session
6467 vev attach <name> attach to an existing named session (alias: a)
68+ vev attach user@host[:session]
69+ attach through SSH to a remote vev daemon
6570 vev ls list sessions
6671 vev kill <name> kill a named session
6772 vev --help show this help
@@ -92,6 +97,15 @@ func parseArgs(args []string) (command, error) {
9297 switch args[0] {
9398 case "--daemon":
9499 return command{kind: kindDaemon}, nil
100+ case "_stdio":
101+ if len(args) > 2 {
102+ return command{}, usagef("`_stdio` accepts at most one session name")
103+ }
104+ cmd := command{kind: kindStdio}
105+ if len(args) == 2 {
106+ cmd.name = args[1]
107+ }
108+ return cmd, nil
95109 case "new":
96110 if len(args) < 2 || args[1] == "" {
97111 return command{}, usagef("`new` requires a session name")
@@ -101,7 +115,12 @@ func parseArgs(args []string) (command, error) {
101115 if len(args) < 2 || args[1] == "" {
102116 return command{}, usagef("`attach` requires a session name")
103117 }
104- return command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}, nil
118+ cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}
119+ if target, session, ok := parseRemoteAttachTarget(args[1]); ok {
120+ cmd.remoteTarget = target
121+ cmd.name = session
122+ }
123+ return cmd, nil
105124 case "ls", "list":
106125 return command{kind: kindList}, nil
107126 case "kill":
@@ -129,12 +148,14 @@ func dispatch(ctx context.Context, cmd command) error {
129148 return nil
130149 case kindDaemon:
131150 return runDaemon()
151+ case kindStdio:
152+ return runStdio(ctx)
132153 case kindList:
133154 return runList(ctx)
134155 case kindKill:
135156 return runKill(ctx, cmd.name)
136157 case kindAttach:
137- return runAttach(ctx, cmd.intent, cmd.name)
158+ return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
138159 default:
139160 return usagef("unhandled command")
140161 }
@@ -182,14 +203,19 @@ func runDaemon() error {
182203 // runAttach dials (auto-spawning the daemon if needed) and runs the client
183204 // attach loop. Logging goes to the shared file: the client must never write
184205 // to the console while the terminal is raw.
185-func runAttach(ctx context.Context, intent uint8, name string) error {
206+func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
186207 logFile, err := setupLogging()
187208 if err != nil {
188209 return err
189210 }
190211 defer func() { _ = logFile.Close() }()
191212
192- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
213+ var transport ports.Transport
214+ if remoteTarget != "" {
215+ transport, err = sshstdio.Dial(remoteTarget, name)
216+ } else {
217+ transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
218+ }
193219 if err != nil {
194220 return err
195221 }
@@ -197,6 +223,49 @@ func runAttach(ctx context.Context, intent uint8, name string) error {
197223 return client.Attach(ctx, transport, term.New(), intent, name)
198224 }
199225
226+// runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
227+// connects to the per-user daemon (auto-spawning it if needed) and proxies the
228+// framed protocol between process stdio and the daemon socket.
229+func runStdio(ctx context.Context) error {
230+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
231+ if err != nil {
232+ return err
233+ }
234+ defer func() { _ = transport.Close() }()
235+
236+ stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
237+ return proxyTransports(ctx, stdio, transport)
238+}
239+
240+func proxyTransports(ctx context.Context, a, b ports.Transport) error {
241+ errCh := make(chan error, 2)
242+ copyFrames := func(dst, src ports.Transport) {
243+ for {
244+ f, err := src.Recv()
245+ if err != nil {
246+ errCh <- err
247+ return
248+ }
249+ if err := dst.Send(f); err != nil {
250+ errCh <- err
251+ return
252+ }
253+ }
254+ }
255+ go copyFrames(b, a)
256+ go copyFrames(a, b)
257+
258+ select {
259+ case <-ctx.Done():
260+ return ctx.Err()
261+ case err := <-errCh:
262+ if errors.Is(err, io.EOF) {
263+ return nil
264+ }
265+ return err
266+ }
267+}
268+
200269 // runList prints the daemon's session listing. With no daemon running there
201270 // are, by definition, no sessions.
202271 func runList(_ context.Context) error {
@@ -20,6 +20,7 @@ import (
20 "github.com/bnema/vev/internal/adapters/clock"20 "github.com/bnema/vev/internal/adapters/clock"
21 "github.com/bnema/vev/internal/adapters/ipc"21 "github.com/bnema/vev/internal/adapters/ipc"
22 "github.com/bnema/vev/internal/adapters/pty"22 "github.com/bnema/vev/internal/adapters/pty"
23+ "github.com/bnema/vev/internal/adapters/sshstdio"
23 "github.com/bnema/vev/internal/adapters/term"24 "github.com/bnema/vev/internal/adapters/term"
24 "github.com/bnema/vev/internal/ports"25 "github.com/bnema/vev/internal/ports"
25 "github.com/bnema/vev/internal/usecase/client"26 "github.com/bnema/vev/internal/usecase/client"
@@ -34,6 +35,7 @@ const (
34 kindList35 kindList
35 kindKill36 kindKill
36 kindDaemon37 kindDaemon
38+ kindStdio
37 kindHelp39 kindHelp
38 kindVersion40 kindVersion
39 )41 )
@@ -41,9 +43,10 @@ const (
41 // command is the parsed CLI invocation: what to do, plus the attach intent43 // command is the parsed CLI invocation: what to do, plus the attach intent
42 // and session name where relevant.44 // and session name where relevant.
43 type command struct {45 type command struct {
44- kind cmdKind46+ kind cmdKind
45- intent uint847+ intent uint8
46- name string48+ name string
49+ remoteTarget string
47 }50 }
48 51
49 // usageError is a user-facing argument error; the app prints it (with usage)52 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -62,6 +65,8 @@ usage:
62 vev attach to (or create) an ephemeral session65 vev attach to (or create) an ephemeral session
63 vev new <name> create and attach to a named session66 vev new <name> create and attach to a named session
64 vev attach <name> attach to an existing named session (alias: a)67 vev attach <name> attach to an existing named session (alias: a)
68+ vev attach user@host[:session]
69+ attach through SSH to a remote vev daemon
65 vev ls list sessions70 vev ls list sessions
66 vev kill <name> kill a named session71 vev kill <name> kill a named session
67 vev --help show this help72 vev --help show this help
@@ -92,6 +97,15 @@ func parseArgs(args []string) (command, error) {
92 switch args[0] {97 switch args[0] {
93 case "--daemon":98 case "--daemon":
94 return command{kind: kindDaemon}, nil99 return command{kind: kindDaemon}, nil
100+ case "_stdio":
101+ if len(args) > 2 {
102+ return command{}, usagef("`_stdio` accepts at most one session name")
103+ }
104+ cmd := command{kind: kindStdio}
105+ if len(args) == 2 {
106+ cmd.name = args[1]
107+ }
108+ return cmd, nil
95 case "new":109 case "new":
96 if len(args) < 2 || args[1] == "" {110 if len(args) < 2 || args[1] == "" {
97 return command{}, usagef("`new` requires a session name")111 return command{}, usagef("`new` requires a session name")
@@ -101,7 +115,12 @@ func parseArgs(args []string) (command, error) {
101 if len(args) < 2 || args[1] == "" {115 if len(args) < 2 || args[1] == "" {
102 return command{}, usagef("`attach` requires a session name")116 return command{}, usagef("`attach` requires a session name")
103 }117 }
104- return command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}, nil118+ cmd := command{kind: kindAttach, intent: ports.IntentAttach, name: args[1]}
119+ if target, session, ok := parseRemoteAttachTarget(args[1]); ok {
120+ cmd.remoteTarget = target
121+ cmd.name = session
122+ }
123+ return cmd, nil
105 case "ls", "list":124 case "ls", "list":
106 return command{kind: kindList}, nil125 return command{kind: kindList}, nil
107 case "kill":126 case "kill":
@@ -129,12 +148,14 @@ func dispatch(ctx context.Context, cmd command) error {
129 return nil148 return nil
130 case kindDaemon:149 case kindDaemon:
131 return runDaemon()150 return runDaemon()
151+ case kindStdio:
152+ return runStdio(ctx)
132 case kindList:153 case kindList:
133 return runList(ctx)154 return runList(ctx)
134 case kindKill:155 case kindKill:
135 return runKill(ctx, cmd.name)156 return runKill(ctx, cmd.name)
136 case kindAttach:157 case kindAttach:
137- return runAttach(ctx, cmd.intent, cmd.name)158+ return runAttach(ctx, cmd.intent, cmd.name, cmd.remoteTarget)
138 default:159 default:
139 return usagef("unhandled command")160 return usagef("unhandled command")
140 }161 }
@@ -182,14 +203,19 @@ func runDaemon() error {
182 // runAttach dials (auto-spawning the daemon if needed) and runs the client203 // runAttach dials (auto-spawning the daemon if needed) and runs the client
183 // attach loop. Logging goes to the shared file: the client must never write204 // attach loop. Logging goes to the shared file: the client must never write
184 // to the console while the terminal is raw.205 // to the console while the terminal is raw.
185-func runAttach(ctx context.Context, intent uint8, name string) error {206+func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error {
186 logFile, err := setupLogging()207 logFile, err := setupLogging()
187 if err != nil {208 if err != nil {
188 return err209 return err
189 }210 }
190 defer func() { _ = logFile.Close() }()211 defer func() { _ = logFile.Close() }()
191 212
192- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)213+ var transport ports.Transport
214+ if remoteTarget != "" {
215+ transport, err = sshstdio.Dial(remoteTarget, name)
216+ } else {
217+ transport, err = ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
218+ }
193 if err != nil {219 if err != nil {
194 return err220 return err
195 }221 }
@@ -197,6 +223,49 @@ func runAttach(ctx context.Context, intent uint8, name string) error {
197 return client.Attach(ctx, transport, term.New(), intent, name)223 return client.Attach(ctx, transport, term.New(), intent, name)
198 }224 }
199 225
226+// runStdio is the hidden remote-side mode used by `ssh host vev _stdio`: it
227+// connects to the per-user daemon (auto-spawning it if needed) and proxies the
228+// framed protocol between process stdio and the daemon socket.
229+func runStdio(ctx context.Context) error {
230+ transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
231+ if err != nil {
232+ return err
233+ }
234+ defer func() { _ = transport.Close() }()
235+
236+ stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil)
237+ return proxyTransports(ctx, stdio, transport)
238+}
239+
240+func proxyTransports(ctx context.Context, a, b ports.Transport) error {
241+ errCh := make(chan error, 2)
242+ copyFrames := func(dst, src ports.Transport) {
243+ for {
244+ f, err := src.Recv()
245+ if err != nil {
246+ errCh <- err
247+ return
248+ }
249+ if err := dst.Send(f); err != nil {
250+ errCh <- err
251+ return
252+ }
253+ }
254+ }
255+ go copyFrames(b, a)
256+ go copyFrames(a, b)
257+
258+ select {
259+ case <-ctx.Done():
260+ return ctx.Err()
261+ case err := <-errCh:
262+ if errors.Is(err, io.EOF) {
263+ return nil
264+ }
265+ return err
266+ }
267+}
268+
200 // runList prints the daemon's session listing. With no daemon running there269 // runList prints the daemon's session listing. With no daemon running there
201 // are, by definition, no sessions.270 // are, by definition, no sessions.
202 func runList(_ context.Context) error {271 func runList(_ context.Context) error {
M internal/app/run_test.go +9 -0
@@ -14,6 +14,7 @@ func TestParseArgs(t *testing.T) {
1414 wantKind cmdKind
1515 wantIntent uint8
1616 wantName string
17+ wantRemote string
1718 wantErr bool
1819 }{
1920 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
@@ -23,12 +24,17 @@ func TestParseArgs(t *testing.T) {
2324 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
2425 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
2526 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
27+ {name: "attach remote without session", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantRemote: "user@example.com"},
28+ {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
2629 {name: "attach without name", args: []string{"attach"}, wantErr: true},
2730 {name: "ls", args: []string{"ls"}, wantKind: kindList},
2831 {name: "list", args: []string{"list"}, wantKind: kindList},
2932 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
3033 {name: "kill without name", args: []string{"kill"}, wantErr: true},
3134 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
35+ {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
36+ {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
37+ {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
3238 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
3339 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
3440 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -60,6 +66,9 @@ func TestParseArgs(t *testing.T) {
6066 if got.name != tt.wantName {
6167 t.Errorf("name = %q, want %q", got.name, tt.wantName)
6268 }
69+ if got.remoteTarget != tt.wantRemote {
70+ t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
71+ }
6372 })
6473 }
6574 }
@@ -14,6 +14,7 @@ func TestParseArgs(t *testing.T) {
14 wantKind cmdKind14 wantKind cmdKind
15 wantIntent uint815 wantIntent uint8
16 wantName string16 wantName string
17+ wantRemote string
17 wantErr bool18 wantErr bool
18 }{19 }{
19 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},20 {name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
@@ -23,12 +24,17 @@ func TestParseArgs(t *testing.T) {
23 {name: "new empty name", args: []string{"new", ""}, wantErr: true},24 {name: "new empty name", args: []string{"new", ""}, wantErr: true},
24 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},25 {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
25 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},26 {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
27+ {name: "attach remote without session", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantRemote: "user@example.com"},
28+ {name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
26 {name: "attach without name", args: []string{"attach"}, wantErr: true},29 {name: "attach without name", args: []string{"attach"}, wantErr: true},
27 {name: "ls", args: []string{"ls"}, wantKind: kindList},30 {name: "ls", args: []string{"ls"}, wantKind: kindList},
28 {name: "list", args: []string{"list"}, wantKind: kindList},31 {name: "list", args: []string{"list"}, wantKind: kindList},
29 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},32 {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
30 {name: "kill without name", args: []string{"kill"}, wantErr: true},33 {name: "kill without name", args: []string{"kill"}, wantErr: true},
31 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},34 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
35+ {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
36+ {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
37+ {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
32 {name: "help", args: []string{"--help"}, wantKind: kindHelp},38 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
33 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},39 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
34 {name: "version", args: []string{"--version"}, wantKind: kindVersion},40 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -60,6 +66,9 @@ func TestParseArgs(t *testing.T) {
60 if got.name != tt.wantName {66 if got.name != tt.wantName {
61 t.Errorf("name = %q, want %q", got.name, tt.wantName)67 t.Errorf("name = %q, want %q", got.name, tt.wantName)
62 }68 }
69+ if got.remoteTarget != tt.wantRemote {
70+ t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
71+ }
63 })72 })
64 }73 }
65 }74 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close