detached 0db7657

feat(host): select remote transport with host add --transport

commit
0db7657ab2ffe11c9a4e6527fd6f7f5f28c43411
author
brice · · 2026-10-03 14:50 UTC
parent
8a884f0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +347 -104
M AGENTS.md +1 -1
@@ -82,6 +82,6 @@ Typed messages and negotiated version live in `internal/protocol`. Remote discov
8282 - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen.
8383 - Each connection has a 15-second handshake budget from connect through the initial committed publication.
8484 - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio.
85-- Remote attach bootstraps an authenticated direct QUIC connection by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
85+- Remote attach bootstraps an authenticated direct QUIC connection by default; `vev host add --transport ssh <host>` explicitly selects an SSH-only carriage.
8686 - A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative.
8787 - Command requests have a 10-second result deadline and are tracked per connection.
@@ -82,6 +82,6 @@ Typed messages and negotiated version live in `internal/protocol`. Remote discov
82 - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen.82 - A live unit whose teardown aborts before destructive ownership stays registered, and its snapshot-coordinator quarantine is rolled back so the surviving session resumes checkpoint scheduling and its attached clients are unfrozen.
83 - Each connection has a 15-second handshake budget from connect through the initial committed publication.83 - Each connection has a 15-second handshake budget from connect through the initial committed publication.
84 - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio.84 - Local and remote attach use the same typed `Hello`/`Welcome` session protocol. `sessionwire` translates typed traffic to Protobuf envelopes carried by IPC, QUIC, or SSH stdio.
85-- Remote attach bootstraps an authenticated direct QUIC connection by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.85+- Remote attach bootstraps an authenticated direct QUIC connection by default; `vev host add --transport ssh <host>` explicitly selects an SSH-only carriage.
86 - A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative.86 - A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative.
87 - Command requests have a 10-second result deadline and are tracked per connection.87 - Command requests have a 10-second result deadline and are tracked per connection.
M README.md +1 -1
@@ -23,7 +23,7 @@
2323 | **Remote** | `vev attach user@host` | Sessions on a server, rendered there and sent as small diffs over SSH + QUIC. |
2424 | **Hybrid** | `vev host add user@host` | Local and remote sessions in one picker; switch between them without leaving vev. |
2525
26-Remote and hybrid need vev installed on the remote host. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. See [remote sessions](docs/remote-resilience.md).
26+Remote and hybrid need vev installed on the remote host. Use `vev host add --transport ssh user@host` for SSH only. See [remote sessions](docs/remote-resilience.md).
2727
2828 ## Features
2929
@@ -23,7 +23,7 @@
23 | **Remote** | `vev attach user@host` | Sessions on a server, rendered there and sent as small diffs over SSH + QUIC. |23 | **Remote** | `vev attach user@host` | Sessions on a server, rendered there and sent as small diffs over SSH + QUIC. |
24 | **Hybrid** | `vev host add user@host` | Local and remote sessions in one picker; switch between them without leaving vev. |24 | **Hybrid** | `vev host add user@host` | Local and remote sessions in one picker; switch between them without leaving vev. |
25 25
26-Remote and hybrid need vev installed on the remote host. Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH only. See [remote sessions](docs/remote-resilience.md).26+Remote and hybrid need vev installed on the remote host. Use `vev host add --transport ssh user@host` for SSH only. See [remote sessions](docs/remote-resilience.md).
27 27
28 ## Features28 ## Features
29 29
M cmd/vev-perf-harness/launcher.go +11 -11
@@ -194,18 +194,17 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
194194 // a role working directory: daemon-owned subprocess cleanup may otherwise
195195 // treat the evidence directory as its working tree and remove a preallocated
196196 // trace while a later repetition is being merged.
197- cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...)
197+ cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...)
198198 cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir,
199199 "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "XDG_CONFIG_HOME="+filepath.Join(runDir, "config"), "TERM=xterm-256color", "SHELL=/bin/sh", "VEV_PERF_BIN="+bin)
200200 l.mu.Lock()
201201 peer, routed := l.peers[runDir]
202202 l.mu.Unlock()
203203 if routed && (m.Role == "client" || m.Role == "daemon") {
204- mode, err := remoteMode(peer.command.Transport)
205- if err != nil {
204+ if _, err := hostTransport(peer.command.Transport); err != nil {
206205 return nil, err
207206 }
208- cmd.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
207+ cmd.Env = append(withoutEnv(cmd.Env, "PATH"), "PATH="+runDir+":"+os.Getenv("PATH"))
209208 }
210209 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
211210 return nil, err
@@ -231,12 +230,12 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
231230 peer, routed := l.peers[runDir]
232231 l.mu.Unlock()
233232 if routed {
234- mode, modeErr := remoteMode(peer.command.Transport)
235- if modeErr != nil {
236- return nil, modeErr
233+ transportName, transportErr := hostTransport(peer.command.Transport)
234+ if transportErr != nil {
235+ return nil, transportErr
237236 }
238- p.configureCommand = exec.Command(bin, "host", "add", "harness@127.0.0.1")
239- p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
237+ p.configureCommand = exec.Command(bin, "host", "add", "--transport", transportName, "harness@127.0.0.1")
238+ p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH"), "PATH="+runDir+":"+os.Getenv("PATH"))
240239 peerStart := exec.Command(bin, "--daemon")
241240 peerStart.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "PATH"),
242241 "PATH="+runDir+":"+os.Getenv("PATH"),
@@ -455,10 +454,11 @@ esac
455454 return peer, nil
456455 }
457456
458-func remoteMode(t transport) (string, error) {
457+// hostTransport maps a harness transport to the `vev host add --transport` value.
458+func hostTransport(t transport) (string, error) {
459459 switch t.Kind {
460460 case "ssh_stdio":
461- return "stdio", nil
461+ return "ssh", nil
462462 default:
463463 return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)
464464 }
@@ -194,18 +194,17 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
194 // a role working directory: daemon-owned subprocess cleanup may otherwise194 // a role working directory: daemon-owned subprocess cleanup may otherwise
195 // treat the evidence directory as its working tree and remove a preallocated195 // treat the evidence directory as its working tree and remove a preallocated
196 // trace while a later repetition is being merged.196 // trace while a later repetition is being merged.
197- cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...)197+ cmd.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "VEV_PERF_BIN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...)
198 cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir,198 cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir,
199 "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "XDG_CONFIG_HOME="+filepath.Join(runDir, "config"), "TERM=xterm-256color", "SHELL=/bin/sh", "VEV_PERF_BIN="+bin)199 "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "XDG_CONFIG_HOME="+filepath.Join(runDir, "config"), "TERM=xterm-256color", "SHELL=/bin/sh", "VEV_PERF_BIN="+bin)
200 l.mu.Lock()200 l.mu.Lock()
201 peer, routed := l.peers[runDir]201 peer, routed := l.peers[runDir]
202 l.mu.Unlock()202 l.mu.Unlock()
203 if routed && (m.Role == "client" || m.Role == "daemon") {203 if routed && (m.Role == "client" || m.Role == "daemon") {
204- mode, err := remoteMode(peer.command.Transport)204+ if _, err := hostTransport(peer.command.Transport); err != nil {
205- if err != nil {
206 return nil, err205 return nil, err
207 }206 }
208- cmd.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)207+ cmd.Env = append(withoutEnv(cmd.Env, "PATH"), "PATH="+runDir+":"+os.Getenv("PATH"))
209 }208 }
210 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {209 if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
211 return nil, err210 return nil, err
@@ -231,12 +230,12 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
231 peer, routed := l.peers[runDir]230 peer, routed := l.peers[runDir]
232 l.mu.Unlock()231 l.mu.Unlock()
233 if routed {232 if routed {
234- mode, modeErr := remoteMode(peer.command.Transport)233+ transportName, transportErr := hostTransport(peer.command.Transport)
235- if modeErr != nil {234+ if transportErr != nil {
236- return nil, modeErr235+ return nil, transportErr
237 }236 }
238- p.configureCommand = exec.Command(bin, "host", "add", "harness@127.0.0.1")237+ p.configureCommand = exec.Command(bin, "host", "add", "--transport", transportName, "harness@127.0.0.1")
239- p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)238+ p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH"), "PATH="+runDir+":"+os.Getenv("PATH"))
240 peerStart := exec.Command(bin, "--daemon")239 peerStart := exec.Command(bin, "--daemon")
241 peerStart.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "PATH"),240 peerStart.Env = append(withoutEnv(os.Environ(), "VEV", "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "PATH"),
242 "PATH="+runDir+":"+os.Getenv("PATH"),241 "PATH="+runDir+":"+os.Getenv("PATH"),
@@ -455,10 +454,11 @@ esac
455 return peer, nil454 return peer, nil
456 }455 }
457 456
458-func remoteMode(t transport) (string, error) {457+// hostTransport maps a harness transport to the `vev host add --transport` value.
458+func hostTransport(t transport) (string, error) {
459 switch t.Kind {459 switch t.Kind {
460 case "ssh_stdio":460 case "ssh_stdio":
461- return "stdio", nil461+ return "ssh", nil
462 default:462 default:
463 return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)463 return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID)
464 }464 }
M docs/configuration.md +6 -3
@@ -154,11 +154,14 @@ rm -rf .dev/dev
154154 ## Remote hosts
155155
156156 ```sh
157-vev host add user@host # add a host
158-vev host rm user@host # remove it
159-vev host list # show hosts and their status
157+vev host add user@host # add a host (QUIC, the default)
158+vev host add --transport ssh user@host # add a host that uses SSH only
159+vev host rm user@host # remove it
160+vev host list # show hosts and their status
160161 ```
161162
163+- `--transport quic|ssh` selects the carriage. `quic` (default) opens a direct QUIC connection bootstrapped over SSH; `ssh` carries everything over SSH, for networks that block UDP.
164+- A host keeps the transport it was added with. To change it, run `vev host rm` and then `vev host add` again; adding it with a different transport is refused.
162165 - The broker stores hosts in `~/.local/state/vev/broker/state/state.json`.
163166 - Hosts connect over SSH, so your SSH config (aliases, keys) applies.
164167 - Remote sessions appear as `session@host` in `vev ls --all` and in the picker.
@@ -154,11 +154,14 @@ rm -rf .dev/dev
154 ## Remote hosts154 ## Remote hosts
155 155
156 ```sh156 ```sh
157-vev host add user@host # add a host157+vev host add user@host # add a host (QUIC, the default)
158-vev host rm user@host # remove it158+vev host add --transport ssh user@host # add a host that uses SSH only
159-vev host list # show hosts and their status159+vev host rm user@host # remove it
160+vev host list # show hosts and their status
160 ```161 ```
161 162
163+- `--transport quic|ssh` selects the carriage. `quic` (default) opens a direct QUIC connection bootstrapped over SSH; `ssh` carries everything over SSH, for networks that block UDP.
164+- A host keeps the transport it was added with. To change it, run `vev host rm` and then `vev host add` again; adding it with a different transport is refused.
162 - The broker stores hosts in `~/.local/state/vev/broker/state/state.json`.165 - The broker stores hosts in `~/.local/state/vev/broker/state/state.json`.
163 - Hosts connect over SSH, so your SSH config (aliases, keys) applies.166 - Hosts connect over SSH, so your SSH config (aliases, keys) applies.
164 - Remote sessions appear as `session@host` in `vev ls --all` and in the picker.167 - Remote sessions appear as `session@host` in `vev ls --all` and in the picker.
M docs/protocol.md +1 -1
@@ -251,7 +251,7 @@ acceptance. Local carriage requires the preamble + `Hello`/`Welcome`
251251 inside the 15-second budget; a failed handshake closes the exact
252252 connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`,
253253 TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream,
254-no 0-RTT); `VEV_REMOTE_TRANSPORT=stdio` selects SSH stdio instead.
254+no 0-RTT); a host added with `vev host add --transport ssh` uses SSH stdio instead.
255255 For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH
256256 which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener +
257257 certificate, 32-byte token, ≤4 KiB readiness with host-independent
@@ -251,7 +251,7 @@ acceptance. Local carriage requires the preamble + `Hello`/`Welcome`
251 inside the 15-second budget; a failed handshake closes the exact251 inside the 15-second budget; a failed handshake closes the exact
252 connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`,252 connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`,
253 TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream,253 TLS 1.3, ALPN `vev/1`, exact SHA-256 pin, one bidirectional stream,
254-no 0-RTT); `VEV_REMOTE_TRANSPORT=stdio` selects SSH stdio instead.254+no 0-RTT); a host added with `vev host add --transport ssh` uses SSH stdio instead.
255 For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH255 For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH
256 which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener +256 which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener +
257 certificate, 32-byte token, ≤4 KiB readiness with host-independent257 certificate, 32-byte token, ≤4 KiB readiness with host-independent
M docs/remote-resilience.md +4 -2
@@ -14,12 +14,14 @@ vev must be installed on the remote host. The remote daemon owns the session: it
1414
1515 Terminal traffic then goes over QUIC, not SSH. The token and keys stay in memory only.
1616
17-If UDP is blocked, use SSH only:
17+If UDP is blocked, register the host with SSH only:
1818
1919 ```sh
20-VEV_REMOTE_TRANSPORT=stdio vev attach user@host
20+vev host add --transport ssh user@host
2121 ```
2222
23+The transport is fixed when the host is added (`quic` by default). To switch, run `vev host rm user@host`, then add it again with the other `--transport`.
24+
2325 ## What survives a network problem
2426
2527 - The remote session and its shells keep running while you are offline.
@@ -14,12 +14,14 @@ vev must be installed on the remote host. The remote daemon owns the session: it
14 14
15 Terminal traffic then goes over QUIC, not SSH. The token and keys stay in memory only.15 Terminal traffic then goes over QUIC, not SSH. The token and keys stay in memory only.
16 16
17-If UDP is blocked, use SSH only:17+If UDP is blocked, register the host with SSH only:
18 18
19 ```sh19 ```sh
20-VEV_REMOTE_TRANSPORT=stdio vev attach user@host20+vev host add --transport ssh user@host
21 ```21 ```
22 22
23+The transport is fixed when the host is added (`quic` by default). To switch, run `vev host rm user@host`, then add it again with the other `--transport`.
24+
23 ## What survives a network problem25 ## What survives a network problem
24 26
25 - The remote session and its shells keep running while you are offline.27 - The remote session and its shells keep running while you are offline.
M internal/app/broker_client_test.go +2 -5
@@ -530,10 +530,9 @@ func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget str
530530 // daemon owns daemonSessions even when the catalogue has not observed them.
531531 func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun {
532532 t.Helper()
533- // The ordinary terminal path reads the nested-session and remote-transport
534- // environment; a test drives the broker composition with both unset.
533+ // The ordinary terminal path reads the nested-session environment; a test
534+ // drives the broker composition with it unset.
535535 t.Setenv("VEV", "")
536- t.Setenv(envRemoteTransport, "")
537536 service := newTerminalCompositionService(snapshot)
538537 service.daemonSessions = daemonSessions
539538 terminal := newTerminalCompositionTerminal()
@@ -926,7 +925,6 @@ func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T)
926925 // without opening any stream. It remains live until the terminal ends.
927926 func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) {
928927 t.Setenv("VEV", "")
929- t.Setenv(envRemoteTransport, "")
930928 startupErr := errors.New("broker not reachable")
931929 previousConnect := connectProductionClientBroker
932930 terminal := newTerminalCompositionTerminal()
@@ -971,7 +969,6 @@ func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) {
971969 // without reaching the broker.
972970 func TestRunAttachNestedSessionBehavior(t *testing.T) {
973971 t.Setenv("VEV", "outer")
974- t.Setenv(envRemoteTransport, "")
975972 connectCalls := 0
976973 previousConnect := connectProductionClientBroker
977974 previousCreate := createDetachedTerminalSession
@@ -530,10 +530,9 @@ func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget str
530 // daemon owns daemonSessions even when the catalogue has not observed them.530 // daemon owns daemonSessions even when the catalogue has not observed them.
531 func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun {531 func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun {
532 t.Helper()532 t.Helper()
533- // The ordinary terminal path reads the nested-session and remote-transport533+ // The ordinary terminal path reads the nested-session environment; a test
534- // environment; a test drives the broker composition with both unset.534+ // drives the broker composition with it unset.
535 t.Setenv("VEV", "")535 t.Setenv("VEV", "")
536- t.Setenv(envRemoteTransport, "")
537 service := newTerminalCompositionService(snapshot)536 service := newTerminalCompositionService(snapshot)
538 service.daemonSessions = daemonSessions537 service.daemonSessions = daemonSessions
539 terminal := newTerminalCompositionTerminal()538 terminal := newTerminalCompositionTerminal()
@@ -926,7 +925,6 @@ func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T)
926 // without opening any stream. It remains live until the terminal ends.925 // without opening any stream. It remains live until the terminal ends.
927 func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) {926 func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) {
928 t.Setenv("VEV", "")927 t.Setenv("VEV", "")
929- t.Setenv(envRemoteTransport, "")
930 startupErr := errors.New("broker not reachable")928 startupErr := errors.New("broker not reachable")
931 previousConnect := connectProductionClientBroker929 previousConnect := connectProductionClientBroker
932 terminal := newTerminalCompositionTerminal()930 terminal := newTerminalCompositionTerminal()
@@ -971,7 +969,6 @@ func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) {
971 // without reaching the broker.969 // without reaching the broker.
972 func TestRunAttachNestedSessionBehavior(t *testing.T) {970 func TestRunAttachNestedSessionBehavior(t *testing.T) {
973 t.Setenv("VEV", "outer")971 t.Setenv("VEV", "outer")
974- t.Setenv(envRemoteTransport, "")
975 connectCalls := 0972 connectCalls := 0
976 previousConnect := connectProductionClientBroker973 previousConnect := connectProductionClientBroker
977 previousCreate := createDetachedTerminalSession974 previousCreate := createDetachedTerminalSession
M internal/app/broker_composition_test.go +3 -3
@@ -110,7 +110,7 @@ func TestProductionBrokerCompositionLocalAndMutableRemote(t *testing.T) {
110110 return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Identity != ""
111111 }, 10*time.Second, 10*time.Millisecond, "local observation: %+v", service.Snapshot())
112112
113- registration, err := service.AddHost(ctx, "harness@127.0.0.1", remoteBrokerPolicy("stdio"))
113+ registration, err := service.AddHost(ctx, "harness@127.0.0.1", remoteBrokerPolicy(hostTransportSSH))
114114 require.NoError(t, err)
115115 require.Equal(t, "harness@127.0.0.1", registration.Endpoint)
116116 require.Eventually(t, func() bool {
@@ -128,8 +128,8 @@ func TestProductionBrokerRemoteMuxTransports(t *testing.T) {
128128 for _, tc := range []struct {
129129 name, helper string
130130 }{
131- {"stdio", brokerMuxStdioCommand},
132- {"quic", brokerMuxQUICBootstrapCommand},
131+ {hostTransportSSH, brokerMuxStdioCommand},
132+ {hostTransportQUIC, brokerMuxQUICBootstrapCommand},
133133 } {
134134 t.Run(tc.name, func(t *testing.T) {
135135 binDir := shortTempDir(t, "vb")
@@ -110,7 +110,7 @@ func TestProductionBrokerCompositionLocalAndMutableRemote(t *testing.T) {
110 return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Identity != ""110 return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Identity != ""
111 }, 10*time.Second, 10*time.Millisecond, "local observation: %+v", service.Snapshot())111 }, 10*time.Second, 10*time.Millisecond, "local observation: %+v", service.Snapshot())
112 112
113- registration, err := service.AddHost(ctx, "harness@127.0.0.1", remoteBrokerPolicy("stdio"))113+ registration, err := service.AddHost(ctx, "harness@127.0.0.1", remoteBrokerPolicy(hostTransportSSH))
114 require.NoError(t, err)114 require.NoError(t, err)
115 require.Equal(t, "harness@127.0.0.1", registration.Endpoint)115 require.Equal(t, "harness@127.0.0.1", registration.Endpoint)
116 require.Eventually(t, func() bool {116 require.Eventually(t, func() bool {
@@ -128,8 +128,8 @@ func TestProductionBrokerRemoteMuxTransports(t *testing.T) {
128 for _, tc := range []struct {128 for _, tc := range []struct {
129 name, helper string129 name, helper string
130 }{130 }{
131- {"stdio", brokerMuxStdioCommand},131+ {hostTransportSSH, brokerMuxStdioCommand},
132- {"quic", brokerMuxQUICBootstrapCommand},132+ {hostTransportQUIC, brokerMuxQUICBootstrapCommand},
133 } {133 } {
134 t.Run(tc.name, func(t *testing.T) {134 t.Run(tc.name, func(t *testing.T) {
135 binDir := shortTempDir(t, "vb")135 binDir := shortTempDir(t, "vb")
M internal/app/broker_host_upgrade_test.go +2 -2
@@ -22,7 +22,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) {
2222 {
2323 name: "older build policy is upgraded and identity kept",
2424 policy: func() ports.BrokerPolicy {
25- p := remoteBrokerPolicy("")
25+ p := remoteBrokerPolicy(hostTransportQUIC)
2626 p.ProtocolVersion--
2727 return p
2828 },
@@ -30,7 +30,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) {
3030 },
3131 {
3232 name: "current policy is left untouched",
33- policy: func() ports.BrokerPolicy { return remoteBrokerPolicy("") },
33+ policy: func() ports.BrokerPolicy { return remoteBrokerPolicy(hostTransportQUIC) },
3434 wantRevision: 0,
3535 },
3636 }
@@ -22,7 +22,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) {
22 {22 {
23 name: "older build policy is upgraded and identity kept",23 name: "older build policy is upgraded and identity kept",
24 policy: func() ports.BrokerPolicy {24 policy: func() ports.BrokerPolicy {
25- p := remoteBrokerPolicy("")25+ p := remoteBrokerPolicy(hostTransportQUIC)
26 p.ProtocolVersion--26 p.ProtocolVersion--
27 return p27 return p
28 },28 },
@@ -30,7 +30,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) {
30 },30 },
31 {31 {
32 name: "current policy is left untouched",32 name: "current policy is left untouched",
33- policy: func() ports.BrokerPolicy { return remoteBrokerPolicy("") },33+ policy: func() ports.BrokerPolicy { return remoteBrokerPolicy(hostTransportQUIC) },
34 wantRevision: 0,34 wantRevision: 0,
35 },35 },
36 }36 }
M internal/app/broker_offline_local_test.go +1 -1
@@ -618,7 +618,7 @@ func TestProductionRegistryOwnsMutableMembershipWithZeroHosts(t *testing.T) {
618618 startLocalRegistry(t, registry)
619619
620620 require.Empty(t, registry.Snapshot().Daemons[0].Registration, "the local entry carries no configured registration")
621- registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy("quic"))
621+ registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy(hostTransportQUIC))
622622 require.NoError(t, err, "a zero-host production run must admit a first host")
623623 require.Equal(t, "user@example.com", registration.Endpoint)
624624
@@ -618,7 +618,7 @@ func TestProductionRegistryOwnsMutableMembershipWithZeroHosts(t *testing.T) {
618 startLocalRegistry(t, registry)618 startLocalRegistry(t, registry)
619 619
620 require.Empty(t, registry.Snapshot().Daemons[0].Registration, "the local entry carries no configured registration")620 require.Empty(t, registry.Snapshot().Daemons[0].Registration, "the local entry carries no configured registration")
621- registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy("quic"))621+ registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy(hostTransportQUIC))
622 require.NoError(t, err, "a zero-host production run must admit a first host")622 require.NoError(t, err, "a zero-host production run must admit a first host")
623 require.Equal(t, "user@example.com", registration.Endpoint)623 require.Equal(t, "user@example.com", registration.Endpoint)
624 624
M internal/app/broker_production.go +7 -5
@@ -51,15 +51,17 @@ func ensureProductionBrokerConfig() error {
5151 return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr)
5252 }
5353
54+// remoteBrokerPolicy builds the remote host policy for a CLI transport name
55+// (hostTransportQUIC or hostTransportSSH). Callers pass a parsed value, so an
56+// unknown name is a programming error.
5457 func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
55- if transport == "stdio" {
56- transport = "ssh-stdio"
57- } else {
58- transport = "ssh-quic"
58+ route, ok := hostTransportRoutes[transport]
59+ if !ok {
60+ panic(fmt.Sprintf("vev: unknown host transport %q", transport))
5961 }
6062 return ports.BrokerPolicy{
6163 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
62- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport,
64+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: string(route),
6365 Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user",
6466 }
6567 }
@@ -51,15 +51,17 @@ func ensureProductionBrokerConfig() error {
51 return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr)51 return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr)
52 }52 }
53 53
54+// remoteBrokerPolicy builds the remote host policy for a CLI transport name
55+// (hostTransportQUIC or hostTransportSSH). Callers pass a parsed value, so an
56+// unknown name is a programming error.
54 func remoteBrokerPolicy(transport string) ports.BrokerPolicy {57 func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
55- if transport == "stdio" {58+ route, ok := hostTransportRoutes[transport]
56- transport = "ssh-stdio"59+ if !ok {
57- } else {60+ panic(fmt.Sprintf("vev: unknown host transport %q", transport))
58- transport = "ssh-quic"
59 }61 }
60 return ports.BrokerPolicy{62 return ports.BrokerPolicy{
61 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,63 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
62- EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport,64+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: string(route),
63 Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user",65 Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user",
64 }66 }
65 }67 }
M internal/app/broker_routes_test.go +2 -2
@@ -40,7 +40,7 @@ func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) {
4040 registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})
4141 require.NoError(t, err)
4242 routes.Hosts = registry
43- policy := remoteBrokerPolicy("stdio")
43+ policy := remoteBrokerPolicy(hostTransportSSH)
4444 reg, err := registry.AddHost(ctx, "user@example.test", policy)
4545 require.NoError(t, err)
4646 request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly}
@@ -110,7 +110,7 @@ func TestProductionBrokerFirstHostAdd(t *testing.T) {
110110 socket := awaitSandboxReady(t, ready, done)
111111 service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx)
112112 require.NoError(t, err)
113- reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy("stdio"))
113+ reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy(hostTransportSSH))
114114 require.NoError(t, err)
115115 if i == 0 {
116116 registered = reg
@@ -40,7 +40,7 @@ func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) {
40 registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})40 registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})
41 require.NoError(t, err)41 require.NoError(t, err)
42 routes.Hosts = registry42 routes.Hosts = registry
43- policy := remoteBrokerPolicy("stdio")43+ policy := remoteBrokerPolicy(hostTransportSSH)
44 reg, err := registry.AddHost(ctx, "user@example.test", policy)44 reg, err := registry.AddHost(ctx, "user@example.test", policy)
45 require.NoError(t, err)45 require.NoError(t, err)
46 request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly}46 request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly}
@@ -110,7 +110,7 @@ func TestProductionBrokerFirstHostAdd(t *testing.T) {
110 socket := awaitSandboxReady(t, ready, done)110 socket := awaitSandboxReady(t, ready, done)
111 service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx)111 service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx)
112 require.NoError(t, err)112 require.NoError(t, err)
113- reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy("stdio"))113+ reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy(hostTransportSSH))
114 require.NoError(t, err)114 require.NoError(t, err)
115 if i == 0 {115 if i == 0 {
116 registered = reg116 registered = reg
M internal/app/remote_hosts.go +44 -4
@@ -24,6 +24,18 @@ const (
2424 hostActionList = "list"
2525 )
2626
27+// Remote carriages selectable with `host add --transport`.
28+const (
29+ hostTransportQUIC = "quic"
30+ hostTransportSSH = "ssh"
31+)
32+
33+// hostTransportRoutes maps each CLI transport to the broker policy transport.
34+var hostTransportRoutes = map[string]ports.BrokerRouteKind{
35+ hostTransportQUIC: ports.BrokerRouteSSHQUIC,
36+ hostTransportSSH: ports.BrokerRouteSSHStdio,
37+}
38+
2739 type remoteHostDeps struct {
2840 connect func(context.Context) (ports.BrokerService, error)
2941 connectExisting func(context.Context) (ports.BrokerService, error)
@@ -66,11 +78,13 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error
6678 if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil {
6779 return err
6880 }
69- transport, err := remoteTransportModeFromEnv(os.Getenv(envRemoteTransport))
70- if err != nil {
71- return err
81+ policy := remoteBrokerPolicy(cmd.hostTransport)
82+ _, err = service.AddHost(ctx, cmd.hostTarget, policy)
83+ if isHostConflict(err) {
84+ if stored := storedHostTransport(service.Snapshot(), cmd.hostTarget); stored != "" && stored != cmd.hostTransport {
85+ return fmt.Errorf("vev: host %q is already registered with transport %q; run `vev host rm %s` first, then add it again: %w", cmd.hostTarget, stored, cmd.hostTarget, err)
86+ }
7287 }
73- _, err = service.AddHost(ctx, cmd.hostTarget, remoteBrokerPolicy(transport))
7488 return err
7589 case hostActionRm:
7690 if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil {
@@ -101,6 +115,32 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error
101115 }
102116 }
103117
118+// isHostConflict matches a host conflict both in-process and after it crossed
119+// broker IPC, where only the error code survives.
120+func isHostConflict(err error) bool {
121+ if errors.Is(err, ports.ErrBrokerHostConflict) {
122+ return true
123+ }
124+ var brokerErr ports.BrokerError
125+ return errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorHostConflict
126+}
127+
128+// storedHostTransport returns the CLI transport name registered for target,
129+// or "" when it cannot be determined.
130+func storedHostTransport(snapshot ports.BrokerSnapshot, target string) string {
131+ for _, daemon := range snapshot.Daemons {
132+ if daemon.Local || daemon.Endpoint != target {
133+ continue
134+ }
135+ for name, route := range hostTransportRoutes {
136+ if daemon.Policy.Transport == string(route) {
137+ return name
138+ }
139+ }
140+ }
141+ return ""
142+}
143+
104144 // listDisconnectedHosts reads configured endpoints without dialing any remote.
105145 func listDisconnectedHosts(w io.Writer) error {
106146 state := productionBrokerLayout().State
@@ -24,6 +24,18 @@ const (
24 hostActionList = "list"24 hostActionList = "list"
25 )25 )
26 26
27+// Remote carriages selectable with `host add --transport`.
28+const (
29+ hostTransportQUIC = "quic"
30+ hostTransportSSH = "ssh"
31+)
32+
33+// hostTransportRoutes maps each CLI transport to the broker policy transport.
34+var hostTransportRoutes = map[string]ports.BrokerRouteKind{
35+ hostTransportQUIC: ports.BrokerRouteSSHQUIC,
36+ hostTransportSSH: ports.BrokerRouteSSHStdio,
37+}
38+
27 type remoteHostDeps struct {39 type remoteHostDeps struct {
28 connect func(context.Context) (ports.BrokerService, error)40 connect func(context.Context) (ports.BrokerService, error)
29 connectExisting func(context.Context) (ports.BrokerService, error)41 connectExisting func(context.Context) (ports.BrokerService, error)
@@ -66,11 +78,13 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error
66 if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil {78 if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil {
67 return err79 return err
68 }80 }
69- transport, err := remoteTransportModeFromEnv(os.Getenv(envRemoteTransport))81+ policy := remoteBrokerPolicy(cmd.hostTransport)
70- if err != nil {82+ _, err = service.AddHost(ctx, cmd.hostTarget, policy)
71- return err83+ if isHostConflict(err) {
84+ if stored := storedHostTransport(service.Snapshot(), cmd.hostTarget); stored != "" && stored != cmd.hostTransport {
85+ return fmt.Errorf("vev: host %q is already registered with transport %q; run `vev host rm %s` first, then add it again: %w", cmd.hostTarget, stored, cmd.hostTarget, err)
86+ }
72 }87 }
73- _, err = service.AddHost(ctx, cmd.hostTarget, remoteBrokerPolicy(transport))
74 return err88 return err
75 case hostActionRm:89 case hostActionRm:
76 if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil {90 if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil {
@@ -101,6 +115,32 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error
101 }115 }
102 }116 }
103 117
118+// isHostConflict matches a host conflict both in-process and after it crossed
119+// broker IPC, where only the error code survives.
120+func isHostConflict(err error) bool {
121+ if errors.Is(err, ports.ErrBrokerHostConflict) {
122+ return true
123+ }
124+ var brokerErr ports.BrokerError
125+ return errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorHostConflict
126+}
127+
128+// storedHostTransport returns the CLI transport name registered for target,
129+// or "" when it cannot be determined.
130+func storedHostTransport(snapshot ports.BrokerSnapshot, target string) string {
131+ for _, daemon := range snapshot.Daemons {
132+ if daemon.Local || daemon.Endpoint != target {
133+ continue
134+ }
135+ for name, route := range hostTransportRoutes {
136+ if daemon.Policy.Transport == string(route) {
137+ return name
138+ }
139+ }
140+ }
141+ return ""
142+}
143+
104 // listDisconnectedHosts reads configured endpoints without dialing any remote.144 // listDisconnectedHosts reads configured endpoints without dialing any remote.
105 func listDisconnectedHosts(w io.Writer) error {145 func listDisconnectedHosts(w io.Writer) error {
106 state := productionBrokerLayout().State146 state := productionBrokerLayout().State
A internal/app/remote_hosts_test.go +132 -0
@@ -0,0 +1,132 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+)
15+
16+func TestParseHostArgs(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ args []string
20+ wantAction string
21+ wantTarget string
22+ wantTransport string
23+ wantErr string
24+ nonUsageErr bool
25+ }{
26+ {name: "add defaults to quic", args: []string{"add", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"},
27+ {name: "add explicit quic", args: []string{"add", "--transport", "quic", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"},
28+ {name: "add ssh", args: []string{"add", "--transport", "ssh", "user@arch"}, wantAction: "add", wantTarget: "user@arch", wantTransport: "ssh"},
29+ {name: "add ssh equals form", args: []string{"add", "--transport=ssh", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"},
30+ {name: "add flag after target", args: []string{"add", "arch", "--transport", "ssh"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"},
31+ {name: "add invalid value", args: []string{"add", "--transport", "stdio", "arch"}, wantErr: `want "quic" or "ssh"`},
32+ {name: "add invalid equals value", args: []string{"add", "--transport=udp", "arch"}, wantErr: `want "quic" or "ssh"`},
33+ {name: "add empty equals value", args: []string{"add", "--transport=", "arch"}, wantErr: `want "quic" or "ssh"`},
34+ {name: "add missing value", args: []string{"add", "arch", "--transport"}, wantErr: "requires a value"},
35+ {name: "add repeated flag", args: []string{"add", "--transport", "ssh", "--transport", "quic", "arch"}, wantErr: "once"},
36+ {name: "add unknown flag", args: []string{"add", "--bogus", "arch"}, wantErr: "unknown flag"},
37+ {name: "add missing target", args: []string{"add"}, wantErr: "requires a host target"},
38+ {name: "add flag without target", args: []string{"add", "--transport", "ssh"}, wantErr: "requires a host target"},
39+ {name: "add extra target", args: []string{"add", "--transport", "ssh", "arch", "extra"}, wantErr: "exactly one host target"},
40+ {name: "add invalid target", args: []string{"add", "--transport", "ssh", " bad"}, wantErr: "host", nonUsageErr: true},
41+ {name: "rm", args: []string{"rm", "arch"}, wantAction: "rm", wantTarget: "arch"},
42+ {name: "rm rejects transport", args: []string{"rm", "--transport", "ssh", "arch"}, wantErr: "unknown flag"},
43+ {name: "rm rejects transport after target", args: []string{"rm", "arch", "--transport=ssh"}, wantErr: "exactly one host target"},
44+ {name: "rm missing target", args: []string{"rm"}, wantErr: "requires a host target"},
45+ {name: "list", args: []string{"list"}, wantAction: "list"},
46+ {name: "list rejects transport", args: []string{"list", "--transport", "ssh"}, wantErr: "unknown flag"},
47+ {name: "list extra", args: []string{"list", "arch"}, wantErr: "accepts no arguments"},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ got, err := parseHostArgs(tt.args)
52+ if tt.wantErr != "" {
53+ require.Error(t, err)
54+ require.Contains(t, err.Error(), tt.wantErr)
55+ _, usage := errors.AsType[*usageError](err)
56+ require.Equal(t, !tt.nonUsageErr, usage, "error type %T", err)
57+ return
58+ }
59+ require.NoError(t, err)
60+ require.Equal(t, kindHost, got.kind)
61+ require.Equal(t, tt.wantAction, got.hostAction)
62+ require.Equal(t, tt.wantTarget, got.hostTarget)
63+ require.Equal(t, tt.wantTransport, got.hostTransport)
64+ })
65+ }
66+}
67+
68+func TestHostAddPolicyFollowsTransport(t *testing.T) {
69+ tests := []struct {
70+ name string
71+ args []string
72+ wantPolicy ports.BrokerPolicy
73+ }{
74+ {name: "default", args: []string{"add", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)},
75+ {name: "quic", args: []string{"add", "--transport", "quic", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)},
76+ {name: "ssh", args: []string{"add", "--transport", "ssh", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportSSH)},
77+ }
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ cmd, err := parseHostArgs(tt.args)
81+ require.NoError(t, err)
82+ service := portsmocks.NewMockBrokerService(t)
83+ service.EXPECT().AddHost(mock.Anything, "arch", tt.wantPolicy).Return(domain.RemoteRegistration{}, nil).Once()
84+ service.EXPECT().Close().Return(nil).Once()
85+ deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }}
86+ require.NoError(t, runHostCommand(context.Background(), cmd, deps))
87+ })
88+ }
89+}
90+
91+func TestHostAddReportsTransportConflict(t *testing.T) {
92+ // ipcConflict is the shape a conflict has after crossing broker IPC: only
93+ // the code survives, not the sentinel.
94+ ipcConflict := ports.BrokerError{Code: ports.BrokerErrorHostConflict, Text: "host registration conflict"}
95+ storedSSH := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
96+ {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportSSH)},
97+ }}
98+ storedLegacy := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
99+ {Endpoint: "arch", Policy: ports.BrokerPolicy{Transport: "stdio"}},
100+ }}
101+ tests := []struct {
102+ name string
103+ addErr error
104+ snapshot ports.BrokerSnapshot
105+ wantHint bool
106+ }{
107+ {name: "in-process sentinel with other transport", addErr: ports.ErrBrokerHostConflict, snapshot: storedSSH, wantHint: true},
108+ {name: "IPC code with other transport", addErr: ipcConflict, snapshot: storedSSH, wantHint: true},
109+ {name: "same transport conflicts elsewhere", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
110+ {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportQUIC)},
111+ }}},
112+ {name: "host not in snapshot", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{}},
113+ {name: "unrecognised stored transport", addErr: ipcConflict, snapshot: storedLegacy},
114+ }
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ service := portsmocks.NewMockBrokerService(t)
118+ service.EXPECT().AddHost(mock.Anything, "arch", remoteBrokerPolicy(hostTransportQUIC)).Return(domain.RemoteRegistration{}, tt.addErr).Once()
119+ service.EXPECT().Snapshot().Return(tt.snapshot).Once()
120+ service.EXPECT().Close().Return(nil).Once()
121+ deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }}
122+ err := runHostCommand(context.Background(), command{kind: kindHost, hostAction: hostActionAdd, hostTarget: "arch", hostTransport: hostTransportQUIC}, deps)
123+ require.ErrorIs(t, err, tt.addErr)
124+ if !tt.wantHint {
125+ require.NotContains(t, err.Error(), "vev host rm")
126+ return
127+ }
128+ require.Contains(t, err.Error(), `transport "ssh"`)
129+ require.Contains(t, err.Error(), "vev host rm arch")
130+ })
131+ }
132+}
@@ -0,0 +1,132 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14+)
15+
16+func TestParseHostArgs(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ args []string
20+ wantAction string
21+ wantTarget string
22+ wantTransport string
23+ wantErr string
24+ nonUsageErr bool
25+ }{
26+ {name: "add defaults to quic", args: []string{"add", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"},
27+ {name: "add explicit quic", args: []string{"add", "--transport", "quic", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "quic"},
28+ {name: "add ssh", args: []string{"add", "--transport", "ssh", "user@arch"}, wantAction: "add", wantTarget: "user@arch", wantTransport: "ssh"},
29+ {name: "add ssh equals form", args: []string{"add", "--transport=ssh", "arch"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"},
30+ {name: "add flag after target", args: []string{"add", "arch", "--transport", "ssh"}, wantAction: "add", wantTarget: "arch", wantTransport: "ssh"},
31+ {name: "add invalid value", args: []string{"add", "--transport", "stdio", "arch"}, wantErr: `want "quic" or "ssh"`},
32+ {name: "add invalid equals value", args: []string{"add", "--transport=udp", "arch"}, wantErr: `want "quic" or "ssh"`},
33+ {name: "add empty equals value", args: []string{"add", "--transport=", "arch"}, wantErr: `want "quic" or "ssh"`},
34+ {name: "add missing value", args: []string{"add", "arch", "--transport"}, wantErr: "requires a value"},
35+ {name: "add repeated flag", args: []string{"add", "--transport", "ssh", "--transport", "quic", "arch"}, wantErr: "once"},
36+ {name: "add unknown flag", args: []string{"add", "--bogus", "arch"}, wantErr: "unknown flag"},
37+ {name: "add missing target", args: []string{"add"}, wantErr: "requires a host target"},
38+ {name: "add flag without target", args: []string{"add", "--transport", "ssh"}, wantErr: "requires a host target"},
39+ {name: "add extra target", args: []string{"add", "--transport", "ssh", "arch", "extra"}, wantErr: "exactly one host target"},
40+ {name: "add invalid target", args: []string{"add", "--transport", "ssh", " bad"}, wantErr: "host", nonUsageErr: true},
41+ {name: "rm", args: []string{"rm", "arch"}, wantAction: "rm", wantTarget: "arch"},
42+ {name: "rm rejects transport", args: []string{"rm", "--transport", "ssh", "arch"}, wantErr: "unknown flag"},
43+ {name: "rm rejects transport after target", args: []string{"rm", "arch", "--transport=ssh"}, wantErr: "exactly one host target"},
44+ {name: "rm missing target", args: []string{"rm"}, wantErr: "requires a host target"},
45+ {name: "list", args: []string{"list"}, wantAction: "list"},
46+ {name: "list rejects transport", args: []string{"list", "--transport", "ssh"}, wantErr: "unknown flag"},
47+ {name: "list extra", args: []string{"list", "arch"}, wantErr: "accepts no arguments"},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ got, err := parseHostArgs(tt.args)
52+ if tt.wantErr != "" {
53+ require.Error(t, err)
54+ require.Contains(t, err.Error(), tt.wantErr)
55+ _, usage := errors.AsType[*usageError](err)
56+ require.Equal(t, !tt.nonUsageErr, usage, "error type %T", err)
57+ return
58+ }
59+ require.NoError(t, err)
60+ require.Equal(t, kindHost, got.kind)
61+ require.Equal(t, tt.wantAction, got.hostAction)
62+ require.Equal(t, tt.wantTarget, got.hostTarget)
63+ require.Equal(t, tt.wantTransport, got.hostTransport)
64+ })
65+ }
66+}
67+
68+func TestHostAddPolicyFollowsTransport(t *testing.T) {
69+ tests := []struct {
70+ name string
71+ args []string
72+ wantPolicy ports.BrokerPolicy
73+ }{
74+ {name: "default", args: []string{"add", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)},
75+ {name: "quic", args: []string{"add", "--transport", "quic", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportQUIC)},
76+ {name: "ssh", args: []string{"add", "--transport", "ssh", "arch"}, wantPolicy: remoteBrokerPolicy(hostTransportSSH)},
77+ }
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ cmd, err := parseHostArgs(tt.args)
81+ require.NoError(t, err)
82+ service := portsmocks.NewMockBrokerService(t)
83+ service.EXPECT().AddHost(mock.Anything, "arch", tt.wantPolicy).Return(domain.RemoteRegistration{}, nil).Once()
84+ service.EXPECT().Close().Return(nil).Once()
85+ deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }}
86+ require.NoError(t, runHostCommand(context.Background(), cmd, deps))
87+ })
88+ }
89+}
90+
91+func TestHostAddReportsTransportConflict(t *testing.T) {
92+ // ipcConflict is the shape a conflict has after crossing broker IPC: only
93+ // the code survives, not the sentinel.
94+ ipcConflict := ports.BrokerError{Code: ports.BrokerErrorHostConflict, Text: "host registration conflict"}
95+ storedSSH := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
96+ {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportSSH)},
97+ }}
98+ storedLegacy := ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
99+ {Endpoint: "arch", Policy: ports.BrokerPolicy{Transport: "stdio"}},
100+ }}
101+ tests := []struct {
102+ name string
103+ addErr error
104+ snapshot ports.BrokerSnapshot
105+ wantHint bool
106+ }{
107+ {name: "in-process sentinel with other transport", addErr: ports.ErrBrokerHostConflict, snapshot: storedSSH, wantHint: true},
108+ {name: "IPC code with other transport", addErr: ipcConflict, snapshot: storedSSH, wantHint: true},
109+ {name: "same transport conflicts elsewhere", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{Daemons: []ports.BrokerDaemonObservation{
110+ {Endpoint: "arch", Policy: remoteBrokerPolicy(hostTransportQUIC)},
111+ }}},
112+ {name: "host not in snapshot", addErr: ipcConflict, snapshot: ports.BrokerSnapshot{}},
113+ {name: "unrecognised stored transport", addErr: ipcConflict, snapshot: storedLegacy},
114+ }
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ service := portsmocks.NewMockBrokerService(t)
118+ service.EXPECT().AddHost(mock.Anything, "arch", remoteBrokerPolicy(hostTransportQUIC)).Return(domain.RemoteRegistration{}, tt.addErr).Once()
119+ service.EXPECT().Snapshot().Return(tt.snapshot).Once()
120+ service.EXPECT().Close().Return(nil).Once()
121+ deps := remoteHostDeps{connect: func(context.Context) (ports.BrokerService, error) { return service, nil }}
122+ err := runHostCommand(context.Background(), command{kind: kindHost, hostAction: hostActionAdd, hostTarget: "arch", hostTransport: hostTransportQUIC}, deps)
123+ require.ErrorIs(t, err, tt.addErr)
124+ if !tt.wantHint {
125+ require.NotContains(t, err.Error(), "vev host rm")
126+ return
127+ }
128+ require.Contains(t, err.Error(), `transport "ssh"`)
129+ require.Contains(t, err.Error(), "vev host rm arch")
130+ })
131+ }
132+}
M internal/app/run.go +74 -31
@@ -88,18 +88,20 @@ type command struct {
8888 listAll bool
8989 hostAction string
9090 hostTarget string
91- killAll bool
92- killSessions bool
93- cmd cmdInvocation
94- env envInvocation
95- brokerServe brokerServeOptions
96- brokerMux brokerMuxOptions
97- brokerReady brokerReadyOptions
98- uiDriver uiDriverOptions
99- uiObserve bool
100- uiControl bool
101- uiSocket string
102- web webOptions
91+ // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH.
92+ hostTransport string
93+ killAll bool
94+ killSessions bool
95+ cmd cmdInvocation
96+ env envInvocation
97+ brokerServe brokerServeOptions
98+ brokerMux brokerMuxOptions
99+ brokerReady brokerReadyOptions
100+ uiDriver uiDriverOptions
101+ uiObserve bool
102+ uiControl bool
103+ uiSocket string
104+ web webOptions
103105 }
104106
105107 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -123,7 +125,8 @@ usage:
123125 vev ls list local sessions
124126 vev ls <host> list sessions on a known remote host
125127 vev ls --all list local and remote sessions
126- vev host add <host> add a pinned remote host
128+ vev host add [--transport quic|ssh] <host>
129+ add a pinned remote host (default transport: quic)
127130 vev host rm <host> remove a pinned remote host
128131 vev host list list known remote hosts
129132 vev kill <name> kill a session
@@ -439,19 +442,27 @@ func parseHostArgs(args []string) (command, error) {
439442 return command{}, usagef("`host` requires add, rm, or list")
440443 }
441444 switch args[0] {
442- case hostActionAdd, hostActionRm:
445+ case hostActionAdd:
446+ return parseHostAddArgs(args[1:])
447+ case hostActionRm:
443448 if len(args) < 2 || args[1] == "" {
444- return command{}, usagef("`host %s` requires a host target", args[0])
449+ return command{}, usagef("`host rm` requires a host target")
450+ }
451+ if strings.HasPrefix(args[1], "-") {
452+ return command{}, usagef("unknown flag %q for `host rm`", args[1])
445453 }
446454 if len(args) > 2 {
447- return command{}, usagef("`host %s` accepts exactly one host target", args[0])
455+ return command{}, usagef("`host rm` accepts exactly one host target")
448456 }
449457 if err := domain.ValidateRemoteHostTarget(args[1]); err != nil {
450458 return command{}, err
451459 }
452- return command{kind: kindHost, hostAction: args[0], hostTarget: args[1]}, nil
460+ return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil
453461 case hostActionList:
454462 if len(args) > 1 {
463+ if strings.HasPrefix(args[1], "-") {
464+ return command{}, usagef("unknown flag %q for `host list`", args[1])
465+ }
455466 return command{}, usagef("`host list` accepts no arguments")
456467 }
457468 return command{kind: kindHost, hostAction: hostActionList}, nil
@@ -460,6 +471,50 @@ func parseHostArgs(args []string) (command, error) {
460471 }
461472 }
462473
474+// parseHostAddArgs parses `host add [--transport quic|ssh] <host>`.
475+func parseHostAddArgs(args []string) (command, error) {
476+ transport, target := "", ""
477+ for i := 0; i < len(args); i++ {
478+ arg := args[i]
479+ switch {
480+ case arg == "--transport" || strings.HasPrefix(arg, "--transport="):
481+ if transport != "" {
482+ return command{}, usagef("`host add` accepts --transport once")
483+ }
484+ value, hasValue := strings.CutPrefix(arg, "--transport=")
485+ if !hasValue {
486+ if i+1 >= len(args) {
487+ return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH)
488+ }
489+ i++
490+ value = args[i]
491+ }
492+ switch value {
493+ case hostTransportQUIC, hostTransportSSH:
494+ transport = value
495+ default:
496+ return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH)
497+ }
498+ case strings.HasPrefix(arg, "-"):
499+ return command{}, usagef("unknown flag %q for `host add`", arg)
500+ case target != "":
501+ return command{}, usagef("`host add` accepts exactly one host target")
502+ default:
503+ target = arg
504+ }
505+ }
506+ if target == "" {
507+ return command{}, usagef("`host add` requires a host target")
508+ }
509+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
510+ return command{}, err
511+ }
512+ if transport == "" {
513+ transport = hostTransportQUIC
514+ }
515+ return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil
516+}
517+
463518 // performanceTrace creates one serialized timestamp owner for this process.
464519 // An empty trace environment leaves all production behavior and wire bytes
465520 // unchanged.
@@ -802,8 +857,8 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
802857 }
803858 binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{
804859 {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal},
805- {Policy: remoteBrokerPolicy("quic"), Origin: ports.SessionOriginRemote},
806- {Policy: remoteBrokerPolicy("stdio"), Origin: ports.SessionOriginRemote},
860+ {Policy: remoteBrokerPolicy(hostTransportQUIC), Origin: ports.SessionOriginRemote},
861+ {Policy: remoteBrokerPolicy(hostTransportSSH), Origin: ports.SessionOriginRemote},
807862 })
808863 if err != nil {
809864 return fmt.Errorf("vev: establish daemon mux authority: %w", err)
@@ -913,7 +968,6 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
913968 }
914969
915970 const (
916- envRemoteTransport = "VEV_REMOTE_TRANSPORT"
917971 uiRemoteCleanupCommand = "_ui-cleanup"
918972 // daemonStopCommand is the hidden, environment-scoped graceful stop of
919973 // this environment's daemon, for harnesses and scripts. Users run
@@ -921,17 +975,6 @@ const (
921975 daemonStopCommand = "_daemon-stop"
922976 )
923977
924-func remoteTransportModeFromEnv(value string) (string, error) {
925- switch value {
926- case "", "quic":
927- return "quic", nil
928- case "stdio":
929- return "stdio", nil
930- default:
931- return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, "quic", "stdio")
932- }
933-}
934-
935978 const daemonStopTimeout = 2 * time.Second
936979
937980 var (
@@ -88,18 +88,20 @@ type command struct {
88 listAll bool88 listAll bool
89 hostAction string89 hostAction string
90 hostTarget string90 hostTarget string
91- killAll bool91+ // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH.
92- killSessions bool92+ hostTransport string
93- cmd cmdInvocation93+ killAll bool
94- env envInvocation94+ killSessions bool
95- brokerServe brokerServeOptions95+ cmd cmdInvocation
96- brokerMux brokerMuxOptions96+ env envInvocation
97- brokerReady brokerReadyOptions97+ brokerServe brokerServeOptions
98- uiDriver uiDriverOptions98+ brokerMux brokerMuxOptions
99- uiObserve bool99+ brokerReady brokerReadyOptions
100- uiControl bool100+ uiDriver uiDriverOptions
101- uiSocket string101+ uiObserve bool
102- web webOptions102+ uiControl bool
103+ uiSocket string
104+ web webOptions
103 }105 }
104 106
105 // usageError is a user-facing argument error; the app prints it (with usage)107 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -123,7 +125,8 @@ usage:
123 vev ls list local sessions125 vev ls list local sessions
124 vev ls <host> list sessions on a known remote host126 vev ls <host> list sessions on a known remote host
125 vev ls --all list local and remote sessions127 vev ls --all list local and remote sessions
126- vev host add <host> add a pinned remote host128+ vev host add [--transport quic|ssh] <host>
129+ add a pinned remote host (default transport: quic)
127 vev host rm <host> remove a pinned remote host130 vev host rm <host> remove a pinned remote host
128 vev host list list known remote hosts131 vev host list list known remote hosts
129 vev kill <name> kill a session132 vev kill <name> kill a session
@@ -439,19 +442,27 @@ func parseHostArgs(args []string) (command, error) {
439 return command{}, usagef("`host` requires add, rm, or list")442 return command{}, usagef("`host` requires add, rm, or list")
440 }443 }
441 switch args[0] {444 switch args[0] {
442- case hostActionAdd, hostActionRm:445+ case hostActionAdd:
446+ return parseHostAddArgs(args[1:])
447+ case hostActionRm:
443 if len(args) < 2 || args[1] == "" {448 if len(args) < 2 || args[1] == "" {
444- return command{}, usagef("`host %s` requires a host target", args[0])449+ return command{}, usagef("`host rm` requires a host target")
450+ }
451+ if strings.HasPrefix(args[1], "-") {
452+ return command{}, usagef("unknown flag %q for `host rm`", args[1])
445 }453 }
446 if len(args) > 2 {454 if len(args) > 2 {
447- return command{}, usagef("`host %s` accepts exactly one host target", args[0])455+ return command{}, usagef("`host rm` accepts exactly one host target")
448 }456 }
449 if err := domain.ValidateRemoteHostTarget(args[1]); err != nil {457 if err := domain.ValidateRemoteHostTarget(args[1]); err != nil {
450 return command{}, err458 return command{}, err
451 }459 }
452- return command{kind: kindHost, hostAction: args[0], hostTarget: args[1]}, nil460+ return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil
453 case hostActionList:461 case hostActionList:
454 if len(args) > 1 {462 if len(args) > 1 {
463+ if strings.HasPrefix(args[1], "-") {
464+ return command{}, usagef("unknown flag %q for `host list`", args[1])
465+ }
455 return command{}, usagef("`host list` accepts no arguments")466 return command{}, usagef("`host list` accepts no arguments")
456 }467 }
457 return command{kind: kindHost, hostAction: hostActionList}, nil468 return command{kind: kindHost, hostAction: hostActionList}, nil
@@ -460,6 +471,50 @@ func parseHostArgs(args []string) (command, error) {
460 }471 }
461 }472 }
462 473
474+// parseHostAddArgs parses `host add [--transport quic|ssh] <host>`.
475+func parseHostAddArgs(args []string) (command, error) {
476+ transport, target := "", ""
477+ for i := 0; i < len(args); i++ {
478+ arg := args[i]
479+ switch {
480+ case arg == "--transport" || strings.HasPrefix(arg, "--transport="):
481+ if transport != "" {
482+ return command{}, usagef("`host add` accepts --transport once")
483+ }
484+ value, hasValue := strings.CutPrefix(arg, "--transport=")
485+ if !hasValue {
486+ if i+1 >= len(args) {
487+ return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH)
488+ }
489+ i++
490+ value = args[i]
491+ }
492+ switch value {
493+ case hostTransportQUIC, hostTransportSSH:
494+ transport = value
495+ default:
496+ return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH)
497+ }
498+ case strings.HasPrefix(arg, "-"):
499+ return command{}, usagef("unknown flag %q for `host add`", arg)
500+ case target != "":
501+ return command{}, usagef("`host add` accepts exactly one host target")
502+ default:
503+ target = arg
504+ }
505+ }
506+ if target == "" {
507+ return command{}, usagef("`host add` requires a host target")
508+ }
509+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
510+ return command{}, err
511+ }
512+ if transport == "" {
513+ transport = hostTransportQUIC
514+ }
515+ return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil
516+}
517+
463 // performanceTrace creates one serialized timestamp owner for this process.518 // performanceTrace creates one serialized timestamp owner for this process.
464 // An empty trace environment leaves all production behavior and wire bytes519 // An empty trace environment leaves all production behavior and wire bytes
465 // unchanged.520 // unchanged.
@@ -802,8 +857,8 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
802 }857 }
803 binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{858 binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{
804 {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal},859 {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal},
805- {Policy: remoteBrokerPolicy("quic"), Origin: ports.SessionOriginRemote},860+ {Policy: remoteBrokerPolicy(hostTransportQUIC), Origin: ports.SessionOriginRemote},
806- {Policy: remoteBrokerPolicy("stdio"), Origin: ports.SessionOriginRemote},861+ {Policy: remoteBrokerPolicy(hostTransportSSH), Origin: ports.SessionOriginRemote},
807 })862 })
808 if err != nil {863 if err != nil {
809 return fmt.Errorf("vev: establish daemon mux authority: %w", err)864 return fmt.Errorf("vev: establish daemon mux authority: %w", err)
@@ -913,7 +968,6 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
913 }968 }
914 969
915 const (970 const (
916- envRemoteTransport = "VEV_REMOTE_TRANSPORT"
917 uiRemoteCleanupCommand = "_ui-cleanup"971 uiRemoteCleanupCommand = "_ui-cleanup"
918 // daemonStopCommand is the hidden, environment-scoped graceful stop of972 // daemonStopCommand is the hidden, environment-scoped graceful stop of
919 // this environment's daemon, for harnesses and scripts. Users run973 // this environment's daemon, for harnesses and scripts. Users run
@@ -921,17 +975,6 @@ const (
921 daemonStopCommand = "_daemon-stop"975 daemonStopCommand = "_daemon-stop"
922 )976 )
923 977
924-func remoteTransportModeFromEnv(value string) (string, error) {
925- switch value {
926- case "", "quic":
927- return "quic", nil
928- case "stdio":
929- return "stdio", nil
930- default:
931- return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, "quic", "stdio")
932- }
933-}
934-
935 const daemonStopTimeout = 2 * time.Second978 const daemonStopTimeout = 2 * time.Second
936 979
937 var (980 var (
M internal/app/web_daemon_broker_test.go +2 -3
@@ -314,10 +314,9 @@ func startWebGatewayFixture(t *testing.T) *webGatewayFixture {
314314 // connector composition, so a test can script the broker without a process.
315315 func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture {
316316 t.Helper()
317- // The gateway composition reads the nested-session and transport environment;
318- // a test drives the broker client with both unset.
317+ // The gateway composition reads the nested-session environment; a test
318+ // drives the broker client with it unset.
319319 t.Setenv("VEV", "")
320- t.Setenv(envRemoteTransport, "")
321320 settings, err := webterm.ParseSettings("", webterm.Origin)
322321 require.NoError(t, err)
323322 fixture := &webGatewayFixture{
@@ -314,10 +314,9 @@ func startWebGatewayFixture(t *testing.T) *webGatewayFixture {
314 // connector composition, so a test can script the broker without a process.314 // connector composition, so a test can script the broker without a process.
315 func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture {315 func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture {
316 t.Helper()316 t.Helper()
317- // The gateway composition reads the nested-session and transport environment;317+ // The gateway composition reads the nested-session environment; a test
318- // a test drives the broker client with both unset.318+ // drives the broker client with it unset.
319 t.Setenv("VEV", "")319 t.Setenv("VEV", "")
320- t.Setenv(envRemoteTransport, "")
321 settings, err := webterm.ParseSettings("", webterm.Origin)320 settings, err := webterm.ParseSettings("", webterm.Origin)
322 require.NoError(t, err)321 require.NoError(t, err)
323 fixture := &webGatewayFixture{322 fixture := &webGatewayFixture{
M scripts/debug-remote-attach.sh +10 -7
@@ -3,7 +3,7 @@ set -euo pipefail
33
44 usage() {
55 cat <<'USAGE' >&2
6-Usage: scripts/debug-remote-attach.sh [--mode stdio|quic|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host
6+Usage: scripts/debug-remote-attach.sh [--mode ssh|quic|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host
77
88 Runs a real remote attach smoke/debug attempt under a PTY, captures local and
99 remote vev versions, and stores local/remote log tails around the attempt.
@@ -20,6 +20,8 @@ Notes:
2020 vev/ssh error is captured; inspect the bundle for confirmation.
2121 - The remote host must be reachable by SSH for log collection.
2222 - The attach attempt unsets VEV so the smoke test can run from inside vev.
23+ - --mode ssh|quic re-registers the host (vev host rm, then vev host add
24+ --transport MODE) before attaching; default keeps the existing registration.
2325 USAGE
2426 }
2527
@@ -53,8 +55,8 @@ if [[ $# -ne 1 ]]; then
5355 exit 2
5456 fi
5557 case "$mode" in
56- default|stdio|quic) ;;
57- *) echo "invalid --mode $mode (want default, stdio, or quic)" >&2; exit 2 ;;
58+ default|ssh|quic) ;;
59+ *) echo "invalid --mode $mode (want default, ssh, or quic)" >&2; exit 2 ;;
5860 esac
5961
6062 target="$1"
@@ -153,10 +155,11 @@ attach_log_raw="$out_dir/.attach.typescript.raw"
153155 attach_err_raw="$out_dir/.attach.err.raw"
154156 attach_stdout_raw="$out_dir/.attach.stdout.raw"
155157 cmd=("$vev_bin" attach "$attach_target")
156-if [[ "$mode" == "default" ]]; then
157- env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT)
158-else
159- env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")
158+env_cmd=(env -u VEV)
159+if [[ "$mode" != "default" ]]; then
160+ # A host keeps the transport it was added with: switching is rm then add.
161+ run_capture host-rm "${env_cmd[@]}" "$vev_bin" host rm "$target"
162+ run_capture host-add "${env_cmd[@]}" "$vev_bin" host add --transport "$mode" "$target"
160163 fi
161164
162165 set +e
@@ -3,7 +3,7 @@ set -euo pipefail
3 3
4 usage() {4 usage() {
5 cat <<'USAGE' >&25 cat <<'USAGE' >&2
6-Usage: scripts/debug-remote-attach.sh [--mode stdio|quic|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host6+Usage: scripts/debug-remote-attach.sh [--mode ssh|quic|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host
7 7
8 Runs a real remote attach smoke/debug attempt under a PTY, captures local and8 Runs a real remote attach smoke/debug attempt under a PTY, captures local and
9 remote vev versions, and stores local/remote log tails around the attempt.9 remote vev versions, and stores local/remote log tails around the attempt.
@@ -20,6 +20,8 @@ Notes:
20 vev/ssh error is captured; inspect the bundle for confirmation.20 vev/ssh error is captured; inspect the bundle for confirmation.
21 - The remote host must be reachable by SSH for log collection.21 - The remote host must be reachable by SSH for log collection.
22 - The attach attempt unsets VEV so the smoke test can run from inside vev.22 - The attach attempt unsets VEV so the smoke test can run from inside vev.
23+ - --mode ssh|quic re-registers the host (vev host rm, then vev host add
24+ --transport MODE) before attaching; default keeps the existing registration.
23 USAGE25 USAGE
24 }26 }
25 27
@@ -53,8 +55,8 @@ if [[ $# -ne 1 ]]; then
53 exit 255 exit 2
54 fi56 fi
55 case "$mode" in57 case "$mode" in
56- default|stdio|quic) ;;58+ default|ssh|quic) ;;
57- *) echo "invalid --mode $mode (want default, stdio, or quic)" >&2; exit 2 ;;59+ *) echo "invalid --mode $mode (want default, ssh, or quic)" >&2; exit 2 ;;
58 esac60 esac
59 61
60 target="$1"62 target="$1"
@@ -153,10 +155,11 @@ attach_log_raw="$out_dir/.attach.typescript.raw"
153 attach_err_raw="$out_dir/.attach.err.raw"155 attach_err_raw="$out_dir/.attach.err.raw"
154 attach_stdout_raw="$out_dir/.attach.stdout.raw"156 attach_stdout_raw="$out_dir/.attach.stdout.raw"
155 cmd=("$vev_bin" attach "$attach_target")157 cmd=("$vev_bin" attach "$attach_target")
156-if [[ "$mode" == "default" ]]; then158+env_cmd=(env -u VEV)
157- env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT)159+if [[ "$mode" != "default" ]]; then
158-else160+ # A host keeps the transport it was added with: switching is rm then add.
159- env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")161+ run_capture host-rm "${env_cmd[@]}" "$vev_bin" host rm "$target"
162+ run_capture host-add "${env_cmd[@]}" "$vev_bin" host add --transport "$mode" "$target"
160 fi163 fi
161 164
162 set +e165 set +e
M scripts/demo/README.md +1 -1
@@ -29,7 +29,7 @@ shell --exec [vev args]`. It sets:
2929 option, so `.dev/test/bin/ssh` wraps the real client with
3030 `-F .dev/test/ssh/config`. The key, `known_hosts` and config are generated in
3131 `.dev/test/ssh/`. `~/.ssh` and your agent are never used.
32-- `VEV_REMOTE_TRANSPORT=stdio`: hosts are registered with the SSH-only carriage.
32+- `vev host add --transport ssh`: hosts are registered with the SSH-only carriage.
3333 QUIC cannot work here. The remote proxy listens on a random UDP port, and
3434 the client dials that port on the SSH target name (`remote-a`), which does
3535 not resolve to the loopback port mapping.
@@ -29,7 +29,7 @@ shell --exec [vev args]`. It sets:
29 option, so `.dev/test/bin/ssh` wraps the real client with29 option, so `.dev/test/bin/ssh` wraps the real client with
30 `-F .dev/test/ssh/config`. The key, `known_hosts` and config are generated in30 `-F .dev/test/ssh/config`. The key, `known_hosts` and config are generated in
31 `.dev/test/ssh/`. `~/.ssh` and your agent are never used.31 `.dev/test/ssh/`. `~/.ssh` and your agent are never used.
32-- `VEV_REMOTE_TRANSPORT=stdio`: hosts are registered with the SSH-only carriage.32+- `vev host add --transport ssh`: hosts are registered with the SSH-only carriage.
33 QUIC cannot work here. The remote proxy listens on a random UDP port, and33 QUIC cannot work here. The remote proxy listens on a random UDP port, and
34 the client dials that port on the SSH target name (`remote-a`), which does34 the client dials that port on the SSH target name (`remote-a`), which does
35 not resolve to the loopback port mapping.35 not resolve to the loopback port mapping.
M scripts/demo/compose.test.yaml +1 -1
@@ -3,7 +3,7 @@
33 #
44 # sshd is published on loopback only. QUIC is not published: the remote QUIC
55 # proxy listens on a random UDP port per bootstrap and the client dials the
6-# SSH target name, so hosts are registered with VEV_REMOTE_TRANSPORT=stdio.
6+# SSH target name, so hosts are registered with `vev host add --transport ssh`.
77 # The project name is derived from VEV_ENV/VEV_TEST_ENV so distinct sandbox
88 # environments (see scripts/demo/test-remotes.sh) never share one compose
99 # project's containers or network.
@@ -3,7 +3,7 @@
3 #3 #
4 # sshd is published on loopback only. QUIC is not published: the remote QUIC4 # sshd is published on loopback only. QUIC is not published: the remote QUIC
5 # proxy listens on a random UDP port per bootstrap and the client dials the5 # proxy listens on a random UDP port per bootstrap and the client dials the
6-# SSH target name, so hosts are registered with VEV_REMOTE_TRANSPORT=stdio.6+# SSH target name, so hosts are registered with `vev host add --transport ssh`.
7 # The project name is derived from VEV_ENV/VEV_TEST_ENV so distinct sandbox7 # The project name is derived from VEV_ENV/VEV_TEST_ENV so distinct sandbox
8 # environments (see scripts/demo/test-remotes.sh) never share one compose8 # environments (see scripts/demo/test-remotes.sh) never share one compose
9 # project's containers or network.9 # project's containers or network.
M scripts/demo/test-remotes.sh +4 -4
@@ -52,7 +52,7 @@ port_for() {
5252 # command launched from inside a real vev pane does not look nested.
5353 vev_env() {
5454 env -u VEV -u VEV_ENV_ROOT \
55- VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" VEV_REMOTE_TRANSPORT=stdio \
55+ VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" \
5656 PATH="$bin_dir:$PATH" "$@"
5757 }
5858
@@ -152,7 +152,7 @@ register_hosts() {
152152 log "host already registered: $host"
153153 continue
154154 fi
155- vev_env timeout 30 "$vev_bin" host add "$host" || die "vev host add $host failed"
155+ vev_env timeout 30 "$vev_bin" host add --transport ssh "$host" || die "vev host add --transport ssh $host failed"
156156 log "host registered: $host"
157157 done
158158 }
@@ -277,7 +277,7 @@ cmd_shell_hint() {
277277 cat <<EOF
278278 Sandbox ready. Launch the client from any terminal with:
279279
280- cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root VEV_REMOTE_TRANSPORT=stdio PATH=$bin_dir:\$PATH ./build/vev
280+ cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root PATH=$bin_dir:\$PATH ./build/vev
281281
282282 or: $0 shell --exec
283283 Plain ssh: $bin_dir/ssh remote-a
@@ -288,7 +288,7 @@ cmd_shell() {
288288 [[ -x $bin_dir/ssh ]] || die "sandbox is not up; run: $0 up"
289289 if [[ ${1:-} == --exec ]]; then
290290 shift
291- exec env -u VEV -u VEV_ENV_ROOT VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" VEV_REMOTE_TRANSPORT=stdio \
291+ exec env -u VEV -u VEV_ENV_ROOT VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" \
292292 PATH="$bin_dir:$PATH" "$vev_bin" "$@"
293293 fi
294294 cmd_shell_hint
@@ -52,7 +52,7 @@ port_for() {
52 # command launched from inside a real vev pane does not look nested.52 # command launched from inside a real vev pane does not look nested.
53 vev_env() {53 vev_env() {
54 env -u VEV -u VEV_ENV_ROOT \54 env -u VEV -u VEV_ENV_ROOT \
55- VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" VEV_REMOTE_TRANSPORT=stdio \55+ VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" \
56 PATH="$bin_dir:$PATH" "$@"56 PATH="$bin_dir:$PATH" "$@"
57 }57 }
58 58
@@ -152,7 +152,7 @@ register_hosts() {
152 log "host already registered: $host"152 log "host already registered: $host"
153 continue153 continue
154 fi154 fi
155- vev_env timeout 30 "$vev_bin" host add "$host" || die "vev host add $host failed"155+ vev_env timeout 30 "$vev_bin" host add --transport ssh "$host" || die "vev host add --transport ssh $host failed"
156 log "host registered: $host"156 log "host registered: $host"
157 done157 done
158 }158 }
@@ -277,7 +277,7 @@ cmd_shell_hint() {
277 cat <<EOF277 cat <<EOF
278 Sandbox ready. Launch the client from any terminal with:278 Sandbox ready. Launch the client from any terminal with:
279 279
280- cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root VEV_REMOTE_TRANSPORT=stdio PATH=$bin_dir:\$PATH ./build/vev280+ cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root PATH=$bin_dir:\$PATH ./build/vev
281 281
282 or: $0 shell --exec282 or: $0 shell --exec
283 Plain ssh: $bin_dir/ssh remote-a283 Plain ssh: $bin_dir/ssh remote-a
@@ -288,7 +288,7 @@ cmd_shell() {
288 [[ -x $bin_dir/ssh ]] || die "sandbox is not up; run: $0 up"288 [[ -x $bin_dir/ssh ]] || die "sandbox is not up; run: $0 up"
289 if [[ ${1:-} == --exec ]]; then289 if [[ ${1:-} == --exec ]]; then
290 shift290 shift
291- exec env -u VEV -u VEV_ENV_ROOT VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" VEV_REMOTE_TRANSPORT=stdio \291+ exec env -u VEV -u VEV_ENV_ROOT VEV_ENV="$env_name" VEV_ENV_ROOT="$env_root" \
292 PATH="$bin_dir:$PATH" "$vev_bin" "$@"292 PATH="$bin_dir:$PATH" "$vev_bin" "$@"
293 fi293 fi
294 cmd_shell_hint294 cmd_shell_hint
M scripts/remote-picker-harness/README.md +6 -5
@@ -16,17 +16,18 @@ lifecycle at return, and a successfully committed navigation action.
1616 `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs
1717 the table-driven baseline matrix. Every scenario asserts committed outcomes
1818 (action IDs, exact lifecycle/session/focus context, observed published
19-output); sending a key is never success. Transport selection is forwarded
20-into the client container (`docker exec -e VEV_REMOTE_TRANSPORT=stdio`, or
21-unset for QUIC); passing it to the `docker` CLI process alone never reached
22-the driver. Direct-remote scenarios use `--remote remote --session NAME`
19+output); sending a key is never success. Before each scenario the
20+client container's `remote` host is re-registered with
21+`vev host add --transport quic|ssh remote` (`@stdio` selects `ssh`; a host
22+keeps the transport it was added with, so the script runs `host rm` first).
23+Direct-remote scenarios use `--remote remote --session NAME`
2324 against a named fixture session, so the attached target is a named exact
2425 lifecycle rather than an ephemeral session, and never create a local home
2526 session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon
2627 serves the client-picker scenario: `local` keeps the attachment and the
2728 picker rows in the client container, `direct` serves both from the remote
2829 container. The client needs a pinned `remote` SSH alias and
29-`vev host add remote`. Scripts create uniquely named sessions and detach
30+`vev host add remote` (the script then re-registers it per scenario). Scripts create uniquely named sessions and detach
3031 their clients; the container owner handles session cleanup.
3132
3233 Client and daemon debug logging is on (`VEV_LOG=debug`); the remote accepts
@@ -16,17 +16,18 @@ lifecycle at return, and a successfully committed navigation action.
16 `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs16 `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs
17 the table-driven baseline matrix. Every scenario asserts committed outcomes17 the table-driven baseline matrix. Every scenario asserts committed outcomes
18 (action IDs, exact lifecycle/session/focus context, observed published18 (action IDs, exact lifecycle/session/focus context, observed published
19-output); sending a key is never success. Transport selection is forwarded19+output); sending a key is never success. Before each scenario the
20-into the client container (`docker exec -e VEV_REMOTE_TRANSPORT=stdio`, or20+client container's `remote` host is re-registered with
21-unset for QUIC); passing it to the `docker` CLI process alone never reached21+`vev host add --transport quic|ssh remote` (`@stdio` selects `ssh`; a host
22-the driver. Direct-remote scenarios use `--remote remote --session NAME`22+keeps the transport it was added with, so the script runs `host rm` first).
23+Direct-remote scenarios use `--remote remote --session NAME`
23 against a named fixture session, so the attached target is a named exact24 against a named fixture session, so the attached target is a named exact
24 lifecycle rather than an ephemeral session, and never create a local home25 lifecycle rather than an ephemeral session, and never create a local home
25 session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon26 session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon
26 serves the client-picker scenario: `local` keeps the attachment and the27 serves the client-picker scenario: `local` keeps the attachment and the
27 picker rows in the client container, `direct` serves both from the remote28 picker rows in the client container, `direct` serves both from the remote
28 container. The client needs a pinned `remote` SSH alias and29 container. The client needs a pinned `remote` SSH alias and
29-`vev host add remote`. Scripts create uniquely named sessions and detach30+`vev host add remote` (the script then re-registers it per scenario). Scripts create uniquely named sessions and detach
30 their clients; the container owner handles session cleanup.31 their clients; the container owner handles session cleanup.
31 32
32 Client and daemon debug logging is on (`VEV_LOG=debug`); the remote accepts33 Client and daemon debug logging is on (`VEV_LOG=debug`); the remote accepts
M scripts/remote-picker-harness/acceptance.py +32 -11
@@ -7,10 +7,12 @@ lifecycle/session/focus context, observed published output). Sending a key
77 or accepting a request is never success: every scenario ends on a
88 postcondition ``wait`` or a verified capture.
99
10-Transport selection passes through the client process environment:
11-``VEV_REMOTE_TRANSPORT`` unset means QUIC, ``stdio`` means SSH stdio. The
12-value is forwarded into the container with ``docker exec -e`` so it actually
13-reaches the driver process.
10+Transport selection is host registration state: before each scenario the
11+``remote`` host is re-registered in the client container with
12+``vev host add --transport quic|ssh remote``. A bare scenario or ``@quic``
13+means QUIC; ``@stdio`` means the SSH stdio carriage (``--transport ssh``).
14+``VEV_ACCEPTANCE_TRANSPORT`` carries the selected name to the scenarios for
15+evidence labels only.
1416
1517 Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a
1618 named fixture session: a named exact remote target, never an ephemeral
@@ -369,7 +371,7 @@ def scenario_direct_remote_named(client_container, remote_container, env):
369371 --session NAME`` attaches to that exact lifecycle instead of creating an
370372 ephemeral one, so the daemon-owned target is pinned, not inferred.
371373 """
372- mode = env.get("VEV_REMOTE_TRANSPORT", "quic")
374+ mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
373375 suffix = uuid.uuid4().hex[:8]
374376 name = "direct" + suffix
375377 fixture = Driver(remote_container, ["--session", name])
@@ -416,7 +418,7 @@ def scenario_warm_reuse(client_container, remote_container, env):
416418 fails the scenario. The remote daemon's attach count is recorded, not
417419 asserted: each visit is a new logical attachment by design.
418420 """
419- mode = env.get("VEV_REMOTE_TRANSPORT", "quic")
421+ mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
420422 suffix = uuid.uuid4().hex[:8]
421423 local_name = "locl" + suffix
422424 remote_name = "remt" + suffix
@@ -556,7 +558,7 @@ def commit_target(driver, target, before):
556558 def scenario_client_picker_navigate(client_container, remote_container, env):
557559 """P6.2: client-owned navigation picker end to end, plus its cancel path."""
558560 topology = picker_topology(env)
559- mode = env.get("VEV_REMOTE_TRANSPORT", "quic")
561+ mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
560562 suffix = uuid.uuid4().hex[:8]
561563 first, second, entry = (name + suffix for name in ("picka", "pickb", "pickc"))
562564 # Direct mode is served by the remote daemon: the picker interaction
@@ -601,7 +603,7 @@ def scenario_client_picker_navigate(client_container, remote_container, env):
601603
602604 def scenario_hybrid_exact_return(client_container, remote_container, env):
603605 """M4/M5: local A -> local B -> remote -> exact local A with committed action."""
604- mode = env.get("VEV_REMOTE_TRANSPORT", "quic")
606+ mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
605607 suffix = uuid.uuid4().hex[:8]
606608 first, second, remote = (name + suffix for name in ("sample", "second", "other"))
607609 fixture = Driver(remote_container, ["--session", remote])
@@ -643,6 +645,24 @@ SCENARIOS = {
643645 }
644646
645647
648+REMOTE_HOST = "remote"
649+HOST_TRANSPORT_FLAGS = {"quic": "quic", "stdio": "ssh"}
650+
651+
652+def register_remote(client_container, transport):
653+ """Re-register the remote host with the requested carriage.
654+
655+ A host keeps the transport it was added with, so switching requires
656+ ``host rm`` then ``host add``. The rm may fail when nothing is registered.
657+ """
658+ docker_capture(["exec", client_container, "vev", "host", "rm", REMOTE_HOST])
659+ result = docker_capture(["exec", client_container, "vev", "host", "add",
660+ "--transport", HOST_TRANSPORT_FLAGS[transport], REMOTE_HOST])
661+ check(result.returncode == 0,
662+ f"vev host add --transport {HOST_TRANSPORT_FLAGS[transport]} failed",
663+ {"stdout": result.stdout, "stderr": result.stderr})
664+
665+
646666 def main(argv):
647667 if len(argv) != 4:
648668 raise SystemExit("usage: acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]")
@@ -651,10 +671,11 @@ def main(argv):
651671 if name not in SCENARIOS:
652672 raise SystemExit(f"unknown scenario {name}; want one of {sorted(SCENARIOS)}")
653673 env = {}
654- if transport == "stdio":
655- env["VEV_REMOTE_TRANSPORT"] = "stdio"
656- elif transport and transport != "quic":
674+ if transport and transport not in HOST_TRANSPORT_FLAGS:
657675 raise SystemExit(f"unknown transport {transport}; want stdio or quic")
676+ transport = transport or "quic"
677+ env["VEV_ACCEPTANCE_TRANSPORT"] = transport
678+ register_remote(client_container, transport)
658679 topology = os.environ.get("VEV_ACCEPTANCE_TOPOLOGY")
659680 if topology:
660681 env["VEV_ACCEPTANCE_TOPOLOGY"] = topology
@@ -7,10 +7,12 @@ lifecycle/session/focus context, observed published output). Sending a key
7 or accepting a request is never success: every scenario ends on a7 or accepting a request is never success: every scenario ends on a
8 postcondition ``wait`` or a verified capture.8 postcondition ``wait`` or a verified capture.
9 9
10-Transport selection passes through the client process environment:10+Transport selection is host registration state: before each scenario the
11-``VEV_REMOTE_TRANSPORT`` unset means QUIC, ``stdio`` means SSH stdio. The11+``remote`` host is re-registered in the client container with
12-value is forwarded into the container with ``docker exec -e`` so it actually12+``vev host add --transport quic|ssh remote``. A bare scenario or ``@quic``
13-reaches the driver process.13+means QUIC; ``@stdio`` means the SSH stdio carriage (``--transport ssh``).
14+``VEV_ACCEPTANCE_TRANSPORT`` carries the selected name to the scenarios for
15+evidence labels only.
14 16
15 Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a17 Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a
16 named fixture session: a named exact remote target, never an ephemeral18 named fixture session: a named exact remote target, never an ephemeral
@@ -369,7 +371,7 @@ def scenario_direct_remote_named(client_container, remote_container, env):
369 --session NAME`` attaches to that exact lifecycle instead of creating an371 --session NAME`` attaches to that exact lifecycle instead of creating an
370 ephemeral one, so the daemon-owned target is pinned, not inferred.372 ephemeral one, so the daemon-owned target is pinned, not inferred.
371 """373 """
372- mode = env.get("VEV_REMOTE_TRANSPORT", "quic")374+ mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
373 suffix = uuid.uuid4().hex[:8]375 suffix = uuid.uuid4().hex[:8]
374 name = "direct" + suffix376 name = "direct" + suffix
375 fixture = Driver(remote_container, ["--session", name])377 fixture = Driver(remote_container, ["--session", name])
@@ -416,7 +418,7 @@ def scenario_warm_reuse(client_container, remote_container, env):
416 fails the scenario. The remote daemon's attach count is recorded, not418 fails the scenario. The remote daemon's attach count is recorded, not
417 asserted: each visit is a new logical attachment by design.419 asserted: each visit is a new logical attachment by design.
418 """420 """
419- mode = env.get("VEV_REMOTE_TRANSPORT", "quic")421+ mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
420 suffix = uuid.uuid4().hex[:8]422 suffix = uuid.uuid4().hex[:8]
421 local_name = "locl" + suffix423 local_name = "locl" + suffix
422 remote_name = "remt" + suffix424 remote_name = "remt" + suffix
@@ -556,7 +558,7 @@ def commit_target(driver, target, before):
556 def scenario_client_picker_navigate(client_container, remote_container, env):558 def scenario_client_picker_navigate(client_container, remote_container, env):
557 """P6.2: client-owned navigation picker end to end, plus its cancel path."""559 """P6.2: client-owned navigation picker end to end, plus its cancel path."""
558 topology = picker_topology(env)560 topology = picker_topology(env)
559- mode = env.get("VEV_REMOTE_TRANSPORT", "quic")561+ mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
560 suffix = uuid.uuid4().hex[:8]562 suffix = uuid.uuid4().hex[:8]
561 first, second, entry = (name + suffix for name in ("picka", "pickb", "pickc"))563 first, second, entry = (name + suffix for name in ("picka", "pickb", "pickc"))
562 # Direct mode is served by the remote daemon: the picker interaction564 # Direct mode is served by the remote daemon: the picker interaction
@@ -601,7 +603,7 @@ def scenario_client_picker_navigate(client_container, remote_container, env):
601 603
602 def scenario_hybrid_exact_return(client_container, remote_container, env):604 def scenario_hybrid_exact_return(client_container, remote_container, env):
603 """M4/M5: local A -> local B -> remote -> exact local A with committed action."""605 """M4/M5: local A -> local B -> remote -> exact local A with committed action."""
604- mode = env.get("VEV_REMOTE_TRANSPORT", "quic")606+ mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic")
605 suffix = uuid.uuid4().hex[:8]607 suffix = uuid.uuid4().hex[:8]
606 first, second, remote = (name + suffix for name in ("sample", "second", "other"))608 first, second, remote = (name + suffix for name in ("sample", "second", "other"))
607 fixture = Driver(remote_container, ["--session", remote])609 fixture = Driver(remote_container, ["--session", remote])
@@ -643,6 +645,24 @@ SCENARIOS = {
643 }645 }
644 646
645 647
648+REMOTE_HOST = "remote"
649+HOST_TRANSPORT_FLAGS = {"quic": "quic", "stdio": "ssh"}
650+
651+
652+def register_remote(client_container, transport):
653+ """Re-register the remote host with the requested carriage.
654+
655+ A host keeps the transport it was added with, so switching requires
656+ ``host rm`` then ``host add``. The rm may fail when nothing is registered.
657+ """
658+ docker_capture(["exec", client_container, "vev", "host", "rm", REMOTE_HOST])
659+ result = docker_capture(["exec", client_container, "vev", "host", "add",
660+ "--transport", HOST_TRANSPORT_FLAGS[transport], REMOTE_HOST])
661+ check(result.returncode == 0,
662+ f"vev host add --transport {HOST_TRANSPORT_FLAGS[transport]} failed",
663+ {"stdout": result.stdout, "stderr": result.stderr})
664+
665+
646 def main(argv):666 def main(argv):
647 if len(argv) != 4:667 if len(argv) != 4:
648 raise SystemExit("usage: acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]")668 raise SystemExit("usage: acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]")
@@ -651,10 +671,11 @@ def main(argv):
651 if name not in SCENARIOS:671 if name not in SCENARIOS:
652 raise SystemExit(f"unknown scenario {name}; want one of {sorted(SCENARIOS)}")672 raise SystemExit(f"unknown scenario {name}; want one of {sorted(SCENARIOS)}")
653 env = {}673 env = {}
654- if transport == "stdio":674+ if transport and transport not in HOST_TRANSPORT_FLAGS:
655- env["VEV_REMOTE_TRANSPORT"] = "stdio"
656- elif transport and transport != "quic":
657 raise SystemExit(f"unknown transport {transport}; want stdio or quic")675 raise SystemExit(f"unknown transport {transport}; want stdio or quic")
676+ transport = transport or "quic"
677+ env["VEV_ACCEPTANCE_TRANSPORT"] = transport
678+ register_remote(client_container, transport)
658 topology = os.environ.get("VEV_ACCEPTANCE_TOPOLOGY")679 topology = os.environ.get("VEV_ACCEPTANCE_TOPOLOGY")
659 if topology:680 if topology:
660 env["VEV_ACCEPTANCE_TOPOLOGY"] = topology681 env["VEV_ACCEPTANCE_TOPOLOGY"] = topology

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