feat(host): select remote transport with host add --transport
- commit
- 0db7657ab2ffe11c9a4e6527fd6f7f5f28c43411
- author
- brice · · 2026-10-03 14:50 UTC
- parent
- 8a884f0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 82 | 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 | 83 | - Each connection has a 15-second handshake budget from connect through the initial committed publication. | |
| 84 | 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 | 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 | 87 | - 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 @@ | |||
| 23 | 23 | | **Remote** | `vev attach user@host` | Sessions on a server, rendered there and sent as small diffs over SSH + QUIC. | | |
| 24 | 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 | 28 | ## Features | |
| 29 | 29 | ||
| @@ -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 | ## Features | 28 | ## 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 | |||
| 194 | 194 | // a role working directory: daemon-owned subprocess cleanup may otherwise | |
| 195 | 195 | // treat the evidence directory as its working tree and remove a preallocated | |
| 196 | 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 | 198 | cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir, | |
| 199 | 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 | 200 | l.mu.Lock() | |
| 201 | 201 | peer, routed := l.peers[runDir] | |
| 202 | 202 | l.mu.Unlock() | |
| 203 | 203 | 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 { | |
| 206 | 205 | 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 | 209 | if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil { | |
| 211 | 210 | return nil, err | |
| @@ -231,12 +230,12 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||
| 231 | 230 | peer, routed := l.peers[runDir] | |
| 232 | 231 | l.mu.Unlock() | |
| 233 | 232 | 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 | |
| 237 | 236 | } | |
| 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")) | |
| 240 | 239 | peerStart := exec.Command(bin, "--daemon") | |
| 241 | 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 | 241 | "PATH="+runDir+":"+os.Getenv("PATH"), | |
| @@ -455,10 +454,11 @@ esac | |||
| 455 | 454 | 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 | 459 | switch t.Kind { | |
| 460 | 460 | case "ssh_stdio": | |
| 461 | - | return "stdio", nil | |
| 461 | + | return "ssh", nil | |
| 462 | 462 | default: | |
| 463 | 463 | return "", fmt.Errorf("transport %q cannot route through a remote peer", t.ID) | |
| 464 | 464 | } | |
| @@ -194,18 +194,17 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces | |||||
| 194 | // a role working directory: daemon-owned subprocess cleanup may otherwise | 194 | // a role working directory: daemon-owned subprocess cleanup may otherwise | ||
| 195 | // treat the evidence directory as its working tree and remove a preallocated | 195 | // 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, err | 205 | 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, err | 210 | 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, modeErr | 235 | + | 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, nil | 454 | 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", nil | 461 | + | 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 | |||
| 154 | 154 | ## Remote hosts | |
| 155 | 155 | ||
| 156 | 156 | ```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 | |
| 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 | 165 | - The broker stores hosts in `~/.local/state/vev/broker/state/state.json`. | |
| 163 | 166 | - Hosts connect over SSH, so your SSH config (aliases, keys) applies. | |
| 164 | 167 | - Remote sessions appear as `session@host` in `vev ls --all` and in the picker. | |
| @@ -154,11 +154,14 @@ rm -rf .dev/dev | |||||
| 154 | ## Remote hosts | 154 | ## Remote hosts | ||
| 155 | 155 | ||||
| 156 | ```sh | 156 | ```sh | ||
| 157 | - | vev host add user@host # add a host | 157 | + | vev host add user@host # add a host (QUIC, the default) |
| 158 | - | vev host rm user@host # remove it | 158 | + | vev host add --transport ssh user@host # add a host that uses SSH only |
| 159 | - | vev host list # show hosts and their status | 159 | + | 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` | |||
| 251 | 251 | inside the 15-second budget; a failed handshake closes the exact | |
| 252 | 252 | connection. Broker-owned remote carriage uses QUIC (`internal/adapters/quic`, | |
| 253 | 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 | 255 | For the QUIC route the broker starts one short-lived `_broker-mux-quic-bootstrap --production` over SSH | |
| 256 | 256 | which spawns a detached `_broker-mux-quic-proxy` (ephemeral listener + | |
| 257 | 257 | 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 exact | 251 | 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 SSH | 255 | 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-independent | 257 | 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 | |||
| 14 | 14 | ||
| 15 | 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 | 19 | ```sh | |
| 20 | - | VEV_REMOTE_TRANSPORT=stdio vev attach user@host | |
| 20 | + | 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 | 25 | ## What survives a network problem | |
| 24 | 26 | ||
| 25 | 27 | - 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 | ```sh | 19 | ```sh | ||
| 20 | - | VEV_REMOTE_TRANSPORT=stdio vev attach user@host | 20 | + | 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 problem | 25 | ## 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 | |||
| 530 | 530 | // daemon owns daemonSessions even when the catalogue has not observed them. | |
| 531 | 531 | func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun { | |
| 532 | 532 | 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. | |
| 535 | 535 | t.Setenv("VEV", "") | |
| 536 | - | t.Setenv(envRemoteTransport, "") | |
| 537 | 536 | service := newTerminalCompositionService(snapshot) | |
| 538 | 537 | service.daemonSessions = daemonSessions | |
| 539 | 538 | terminal := newTerminalCompositionTerminal() | |
| @@ -926,7 +925,6 @@ func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T) | |||
| 926 | 925 | // without opening any stream. It remains live until the terminal ends. | |
| 927 | 926 | func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) { | |
| 928 | 927 | t.Setenv("VEV", "") | |
| 929 | - | t.Setenv(envRemoteTransport, "") | |
| 930 | 928 | startupErr := errors.New("broker not reachable") | |
| 931 | 929 | previousConnect := connectProductionClientBroker | |
| 932 | 930 | terminal := newTerminalCompositionTerminal() | |
| @@ -971,7 +969,6 @@ func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) { | |||
| 971 | 969 | // without reaching the broker. | |
| 972 | 970 | func TestRunAttachNestedSessionBehavior(t *testing.T) { | |
| 973 | 971 | t.Setenv("VEV", "outer") | |
| 974 | - | t.Setenv(envRemoteTransport, "") | |
| 975 | 972 | connectCalls := 0 | |
| 976 | 973 | previousConnect := connectProductionClientBroker | |
| 977 | 974 | 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-transport | 533 | + | // 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 = daemonSessions | 537 | 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 := connectProductionClientBroker | 929 | 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 := 0 | 972 | connectCalls := 0 | ||
| 976 | previousConnect := connectProductionClientBroker | 973 | previousConnect := connectProductionClientBroker | ||
| 977 | previousCreate := createDetachedTerminalSession | 974 | previousCreate := createDetachedTerminalSession | ||
M internal/app/broker_composition_test.go +3 -3
| @@ -110,7 +110,7 @@ func TestProductionBrokerCompositionLocalAndMutableRemote(t *testing.T) { | |||
| 110 | 110 | return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Identity != "" | |
| 111 | 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 | 114 | require.NoError(t, err) | |
| 115 | 115 | require.Equal(t, "harness@127.0.0.1", registration.Endpoint) | |
| 116 | 116 | require.Eventually(t, func() bool { | |
| @@ -128,8 +128,8 @@ func TestProductionBrokerRemoteMuxTransports(t *testing.T) { | |||
| 128 | 128 | for _, tc := range []struct { | |
| 129 | 129 | name, helper string | |
| 130 | 130 | }{ | |
| 131 | - | {"stdio", brokerMuxStdioCommand}, | |
| 132 | - | {"quic", brokerMuxQUICBootstrapCommand}, | |
| 131 | + | {hostTransportSSH, brokerMuxStdioCommand}, | |
| 132 | + | {hostTransportQUIC, brokerMuxQUICBootstrapCommand}, | |
| 133 | 133 | } { | |
| 134 | 134 | t.Run(tc.name, func(t *testing.T) { | |
| 135 | 135 | 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 string | 129 | 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) { | |||
| 22 | 22 | { | |
| 23 | 23 | name: "older build policy is upgraded and identity kept", | |
| 24 | 24 | policy: func() ports.BrokerPolicy { | |
| 25 | - | p := remoteBrokerPolicy("") | |
| 25 | + | p := remoteBrokerPolicy(hostTransportQUIC) | |
| 26 | 26 | p.ProtocolVersion-- | |
| 27 | 27 | return p | |
| 28 | 28 | }, | |
| @@ -30,7 +30,7 @@ func TestUpgradeBrokerHostVersions(t *testing.T) { | |||
| 30 | 30 | }, | |
| 31 | 31 | { | |
| 32 | 32 | name: "current policy is left untouched", | |
| 33 | - | policy: func() ports.BrokerPolicy { return remoteBrokerPolicy("") }, | |
| 33 | + | policy: func() ports.BrokerPolicy { return remoteBrokerPolicy(hostTransportQUIC) }, | |
| 34 | 34 | wantRevision: 0, | |
| 35 | 35 | }, | |
| 36 | 36 | } | |
| @@ -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 p | 27 | 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) { | |||
| 618 | 618 | startLocalRegistry(t, registry) | |
| 619 | 619 | ||
| 620 | 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 | 622 | require.NoError(t, err, "a zero-host production run must admit a first host") | |
| 623 | 623 | require.Equal(t, "user@example.com", registration.Endpoint) | |
| 624 | 624 | ||
| @@ -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 { | |||
| 51 | 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 | 57 | 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)) | |
| 59 | 61 | } | |
| 60 | 62 | return ports.BrokerPolicy{ | |
| 61 | 63 | ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 62 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport, | |
| 64 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: string(route), | |
| 63 | 65 | Trust: "openssh-config-v1", Launch: broker.DaemonLaunchAuthority, Isolation: "per-user", | |
| 64 | 66 | } | |
| 65 | 67 | } | |
| @@ -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) { | |||
| 40 | 40 | registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable}) | |
| 41 | 41 | require.NoError(t, err) | |
| 42 | 42 | routes.Hosts = registry | |
| 43 | - | policy := remoteBrokerPolicy("stdio") | |
| 43 | + | policy := remoteBrokerPolicy(hostTransportSSH) | |
| 44 | 44 | reg, err := registry.AddHost(ctx, "user@example.test", policy) | |
| 45 | 45 | require.NoError(t, err) | |
| 46 | 46 | request := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly} | |
| @@ -110,7 +110,7 @@ func TestProductionBrokerFirstHostAdd(t *testing.T) { | |||
| 110 | 110 | socket := awaitSandboxReady(t, ready, done) | |
| 111 | 111 | service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx) | |
| 112 | 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 | 114 | require.NoError(t, err) | |
| 115 | 115 | if i == 0 { | |
| 116 | 116 | 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 = registry | 42 | 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 = reg | 116 | registered = reg | ||
M internal/app/remote_hosts.go +44 -4
| @@ -24,6 +24,18 @@ const ( | |||
| 24 | 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 | 39 | type remoteHostDeps struct { | |
| 28 | 40 | connect func(context.Context) (ports.BrokerService, error) | |
| 29 | 41 | connectExisting func(context.Context) (ports.BrokerService, error) | |
| @@ -66,11 +78,13 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error | |||
| 66 | 78 | if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil { | |
| 67 | 79 | return err | |
| 68 | 80 | } | |
| 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 | + | } | |
| 72 | 87 | } | |
| 73 | - | _, err = service.AddHost(ctx, cmd.hostTarget, remoteBrokerPolicy(transport)) | |
| 74 | 88 | return err | |
| 75 | 89 | case hostActionRm: | |
| 76 | 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 | 144 | // listDisconnectedHosts reads configured endpoints without dialing any remote. | |
| 105 | 145 | func listDisconnectedHosts(w io.Writer) error { | |
| 106 | 146 | 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 err | 79 | 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 err | 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 | + | } | |||
| 72 | } | 87 | } | ||
| 73 | - | _, err = service.AddHost(ctx, cmd.hostTarget, remoteBrokerPolicy(transport)) | |||
| 74 | return err | 88 | 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().State | 146 | 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 { | |||
| 88 | 88 | listAll bool | |
| 89 | 89 | hostAction string | |
| 90 | 90 | 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 | |
| 103 | 105 | } | |
| 104 | 106 | ||
| 105 | 107 | // usageError is a user-facing argument error; the app prints it (with usage) | |
| @@ -123,7 +125,8 @@ usage: | |||
| 123 | 125 | vev ls list local sessions | |
| 124 | 126 | vev ls <host> list sessions on a known remote host | |
| 125 | 127 | 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) | |
| 127 | 130 | vev host rm <host> remove a pinned remote host | |
| 128 | 131 | vev host list list known remote hosts | |
| 129 | 132 | vev kill <name> kill a session | |
| @@ -439,19 +442,27 @@ func parseHostArgs(args []string) (command, error) { | |||
| 439 | 442 | return command{}, usagef("`host` requires add, rm, or list") | |
| 440 | 443 | } | |
| 441 | 444 | switch args[0] { | |
| 442 | - | case hostActionAdd, hostActionRm: | |
| 445 | + | case hostActionAdd: | |
| 446 | + | return parseHostAddArgs(args[1:]) | |
| 447 | + | case hostActionRm: | |
| 443 | 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 | 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 | 457 | if err := domain.ValidateRemoteHostTarget(args[1]); err != nil { | |
| 450 | 458 | return command{}, err | |
| 451 | 459 | } | |
| 452 | - | return command{kind: kindHost, hostAction: args[0], hostTarget: args[1]}, nil | |
| 460 | + | return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil | |
| 453 | 461 | case hostActionList: | |
| 454 | 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 | 466 | return command{}, usagef("`host list` accepts no arguments") | |
| 456 | 467 | } | |
| 457 | 468 | 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 | 518 | // performanceTrace creates one serialized timestamp owner for this process. | |
| 464 | 519 | // An empty trace environment leaves all production behavior and wire bytes | |
| 465 | 520 | // unchanged. | |
| @@ -802,8 +857,8 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 802 | 857 | } | |
| 803 | 858 | binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{ | |
| 804 | 859 | {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}, | |
| 807 | 862 | }) | |
| 808 | 863 | if err != nil { | |
| 809 | 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 | 970 | const ( | |
| 916 | - | envRemoteTransport = "VEV_REMOTE_TRANSPORT" | |
| 917 | 971 | uiRemoteCleanupCommand = "_ui-cleanup" | |
| 918 | 972 | // daemonStopCommand is the hidden, environment-scoped graceful stop of | |
| 919 | 973 | // this environment's daemon, for harnesses and scripts. Users run | |
| @@ -921,17 +975,6 @@ const ( | |||
| 921 | 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 | 978 | const daemonStopTimeout = 2 * time.Second | |
| 936 | 979 | ||
| 937 | 980 | var ( | |
| @@ -88,18 +88,20 @@ type command struct { | |||||
| 88 | listAll bool | 88 | listAll bool | ||
| 89 | hostAction string | 89 | hostAction string | ||
| 90 | hostTarget string | 90 | hostTarget string | ||
| 91 | - | killAll bool | 91 | + | // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH. |
| 92 | - | killSessions bool | 92 | + | hostTransport string |
| 93 | - | cmd cmdInvocation | 93 | + | killAll bool |
| 94 | - | env envInvocation | 94 | + | killSessions bool |
| 95 | - | brokerServe brokerServeOptions | 95 | + | cmd cmdInvocation |
| 96 | - | brokerMux brokerMuxOptions | 96 | + | env envInvocation |
| 97 | - | brokerReady brokerReadyOptions | 97 | + | brokerServe brokerServeOptions |
| 98 | - | uiDriver uiDriverOptions | 98 | + | brokerMux brokerMuxOptions |
| 99 | - | uiObserve bool | 99 | + | brokerReady brokerReadyOptions |
| 100 | - | uiControl bool | 100 | + | uiDriver uiDriverOptions |
| 101 | - | uiSocket string | 101 | + | uiObserve bool |
| 102 | - | web webOptions | 102 | + | 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 sessions | 125 | vev ls list local sessions | ||
| 124 | vev ls <host> list sessions on a known remote host | 126 | vev ls <host> list sessions on a known remote host | ||
| 125 | vev ls --all list local and remote sessions | 127 | 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) | |||
| 127 | vev host rm <host> remove a pinned remote host | 130 | vev host rm <host> remove a pinned remote host | ||
| 128 | vev host list list known remote hosts | 131 | vev host list list known remote hosts | ||
| 129 | vev kill <name> kill a session | 132 | 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{}, err | 458 | return command{}, err | ||
| 451 | } | 459 | } | ||
| 452 | - | return command{kind: kindHost, hostAction: args[0], hostTarget: args[1]}, nil | 460 | + | 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}, nil | 468 | 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 bytes | 519 | // 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 of | 972 | // daemonStopCommand is the hidden, environment-scoped graceful stop of | ||
| 919 | // this environment's daemon, for harnesses and scripts. Users run | 973 | // 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.Second | 978 | 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 { | |||
| 314 | 314 | // connector composition, so a test can script the broker without a process. | |
| 315 | 315 | func startWebGatewayFixtureOver(t *testing.T, broker offlineClientFixture, connector func() ports.BrokerConnector) *webGatewayFixture { | |
| 316 | 316 | 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. | |
| 319 | 319 | t.Setenv("VEV", "") | |
| 320 | - | t.Setenv(envRemoteTransport, "") | |
| 321 | 320 | settings, err := webterm.ParseSettings("", webterm.Origin) | |
| 322 | 321 | require.NoError(t, err) | |
| 323 | 322 | 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 | |||
| 3 | 3 | ||
| 4 | 4 | usage() { | |
| 5 | 5 | 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 | |
| 7 | 7 | ||
| 8 | 8 | Runs a real remote attach smoke/debug attempt under a PTY, captures local and | |
| 9 | 9 | remote vev versions, and stores local/remote log tails around the attempt. | |
| @@ -20,6 +20,8 @@ Notes: | |||
| 20 | 20 | vev/ssh error is captured; inspect the bundle for confirmation. | |
| 21 | 21 | - The remote host must be reachable by SSH for log collection. | |
| 22 | 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 | 25 | USAGE | |
| 24 | 26 | } | |
| 25 | 27 | ||
| @@ -53,8 +55,8 @@ if [[ $# -ne 1 ]]; then | |||
| 53 | 55 | exit 2 | |
| 54 | 56 | fi | |
| 55 | 57 | 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 ;; | |
| 58 | 60 | esac | |
| 59 | 61 | ||
| 60 | 62 | target="$1" | |
| @@ -153,10 +155,11 @@ attach_log_raw="$out_dir/.attach.typescript.raw" | |||
| 153 | 155 | attach_err_raw="$out_dir/.attach.err.raw" | |
| 154 | 156 | attach_stdout_raw="$out_dir/.attach.stdout.raw" | |
| 155 | 157 | 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" | |
| 160 | 163 | fi | |
| 161 | 164 | ||
| 162 | 165 | set +e | |
| @@ -3,7 +3,7 @@ set -euo pipefail | |||||
| 3 | 3 | ||||
| 4 | usage() { | 4 | usage() { | ||
| 5 | cat <<'USAGE' >&2 | 5 | 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 |
| 7 | 7 | ||||
| 8 | Runs a real remote attach smoke/debug attempt under a PTY, captures local and | 8 | 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 | USAGE | 25 | USAGE | ||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| @@ -53,8 +55,8 @@ if [[ $# -ne 1 ]]; then | |||||
| 53 | exit 2 | 55 | exit 2 | ||
| 54 | fi | 56 | fi | ||
| 55 | case "$mode" in | 57 | 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 | esac | 60 | 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" ]]; then | 158 | + | env_cmd=(env -u VEV) |
| 157 | - | env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT) | 159 | + | if [[ "$mode" != "default" ]]; then |
| 158 | - | else | 160 | + | # 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 | fi | 163 | fi | ||
| 161 | 164 | ||||
| 162 | set +e | 165 | set +e | ||
M scripts/demo/README.md +1 -1
| @@ -29,7 +29,7 @@ shell --exec [vev args]`. It sets: | |||
| 29 | 29 | option, so `.dev/test/bin/ssh` wraps the real client with | |
| 30 | 30 | `-F .dev/test/ssh/config`. The key, `known_hosts` and config are generated in | |
| 31 | 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 | 33 | QUIC cannot work here. The remote proxy listens on a random UDP port, and | |
| 34 | 34 | the client dials that port on the SSH target name (`remote-a`), which does | |
| 35 | 35 | 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 with | 29 | 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 in | 30 | `-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, and | 33 | 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 does | 34 | 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 @@ | |||
| 3 | 3 | # | |
| 4 | 4 | # sshd is published on loopback only. QUIC is not published: the remote QUIC | |
| 5 | 5 | # 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 | 7 | # The project name is derived from VEV_ENV/VEV_TEST_ENV so distinct sandbox | |
| 8 | 8 | # environments (see scripts/demo/test-remotes.sh) never share one compose | |
| 9 | 9 | # project's containers or network. | |
| @@ -3,7 +3,7 @@ | |||||
| 3 | # | 3 | # | ||
| 4 | # sshd is published on loopback only. QUIC is not published: the remote QUIC | 4 | # 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 the | 5 | # 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 sandbox | 7 | # 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 compose | 8 | # 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() { | |||
| 52 | 52 | # command launched from inside a real vev pane does not look nested. | |
| 53 | 53 | vev_env() { | |
| 54 | 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 | 56 | PATH="$bin_dir:$PATH" "$@" | |
| 57 | 57 | } | |
| 58 | 58 | ||
| @@ -152,7 +152,7 @@ register_hosts() { | |||
| 152 | 152 | log "host already registered: $host" | |
| 153 | 153 | continue | |
| 154 | 154 | 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 | 156 | log "host registered: $host" | |
| 157 | 157 | done | |
| 158 | 158 | } | |
| @@ -277,7 +277,7 @@ cmd_shell_hint() { | |||
| 277 | 277 | cat <<EOF | |
| 278 | 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/vev | |
| 280 | + | cd $root && env -u VEV VEV_ENV=$env_name VEV_ENV_ROOT=$env_root PATH=$bin_dir:\$PATH ./build/vev | |
| 281 | 281 | ||
| 282 | 282 | or: $0 shell --exec | |
| 283 | 283 | Plain ssh: $bin_dir/ssh remote-a | |
| @@ -288,7 +288,7 @@ cmd_shell() { | |||
| 288 | 288 | [[ -x $bin_dir/ssh ]] || die "sandbox is not up; run: $0 up" | |
| 289 | 289 | if [[ ${1:-} == --exec ]]; then | |
| 290 | 290 | 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 | 292 | PATH="$bin_dir:$PATH" "$vev_bin" "$@" | |
| 293 | 293 | fi | |
| 294 | 294 | 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 | continue | 153 | continue | ||
| 154 | fi | 154 | 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 | done | 157 | done | ||
| 158 | } | 158 | } | ||
| @@ -277,7 +277,7 @@ cmd_shell_hint() { | |||||
| 277 | cat <<EOF | 277 | 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/vev | 280 | + | 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 --exec | 282 | or: $0 shell --exec | ||
| 283 | Plain ssh: $bin_dir/ssh remote-a | 283 | 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 ]]; then | 289 | if [[ ${1:-} == --exec ]]; then | ||
| 290 | shift | 290 | 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 | fi | 293 | fi | ||
| 294 | cmd_shell_hint | 294 | 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. | |||
| 16 | 16 | `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs | |
| 17 | 17 | the table-driven baseline matrix. Every scenario asserts committed outcomes | |
| 18 | 18 | (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` | |
| 23 | 24 | against a named fixture session, so the attached target is a named exact | |
| 24 | 25 | lifecycle rather than an ephemeral session, and never create a local home | |
| 25 | 26 | session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon | |
| 26 | 27 | serves the client-picker scenario: `local` keeps the attachment and the | |
| 27 | 28 | picker rows in the client container, `direct` serves both from the remote | |
| 28 | 29 | 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 | |
| 30 | 31 | their clients; the container owner handles session cleanup. | |
| 31 | 32 | ||
| 32 | 33 | 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]` runs | 16 | `acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]` runs | ||
| 17 | the table-driven baseline matrix. Every scenario asserts committed outcomes | 17 | the table-driven baseline matrix. Every scenario asserts committed outcomes | ||
| 18 | (action IDs, exact lifecycle/session/focus context, observed published | 18 | (action IDs, exact lifecycle/session/focus context, observed published | ||
| 19 | - | output); sending a key is never success. Transport selection is forwarded | 19 | + | output); sending a key is never success. Before each scenario the |
| 20 | - | into the client container (`docker exec -e VEV_REMOTE_TRANSPORT=stdio`, or | 20 | + | client container's `remote` host is re-registered with |
| 21 | - | unset for QUIC); passing it to the `docker` CLI process alone never reached | 21 | + | `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 exact | 24 | 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 home | 25 | lifecycle rather than an ephemeral session, and never create a local home | ||
| 25 | session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon | 26 | session as setup. `VEV_ACCEPTANCE_TOPOLOGY=local|direct` selects which daemon | ||
| 26 | serves the client-picker scenario: `local` keeps the attachment and the | 27 | serves the client-picker scenario: `local` keeps the attachment and the | ||
| 27 | picker rows in the client container, `direct` serves both from the remote | 28 | picker rows in the client container, `direct` serves both from the remote | ||
| 28 | container. The client needs a pinned `remote` SSH alias and | 29 | 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 |
| 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 accepts | 33 | 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 | |||
| 7 | 7 | or accepting a request is never success: every scenario ends on a | |
| 8 | 8 | postcondition ``wait`` or a verified capture. | |
| 9 | 9 | ||
| 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. | |
| 14 | 16 | ||
| 15 | 17 | Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a | |
| 16 | 18 | 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 | 371 | --session NAME`` attaches to that exact lifecycle instead of creating an | |
| 370 | 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 | 375 | suffix = uuid.uuid4().hex[:8] | |
| 374 | 376 | name = "direct" + suffix | |
| 375 | 377 | fixture = Driver(remote_container, ["--session", name]) | |
| @@ -416,7 +418,7 @@ def scenario_warm_reuse(client_container, remote_container, env): | |||
| 416 | 418 | fails the scenario. The remote daemon's attach count is recorded, not | |
| 417 | 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 | 422 | suffix = uuid.uuid4().hex[:8] | |
| 421 | 423 | local_name = "locl" + suffix | |
| 422 | 424 | remote_name = "remt" + suffix | |
| @@ -556,7 +558,7 @@ def commit_target(driver, target, before): | |||
| 556 | 558 | def scenario_client_picker_navigate(client_container, remote_container, env): | |
| 557 | 559 | """P6.2: client-owned navigation picker end to end, plus its cancel path.""" | |
| 558 | 560 | topology = picker_topology(env) | |
| 559 | - | mode = env.get("VEV_REMOTE_TRANSPORT", "quic") | |
| 561 | + | mode = env.get("VEV_ACCEPTANCE_TRANSPORT", "quic") | |
| 560 | 562 | suffix = uuid.uuid4().hex[:8] | |
| 561 | 563 | first, second, entry = (name + suffix for name in ("picka", "pickb", "pickc")) | |
| 562 | 564 | # 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 | 604 | def scenario_hybrid_exact_return(client_container, remote_container, env): | |
| 603 | 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 | 607 | suffix = uuid.uuid4().hex[:8] | |
| 606 | 608 | first, second, remote = (name + suffix for name in ("sample", "second", "other")) | |
| 607 | 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 | 666 | def main(argv): | |
| 647 | 667 | if len(argv) != 4: | |
| 648 | 668 | raise SystemExit("usage: acceptance.py CLIENT_CONTAINER REMOTE_CONTAINER SCENARIO[@stdio|@quic]") | |
| @@ -651,10 +671,11 @@ def main(argv): | |||
| 651 | 671 | if name not in SCENARIOS: | |
| 652 | 672 | raise SystemExit(f"unknown scenario {name}; want one of {sorted(SCENARIOS)}") | |
| 653 | 673 | 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: | |
| 657 | 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 | 679 | topology = os.environ.get("VEV_ACCEPTANCE_TOPOLOGY") | |
| 659 | 680 | if topology: | |
| 660 | 681 | 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 a | 7 | 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. The | 11 | + | ``remote`` host is re-registered in the client container with |
| 12 | - | value is forwarded into the container with ``docker exec -e`` so it actually | 12 | + | ``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 a | 17 | Direct-remote scenarios use ``--remote ENDPOINT --session NAME`` against a | ||
| 16 | named fixture session: a named exact remote target, never an ephemeral | 18 | 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 an | 371 | --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" + suffix | 376 | 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, not | 418 | 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" + suffix | 423 | local_name = "locl" + suffix | ||
| 422 | remote_name = "remt" + suffix | 424 | 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 interaction | 564 | # 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"] = topology | 681 | env["VEV_ACCEPTANCE_TOPOLOGY"] = topology | ||